From b673a7ec1b7c54d401529012ab9075590a48361a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 15:13:34 -0400 Subject: [PATCH 1/2] refactor(php-transformer): evaluate block validity before reports --- ...l-validation-outcome-consumer-inventory.md | 4 +- .../src/Contract/HtmlValidationOutcome.php | 11 +-- .../src/HtmlToBlocks/HtmlCompilation.php | 7 +- .../src/WordPress/BlockValidityEvaluation.php | 67 +++++++++++++++++++ php-transformer/src/WordPress/Runtime.php | 52 +++++--------- php-transformer/tests/contract/run.php | 12 ++-- .../tests/contract/runtime-no-wordpress.php | 40 +++++++++++ 7 files changed, 141 insertions(+), 52 deletions(-) create mode 100644 php-transformer/src/WordPress/BlockValidityEvaluation.php diff --git a/php-transformer/docs/html-validation-outcome-consumer-inventory.md b/php-transformer/docs/html-validation-outcome-consumer-inventory.md index 80e24bafd..6a1223ec3 100644 --- a/php-transformer/docs/html-validation-outcome-consumer-inventory.md +++ b/php-transformer/docs/html-validation-outcome-consumer-inventory.md @@ -1,6 +1,6 @@ # HTML Validation Outcome Consumer Inventory -`HtmlCompilation` evaluates block validity, semantic parity, and content round-trip once. `SemanticParityReporter::evaluate()` owns the semantic status, detailed enriched findings, landmark counts, and menu pairing/folding facts. Its `report()` facade projects the unchanged detailed report, while `HtmlCompilation` passes the evaluation's status and findings directly to `Contract\HtmlValidationOutcome`, which it carries in `BlockCompilationOutput`. +`HtmlCompilation` evaluates block validity, semantic parity, and content round-trip once. `Runtime::evaluateBlockSerialization()` parses string input once and returns `BlockValidityEvaluation`, which owns the merged structural and canonical save-shape status/findings; its `report()` facade projects the unchanged detailed report. `SemanticParityReporter::evaluate()` owns the semantic status, detailed enriched findings, landmark counts, and menu pairing/folding facts. `HtmlCompilation` passes both evaluations' status and findings directly to `Contract\HtmlValidationOutcome`, which it carries in `BlockCompilationOutput`. | Consumer | Required facts | Source | | --- | --- | --- | @@ -9,4 +9,4 @@ | `ArtifactCompiler`, staged plans, and `WordPressSitePlan` | flattened diagnostics and their severities for artifact acceptance | existing `TransformerResult::diagnostics`, populated from `HtmlValidationOutcome` | | `HtmlResultComposer` and `ConversionReportProjection` | full detailed validator evidence | unchanged `source_reports.wp_block_validity`, `semantic_parity`, and `content_round_trip`; `SemanticParityEvaluation::report()` supplies semantic parity and it remains projected into `conversion_report` | -The required outcome stores each validator status plus only the original finding keys used to emit diagnostics. Semantic facts come from the evaluation, not from a report map. Values remain mixed because the existing collector preserves any non-null severity and location value; it owns the existing defaults for missing or null `summary` and `severity`. Detailed report-only evidence remains outside the outcome and is serialized only through the existing report projections. Empty and parse-failure HTML results carry `BlockCompilationOutput::empty()` with explicit empty, `not_evaluated` validation outcomes. +The required outcome stores each validator status plus only the original finding keys used to emit diagnostics. Block-validity and semantic facts come from their evaluations, not from report maps. Values remain mixed because the existing collector preserves any non-null severity and location value; it owns the existing defaults for missing or null `summary` and `severity`. Detailed report-only evidence remains outside the outcome and is serialized only through the existing report projections. Empty and parse-failure HTML results carry `BlockCompilationOutput::empty()` with explicit empty, `not_evaluated` validation outcomes. diff --git a/php-transformer/src/Contract/HtmlValidationOutcome.php b/php-transformer/src/Contract/HtmlValidationOutcome.php index e07ee779d..ae096bd82 100644 --- a/php-transformer/src/Contract/HtmlValidationOutcome.php +++ b/php-transformer/src/Contract/HtmlValidationOutcome.php @@ -3,6 +3,8 @@ namespace Automattic\BlocksEngine\PhpTransformer\Contract; +use Automattic\BlocksEngine\PhpTransformer\WordPress\BlockValidityEvaluation; + /** * Required HTML validation facts used for diagnostics and acceptance. * @@ -44,19 +46,18 @@ public static function fromReports(array $blockValidityReport, array $semanticPa * Semantic parity is evaluated by its producer; its detailed report remains * a projection rather than the source of required diagnostic facts. * - * @param array $blockValidityReport * @param array> $semanticParityFindings * @param array $contentRoundTripReport */ - public static function fromBlockValidityAndContentRoundTripReports( - array $blockValidityReport, + public static function fromBlockValidityEvaluationAndContentRoundTripReport( + BlockValidityEvaluation $blockValidityEvaluation, string $semanticParityStatus, array $semanticParityFindings, array $contentRoundTripReport ): self { return new self( - blockValidityStatus: self::status($blockValidityReport), - blockValidityFindings: self::findings($blockValidityReport, array('block_name', 'path')), + blockValidityStatus: $blockValidityEvaluation->status, + blockValidityFindings: self::findings(array('findings' => $blockValidityEvaluation->findings), array('block_name', 'path')), semanticParityStatus: $semanticParityStatus, semanticParityFindings: self::findings(array('findings' => $semanticParityFindings), array('selector')), contentRoundTripStatus: self::status($contentRoundTripReport), diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index 5f63507f5..2046aa8d7 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -1291,12 +1291,13 @@ public function transform(string $html, array $options = array()): TransformerRe $authorStylesheetProjections ); $this->navigationStyleProjector->materializeEditorStaticStateStylesheet(); - $blockValidityReport = $this->runtime->validateBlockSerialization($blocks); + $blockValidityEvaluation = $this->runtime->evaluateBlockSerialization($blocks); + $blockValidityReport = $blockValidityEvaluation->report(); $semanticParityEvaluation = $this->semanticParityReporter->evaluate($body, $blocks, $sourceProvenance, $html, (string) ($options['static_css'] ?? '')); $semanticParityReport = $semanticParityEvaluation->report(); $contentRoundTripReport = $this->contentRoundTripReporter->report($serializedBlocks, $html, $this->transformationEvidence()->formControlEchoTexts()); - $validationOutcome = \Automattic\BlocksEngine\PhpTransformer\Contract\HtmlValidationOutcome::fromBlockValidityAndContentRoundTripReports( - $blockValidityReport, + $validationOutcome = \Automattic\BlocksEngine\PhpTransformer\Contract\HtmlValidationOutcome::fromBlockValidityEvaluationAndContentRoundTripReport( + $blockValidityEvaluation, $semanticParityEvaluation->status(), $semanticParityEvaluation->findings, $contentRoundTripReport diff --git a/php-transformer/src/WordPress/BlockValidityEvaluation.php b/php-transformer/src/WordPress/BlockValidityEvaluation.php new file mode 100644 index 000000000..3ddf62083 --- /dev/null +++ b/php-transformer/src/WordPress/BlockValidityEvaluation.php @@ -0,0 +1,67 @@ + $summary + * @param array> $findings + */ + private function __construct( + public readonly string $status, + public readonly array $summary, + public readonly array $findings + ) { + } + + /** @param array> $blocks */ + public static function fromBlocks(array $blocks): self + { + $structuralReport = ( new BlockValidityValidator() )->validateBlocks($blocks); + $findings = is_array($structuralReport['findings'] ?? null) ? $structuralReport['findings'] : array(); + $findings = array_merge($findings, ( new CanonicalSaveShapeValidator() )->findings($blocks)); + + return new self( + status: array() === $findings ? 'pass' : 'warning', + summary: array( + 'block_count' => $structuralReport['summary']['block_count'] ?? 0, + 'finding_count' => count($findings), + 'checked_block_types' => $structuralReport['summary']['checked_block_types'] ?? array(), + ), + findings: $findings + ); + } + + public function withParseFailure(): self + { + $findings = $this->findings; + $findings[] = array( + 'code' => 'serialized_blocks_parse_failed', + 'severity' => 'warning', + 'category' => 'wp_block_validity', + 'path' => 'serialized_blocks', + 'summary' => 'Serialized block comments were present but could not be parsed into a balanced block tree.', + ); + $summary = $this->summary; + $summary['finding_count'] = count($findings); + + return new self('warning', $summary, $findings); + } + + /** @return array */ + public function report(): array + { + return array( + 'schema' => BlockValidityValidator::SCHEMA, + 'status' => $this->status, + 'summary' => $this->summary, + 'findings' => $this->findings, + ); + } +} diff --git a/php-transformer/src/WordPress/Runtime.php b/php-transformer/src/WordPress/Runtime.php index 1b0c666da..135f5078d 100644 --- a/php-transformer/src/WordPress/Runtime.php +++ b/php-transformer/src/WordPress/Runtime.php @@ -488,52 +488,30 @@ private function canonicalRuntimeBlocks(array $blocks): array */ public function validateBlockSerialization(string|array $serializedBlocksOrBlocks): array { - if ( is_string($serializedBlocksOrBlocks) ) { - $blocks = $this->parseBlocks($serializedBlocksOrBlocks); - $report = $this->buildBlockValidityReport($blocks); - - if ( array() === $blocks && str_contains($serializedBlocksOrBlocks, '

Unbalanced'; +$unbalancedEvaluation = $runtime->evaluateBlockSerialization($unbalancedSerialization); +assertSame($runtime->validateBlockSerialization($unbalancedSerialization), $unbalancedEvaluation->report(), 'Block validity evaluation should preserve unbalanced serialized-comment report semantics.'); +assertSame(array('serialized_block_comment_in_inner_content', 'serialized_block_comment_in_inner_html'), array_column($unbalancedEvaluation->findings, 'code'), 'Unbalanced serialized comments should retain structural parser findings and ordering.'); + assertSame('Safe text', $runtime->stripAllTags('

Safe text

'), 'Fallback tag stripping should remove scripts and tags.'); assertSame('wordpress_strip_all_tags_unavailable', $runtime->diagnostics()[0]['code'] ?? null, 'Fallback tag stripping should expose a diagnostic.'); From c13b10ff8712c6fa99e9578ba953ba07be6880fe Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 9 Sep 2026 15:39:50 -0400 Subject: [PATCH 2/2] refactor(php-transformer): produce structural validity facts --- ...l-validation-outcome-consumer-inventory.md | 2 +- .../src/Contract/HtmlValidationOutcome.php | 38 ++++++++----------- .../src/HtmlToBlocks/HtmlCompilation.php | 5 ++- .../src/WordPress/BlockValidityEvaluation.php | 31 ++++++++++++--- .../src/WordPress/BlockValidityValidator.php | 23 ++++++----- php-transformer/tests/contract/run.php | 2 +- .../tests/contract/runtime-no-wordpress.php | 5 +++ 7 files changed, 62 insertions(+), 44 deletions(-) diff --git a/php-transformer/docs/html-validation-outcome-consumer-inventory.md b/php-transformer/docs/html-validation-outcome-consumer-inventory.md index 6a1223ec3..74c2f1ef7 100644 --- a/php-transformer/docs/html-validation-outcome-consumer-inventory.md +++ b/php-transformer/docs/html-validation-outcome-consumer-inventory.md @@ -1,6 +1,6 @@ # HTML Validation Outcome Consumer Inventory -`HtmlCompilation` evaluates block validity, semantic parity, and content round-trip once. `Runtime::evaluateBlockSerialization()` parses string input once and returns `BlockValidityEvaluation`, which owns the merged structural and canonical save-shape status/findings; its `report()` facade projects the unchanged detailed report. `SemanticParityReporter::evaluate()` owns the semantic status, detailed enriched findings, landmark counts, and menu pairing/folding facts. `HtmlCompilation` passes both evaluations' status and findings directly to `Contract\HtmlValidationOutcome`, which it carries in `BlockCompilationOutput`. +`HtmlCompilation` evaluates block validity, semantic parity, and content round-trip once. `BlockValidityValidator::evaluateBlocks()` produces structural `BlockValidityEvaluation` facts and `validateBlocks()` projects its public report; `Runtime::evaluateBlockSerialization()` parses string input once and returns the evaluation merged with canonical save-shape findings. Its `report()` facade projects the unchanged detailed report. `SemanticParityReporter::evaluate()` owns the semantic status, detailed enriched findings, landmark counts, and menu pairing/folding facts. `HtmlCompilation` passes both evaluations' explicit status and findings to the implementation-independent `Contract\HtmlValidationOutcome`, which it carries in `BlockCompilationOutput`. | Consumer | Required facts | Source | | --- | --- | --- | diff --git a/php-transformer/src/Contract/HtmlValidationOutcome.php b/php-transformer/src/Contract/HtmlValidationOutcome.php index ae096bd82..28c372463 100644 --- a/php-transformer/src/Contract/HtmlValidationOutcome.php +++ b/php-transformer/src/Contract/HtmlValidationOutcome.php @@ -3,8 +3,6 @@ namespace Automattic\BlocksEngine\PhpTransformer\Contract; -use Automattic\BlocksEngine\PhpTransformer\WordPress\BlockValidityEvaluation; - /** * Required HTML validation facts used for diagnostics and acceptance. * @@ -29,37 +27,25 @@ public function __construct( ) { } - /** @param array $blockValidityReport @param array $semanticParityReport @param array $contentRoundTripReport */ - public static function fromReports(array $blockValidityReport, array $semanticParityReport, array $contentRoundTripReport): self - { - return new self( - blockValidityStatus: self::status($blockValidityReport), - blockValidityFindings: self::findings($blockValidityReport, array('block_name', 'path')), - semanticParityStatus: self::status($semanticParityReport), - semanticParityFindings: self::findings($semanticParityReport, array('selector')), - contentRoundTripStatus: self::status($contentRoundTripReport), - contentRoundTripFindings: self::findings($contentRoundTripReport, array('text')) - ); - } - /** - * Semantic parity is evaluated by its producer; its detailed report remains - * a projection rather than the source of required diagnostic facts. + * Validators supply facts directly; detailed reports remain projections. * + * @param array> $blockValidityFindings * @param array> $semanticParityFindings * @param array $contentRoundTripReport */ - public static function fromBlockValidityEvaluationAndContentRoundTripReport( - BlockValidityEvaluation $blockValidityEvaluation, + public static function fromValidationFactsAndContentRoundTripReport( + string $blockValidityStatus, + array $blockValidityFindings, string $semanticParityStatus, array $semanticParityFindings, array $contentRoundTripReport ): self { return new self( - blockValidityStatus: $blockValidityEvaluation->status, - blockValidityFindings: self::findings(array('findings' => $blockValidityEvaluation->findings), array('block_name', 'path')), + blockValidityStatus: $blockValidityStatus, + blockValidityFindings: self::filteredFindings($blockValidityFindings, array('block_name', 'path')), semanticParityStatus: $semanticParityStatus, - semanticParityFindings: self::findings(array('findings' => $semanticParityFindings), array('selector')), + semanticParityFindings: self::filteredFindings($semanticParityFindings, array('selector')), contentRoundTripStatus: self::status($contentRoundTripReport), contentRoundTripFindings: self::findings($contentRoundTripReport, array('text')) ); @@ -73,9 +59,15 @@ private static function status(array $report): string /** @param array $report @param array $fields @return array> */ private static function findings(array $report, array $fields): array + { + return self::filteredFindings(is_array($report['findings'] ?? null) ? $report['findings'] : array(), $fields); + } + + /** @param array $sourceFindings @param array $fields @return array> */ + private static function filteredFindings(array $sourceFindings, array $fields): array { $findings = array(); - foreach ($report['findings'] ?? array() as $finding) { + foreach ($sourceFindings as $finding) { if (!is_array($finding)) { continue; } diff --git a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php index 2046aa8d7..1508fd79f 100644 --- a/php-transformer/src/HtmlToBlocks/HtmlCompilation.php +++ b/php-transformer/src/HtmlToBlocks/HtmlCompilation.php @@ -1296,8 +1296,9 @@ public function transform(string $html, array $options = array()): TransformerRe $semanticParityEvaluation = $this->semanticParityReporter->evaluate($body, $blocks, $sourceProvenance, $html, (string) ($options['static_css'] ?? '')); $semanticParityReport = $semanticParityEvaluation->report(); $contentRoundTripReport = $this->contentRoundTripReporter->report($serializedBlocks, $html, $this->transformationEvidence()->formControlEchoTexts()); - $validationOutcome = \Automattic\BlocksEngine\PhpTransformer\Contract\HtmlValidationOutcome::fromBlockValidityEvaluationAndContentRoundTripReport( - $blockValidityEvaluation, + $validationOutcome = \Automattic\BlocksEngine\PhpTransformer\Contract\HtmlValidationOutcome::fromValidationFactsAndContentRoundTripReport( + $blockValidityEvaluation->status, + $blockValidityEvaluation->findings, $semanticParityEvaluation->status(), $semanticParityEvaluation->findings, $contentRoundTripReport diff --git a/php-transformer/src/WordPress/BlockValidityEvaluation.php b/php-transformer/src/WordPress/BlockValidityEvaluation.php index 3ddf62083..5723fed3a 100644 --- a/php-transformer/src/WordPress/BlockValidityEvaluation.php +++ b/php-transformer/src/WordPress/BlockValidityEvaluation.php @@ -23,21 +23,42 @@ private function __construct( /** @param array> $blocks */ public static function fromBlocks(array $blocks): self { - $structuralReport = ( new BlockValidityValidator() )->validateBlocks($blocks); - $findings = is_array($structuralReport['findings'] ?? null) ? $structuralReport['findings'] : array(); - $findings = array_merge($findings, ( new CanonicalSaveShapeValidator() )->findings($blocks)); + return ( new BlockValidityValidator() ) + ->evaluateBlocks($blocks) + ->withAdditionalFindings(( new CanonicalSaveShapeValidator() )->findings($blocks)); + } + /** + * @param array $checkedBlockTypes + * @param array> $findings + */ + public static function fromStructuralFacts(int $blockCount, array $checkedBlockTypes, array $findings): self + { return new self( status: array() === $findings ? 'pass' : 'warning', summary: array( - 'block_count' => $structuralReport['summary']['block_count'] ?? 0, + 'block_count' => $blockCount, 'finding_count' => count($findings), - 'checked_block_types' => $structuralReport['summary']['checked_block_types'] ?? array(), + 'checked_block_types' => $checkedBlockTypes, ), findings: $findings ); } + /** @param array> $findings */ + public function withAdditionalFindings(array $findings): self + { + if ( array() === $findings ) { + return $this; + } + + $findings = array_merge($this->findings, $findings); + $summary = $this->summary; + $summary['finding_count'] = count($findings); + + return new self('warning', $summary, $findings); + } + public function withParseFailure(): self { $findings = $this->findings; diff --git a/php-transformer/src/WordPress/BlockValidityValidator.php b/php-transformer/src/WordPress/BlockValidityValidator.php index f952a8993..009deef58 100644 --- a/php-transformer/src/WordPress/BlockValidityValidator.php +++ b/php-transformer/src/WordPress/BlockValidityValidator.php @@ -26,9 +26,8 @@ final class BlockValidityValidator /** * @param array> $blocks - * @return array */ - public function validateBlocks(array $blocks): array + public function evaluateBlocks(array $blocks): BlockValidityEvaluation { $findings = array(); $checkedBlockTypes = array(); @@ -41,16 +40,16 @@ public function validateBlocks(array $blocks): array sort($checkedBlockTypes); - return array( - 'schema' => self::SCHEMA, - 'status' => array() === $findings ? 'pass' : 'warning', - 'summary' => array( - 'block_count' => $this->countBlocks($blocks), - 'finding_count' => count($findings), - 'checked_block_types' => $checkedBlockTypes, - ), - 'findings' => $findings, - ); + return BlockValidityEvaluation::fromStructuralFacts($this->countBlocks($blocks), $checkedBlockTypes, $findings); + } + + /** + * @param array> $blocks + * @return array + */ + public function validateBlocks(array $blocks): array + { + return $this->evaluateBlocks($blocks)->report(); } /** diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index a9d536565..ae08dc278 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -167,7 +167,7 @@ function serialize_blocks(array $blocks): string && array('unexpected') === ($validationDiagnosticsByCode['html_content_round_trip_invented_text']['text'] ?? null), 'required validation outcomes preserve failure diagnostics, existing fallback messages, and mixed severity and location values without retaining verbose report evidence' ); -$notEvaluatedOutcome = HtmlValidationOutcome::fromReports(array(), array(), array()); +$notEvaluatedOutcome = HtmlValidationOutcome::fromValidationFactsAndContentRoundTripReport('not_evaluated', array(), 'not_evaluated', array(), array()); $notEvaluatedDiagnostics = (new DiagnosticsCollector())->collect('Example\\Transformer', array(), array(), array(), array(), array(), $notEvaluatedOutcome); $assert( 'not_evaluated' === $notEvaluatedOutcome->blockValidityStatus diff --git a/php-transformer/tests/contract/runtime-no-wordpress.php b/php-transformer/tests/contract/runtime-no-wordpress.php index 0c7a95b66..8ebcaa8a7 100644 --- a/php-transformer/tests/contract/runtime-no-wordpress.php +++ b/php-transformer/tests/contract/runtime-no-wordpress.php @@ -4,6 +4,7 @@ require dirname(__DIR__, 2) . '/vendor/autoload.php'; use Automattic\BlocksEngine\PhpTransformer\WordPress\Runtime; +use Automattic\BlocksEngine\PhpTransformer\WordPress\BlockValidityValidator; use Automattic\BlocksEngine\PhpTransformer\Contract\ConversionReportProjection; $runtime = new Runtime(); @@ -183,6 +184,10 @@ ), ); $orderedEvaluation = $runtime->evaluateBlockSerialization($invalidButtonAndGroup); +$structuralValidator = new BlockValidityValidator(); +$structuralEvaluation = $structuralValidator->evaluateBlocks($invalidButtonAndGroup); +assertSame($structuralValidator->validateBlocks($invalidButtonAndGroup), $structuralEvaluation->report(), 'Structural report facade should project direct structural evaluation facts.'); +assertSame(array('button_text_markup_mismatch', 'button_url_markup_mismatch'), array_column($structuralEvaluation->findings, 'code'), 'Structural evaluation should exclude composite canonical save-shape findings.'); assertSame(array('button_text_markup_mismatch', 'button_url_markup_mismatch', 'canonical_save_shape_violation'), array_column($orderedEvaluation->findings, 'code'), 'Block validity evaluation should retain structural findings before canonical save-shape findings.'); assertSame(2, $orderedEvaluation->summary['block_count'] ?? null, 'Block validity evaluation should retain structural summary block count.'); assertSame(3, $orderedEvaluation->summary['finding_count'] ?? null, 'Block validity evaluation should count merged findings.');