From abd85ca4904ffadd74c67c9fbb4cc7f3c0ede8bf Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 21 Jun 2026 12:25:16 -0400 Subject: [PATCH] Enforce canonical php-transformer result contracts --- .../src/ArtifactCompiler/ArtifactCompiler.php | 13 ----- .../src/Contract/TransformerResult.php | 55 +++++++++++++++++-- .../src/FormatBridge/FormatBridge.php | 42 +++++++++++++- php-transformer/tests/contract/run.php | 33 ++++++++++- 4 files changed, 122 insertions(+), 21 deletions(-) diff --git a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php index 16a9fc4..a771b04 100644 --- a/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php +++ b/php-transformer/src/ArtifactCompiler/ArtifactCompiler.php @@ -97,19 +97,6 @@ public function compile(array $artifact): TransformerResult components: $components, blockTypes: $blockTypes, sourceReports: $sourceReports, - legacyMapping: array( - 'block-artifact-compiler/result/v1' => array( - 'status' => 'status', - 'input' => 'source_reports.artifact', - 'wordpress_artifacts.block_markup' => 'serialized_blocks', - 'wordpress_artifacts.blocks' => 'blocks', - 'wordpress_artifacts.block_types' => 'block_types', - 'wordpress_artifacts.components' => 'components', - 'wordpress_artifacts.files' => 'assets', - 'diagnostics' => 'diagnostics', - 'provenance' => 'provenance', - ), - ), blocks: $entryBlocks['blocks'], serializedBlocks: $serializedBlocks, documents: $documents['documents'], diff --git a/php-transformer/src/Contract/TransformerResult.php b/php-transformer/src/Contract/TransformerResult.php index 34627ab..7ee6cee 100644 --- a/php-transformer/src/Contract/TransformerResult.php +++ b/php-transformer/src/Contract/TransformerResult.php @@ -3,6 +3,8 @@ namespace Automattic\BlocksEngine\PhpTransformer\Contract; +use InvalidArgumentException; + final class TransformerResult { public const SCHEMA = 'blocks-engine/php-transformer/result/v1'; @@ -11,7 +13,6 @@ final class TransformerResult * @param array> $components * @param array> $blockTypes * @param array $sourceReports - * @param array $legacyMapping * @param array> $blocks * @param array> $documents * @param array> $assets @@ -27,7 +28,6 @@ public function __construct( public readonly array $components = array(), public readonly array $blockTypes = array(), public readonly array $sourceReports = array(), - public readonly array $legacyMapping = array(), public readonly array $blocks = array(), public readonly string $serializedBlocks = '', public readonly array $documents = array(), @@ -46,13 +46,12 @@ public function __construct( */ public function toArray(): array { - return array( + $result = array( 'schema' => self::SCHEMA, 'status' => $this->status, 'components' => $this->components, 'block_types' => $this->blockTypes, 'source_reports' => $this->sourceReports, - 'legacy_mapping' => $this->legacyMapping, 'blocks' => $this->blocks, 'serialized_blocks' => $this->serializedBlocks, 'documents' => $this->documents, @@ -64,5 +63,53 @@ public function toArray(): array 'context' => $this->context, 'metrics' => $this->metrics, ); + + self::assertCanonicalEnvelope($result); + + return $result; + } + + /** + * Validate the public result shape downstream wrappers should depend on. + * + * @param array $result + */ + public static function assertCanonicalEnvelope(array $result, bool $requireMaterializationPlan = false): void + { + foreach ( array( 'schema', 'status', 'source_reports', 'blocks', 'serialized_blocks', 'documents', 'assets', 'diagnostics', 'fallbacks', 'provenance', 'coverage', 'context', 'metrics' ) as $key ) { + if ( ! array_key_exists($key, $result) ) { + throw new InvalidArgumentException(sprintf('Canonical transformer result is missing "%s".', $key)); + } + } + + if ( self::SCHEMA !== $result['schema'] ) { + throw new InvalidArgumentException('Canonical transformer result has an unsupported schema.'); + } + + if ( array_key_exists('legacy_mapping', $result) ) { + throw new InvalidArgumentException('Canonical transformer result must not expose compatibility-only legacy_mapping.'); + } + + if ( ! is_array($result['source_reports']) ) { + throw new InvalidArgumentException('Canonical transformer result source_reports must be an array.'); + } + + $sourceReports = $result['source_reports']; + $conversionReport = $sourceReports['conversion_report'] ?? null; + if ( ! is_array($conversionReport) ) { + throw new InvalidArgumentException('Canonical transformer result is missing source_reports.conversion_report.'); + } + + if ( ConversionReportProjection::SCHEMA !== ($conversionReport['schema'] ?? null) ) { + throw new InvalidArgumentException('Canonical transformer result has an unsupported conversion report schema.'); + } + + $artifactLike = isset($sourceReports['artifact']) || isset($sourceReports['compiled_site']) || 'artifact' === ($conversionReport['source_format'] ?? null); + if ( $requireMaterializationPlan || $artifactLike ) { + $materializationPlan = $sourceReports['materialization_plan'] ?? null; + if ( ! is_array($materializationPlan) ) { + throw new InvalidArgumentException('Canonical artifact result is missing source_reports.materialization_plan.'); + } + } } } diff --git a/php-transformer/src/FormatBridge/FormatBridge.php b/php-transformer/src/FormatBridge/FormatBridge.php index 8caaacb..b232c2a 100644 --- a/php-transformer/src/FormatBridge/FormatBridge.php +++ b/php-transformer/src/FormatBridge/FormatBridge.php @@ -3,6 +3,7 @@ namespace Automattic\BlocksEngine\PhpTransformer\FormatBridge; +use Automattic\BlocksEngine\PhpTransformer\Contract\ConversionReportProjection; use Automattic\BlocksEngine\PhpTransformer\Contract\TransformationOptions; use Automattic\BlocksEngine\PhpTransformer\Contract\TransformerResult; use InvalidArgumentException; @@ -130,8 +131,25 @@ public function convertResult(string $content, string $from, string $to, array $ $normalizedContent = $this->normalize($content, $from, $options); $blocks = array_values($sourceAdapter->toBlocks($normalizedContent, $options)); $output = $from === $to ? $normalizedContent : $targetAdapter->fromBlocks($blocks, $options); + $metrics = array( + 'input_bytes' => strlen($content), + 'output_bytes' => strlen($output), + 'block_count' => count($blocks), + 'fallback_count' => 0, + 'diagnostic_count' => 1, + ); + $sourceReports = array( + 'format_bridge' => array( + 'source_format' => $from, + 'target_format' => $to, + 'input_bytes' => strlen($content), + 'output_bytes' => strlen($output), + ), + ); + $sourceReports['conversion_report'] = ConversionReportProjection::fromResultParts($from, $blocks, array(), $sourceReports, array(), $provenance, $metrics); return new TransformerResult( + sourceReports: $sourceReports, blocks: $blocks, serializedBlocks: 'blocks' === $to ? $output : '', documents: array( @@ -148,7 +166,8 @@ public function convertResult(string $content, string $from, string $to, array $ ), ), provenance: $provenance, - context: $context + context: $context, + metrics: $metrics ); } catch ( InvalidArgumentException $exception ) { return $this->failedResult('format_bridge_validation_failed', $exception->getMessage(), $provenance, $context); @@ -163,8 +182,26 @@ public function convertResult(string $content, string $from, string $to, array $ */ private function failedResult(string $code, string $message, array $provenance, array $context): TransformerResult { + $metrics = array( + 'input_bytes' => (int) ($provenance[0]['input_bytes'] ?? 0), + 'block_count' => 0, + 'fallback_count' => 0, + 'diagnostic_count' => 1, + 'output_bytes' => 0, + ); + $sourceFormat = (string) ($provenance[0]['source_format'] ?? 'unknown'); + $sourceReports = array( + 'format_bridge' => array( + 'source_format' => $sourceFormat, + 'target_format' => (string) ($provenance[0]['target_format'] ?? ''), + 'error_code' => $code, + ), + ); + $sourceReports['conversion_report'] = ConversionReportProjection::fromResultParts($sourceFormat, array(), array(), $sourceReports, array(), $provenance, $metrics); + return new TransformerResult( status: 'failed', + sourceReports: $sourceReports, diagnostics: array( array( 'code' => $code, @@ -173,7 +210,8 @@ private function failedResult(string $code, string $message, array $provenance, ), ), provenance: $provenance, - context: $context + context: $context, + metrics: $metrics ); } } diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index 0d10191..1f5cf17 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -48,14 +48,34 @@ function serialize_blocks(array $blocks): string exit(1); }; +$assertInvalidCanonicalEnvelope = static function (array $result, string $expectedMessage, string $message, bool $requireMaterializationPlan = false) use ($assert): void { + try { + TransformerResult::assertCanonicalEnvelope($result, $requireMaterializationPlan); + } catch ( \InvalidArgumentException $exception ) { + $assert(str_contains($exception->getMessage(), $expectedMessage), $message, $exception->getMessage()); + return; + } + + $assert(false, $message, 'Canonical envelope validation unexpectedly passed.'); +}; + $fixture = file_get_contents(dirname(__DIR__) . '/fixtures/simple-html.html'); $result = ( new HtmlTransformer() )->transform($fixture . "\n
  • One
  • Two
")->toArray(); $assert(TransformerResult::SCHEMA === $result['schema'], 'result exposes schema'); +TransformerResult::assertCanonicalEnvelope($result); -foreach ( array( 'status', 'components', 'block_types', 'source_reports', 'legacy_mapping', 'blocks', 'serialized_blocks', 'documents', 'assets', 'diagnostics', 'fallbacks', 'provenance', 'coverage', 'context', 'metrics' ) as $key ) { +foreach ( array( 'status', 'components', 'block_types', 'source_reports', 'blocks', 'serialized_blocks', 'documents', 'assets', 'diagnostics', 'fallbacks', 'provenance', 'coverage', 'context', 'metrics' ) as $key ) { $assert(array_key_exists($key, $result), "Missing result key: {$key}"); } +$assert(! array_key_exists('legacy_mapping', $result), 'canonical result omits compatibility-only legacy mapping'); +$assertInvalidCanonicalEnvelope(array_merge($result, array('legacy_mapping' => array())), 'legacy_mapping', 'canonical validation rejects legacy mapping aliases'); + +$missingConversionReport = $result; +unset($missingConversionReport['source_reports']['conversion_report']); +$assertInvalidCanonicalEnvelope($missingConversionReport, 'source_reports.conversion_report', 'canonical validation rejects results without conversion reports'); + +$assertInvalidCanonicalEnvelope($result, 'source_reports.materialization_plan', 'canonical validation can require materialization plans for downstream artifact consumers', true); $contextual = ( new HtmlTransformer() )->transform( '

Context

', @@ -118,11 +138,12 @@ function serialize_blocks(array $blocks): string 'generated_html' => '

Hello artifact

', ) )->toArray(); +TransformerResult::assertCanonicalEnvelope($simple); $assert('success' === $simple['status'], 'simple artifact compiles successfully', (string) $simple['status']); $assert('index.html' === ($simple['source_reports']['artifact']['entry_path'] ?? ''), 'generated HTML becomes an index entry'); $assert(str_contains((string) $simple['serialized_blocks'], ''), 'HTML is preserved as serialized block markup'); $assert('hero' === ($simple['components'][0]['name'] ?? ''), 'component candidates are exposed'); -$assert(is_array($simple['legacy_mapping'] ?? null), 'migration mapping metadata is exposed'); +$assert(! array_key_exists('legacy_mapping', $simple), 'artifact result omits compatibility-only legacy mapping'); $assert(strlen('

Hello artifact

') === ($simple['metrics']['input_bytes'] ?? null), 'artifact metrics expose input bytes'); $assert(strlen((string) $simple['serialized_blocks']) === ($simple['metrics']['output_bytes'] ?? null), 'artifact metrics expose output bytes'); $assert(0 === ($simple['metrics']['block_count'] ?? null), 'artifact metrics expose block count'); @@ -134,6 +155,14 @@ function serialize_blocks(array $blocks): string $assert(1 === ($simple['source_reports']['materialization_plan']['totals']['pages'] ?? null), 'materialization plan counts pages'); $assert('index' === ($simple['source_reports']['materialization_plan']['pages'][0]['slug'] ?? ''), 'materialization plan exposes page slug'); +$missingMaterializationPlan = $simple; +unset($missingMaterializationPlan['source_reports']['materialization_plan']); +$assertInvalidCanonicalEnvelope($missingMaterializationPlan, 'source_reports.materialization_plan', 'canonical validation rejects artifact results without materialization plans'); + +$formatResult = ( new FormatBridge() )->convertResult('# Format report', 'markdown', 'blocks')->toArray(); +TransformerResult::assertCanonicalEnvelope($formatResult); +$assert('blocks-engine/php-transformer/conversion-report/v1' === ($formatResult['source_reports']['conversion_report']['schema'] ?? ''), 'format bridge exposes canonical conversion report'); + $staticSite = $compiler->compile( array( 'entrypoint' => 'index.html',