diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 5abb516d..cb3fd2e5 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -667,6 +667,16 @@ private function finalizeArtifact(array $artifact, array $reduction): Transforme $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']); + foreach ($sourceReports['runtime_dependency_parity']['findings'] ?? array() as $finding) { + if ('runtime_dependency_target_missing' !== ($finding['code'] ?? '') || 'telemetry' === ($finding['script_kind'] ?? '')) { + continue; + } + $diagnostics[] = $this->diagnostic('runtime_dependency_contract_failed', 'error', (string) ($finding['message'] ?? 'A required runtime DOM target is absent from generated markup.'), array_filter(array( + 'selector' => $finding['selector'] ?? null, + 'script_path' => $finding['script_path'] ?? null, + 'source_path' => $finding['source_path'] ?? null, + ), static fn (mixed $value): bool => null !== $value && '' !== $value)); + } if ( array() !== $entryBlocks['runtime_islands'] ) { $sourceReports['runtime_islands'] = $entryBlocks['runtime_islands']; if ( array() !== $runtimeIslandPackage ) { @@ -3667,7 +3677,7 @@ private function isPresentationOnlyScriptSelector(string $script, string $select } $selectorPattern = preg_quote($selector, '/'); - if ( preg_match_all('/\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:document\s*\.\s*)?querySelector(?:All)?\s*\(\s*(["\'])' . $selectorPattern . '\2\s*\)/', $script, $assignments, PREG_SET_ORDER) ) { + if ( preg_match_all('/\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:(?:document|[A-Za-z_$][A-Za-z0-9_$]*)\s*\.\s*)?querySelector(?:All)?\s*\(\s*(["\'])' . $selectorPattern . '\2\s*\)/', $script, $assignments, PREG_SET_ORDER) ) { foreach ($assignments as $assignment) { if (preg_match('/\b' . preg_quote((string) $assignment[1], '/') . '\s*\.\s*(?:addEventListener|appendChild|removeChild|replaceChildren|insertAdjacentHTML|setAttribute|removeAttribute|toggleAttribute|getContext|submit|fetch)\b|\b' . preg_quote((string) $assignment[1], '/') . '\s*\.\s*(?:textContent|innerHTML|outerHTML|value|checked|selectedIndex|hidden|disabled|style|dataset)\b/', $script)) { return false; @@ -5619,7 +5629,7 @@ private function statusFromDiagnostics(array $diagnostics): string return 'failed'; } - if ( in_array(($diagnostic['code'] ?? ''), array('preserved_runtime_island', 'runtime_dom_contract_preserved'), true) ) { + if ( in_array(($diagnostic['code'] ?? ''), array('preserved_runtime_island', 'runtime_dom_contract_preserved', 'runtime_dom_contract_fallback'), true) ) { continue; } diff --git a/php-transformer/src/ArtifactCompiler/RuntimeDependencyParityReport.php b/php-transformer/src/ArtifactCompiler/RuntimeDependencyParityReport.php index b79de405..4c8f092a 100644 --- a/php-transformer/src/ArtifactCompiler/RuntimeDependencyParityReport.php +++ b/php-transformer/src/ArtifactCompiler/RuntimeDependencyParityReport.php @@ -1001,7 +1001,7 @@ private function isPresentationOnlyScriptSelector(string $script, string $select } $selectorPattern = preg_quote($selector, '/'); - if ( preg_match_all('/\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:document\s*\.\s*)?querySelector(?:All)?\s*\(\s*(["\'])' . $selectorPattern . '\2\s*\)/', $script, $assignments, PREG_SET_ORDER) ) { + if ( preg_match_all('/\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:(?:document|[A-Za-z_$][A-Za-z0-9_$]*)\s*\.\s*)?querySelector(?:All)?\s*\(\s*(["\'])' . $selectorPattern . '\2\s*\)/', $script, $assignments, PREG_SET_ORDER) ) { foreach ($assignments as $assignment) { if (preg_match('/\b' . preg_quote((string) $assignment[1], '/') . '\s*\.\s*(?:addEventListener|appendChild|removeChild|replaceChildren|insertAdjacentHTML|setAttribute|removeAttribute|toggleAttribute|getContext|submit|fetch)\b|\b' . preg_quote((string) $assignment[1], '/') . '\s*\.\s*(?:textContent|innerHTML|outerHTML|value|checked|selectedIndex|hidden|disabled|style|dataset)\b/', $script)) { return false; diff --git a/php-transformer/src/HtmlToBlocks/Diagnostics/DiagnosticsCollector.php b/php-transformer/src/HtmlToBlocks/Diagnostics/DiagnosticsCollector.php index 2265019e..061c1e2e 100644 --- a/php-transformer/src/HtmlToBlocks/Diagnostics/DiagnosticsCollector.php +++ b/php-transformer/src/HtmlToBlocks/Diagnostics/DiagnosticsCollector.php @@ -27,6 +27,7 @@ final class DiagnosticsCollector * @param array> $fallbacks Fallback diagnostics emitted during conversion. * @param array> $runtimeIslands Preserved runtime islands. * @param array> $runtimeDomPreservations Runtime contracts retained by native blocks. + * @param array> $runtimeDomFallbacks Runtime contracts retained by bounded islands. * @param array $blockValidityReport Block serialization validity report. * @param array $semanticParityReport Semantic parity report. * @param array $contentRoundTripReport Content round-trip (hallucination) report. @@ -38,6 +39,7 @@ public function collect( array $fallbacks, array $runtimeIslands, array $runtimeDomPreservations, + array $runtimeDomFallbacks, array $blockValidityReport, array $semanticParityReport, array $contentRoundTripReport = array() @@ -146,6 +148,20 @@ public function collect( ), static fn (mixed $value): bool => null !== $value && '' !== $value); } + foreach ( $runtimeDomFallbacks as $fallback ) { + $diagnostics[] = array_filter(array( + 'code' => 'runtime_dom_contract_fallback', + 'message' => 'A script-addressed DOM contract requires a bounded runtime island because its native block cannot serialize the source element unchanged.', + 'source' => $transformerSource, + 'severity' => 'info', + 'conversion_classification' => 'runtime_island_preserved', + 'preservation_strategy' => 'bounded_raw_html_runtime_island', + 'block_name' => $fallback['block_name'] ?? null, + 'tag' => $fallback['tag'] ?? null, + 'selector' => $fallback['selector'] ?? null, + ), static fn (mixed $value): bool => null !== $value && '' !== $value); + } + foreach ( $blockValidityReport['findings'] ?? array() as $finding ) { if ( ! is_array($finding) ) { continue; diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php index cf1cfedc..af8987e6 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformer.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformer.php @@ -833,6 +833,7 @@ public function transform(string $html, array $options = array()): TransformerRe $fallbacks, $this->runtimeIslands, array_values($this->runtimeDomPreservations), + array_values($this->runtimeDomFallbacks), $blockValidityReport, $semanticParityReport, $contentRoundTripReport @@ -897,6 +898,7 @@ public function transform(string $html, array $options = array()): TransformerRe 'head_metadata' => $headMetadata, 'runtime_islands' => $this->runtimeIslands, 'runtime_dom_contracts' => array_values($this->runtimeDomPreservations), + 'runtime_dom_fallbacks' => array_values($this->runtimeDomFallbacks), 'generated_blocks' => $this->generatedBlocks, 'gutenberg_gaps' => $this->descriptionListBlockGenerated ? array( array( @@ -4569,7 +4571,7 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca return $this->capturedDialogBlock($element, $fallbacks); } - if ( $this->shouldPreserveDataAttributeRuntimeTarget($element) && ! in_array($tagName, array('p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'), true) ) { + if ( $this->shouldPreserveDataAttributeRuntimeTarget($element) ) { return $this->htmlPreservationBlock($element); } @@ -4586,7 +4588,6 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca if ( preg_match('/^h([1-6])$/', $tagName, $matches) ) { $content = $this->richTextContentWithMaterializedInlineStyles($element); - $content = $this->richTextContentWithRuntimeRootAttributes($element, $content); $content = $this->headingRichTextContent($content); if ( $this->richTextRequiresHtmlFallbackWithoutNativeSvgImageObjects($content) ) { return $this->htmlPreservationBlock($element); @@ -4607,7 +4608,6 @@ private function convertElement(DOMElement $element, array &$fallbacks, bool $ca return $marquee; } $content = $this->richTextContentWithMaterializedInlineStyles($element); - $content = $this->richTextContentWithRuntimeRootAttributes($element, $content); $inlineSvgContent = $this->richTextContentWithMaterializedSvgImages($element, $content); if ( null !== $inlineSvgContent ) { $content = $inlineSvgContent; @@ -5777,6 +5777,7 @@ private function createBlock(string $name, array $attrs = array(), array $innerB 'events' => $this->eventMetadata($sourceElement), 'required_scripts' => $this->requiredScriptsForElement($sourceElement), )); + $this->recordRuntimeDomFallback($sourceElement, $name); } else { $this->recordNativeRuntimeDomPreservation($sourceElement, $name, in_array($name, array('core/paragraph', 'core/heading'), true)); } @@ -12190,19 +12191,19 @@ private function recordNativeRuntimeDomPreservation(DOMElement $element, string } } - private function richTextContentWithRuntimeRootAttributes(DOMElement $element, string $content): string + private function recordRuntimeDomFallback(DOMElement $element, string $blockName): void { - $attributes = array(); foreach ($this->runtimeDomSelectorsForElement($element) as $selector) { - if (!preg_match('/\[(data-[A-Za-z][A-Za-z0-9_-]*)/', $selector, $match)) { + $key = $blockName . "\n" . $selector; + if (isset($this->runtimeDomFallbacks[$key])) { continue; } - $name = strtolower((string) $match[1]); - if ($element->hasAttribute($name)) { - $attributes[$name] = $element->getAttribute($name); - } + $this->runtimeDomFallbacks[$key] = array( + 'block_name' => $blockName, + 'tag' => strtolower($element->tagName), + 'selector' => $selector, + ); } - return array() === $attributes ? $content : 'htmlAttributeString($attributes) . '>' . $content . ''; } private function canRetainRuntimeDomContractNatively(DOMElement $element, string $blockName): bool diff --git a/php-transformer/src/HtmlToBlocks/HtmlTransformerSession.php b/php-transformer/src/HtmlToBlocks/HtmlTransformerSession.php index 2c58590a..a21dcf2e 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlTransformerSession.php +++ b/php-transformer/src/HtmlToBlocks/HtmlTransformerSession.php @@ -33,6 +33,7 @@ final class HtmlTransformerSession public array $scriptMetadata = array(); public array $runtimeIslands = array(); public array $runtimeDomPreservations = array(); + public array $runtimeDomFallbacks = array(); public array $nativeDisclosureRootIds = array(); public array $generatedBlocks = array(); public bool $descriptionListBlockGenerated = false; diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 643e9465..f0a5bcc3 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -3330,8 +3330,8 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter array( 'entrypoint' => 'index.html', 'files' => array( - 'index.html' => '

0 score

', - 'js/clock.js' => 'document.getElementById("clock").textContent = "1"; document.querySelector(".clock").classList.add("ready"); document.querySelector("[data-counter]").textContent = "2"; document.querySelector("#score").textContent = "3";', + 'index.html' => '

0 score

Counter

Editable sibling

', + 'js/clock.js' => 'document.getElementById("clock").textContent = "1"; document.querySelector(".clock").classList.add("ready"); document.querySelector("[data-counter]").textContent = "2"; document.querySelector("#score").closest(".panel").classList.add("ready");', ), ) )->toArray(); @@ -3339,12 +3339,13 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter $firstPartyRuntimeDependencies = array_column($firstPartyRuntimeContracts['source_reports']['runtime_dependency_parity']['dependencies'] ?? array(), null, 'selector'); $firstPartyRuntimeDiagnostics = array_values(array_filter($firstPartyRuntimeContracts['diagnostics'] ?? array(), static fn (array $diagnostic): bool => 'runtime_dom_contract_preserved' === ($diagnostic['code'] ?? ''))); $firstPartyRuntimeDiagnosticSelectors = array_column($firstPartyRuntimeDiagnostics, 'selector'); -$assert('pass' === ($firstPartyRuntimeContracts['source_reports']['runtime_dependency_parity']['status'] ?? '') && array() === ($firstPartyRuntimeContracts['source_reports']['runtime_dependency_parity']['findings'] ?? array()), 'first-party ID, class, and data-attribute runtime selectors pass only when their generated contracts are present'); -foreach (array('#clock', '.clock', '[data-counter]', '#score') as $selector) { +$firstPartyRuntimeFallbacks = array_values(array_filter($firstPartyRuntimeContracts['diagnostics'] ?? array(), static fn (array $diagnostic): bool => 'runtime_dom_contract_fallback' === ($diagnostic['code'] ?? ''))); +$assert('pass' === ($firstPartyRuntimeContracts['source_reports']['runtime_dependency_parity']['status'] ?? '') && array() === ($firstPartyRuntimeContracts['source_reports']['runtime_dependency_parity']['findings'] ?? array()), 'first-party ID, class, closest-parent, and data-attribute runtime selectors pass only when their generated contracts are present'); +foreach (array('#clock', '.clock', '.panel', '[data-counter]', '#score') as $selector) { $assert(true === ($firstPartyRuntimeDependencies[$selector]['generated_present'] ?? null), 'first-party runtime dependency parity preserves ' . $selector); - $assert(in_array($selector, $firstPartyRuntimeDiagnosticSelectors, true), 'native preservation diagnostics identify ' . $selector); } -$assert(str_contains($firstPartyRuntimeMarkup, 'id="clock" class="clock"') && str_contains($firstPartyRuntimeMarkup, '') && str_contains($firstPartyRuntimeMarkup, 'score'), 'native RichText output retains required ID, class, and data attributes for first-party runtime selectors', $firstPartyRuntimeMarkup); +$assert(in_array('#clock', $firstPartyRuntimeDiagnosticSelectors, true) && in_array('.clock', $firstPartyRuntimeDiagnosticSelectors, true) && in_array('#score', $firstPartyRuntimeDiagnosticSelectors, true) && '[data-counter]' === ($firstPartyRuntimeFallbacks[0]['selector'] ?? ''), 'per-target diagnostics distinguish native ID/class/inline preservation from data-attribute fallback'); +$assert(str_contains($firstPartyRuntimeMarkup, '
') && str_contains($firstPartyRuntimeMarkup, '

0 score

') && str_contains($firstPartyRuntimeMarkup, '

Counter

') && str_contains($firstPartyRuntimeMarkup, '

Editable sibling

') && ! str_contains($firstPartyRuntimeMarkup, 'fromArtifact( array(array('path' => 'js/clock.js', 'kind' => 'js', 'content' => 'document.querySelector("[data-counter]").textContent = "2";')), '

0

', @@ -3352,6 +3353,17 @@ public function recognize(DOMElement $element, PatternContext $context): ?Patter 'index.html' ); $assert('warning' === ($missingRuntimeContract['status'] ?? '') && 'runtime_dependency_target_missing' === ($missingRuntimeContract['findings'][0]['code'] ?? ''), 'runtime dependency parity fails closed when a required source selector is absent from generated markup'); +$missingRuntimeCompile = $compiler->compile( + array( + 'entrypoint' => 'index.html', + 'files' => array( + 'index.html' => '

Editable

', + 'js/clock.js' => 'document.querySelector("[data-counter]").textContent = "2";', + ), + ) +)->toArray(); +$missingRuntimeCompileDiagnostics = array_values(array_filter($missingRuntimeCompile['diagnostics'] ?? array(), static fn (array $diagnostic): bool => 'runtime_dependency_contract_failed' === ($diagnostic['code'] ?? ''))); +$assert('failed' === ($missingRuntimeCompile['status'] ?? '') && '[data-counter]' === ($missingRuntimeCompileDiagnostics[0]['context']['selector'] ?? ''), 'ArtifactCompiler promotes required runtime selector parity failures to deterministic top-level errors'); $expandedRuntimeTargetsSite = $compiler->compile( array( diff --git a/php-transformer/tests/fixtures/parity/artifact-first-party-runtime-dom-contracts.json b/php-transformer/tests/fixtures/parity/artifact-first-party-runtime-dom-contracts.json index 3c2202cc..d6079c0f 100644 --- a/php-transformer/tests/fixtures/parity/artifact-first-party-runtime-dom-contracts.json +++ b/php-transformer/tests/fixtures/parity/artifact-first-party-runtime-dom-contracts.json @@ -1,7 +1,7 @@ { "schema": "blocks-engine/php-transformer/parity-fixture/v1", "name": "artifact-first-party-runtime-dom-contracts", - "description": "Preserves first-party ID, class, and data-attribute DOM contracts in native RichText output.", + "description": "Preserves native first-party ID and class contracts while bounding root data-attribute contracts to their source element.", "source_reference": { "repo": "php-transformer", "path": "tests/fixtures/parity/artifact-first-party-runtime-dom-contracts.json", @@ -19,12 +19,12 @@ { "path": "index.html", "kind": "html", - "content": "

0 score

" + "content": "

0 score

Counter

Editable sibling

" }, { "path": "js/clock.js", "kind": "js", - "content": "document.getElementById('clock').textContent = '1'; document.querySelector('.clock').classList.add('ready'); document.querySelector('[data-counter]').textContent = '2'; document.querySelector('#score').textContent = '3';" + "content": "document.getElementById('clock').textContent = '1'; document.querySelector('.clock').classList.add('ready'); document.querySelector('[data-counter]').textContent = '2'; document.querySelector('#score').closest('.panel').classList.add('ready');" } ] } @@ -32,9 +32,9 @@ "expect": [ { "path": "status", "assert": "equals", "value": "success" }, { "path": "source_reports.runtime_dependency_parity.status", "assert": "equals", "value": "pass" }, - { "path": "source_reports.runtime_dependency_parity.dependencies", "assert": "count", "count": 4 }, + { "path": "source_reports.runtime_dependency_parity.dependencies", "assert": "count", "count": 5 }, { "path": "serialized_blocks", "assert": "contains", "value": "id=\"clock\" class=\"clock\"" }, - { "path": "serialized_blocks", "assert": "contains", "value": "data-counter=\"clock\"" }, + { "path": "serialized_blocks", "assert": "contains", "value": "

Counter

" }, { "path": "serialized_blocks", "assert": "contains", "value": "id=\"score\"" } ] }