From 779550a7176220672b5b70542e1e1bc8b0f91d70 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 23 Aug 2026 12:03:05 -0400 Subject: [PATCH] fix: scope runtime dependency parity --- .../src/ArtifactCompiler/ArtifactCompiler.php | 2 +- .../RuntimeDependencyParityReport.php | 43 ++++++++++++++++-- .../src/HtmlToBlocks/HtmlTransformer.php | 45 +++++++++++++++++++ php-transformer/tests/contract/run.php | 41 +++++++++++++++++ .../contract/staged-artifact-compilation.php | 14 ++++++ 5 files changed, 141 insertions(+), 4 deletions(-) diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index a6ed755f..81a74e23 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -703,7 +703,7 @@ private function finalizeArtifact(array $artifact, array $reduction): Transforme if ( array() !== $entryBlocks['superseded_selectors'] ) { $sourceReports['superseded_selectors'] = $entryBlocks['superseded_selectors']; } - $sourceReports['runtime_dependency_parity'] = ( new RuntimeDependencyParityReport() )->fromArtifact($normalized['files'], $html, $serializedBlocks, $entryPath, $entryBlocks['runtime_islands'], $referenceReports['asset_references'], $entryBlocks['interaction_candidates'], $entryBlocks['superseded_selectors']); + $sourceReports['runtime_dependency_parity'] = ( new RuntimeDependencyParityReport() )->fromArtifact($normalized['files'], $html, $serializedBlocks, $entryPath, $entryBlocks['runtime_islands'], $referenceReports['asset_references'], $entryBlocks['interaction_candidates'], $entryBlocks['superseded_selectors'], $allGeneratedBlocks); foreach ($sourceReports['runtime_dependency_parity']['findings'] ?? array() as $finding) { if ('runtime_dependency_target_missing' !== ($finding['code'] ?? '') || 'telemetry' === ($finding['script_kind'] ?? '')) { continue; diff --git a/php-transformer/src/ArtifactCompiler/RuntimeDependencyParityReport.php b/php-transformer/src/ArtifactCompiler/RuntimeDependencyParityReport.php index 4c8f092a..6b30a537 100644 --- a/php-transformer/src/ArtifactCompiler/RuntimeDependencyParityReport.php +++ b/php-transformer/src/ArtifactCompiler/RuntimeDependencyParityReport.php @@ -40,7 +40,7 @@ final class RuntimeDependencyParityReport * as an acceptable, superseded loss rather than a materialization bug. * @return array */ - public function fromArtifact(array $files, string $sourceHtml, string $generatedHtml, string $sourcePath = '', array $runtimeIslands = array(), array $assetReferences = array(), array $interactionCandidates = array(), array $supersededSelectors = array()): array + public function fromArtifact(array $files, string $sourceHtml, string $generatedHtml, string $sourcePath = '', array $runtimeIslands = array(), array $assetReferences = array(), array $interactionCandidates = array(), array $supersededSelectors = array(), array $generatedBlocks = array()): array { $sourceTargets = $this->sourceTargets($sourceHtml, $sourcePath); $generatedTargets = $this->withBlockCommentAnchorTargets( @@ -52,9 +52,10 @@ public function fromArtifact(array $files, string $sourceHtml, string $generated $findings = array(); $flaggedSelectors = array(); $bundleCanvasSelectors = $this->bundleCanvasSelectors($files, $sourceTargets); + $companionTargets = $this->htmlTargets($this->declaredCompanionRenderHtml($generatedBlocks)); foreach ( $files as $file ) { - if ( ! $this->isScriptFile($file) ) { + if ( ! $this->isScriptFile($file) || ! $this->scriptAppliesToSource($file, $sourcePath) ) { continue; } @@ -68,7 +69,7 @@ public function fromArtifact(array $files, string $sourceHtml, string $generated foreach ( $this->scriptDependencies($script, $bundleCanvasSelectors) as $dependency ) { $selector = (string) $dependency['selector']; $target = $sourceTargets[$selector] ?? array(); - $exists = $this->targetExists($dependency, $generatedTargets); + $exists = $this->targetExists($dependency, $generatedTargets) || $this->targetExists($dependency, $companionTargets); $canvasApi = true === $dependency['canvas_api'] && 'canvas' === ($target['tag'] ?? ''); $dependencyRow = array_filter(array( 'source_path' => $target['source_path'] ?? $sourcePath, @@ -83,6 +84,7 @@ public function fromArtifact(array $files, string $sourceHtml, string $generated 'canvas_api' => $canvasApi, 'source_present' => array() !== $target, 'generated_present' => $exists, + 'generated_target_evidence' => $this->targetExists($dependency, $companionTargets) ? 'declared_companion_render' : '', 'disposition' => $this->isSupersededSelector($selector, $superseded) ? self::DISPOSITION_SUPERSEDED : '', ), static fn (mixed $value): bool => null !== $value && '' !== $value && array() !== $value); $dependencies[] = $dependencyRow; @@ -163,6 +165,41 @@ public function fromArtifact(array $files, string $sourceHtml, string $generated return $report; } + /** + * Page-owned scripts are evaluated only against the page which owns them. + * Shared scripts intentionally retain their cross-page parity behavior. + * + * @param array $file + */ + private function scriptAppliesToSource(array $file, string $sourcePath): bool + { + $ownership = $file['metadata']['compilation'] ?? null; + if ( ! is_array($ownership) || 'page' !== ($ownership['scope'] ?? null) ) { + return true; + } + + return is_string($ownership['id'] ?? null) && $sourcePath === $ownership['id']; + } + + /** + * Exact companion render strings are server-rendered DOM contracts only when + * the generated block explicitly declares its static render file. + * + * @param array> $generatedBlocks + */ + private function declaredCompanionRenderHtml(array $generatedBlocks): string + { + $renders = array(); + foreach ( $generatedBlocks as $block ) { + if ( ! is_array($block) || 'file:./render.php' !== ($block['block_json']['render'] ?? null) || ! is_string($block['render'] ?? null) ) { + continue; + } + $renders[] = $block['render']; + } + + return implode("\n", $renders); + } + /** * Detect source-declared client-script execution dependencies (referenced * external `'), array(), $element); + } + /** * @param array> $fallbacks */ diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 69e88a31..c9d50a1b 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -3483,6 +3483,47 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter 'runtime dependency parity does not fail entry output for shared drum script selectors absent from that entry source' ); +$staticJsonRuntimeSite = $compiler->compile( + array( + 'entrypoint' => 'index.html', + 'files' => array( + 'index.html' => '

Home

', + 'js/app.js' => 'JSON.parse(document.getElementById("config").textContent).message;', + ), + ) +)->toArray(); +$staticJsonRuntimeMarkup = (string) ($staticJsonRuntimeSite['serialized_blocks'] ?? ''); +$staticJsonRuntimeDependency = array_values(array_filter($staticJsonRuntimeSite['source_reports']['runtime_dependency_parity']['dependencies'] ?? array(), static fn (array $dependency): bool => '#config' === ($dependency['selector'] ?? '')))[0] ?? array(); +$assert('pass' === ($staticJsonRuntimeSite['source_reports']['runtime_dependency_parity']['status'] ?? '') && true === ($staticJsonRuntimeDependency['generated_present'] ?? null), 'ID-addressed static JSON remains an addressable runtime target for carried first-party scripts'); +$assert(str_contains($staticJsonRuntimeMarkup, ''), 'addressable static JSON is preserved as bounded non-executable block markup'); +$assert(1 === count(array_filter($staticJsonRuntimeSite['source_reports']['runtime_islands'] ?? array(), static fn (array $island): bool => 'static_script' === ($island['kind'] ?? ''))), 'addressable static JSON target is recorded as a runtime configuration island'); + +$companionRenderReport = (new \Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler\RuntimeDependencyParityReport())->fromArtifact( + array(array('path' => 'js/app.js', 'kind' => 'js', 'content' => 'document.querySelector("a[data-anchor]").addEventListener("click", function () {});')), + '
Docs
', + '', + 'index.html', + array(), + array(), + array(), + array(), + array(array('block_json' => array('render' => 'file:./render.php'), 'render' => 'Docs')) +); +$companionRenderDependency = $companionRenderReport['dependencies'][0] ?? array(); +$assert('pass' === ($companionRenderReport['status'] ?? '') && true === ($companionRenderDependency['generated_present'] ?? null) && 'declared_companion_render' === ($companionRenderDependency['generated_target_evidence'] ?? ''), 'declared exact companion render HTML supplies data-attribute target evidence'); +$undeclaredCompanionRenderReport = (new \Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler\RuntimeDependencyParityReport())->fromArtifact( + array(array('path' => 'js/app.js', 'kind' => 'js', 'content' => 'document.querySelector("a[data-anchor]").addEventListener("click", function () {});')), + '
Docs
', + '', + 'index.html', + array(), + array(), + array(), + array(), + array(array('block_json' => array(), 'render' => 'Docs')) +); +$assert('warning' === ($undeclaredCompanionRenderReport['status'] ?? '') && 'runtime_dependency_target_missing' === ($undeclaredCompanionRenderReport['findings'][0]['code'] ?? ''), 'undeclared companion render strings cannot suppress missing-target failures'); + $hamburgerOverlaySite = $compiler->compile( array( 'entrypoint' => 'index.html', diff --git a/php-transformer/tests/contract/staged-artifact-compilation.php b/php-transformer/tests/contract/staged-artifact-compilation.php index 41025b65..9d8cfc13 100644 --- a/php-transformer/tests/contract/staged-artifact-compilation.php +++ b/php-transformer/tests/contract/staged-artifact-compilation.php @@ -107,6 +107,20 @@ $assert($canonical($manyInline) === $canonical($manyStaged), 'Fifty-page arbitrary-order resume preserves the complete canonical transformer result after observational fields are excluded.'); $manyPageComponent = current(array_filter($manyStaged['components'], static fn(array $component): bool => 'page' === ($component['name'] ?? null))); $assert(50 === ($manyPageComponent['occurrences'] ?? null), 'A class occurring once per page is qualified from the globally summed uncapped component facts.'); +$pageScopedScriptArtifact = array('entrypoint' => 'index.html', 'files' => array( + array('path' => 'index.html', 'content' => '

Home

'), + array('path' => 'about.html', 'content' => '

About

'), + array('path' => 'js/home.js', 'content' => 'document.getElementById("home-target").addEventListener("click", function () {});', 'metadata' => array('compilation' => array('scope' => 'page', 'id' => 'index.html'))), + array('path' => 'js/about.js', 'content' => 'document.getElementById("about-target").addEventListener("click", function () {});', 'metadata' => array('compilation' => array('scope' => 'page', 'id' => 'about.html'))), +)); +$pageScopedWhole = $compiler->compile($pageScopedScriptArtifact)->toArray(); +$pageScopedShared = $compiler->prepareShared($pageScopedScriptArtifact); +$pageScopedReceipts = array(); +foreach ($pageScopedShared['analysis']['page_ids'] as $pageId) $pageScopedReceipts[] = $compiler->compilePage($pageScopedScriptArtifact, $pageScopedShared, $pageId); +$pageScopedStaged = $compiler->compose($pageScopedShared, array_reverse($pageScopedReceipts))->toArray(); +$pageScopedDependencies = $pageScopedWhole['source_reports']['runtime_dependency_parity']['dependencies'] ?? array(); +$assert('pass' === ($pageScopedWhole['source_reports']['runtime_dependency_parity']['status'] ?? '') && array() === array_values(array_filter($pageScopedDependencies, static fn (array $dependency): bool => 'js/about.js' === ($dependency['script_path'] ?? ''))), 'page-owned scripts are not evaluated against another page output'); +$assert($canonical($pageScopedWhole) === $canonical($pageScopedStaged), 'page-owned script parity remains deterministic for staged receipt composition.'); $componentArtifact = array('entrypoint' => 'index.html', 'files' => array( array('path' => 'index.html', 'content' => '

Home

'), array('path' => 'second.html', 'content' => '

Second

'),