From 7a71e496215969061fd3e5247784df7282ef2170 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 29 Jun 2026 08:36:57 -0400 Subject: [PATCH] php-transformer: add deterministic render-free static visual-parity gate Replace flaky full-page pixelmatch as the primary visual-parity signal with a deterministic, render-free static style parity gate. The whole import pipeline (transform + CSS materialization) is deterministic, so the parity check is too: same inputs yield a byte-identical 0..1 score plus a per-element / per-property diff naming exactly which CSS property on which selector diverged. No screenshot rasterization, no dimension-sensitivity, no scroll/animation flakiness, no OOM. Extends the existing render-free probe/comparator class (Typography/ButtonMenu) that already runs inside the fast `composer parity` loop: - StaticCssCascade: deterministic, browser-free CSS cascade resolver (specificity-then-source-order, inline override, inheritance) over

Welcome

Get started
", + "target_content": "

Welcome

Get started
" + }, + "expect": [ + { "path": "schema", "assert": "equals", "value": "blocks-engine/php-transformer/visual-parity-report/v1" }, + { "path": "status", "assert": "equals", "value": "pass" }, + { "path": "severity", "assert": "equals", "value": "none" }, + { "path": "parity.score", "assert": "equals", "value": 1.0 }, + { "path": "parity.property_parity", "assert": "equals", "value": 1.0 }, + { "path": "parity.coverage", "assert": "equals", "value": 1.0 }, + { "path": "summary.matched_total", "assert": "equals", "value": 3 }, + { "path": "summary.compared_properties", "assert": "equals", "value": 11 }, + { "path": "summary.agreed_properties", "assert": "equals", "value": 11 }, + { "path": "summary.finding_total", "assert": "equals", "value": 0 }, + { "path": "matches.0.match_tier", "assert": "equals", "value": "selector" } + ] +} diff --git a/php-transformer/tests/fixtures/parity/static-style-parity-compare-mismatch.json b/php-transformer/tests/fixtures/parity/static-style-parity-compare-mismatch.json new file mode 100644 index 00000000..aed3a0cb --- /dev/null +++ b/php-transformer/tests/fixtures/parity/static-style-parity-compare-mismatch.json @@ -0,0 +1,32 @@ +{ + "schema": "blocks-engine/php-transformer/parity-fixture/v1", + "name": "static-style-parity-compare-mismatch", + "description": "When the candidate drops effective styling (an unstyled hero section and a button that lost its fill and radius), the deterministic static parity comparator reports a failing score and names exactly which CSS property on which selector diverged.", + "source_reference": { + "repo": "php-transformer", + "path": "tests/fixtures/parity/static-style-parity-compare-mismatch.json", + "notes": "Regression case proves per-element / per-property actionability, replacing an opaque pixel ratio." + }, + "legacy_comparison": { + "skip": true, + "reason": "Static style parity comparison emits the shared visual-parity report contract." + }, + "operation": "static_style_parity.compare", + "input": { + "source_content": "

Welcome

Get started
", + "target_content": "

Welcome

Get started
" + }, + "expect": [ + { "path": "schema", "assert": "equals", "value": "blocks-engine/php-transformer/visual-parity-report/v1" }, + { "path": "status", "assert": "equals", "value": "fail" }, + { "path": "severity", "assert": "equals", "value": "error" }, + { "path": "parity.score", "assert": "equals", "value": 0.5455 }, + { "path": "parity.coverage", "assert": "equals", "value": 1.0 }, + { "path": "summary.matched_total", "assert": "equals", "value": 3 }, + { "path": "summary.finding_total", "assert": "equals", "value": 5 }, + { "path": "findings.0.property", "assert": "equals", "value": "background-color" }, + { "path": "findings.0.selector", "assert": "equals", "value": "section.hero" }, + { "path": "findings.0.source_value", "assert": "equals", "value": "#0a0a0a" }, + { "path": "matches.2.style_deltas.1.property", "assert": "equals", "value": "border-radius" } + ] +} diff --git a/php-transformer/tests/fixtures/parity/static-style-parity-extract.json b/php-transformer/tests/fixtures/parity/static-style-parity-extract.json new file mode 100644 index 00000000..e4e5bcef --- /dev/null +++ b/php-transformer/tests/fixtures/parity/static-style-parity-extract.json @@ -0,0 +1,31 @@ +{ + "schema": "blocks-engine/php-transformer/parity-fixture/v1", + "name": "static-style-parity-extract", + "description": "Render-free static style parity probe extracts a deterministic effective-style fingerprint per styleable element from HTML + author CSS (no browser, no rasterization).", + "source_reference": { + "repo": "php-transformer", + "path": "tests/fixtures/parity/static-style-parity-extract.json", + "notes": "Generic PHP-boundary static style extraction; cascade + inheritance resolved statically." + }, + "legacy_comparison": { + "skip": true, + "reason": "Static style parity extraction is a deterministic diagnostic fingerprint, not a block-conversion output." + }, + "operation": "static_style_parity.extract", + "input": { + "content": "

Welcome

Get started
" + }, + "expect": [ + { "path": "schema", "assert": "equals", "value": "blocks-engine/php-transformer/static-style-parity-probes/v1" }, + { "path": "status", "assert": "equals", "value": "success" }, + { "path": "summary.total", "assert": "equals", "value": 3 }, + { "path": "summary.styled_total", "assert": "equals", "value": 3 }, + { "path": "probes.0.tag", "assert": "equals", "value": "section" }, + { "path": "probes.0.selector", "assert": "equals", "value": "section.hero" }, + { "path": "probes.0.structural_path", "assert": "equals", "value": "section:1" }, + { "path": "probes.0.style.background-color", "assert": "equals", "value": "#0a0a0a" }, + { "path": "probes.0.style.color", "assert": "equals", "value": "#fff" }, + { "path": "probes.2.selector", "assert": "equals", "value": "section.hero > a.btn" }, + { "path": "probes.2.style.border-radius", "assert": "equals", "value": "8px" } + ] +} diff --git a/php-transformer/tests/parity/run.php b/php-transformer/tests/parity/run.php index e97baed4..8221d1f2 100644 --- a/php-transformer/tests/parity/run.php +++ b/php-transformer/tests/parity/run.php @@ -8,6 +8,8 @@ use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\HtmlTransformer; use Automattic\BlocksEngine\PhpTransformer\VisualParity\ButtonMenuVisualProbe; use Automattic\BlocksEngine\PhpTransformer\VisualParity\ButtonMenuVisualProbeComparator; +use Automattic\BlocksEngine\PhpTransformer\VisualParity\StaticStyleParityComparator; +use Automattic\BlocksEngine\PhpTransformer\VisualParity\StaticStyleParityProbe; use Automattic\BlocksEngine\PhpTransformer\VisualParity\TypographyVisualProbe; use Automattic\BlocksEngine\PhpTransformer\VisualParity\TypographyVisualProbeComparator; @@ -326,6 +328,23 @@ function runFixture(array $fixture): array return $report; } + if ( 'static_style_parity.extract' === $fixture['operation'] ) { + return ( new StaticStyleParityProbe() )->extract( + (string) ($input['content'] ?? ''), + (string) ($input['css'] ?? '') + ); + } + + if ( 'static_style_parity.compare' === $fixture['operation'] ) { + $probe = new StaticStyleParityProbe(); + $report = ( new StaticStyleParityComparator() )->compare( + $probe->extract((string) ($input['source_content'] ?? ''), (string) ($input['source_css'] ?? '')), + $probe->extract((string) ($input['target_content'] ?? ''), (string) ($input['target_css'] ?? '')) + ); + \Automattic\BlocksEngine\PhpTransformer\Contract\VisualParityReportContract::assertReport($report); + return $report; + } + fail("Fixture {$fixture['name']} declares unsupported operation: {$fixture['operation']}"); } diff --git a/php-transformer/tools/static-parity/run.php b/php-transformer/tools/static-parity/run.php new file mode 100644 index 00000000..57ae36b1 --- /dev/null +++ b/php-transformer/tools/static-parity/run.php @@ -0,0 +1,207 @@ +] [--fixture ] + * [--entry ] [--json] [--top ] + * [--fail-under ] + * + * Options: + * --corpus Corpus root (default: /fixtures/websites). + * --fixture Limit to a single fixture directory name (e.g. 15-saas). + * --entry Entry HTML filename within each fixture (default: auto; + * prefers index.html, else the first *.html). + * --json Emit the machine-readable JSON report to stdout. + * --top Number of lowest-scoring fixtures to print (default: 25). + * --fail-under Exit non-zero if any evaluated fixture scores below . + * Omit to run in report-only mode (always exit 0). + */ +declare(strict_types=1); + +require dirname(__DIR__, 2) . '/vendor/autoload.php'; + +use Automattic\BlocksEngine\PhpTransformer\VisualParity\StaticStyleParityRunner; + +$options = parseArguments($argv); +$repoRoot = dirname(__DIR__, 3); +$corpusDir = $options['corpus'] ?? $repoRoot . '/fixtures/websites'; +if ( ! is_dir($corpusDir) ) { + fwrite(STDERR, "Corpus directory not found: {$corpusDir}\n"); + exit(2); +} + +$runner = new StaticStyleParityRunner(); +$results = array(); + +foreach ( discoverFixtures($corpusDir, $options['fixture'] ?? null) as $fixture ) { + $entry = resolveEntry($fixture['dir'], $options['entry'] ?? null); + if ( null === $entry ) { + continue; + } + $sourceHtml = (string) file_get_contents($entry); + $css = authorCss($sourceHtml, dirname($entry)); + $report = $runner->compareSourceToTransform($sourceHtml, $css); + $parity = is_array($report['parity'] ?? null) ? $report['parity'] : array(); + $summary = is_array($report['summary'] ?? null) ? $report['summary'] : array(); + $results[] = array( + 'fixture' => $fixture['id'], + 'entry' => basename($entry), + 'status' => (string) ($report['status'] ?? 'unknown'), + 'score' => (float) ($parity['score'] ?? 0.0), + 'property_parity' => (float) ($parity['property_parity'] ?? 0.0), + 'coverage' => (float) ($parity['coverage'] ?? 0.0), + 'source_total' => (int) ($summary['source_total'] ?? 0), + 'matched_total' => (int) ($summary['matched_total'] ?? 0), + 'finding_total' => (int) ($summary['finding_total'] ?? 0), + ); +} + +usort($results, static fn (array $a, array $b): int => $a['score'] <=> $b['score'] ?: strcmp($a['fixture'], $b['fixture'])); + +$report = array( + 'schema' => 'blocks-engine/php-transformer/static-parity-gate-report/v1', + 'corpus_dir' => $corpusDir, + 'fixture_count' => count($results), + 'results' => $results, +); + +if ( isset($options['json']) ) { + $json = json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + fwrite(STDOUT, ( false === $json ? '{}' : $json ) . "\n"); +} else { + fwrite(STDOUT, renderSummary($results, (int) ($options['top'] ?? 25))); +} + +if ( array_key_exists('fail-under', $options) ) { + $threshold = (float) $options['fail-under']; + $failed = array_filter($results, static fn (array $r): bool => $r['score'] < $threshold); + if ( array() !== $failed ) { + fwrite(STDERR, sprintf("\nStatic parity gate FAILED: %d fixture(s) below score %.4f.\n", count($failed), $threshold)); + foreach ( $failed as $r ) { + fwrite(STDERR, sprintf(" %-28s score=%.4f coverage=%.4f findings=%d\n", $r['fixture'], $r['score'], $r['coverage'], $r['finding_total'])); + } + exit(1); + } + fwrite(STDOUT, sprintf("\nStatic parity gate PASSED: all %d fixture(s) >= score %.4f.\n", count($results), $threshold)); +} + +exit(0); + +/** + * @param array $argv + * @return array + */ +function parseArguments(array $argv): array +{ + $options = array(); + $count = count($argv); + for ( $i = 1; $i < $count; $i++ ) { + $arg = $argv[$i]; + if ( '--json' === $arg ) { + $options['json'] = '1'; + continue; + } + if ( str_starts_with($arg, '--') ) { + $key = substr($arg, 2); + $value = ($i + 1 < $count && ! str_starts_with($argv[$i + 1], '--')) ? $argv[++$i] : '1'; + $options[$key] = $value; + } + } + + return $options; +} + +/** + * @return array + */ +function discoverFixtures(string $corpusDir, ?string $only): array +{ + $fixtures = array(); + foreach ( (array) glob($corpusDir . '/*', GLOB_ONLYDIR) as $dir ) { + $id = basename((string) $dir); + if ( null !== $only && $id !== $only ) { + continue; + } + $fixtures[] = array('id' => $id, 'dir' => (string) $dir); + } + usort($fixtures, static fn (array $a, array $b): int => strcmp($a['id'], $b['id'])); + + return $fixtures; +} + +function resolveEntry(string $dir, ?string $entry): ?string +{ + if ( null !== $entry ) { + $path = $dir . '/' . $entry; + return is_file($path) ? $path : null; + } + if ( is_file($dir . '/index.html') ) { + return $dir . '/index.html'; + } + $candidates = (array) glob($dir . '/*.html'); + sort($candidates); + + return array() !== $candidates ? (string) $candidates[0] : null; +} + +function authorCss(string $html, string $dir): string +{ + $css = ''; + if ( preg_match_all('/]*>(.*?)<\/style>/is', $html, $styles) ) { + $css .= implode("\n", $styles[1]); + } + if ( preg_match_all('/]*rel=["\']stylesheet["\'][^>]*>/i', $html, $links) ) { + foreach ( $links[0] as $tag ) { + if ( ! preg_match('/href=["\']([^"\']+)["\']/i', $tag, $href) ) { + continue; + } + if ( preg_match('#^https?://#i', $href[1]) ) { + continue; + } + $path = $dir . '/' . ltrim($href[1], '/'); + if ( is_file($path) ) { + $css .= "\n" . (string) file_get_contents($path); + } + } + } + + return $css; +} + +/** + * @param array> $results + */ +function renderSummary(array $results, int $top): string +{ + $out = sprintf("Static visual-parity gate — %d fixture(s) (render-free, deterministic)\n", count($results)); + $out .= str_repeat('-', 92) . "\n"; + $out .= sprintf("%-28s %-8s %8s %10s %9s %8s %9s\n", 'fixture', 'status', 'score', 'prop-par', 'coverage', 'matched', 'findings'); + $out .= str_repeat('-', 92) . "\n"; + foreach ( array_slice($results, 0, $top) as $r ) { + $out .= sprintf( + "%-28s %-8s %8.4f %10.4f %9.4f %8s %9d\n", + $r['fixture'], + $r['status'], + $r['score'], + $r['property_parity'], + $r['coverage'], + $r['matched_total'] . '/' . $r['source_total'], + $r['finding_total'] + ); + } + + return $out; +}