Skip to content

Commit fe1455a

Browse files
authored
Add bounded core/html fallback evidence (#838)
Add deterministic fallback reason taxonomy and bounded source/output evidence for transformer diagnostics.\n\nAI assistance: openai/gpt-5.6-sol via OpenCode was used to design, implement, test, and review the diagnostics. Chris Huber reviewed and remains responsible for the change.
2 parents f81b691 + ca9cfd0 commit fe1455a

7 files changed

Lines changed: 154 additions & 7 deletions

File tree

php-transformer/src/ArtifactCompiler/ArtifactCompiler.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Automattic\BlocksEngine\PhpTransformer\AssetAnalysis\CssUrlRewriter;
77
use Automattic\BlocksEngine\PhpTransformer\AssetAnalysis\ReferenceAnalyzer;
88
use Automattic\BlocksEngine\PhpTransformer\Contract\ConversionReportProjection;
9+
use Automattic\BlocksEngine\PhpTransformer\Contract\CoreHtmlFallbackEvidence;
910
use Automattic\BlocksEngine\PhpTransformer\Contract\TransformerResult;
1011
use Automattic\BlocksEngine\PhpTransformer\FormatBridge\FormatBridge;
1112
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer;
@@ -172,12 +173,14 @@ public function compile(array $artifact): TransformerResult
172173
$allFallbacks = $entryBlocks['fallbacks'];
173174
$allGeneratedBlocks = $entryBlocks['generated_blocks'];
174175
$allGutenbergGaps = $entryBlocks['gutenberg_gaps'];
176+
$coreHtmlFallbackEvidence = array($entryBlocks['core_html_fallback_evidence']);
175177
foreach ( $compiledHtmlDocuments as $sourcePath => $compiledHtmlDocument ) {
176178
$authorStylesheetProjections = array_merge($authorStylesheetProjections, $compiledHtmlDocument['author_stylesheet_projections'] ?? array());
177179
$allDiagnostics = array_merge($allDiagnostics, $this->entryTransformDiagnostics($compiledHtmlDocument['diagnostics'] ?? array(), (string) $sourcePath));
178180
$allFallbacks = array_merge($allFallbacks, $compiledHtmlDocument['fallbacks'] ?? array());
179181
$allGeneratedBlocks = array_merge($allGeneratedBlocks, $compiledHtmlDocument['generated_blocks'] ?? array());
180182
$allGutenbergGaps = array_merge($allGutenbergGaps, $compiledHtmlDocument['gutenberg_gaps'] ?? array());
183+
$coreHtmlFallbackEvidence[] = $compiledHtmlDocument['core_html_fallback_evidence'] ?? array();
181184
}
182185
$allGutenbergGaps = $this->dedupeRows($allGutenbergGaps);
183186
$normalized['runtime_declarations'] = $this->runtimeDeclarationsFromFallbacks($normalized['runtime_declarations'], $allFallbacks, $entryPath, $normalized['files']);
@@ -207,6 +210,7 @@ public function compile(array $artifact): TransformerResult
207210
$serializedBlocks = (string) $documents['documents'][0]['block_markup'];
208211
}
209212
$sourceReports = array(
213+
'core_html_fallback_evidence' => CoreHtmlFallbackEvidence::merge($coreHtmlFallbackEvidence),
210214
'artifact' => array(
211215
'schema' => self::INPUT_SCHEMA,
212216
'original_schema' => is_string($artifact['schema'] ?? null) ? $artifact['schema'] : '',
@@ -676,7 +680,7 @@ public function compileFragment(string $content, string $source = 'fragment', st
676680

677681
/**
678682
* @param array<int, array<string, mixed>> $files
679-
* @return array{blocks: array<int, array<string, mixed>>, serialized_blocks: string, diagnostics: array<int, array<string, mixed>>, fallbacks: array<int, array<string, mixed>>, assets: array<int, array<string, mixed>>, runtime_islands: array<int, array<string, mixed>>, generated_blocks: array<int, array<string, mixed>>, gutenberg_gaps: array<int, array<string, mixed>>, interaction_candidates: array<int, array<string, mixed>>, superseded_selectors: array<int, string>, author_stylesheet_projections: array<int, array<string, mixed>>, shell_artifacts: array<int, array<string, mixed>>}
683+
* @return array{blocks: array<int, array<string, mixed>>, serialized_blocks: string, diagnostics: array<int, array<string, mixed>>, fallbacks: array<int, array<string, mixed>>, assets: array<int, array<string, mixed>>, runtime_islands: array<int, array<string, mixed>>, generated_blocks: array<int, array<string, mixed>>, gutenberg_gaps: array<int, array<string, mixed>>, interaction_candidates: array<int, array<string, mixed>>, superseded_selectors: array<int, string>, author_stylesheet_projections: array<int, array<string, mixed>>, shell_artifacts: array<int, array<string, mixed>>, core_html_fallback_evidence: array<string, mixed>}
680684
*/
681685
private function compileEntryBlocks(string $html, string $entryPath, array $files, string $generatedBlockNamespace = ''): array
682686
{
@@ -695,12 +699,13 @@ private function compileEntryBlocks(string $html, string $entryPath, array $file
695699
'superseded_selectors' => $result['superseded_selectors'],
696700
'author_stylesheet_projections' => $result['author_stylesheet_projections'],
697701
'shell_artifacts' => $result['shell_artifacts'],
702+
'core_html_fallback_evidence' => $result['core_html_fallback_evidence'],
698703
);
699704
}
700705

701706
/**
702707
* @param array<int, array<string, mixed>> $files
703-
* @return array{blocks: array<int, array<string, mixed>>, serialized_blocks: string, diagnostics: array<int, array<string, mixed>>, fallbacks: array<int, array<string, mixed>>, assets: array<int, array<string, mixed>>, runtime_islands: array<int, array<string, mixed>>, generated_blocks: array<int, array<string, mixed>>, gutenberg_gaps: array<int, array<string, mixed>>, interaction_candidates: array<int, array<string, mixed>>, superseded_selectors: array<int, string>, author_stylesheet_projections: array<int, array<string, mixed>>, shell_artifacts: array<int, array<string, mixed>>}
708+
* @return array{blocks: array<int, array<string, mixed>>, serialized_blocks: string, diagnostics: array<int, array<string, mixed>>, fallbacks: array<int, array<string, mixed>>, assets: array<int, array<string, mixed>>, runtime_islands: array<int, array<string, mixed>>, generated_blocks: array<int, array<string, mixed>>, gutenberg_gaps: array<int, array<string, mixed>>, interaction_candidates: array<int, array<string, mixed>>, superseded_selectors: array<int, string>, author_stylesheet_projections: array<int, array<string, mixed>>, shell_artifacts: array<int, array<string, mixed>>, core_html_fallback_evidence: array<string, mixed>}
704709
*/
705710
private function compileHtmlDocumentBlocks(string $html, string $sourcePath, array $files, string $sourceScope, string $generatedBlockNamespace = '', bool $extractGlobalShell = false): array
706711
{
@@ -718,6 +723,7 @@ private function compileHtmlDocumentBlocks(string $html, string $sourcePath, arr
718723
'superseded_selectors' => array(),
719724
'author_stylesheet_projections' => array(),
720725
'shell_artifacts' => array(),
726+
'core_html_fallback_evidence' => CoreHtmlFallbackEvidence::fromBlocks(array(), array(), array()),
721727
);
722728
}
723729

@@ -735,6 +741,7 @@ private function compileHtmlDocumentBlocks(string $html, string $sourcePath, arr
735741
'superseded_selectors' => array(),
736742
'author_stylesheet_projections' => array(),
737743
'shell_artifacts' => array(),
744+
'core_html_fallback_evidence' => CoreHtmlFallbackEvidence::fromBlocks(array(), array(), array()),
738745
);
739746
}
740747

@@ -758,6 +765,7 @@ private function compileHtmlDocumentBlocks(string $html, string $sourcePath, arr
758765
'serialized_blocks' => (string) ($result['serialized_blocks'] ?? ''),
759766
'diagnostics' => is_array($result['diagnostics'] ?? null) ? $result['diagnostics'] : array(),
760767
'fallbacks' => is_array($result['fallbacks'] ?? null) ? $result['fallbacks'] : array(),
768+
'core_html_fallback_evidence' => is_array($result['source_reports']['html']['core_html_fallback_evidence'] ?? null) ? $result['source_reports']['html']['core_html_fallback_evidence'] : CoreHtmlFallbackEvidence::fromBlocks(array(), array(), array()),
761769
'assets' => is_array($result['assets'] ?? null) ? $result['assets'] : array(),
762770
'runtime_islands' => $this->runtimeIslandsWithMaterializedInlineScripts(
763771
is_array($result['source_reports']['runtime_islands'] ?? null) ? $result['source_reports']['runtime_islands'] : array(),

php-transformer/src/Contract/ConversionReportProjection.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public static function fromResultParts(string $sourceFormat, array $blocks, arra
3030
'selector_summary' => self::selectorSummary($sourceReports, $fallbacks),
3131
'conversion_classification_summary' => self::conversionClassificationSummary($sourceReports, $fallbacks),
3232
'fallback_diagnostics' => self::fallbackDiagnostics($fallbacks),
33+
'core_html_fallback_evidence' => self::coreHtmlFallbackEvidence($sourceReports),
3334
'asset_refs' => self::assetReferences($blocks, $sourceReports),
3435
'navigation_candidates' => self::navigationCandidates($blocks, $sourceReports),
3536
'semantic_parity' => self::semanticParity($sourceReports),
@@ -458,6 +459,14 @@ private static function sourceProvenance(array $sourceReports): array
458459
return is_array($html['source_provenance'] ?? null) ? $html['source_provenance'] : array();
459460
}
460461

462+
/** @param array<string, mixed> $sourceReports @return array<string, mixed> */
463+
private static function coreHtmlFallbackEvidence(array $sourceReports): array
464+
{
465+
$html = is_array($sourceReports['html'] ?? null) ? $sourceReports['html'] : array();
466+
$evidence = $sourceReports['core_html_fallback_evidence'] ?? ($html['core_html_fallback_evidence'] ?? array());
467+
return is_array($evidence) ? $evidence : array();
468+
}
469+
461470
/**
462471
* @param array<string, mixed> $sourceReports
463472
* @return array<int, array<string, mixed>>
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Automattic\BlocksEngine\PhpTransformer\Contract;
5+
6+
/** Bounded, payload-safe evidence for raw HTML escape-hatch emissions. */
7+
final class CoreHtmlFallbackEvidence
8+
{
9+
public const SCHEMA = 'blocks-engine/core-html-fallback-evidence/v1';
10+
private const MAX_EMISSIONS = 100;
11+
private const MAX_SNIPPET_BYTES = 320;
12+
13+
/**
14+
* @param array<int, array<string, mixed>> $blocks
15+
* @param array<int, array<string, mixed>> $fallbacks
16+
* @param array<int, array<string, mixed>> $sourceProvenance
17+
* @return array<string, mixed>
18+
*/
19+
public static function fromBlocks(array $blocks, array $fallbacks, array $sourceProvenance): array
20+
{
21+
$fallbacksBySelector = array();
22+
foreach ($fallbacks as $fallback) {
23+
if (is_array($fallback) && is_string($fallback['selector'] ?? null)) {
24+
$fallbacksBySelector[$fallback['selector']][] = $fallback;
25+
}
26+
}
27+
28+
$emissions = array();
29+
self::collect($blocks, 'blocks', $sourceProvenance, $fallbacksBySelector, $emissions);
30+
$total = count($emissions);
31+
$truncated = $total > self::MAX_EMISSIONS;
32+
33+
return array(
34+
'schema' => self::SCHEMA,
35+
'taxonomy' => array(
36+
'schema' => 'blocks-engine/core-html-fallback-reason-taxonomy/v1',
37+
'reasons' => array('unsupported_element', 'unsupported_attribute', 'unsupported_style', 'runtime_semantics', 'block_grammar', 'sanitization'),
38+
),
39+
'emissions' => array_slice($emissions, 0, self::MAX_EMISSIONS),
40+
'totals' => array('emissions' => $total, 'reported' => min($total, self::MAX_EMISSIONS), 'omitted' => max(0, $total - self::MAX_EMISSIONS), 'truncated' => $truncated),
41+
);
42+
}
43+
44+
/** @param array<int, array<string, mixed>> $evidence @return array<string, mixed> */
45+
public static function merge(array $evidence): array
46+
{
47+
$emissions = array();
48+
foreach ($evidence as $entry) foreach (is_array($entry['emissions'] ?? null) ? $entry['emissions'] : array() as $emission) if (is_array($emission)) $emissions[] = $emission;
49+
usort($emissions, static fn (array $a, array $b): int => strcmp((string) ($a['source_path'] ?? '') . "\0" . (string) ($a['block_path'] ?? ''), (string) ($b['source_path'] ?? '') . "\0" . (string) ($b['block_path'] ?? '')));
50+
$total = array_sum(array_map(static fn (array $entry): int => (int) ($entry['totals']['emissions'] ?? 0), $evidence));
51+
$reported = min(count($emissions), self::MAX_EMISSIONS);
52+
53+
return array(
54+
'schema' => self::SCHEMA,
55+
'taxonomy' => array('schema' => 'blocks-engine/core-html-fallback-reason-taxonomy/v1', 'reasons' => array('unsupported_element', 'unsupported_attribute', 'unsupported_style', 'runtime_semantics', 'block_grammar', 'sanitization')),
56+
'emissions' => array_slice($emissions, 0, self::MAX_EMISSIONS),
57+
'totals' => array('emissions' => $total, 'reported' => $reported, 'omitted' => max(0, $total - $reported), 'truncated' => $total > $reported),
58+
);
59+
}
60+
61+
/** @param array<int, array<string, mixed>> $blocks @param array<int, array<string, mixed>> $provenance @param array<string, array<int, array<string, mixed>>> $fallbacks @param array<int, array<string, mixed>> $emissions */
62+
private static function collect(array $blocks, string $path, array $provenance, array $fallbacks, array &$emissions): void
63+
{
64+
foreach ($blocks as $index => $block) {
65+
if (!is_array($block)) continue;
66+
$blockPath = $path . '.' . $index;
67+
if ('core/html' === ($block['blockName'] ?? null)) {
68+
$source = self::sourceForPath($provenance, $blockPath);
69+
$fallback = $fallbacks[(string) ($source['selector'] ?? '')][0] ?? array();
70+
$fragment = (string) ($source['source_fragment'] ?? '');
71+
$content = (string) ($block['attrs']['content'] ?? '');
72+
$emissions[] = array(
73+
'reason' => self::reason($fallback, $source),
74+
'source_path' => (string) ($fallback['source'] ?? $source['source_path'] ?? ''),
75+
'source_selector' => (string) ($source['selector'] ?? $fallback['selector'] ?? ''),
76+
'block_path' => $blockPath,
77+
'source_subtree' => array('digest' => (string) ($source['source_digest'] ?? hash('sha256', $fragment)), 'bytes' => (int) ($source['source_bytes'] ?? strlen($fragment)), 'snippet' => self::structuralSnippet($fragment), 'truncated' => strlen($fragment) > self::MAX_SNIPPET_BYTES),
78+
'emitted' => array('block_digest' => hash('sha256', json_encode($block, JSON_UNESCAPED_SLASHES) ?: ''), 'content_digest' => hash('sha256', $content), 'content_bytes' => strlen($content)),
79+
);
80+
}
81+
if (is_array($block['innerBlocks'] ?? null)) self::collect($block['innerBlocks'], $blockPath . '.innerBlocks', $provenance, $fallbacks, $emissions);
82+
}
83+
}
84+
85+
/** @param array<int, array<string, mixed>> $provenance @return array<string, mixed> */
86+
private static function sourceForPath(array $provenance, string $blockPath): array
87+
{
88+
foreach ($provenance as $entry) if (is_array($entry) && $blockPath === ($entry['block_path'] ?? null)) return $entry;
89+
return array();
90+
}
91+
92+
/** @param array<string, mixed> $fallback @param array<string, mixed> $source */
93+
private static function reason(array $fallback, array $source): string
94+
{
95+
$code = (string) ($fallback['diagnostic_code'] ?? '');
96+
$reason = (string) ($fallback['reason'] ?? '');
97+
if ('html_unsupported_element' === $code || 'unsupported_element' === $reason) return 'unsupported_element';
98+
if (str_contains($reason, 'runtime') || str_contains($code, 'runtime') || 'html_script_fallback' === $code) return 'runtime_semantics';
99+
if (str_contains($reason, 'unsafe') || 'svg' === ($source['tag'] ?? null)) return 'sanitization';
100+
return 'block_grammar';
101+
}
102+
103+
private static function structuralSnippet(string $html): string
104+
{
105+
preg_match_all('/<\/?[a-zA-Z][^>]*>/', $html, $matches);
106+
$tokens = array_map(static function (string $tag): string {
107+
if (str_starts_with($tag, '</')) return '</' . strtolower(trim($tag, '</> ')) . '>';
108+
preg_match('/^<\s*([a-zA-Z][\w:-]*)/', $tag, $name);
109+
preg_match_all('/\s+([:\w-]+)(?:\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+))?/', $tag, $attrs);
110+
return '<' . strtolower($name[1] ?? 'unknown') . (empty($attrs[1]) ? '' : ' ' . implode(' ', array_map('strtolower', $attrs[1]))) . '>';
111+
}, $matches[0] ?? array());
112+
return substr(implode('', $tokens), 0, self::MAX_SNIPPET_BYTES);
113+
}
114+
}

php-transformer/src/HtmlToBlocks/HtmlTransformer.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
namespace Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks;
55

66
use Automattic\BlocksEngine\PhpTransformer\Contract\ConversionReportProjection;
7+
use Automattic\BlocksEngine\PhpTransformer\Contract\CoreHtmlFallbackEvidence;
78
use Automattic\BlocksEngine\PhpTransformer\Contract\TransformationOptions;
89
use Automattic\BlocksEngine\PhpTransformer\Contract\TransformerResult;
910
use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Classification\FormControlClassifier;
@@ -809,6 +810,7 @@ public function transform(string $html, array $options = array()): TransformerRe
809810
'gutenberg_incompatibilities' => $this->gutenbergIncompatibilities,
810811
'author_layout_topology' => $authorLayoutTopologyFindings,
811812
'source_provenance' => $sourceProvenance,
813+
'core_html_fallback_evidence' => CoreHtmlFallbackEvidence::fromBlocks($blocks, $fallbacks, $sourceProvenance),
812814
'structure_signals' => $this->structureProvenance,
813815
'script_metadata' => $this->scriptMetadata,
814816
'runtime_islands' => $this->runtimeIslands,
@@ -3435,7 +3437,7 @@ private function createBlock(string $name, array $attrs = array(), array $innerB
34353437
if ( $sourceElement instanceof DOMElement && in_array($name, array( 'core/paragraph', 'core/heading' ), true) && $this->richTextRequiresHtmlFallbackWithoutNativeSvgImageObjects((string) ($attrs['content'] ?? '')) ) {
34363438
$attrs['content'] = $this->stripDecorativeSvgFromRichText((string) ($attrs['content'] ?? ''));
34373439
if ( $this->richTextRequiresHtmlFallbackWithoutNativeSvgImageObjects((string) ($attrs['content'] ?? '')) ) {
3438-
return $this->blockFactory->create('core/html', array( 'content' => $this->safeFallbackHtml($sourceElement) ));
3440+
return $this->createBlock('core/html', array( 'content' => $this->safeFallbackHtml($sourceElement) ), array(), $sourceElement);
34393441
}
34403442
}
34413443

@@ -4992,12 +4994,16 @@ private function resolveSourceProvenancePaths(array &$blocks, string $path, arra
49924994
*/
49934995
private function sourceProvenanceEntry(string $blockName, DOMElement $element): array
49944996
{
4997+
$sourceHtml = $this->safeFallbackHtml($element);
49954998
return array_merge(array(
49964999
'block_name' => $blockName,
49975000
'tag' => strtolower($element->tagName),
49985001
'selector' => $this->elementSelector($element),
49995002
'source_attributes' => $this->safeSourceAttributes($element),
50005003
'source_fragment' => $this->safeSourceFragment($element),
5004+
'source_digest' => hash('sha256', $sourceHtml),
5005+
'source_bytes' => strlen($sourceHtml),
5006+
'source_path' => $this->fallbackProvenance['source'] ?? '',
50015007
'context' => $this->sourceContext($element),
50025008
), $this->sourceConversionMetadata($blockName, $element));
50035009
}

0 commit comments

Comments
 (0)