diff --git a/php-transformer/composer.json b/php-transformer/composer.json index 0d9e4cfe..af3e9c21 100644 --- a/php-transformer/composer.json +++ b/php-transformer/composer.json @@ -36,6 +36,7 @@ }, "scripts": { "parity": "@test:parity", + "static-parity": "php tools/static-parity/run.php", "corpus-diagnostics": "php tools/corpus-diagnostics/run.php", "test": [ "@test:canonical", diff --git a/php-transformer/src/VisualParity/StaticCssCascade.php b/php-transformer/src/VisualParity/StaticCssCascade.php new file mode 100644 index 00000000..354520a0 --- /dev/null +++ b/php-transformer/src/VisualParity/StaticCssCascade.php @@ -0,0 +1,281 @@ + block, + * plus any explicitly supplied CSS such as a linked stylesheet inlined by the + * caller) and the element's inline style attribute, then applying CSS + * inheritance for inheritable properties from the nearest declaring ancestor. + * + * This is a deterministic, browser-free approximation of getComputedStyle for + * the subset of author-declared properties that visual parity cares about: same + * input HTML+CSS always yields byte-identical output. It does NOT compute used + * layout geometry (box sizes, resolved lengths) — only the cascaded *declared* + * values — which is exactly the contract a static parity signal needs: "does the + * same effective styling apply to the same content". + * + * Mirrors the proven selector engine in {@see TypographyVisualProbe} and adds + * deterministic specificity-then-source-order cascade ordering so the highest + * specificity declaration wins, with inline styles overriding all author rules. + */ +final class StaticCssCascade +{ + /** + * @var array, specificity: int, order: int}> + */ + private array $rules; + + public function __construct(DOMDocument $document, string $extraCss = '') + { + $this->rules = $this->buildRules($document, $extraCss); + } + + /** + * Resolve the effective declared style for the requested properties. + * + * @param array $properties Properties to resolve (lowercase). + * @param array $inheritable Subset of $properties that inherit. + * @return array property => declared value (ksorted) + */ + public function resolve(DOMElement $element, array $properties, array $inheritable): array + { + $style = $this->cascadedStyle($element); + + foreach ( $inheritable as $field ) { + if ( isset($style[$field]) && '' !== trim($style[$field]) ) { + continue; + } + for ( $node = $element->parentNode; $node instanceof DOMElement; $node = $node->parentNode ) { + $ancestor = $this->cascadedStyle($node); + if ( isset($ancestor[$field]) && '' !== trim($ancestor[$field]) ) { + $style[$field] = $ancestor[$field]; + break; + } + } + } + + $style = array_intersect_key($style, array_flip($properties)); + ksort($style); + + return $style; + } + + /** + * Merge every matching author rule in (specificity, source-order) order, then + * apply the inline style attribute on top. + * + * @return array + */ + private function cascadedStyle(DOMElement $element): array + { + $matched = array(); + foreach ( $this->rules as $rule ) { + if ( $this->matchesSimpleSelector($element, $rule['selector']) ) { + $matched[] = $rule; + } + } + + usort($matched, static function (array $a, array $b): int { + return $a['specificity'] <=> $b['specificity'] ?: $a['order'] <=> $b['order']; + }); + + $style = array(); + foreach ( $matched as $rule ) { + $style = array_merge($style, $rule['declarations']); + } + + if ( $element->hasAttribute('style') ) { + $style = array_merge($style, $this->declarations($element->getAttribute('style'))); + } + + return $style; + } + + /** + * @return array, specificity: int, order: int}> + */ + private function buildRules(DOMDocument $document, string $extraCss): array + { + $rules = array(); + $order = 0; + + $cssBlocks = array(); + if ( '' !== trim($extraCss) ) { + $cssBlocks[] = $extraCss; + } + foreach ( $document->getElementsByTagName('style') as $style ) { + $cssBlocks[] = (string) $style->textContent; + } + + foreach ( $cssBlocks as $css ) { + $css = $this->stripAtRuleBlocks($css); + if ( ! preg_match_all('/([^{}]+)\{([^{}]+)\}/', $css, $matches, PREG_SET_ORDER) ) { + continue; + } + foreach ( $matches as $match ) { + $declarations = $this->declarations((string) $match[2]); + if ( array() === $declarations ) { + continue; + } + foreach ( explode(',', (string) $match[1]) as $selector ) { + $selector = trim($selector); + if ( '' === $selector ) { + continue; + } + $rules[] = array( + 'selector' => $selector, + 'declarations' => $declarations, + 'specificity' => $this->specificity($selector), + 'order' => $order++, + ); + } + } + } + + return $rules; + } + + /** + * Remove at-rule prelude tokens (@media/@supports/@font-face headers and + * @keyframes blocks) that would otherwise corrupt the flat rule grammar. + * The nested rules inside @media/@supports are intentionally kept (flattened) + * because they still declare effective style; @keyframes interiors are dropped + * because their "selectors" (0%, to, from) are not element selectors. + */ + private function stripAtRuleBlocks(string $css): string + { + // Drop @keyframes blocks (including nested braces) entirely. + $css = preg_replace('/@(?:-webkit-|-moz-|-o-)?keyframes\b[^{]*\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/i', '', $css) ?? $css; + // Drop @font-face / @import / @charset prelude+block which carry no element rules. + $css = preg_replace('/@font-face\b[^{]*\{[^{}]*\}/i', '', $css) ?? $css; + $css = preg_replace('/@(?:import|charset)\b[^;]*;/i', '', $css) ?? $css; + // Unwrap @media/@supports/@layer wrappers, keeping their inner rules. + $css = preg_replace('/@(?:media|supports|layer)\b[^{]*\{/i', '', $css) ?? $css; + + return $css; + } + + /** + * Deterministic specificity heuristic: 100 per #id, 10 per .class/[attr]/ + * pseudo-class, 1 per element/pseudo-element. Inline styles are applied + * separately and always win. + */ + private function specificity(string $selector): int + { + $selector = trim(preg_replace('/::?(hover|focus|active|visited|before|after)\b[^ ]*/', '', $selector) ?? $selector); + $ids = preg_match_all('/#[A-Za-z0-9_-]+/', $selector); + $classes = preg_match_all('/\.[A-Za-z0-9_-]+|\[[^\]]+\]/', $selector); + $bare = preg_replace('/[#.][A-Za-z0-9_-]+|\[[^\]]+\]|[>+~]/', ' ', $selector) ?? $selector; + $elements = preg_match_all('/[A-Za-z][A-Za-z0-9_-]*/', $bare); + + return ( (int) $ids * 100 ) + ( (int) $classes * 10 ) + (int) $elements; + } + + /** + * @return array + */ + private function declarations(string $style): array + { + $declarations = array(); + foreach ( explode(';', $style) as $declaration ) { + if ( ! str_contains($declaration, ':') ) { + continue; + } + [$name, $value] = array_map('trim', explode(':', $declaration, 2)); + $name = strtolower($name); + $value = preg_replace('/\s*!important\s*$/i', '', $value) ?? $value; + if ( '' !== $name && '' !== $value ) { + $declarations[$name] = preg_replace('/\s+/', ' ', $value) ?? $value; + } + } + + return $declarations; + } + + private function matchesSimpleSelector(DOMElement $element, string $selector): bool + { + $selector = trim(preg_replace('/:(hover|focus|active|visited|before|after)\b.*/', '', $selector) ?? $selector); + if ( '' === $selector || str_contains($selector, '>') || str_contains($selector, '+') || str_contains($selector, '~') || str_contains($selector, '[') ) { + return false; + } + + if ( str_contains($selector, ' ') ) { + return $this->matchesDescendantSelector($element, $selector); + } + + if ( '*' === $selector ) { + return true; + } + + if ( preg_match('/^#([A-Za-z0-9_-]+)$/', $selector, $match) ) { + return $element->hasAttribute('id') && $element->getAttribute('id') === $match[1]; + } + + if ( preg_match('/^\.([A-Za-z0-9_-]+)$/', $selector, $match) ) { + return in_array($match[1], $this->tokens($element->hasAttribute('class') ? $element->getAttribute('class') : ''), true); + } + + if ( preg_match('/^([A-Za-z0-9_-]+)(\.[A-Za-z0-9_-]+)+$/', $selector) ) { + $parts = explode('.', $selector); + $tag = array_shift($parts); + if ( strtolower((string) $tag) !== strtolower($element->tagName) ) { + return false; + } + $classes = $this->tokens($element->hasAttribute('class') ? $element->getAttribute('class') : ''); + foreach ( $parts as $class ) { + if ( ! in_array($class, $classes, true) ) { + return false; + } + } + + return true; + } + + if ( ! preg_match('/^[A-Za-z][A-Za-z0-9_-]*$/', $selector) ) { + return false; + } + + return strtolower($selector) === strtolower($element->tagName); + } + + private function matchesDescendantSelector(DOMElement $element, string $selector): bool + { + $parts = preg_split('/\s+/', trim($selector)) ?: array(); + if ( array() === $parts || ! $this->matchesSimpleSelector($element, array_pop($parts)) ) { + return false; + } + + $current = $element->parentNode instanceof DOMElement ? $element->parentNode : null; + for ( $index = count($parts) - 1; $index >= 0; --$index ) { + $matched = false; + for ( $node = $current; $node instanceof DOMElement; $node = $node->parentNode instanceof DOMElement ? $node->parentNode : null ) { + if ( $this->matchesSimpleSelector($node, $parts[$index]) ) { + $matched = true; + $current = $node->parentNode instanceof DOMElement ? $node->parentNode : null; + break; + } + } + if ( ! $matched ) { + return false; + } + } + + return true; + } + + /** + * @return array + */ + private function tokens(string $value): array + { + return array_values(array_filter(preg_split('/\s+/', trim($value)) ?: array(), static fn (string $token): bool => '' !== $token)); + } +} diff --git a/php-transformer/src/VisualParity/StaticStyleParityComparator.php b/php-transformer/src/VisualParity/StaticStyleParityComparator.php new file mode 100644 index 00000000..98af21a6 --- /dev/null +++ b/php-transformer/src/VisualParity/StaticStyleParityComparator.php @@ -0,0 +1,407 @@ + className) and semantic + * tags, source and candidate elements are matched by stable identity in a fixed + * tier order, consuming the first still-unmatched candidate in document order: + * + * 1. selector — identical stable selector path (tag + id/classes + nth-of-type) + * 2. class+text — same tag, same sorted class set, same trimmed text + * 3. class — same tag, same sorted class set + * 4. structural — same class-free structural path (tag + nth-of-type chain) + * + * Same inputs -> byte-identical report on every run. No screenshots, no + * dimensions, no scroll/animation flakiness, no OOM. + */ +final class StaticStyleParityComparator +{ + public const DEFAULT_WARN_AT = 0.98; + public const DEFAULT_FAIL_AT = 0.85; + + /** + * Match tiers in priority order. Each maps a tier id to a confidence weight. + */ + private const TIERS = array( + 'selector' => 1.0, + 'class+text' => 0.9, + 'class' => 0.75, + 'structural' => 0.6, + ); + + private float $warnAt; + private float $failAt; + + public function __construct(float $warnAt = self::DEFAULT_WARN_AT, float $failAt = self::DEFAULT_FAIL_AT) + { + $this->warnAt = $warnAt; + $this->failAt = $failAt; + } + + /** + * @param array $source + * @param array $target + * @return array + */ + public function compare(array $source, array $target): array + { + $sourceProbes = $this->probes($source); + $targetProbes = $this->probes($target); + $available = array_fill_keys(array_keys($targetProbes), true); + + $matches = array(); + $findings = array(); + $recommendations = array(); + $unmatchedSource = array(); + + $comparedProperties = 0; + $agreedProperties = 0; + + foreach ( $sourceProbes as $sourceProbe ) { + [$targetIndex, $tier] = $this->bestTargetIndex($sourceProbe, $targetProbes, $available); + if ( null === $targetIndex ) { + $unmatchedSource[] = $this->candidateSummary($sourceProbe); + $finding = $this->missingElementFinding($sourceProbe, count($findings) + 1); + $recommendation = $this->recommendationFor($finding, count($recommendations) + 1); + $finding['recommendation_ids'] = array($recommendation['id']); + $findings[] = $finding; + $recommendations[] = $recommendation; + continue; + } + + unset($available[$targetIndex]); + $targetProbe = $targetProbes[$targetIndex]; + + $sourceStyle = $this->style($sourceProbe); + $deltas = array(); + foreach ( self::sortedKeys($sourceStyle) as $property ) { + $sourceValue = $this->normalizeValue($property, (string) $sourceStyle[$property]); + if ( '' === $sourceValue ) { + continue; + } + ++$comparedProperties; + $targetRaw = (string) ($this->style($targetProbe)[$property] ?? ''); + $targetValue = $this->normalizeValue($property, $targetRaw); + if ( $sourceValue === $targetValue ) { + ++$agreedProperties; + continue; + } + $deltas[] = array( + 'property' => $property, + 'source' => (string) $sourceStyle[$property], + 'target' => $targetRaw, + ); + } + + $matches[] = array( + 'kind' => 'generic', + 'source_selector' => $this->selector($sourceProbe), + 'target_selector' => $this->selector($targetProbe), + 'confidence' => self::TIERS[$tier], + 'match_tier' => $tier, + 'style_deltas' => $deltas, + ); + + foreach ( $deltas as $delta ) { + $finding = $this->deltaFinding($sourceProbe, $delta, count($findings) + 1); + $recommendation = $this->recommendationFor($finding, count($recommendations) + 1); + $finding['recommendation_ids'] = array($recommendation['id']); + $findings[] = $finding; + $recommendations[] = $recommendation; + } + } + + $sourceTotal = count($sourceProbes); + $matchedTotal = count($matches); + $propertyParity = $comparedProperties > 0 ? $agreedProperties / $comparedProperties : 1.0; + $coverage = $sourceTotal > 0 ? $matchedTotal / $sourceTotal : 1.0; + $score = round($propertyParity * $coverage, 4); + + $status = $this->status($score, array() === $findings); + $severity = $this->severity($status); + + $report = array( + 'schema' => VisualParityReportContract::REPORT_SCHEMA, + 'status' => $status, + 'severity' => $severity, + 'source_render' => array('kind' => 'source', 'renderer' => 'php-transformer-static'), + 'target_render' => array('kind' => 'target', 'renderer' => 'php-transformer-static'), + 'viewports' => array(), + 'matches' => $matches, + 'findings' => $findings, + 'recommendations' => $recommendations, + 'parity' => array( + 'score' => $score, + 'property_parity' => round($propertyParity, 4), + 'coverage' => round($coverage, 4), + 'warn_at' => $this->warnAt, + 'fail_at' => $this->failAt, + ), + 'summary' => array( + 'source_total' => $sourceTotal, + 'target_total' => count($targetProbes), + 'matched_total' => $matchedTotal, + 'unmatched_source_total' => count($unmatchedSource), + 'compared_properties' => $comparedProperties, + 'agreed_properties' => $agreedProperties, + 'finding_total' => count($findings), + ), + 'unmatched_source' => $unmatchedSource, + 'diagnostics' => array(), + ); + + return $report; + } + + /** + * @param array $result + * @return array> + */ + private function probes(array $result): array + { + $probes = $result['probes'] ?? array(); + return is_array($probes) ? array_values(array_filter($probes, 'is_array')) : array(); + } + + /** + * @param array $sourceProbe + * @param array> $targetProbes + * @param array $available + * @return array{0: int|null, 1: string} + */ + private function bestTargetIndex(array $sourceProbe, array $targetProbes, array $available): array + { + foreach ( array_keys(self::TIERS) as $tier ) { + foreach ( array_keys($available) as $index ) { + if ( $this->tierMatches($tier, $sourceProbe, $targetProbes[$index]) ) { + return array($index, $tier); + } + } + } + + return array(null, ''); + } + + /** + * @param array $source + * @param array $target + */ + private function tierMatches(string $tier, array $source, array $target): bool + { + switch ( $tier ) { + case 'selector': + return '' !== $this->selector($source) && $this->selector($source) === $this->selector($target); + case 'class+text': + return $this->tag($source) === $this->tag($target) + && $this->classes($source) === $this->classes($target) + && '' !== $this->text($source) + && $this->text($source) === $this->text($target); + case 'class': + return $this->tag($source) === $this->tag($target) + && array() !== $this->classes($source) + && $this->classes($source) === $this->classes($target); + case 'structural': + return '' !== $this->structuralPath($source) && $this->structuralPath($source) === $this->structuralPath($target); + default: + return false; + } + } + + private function status(float $score, bool $noFindings): string + { + if ( $noFindings && $score >= 1.0 ) { + return 'pass'; + } + if ( $score < $this->failAt ) { + return 'fail'; + } + if ( $score < $this->warnAt ) { + return 'warning'; + } + + return $noFindings ? 'pass' : 'warning'; + } + + private function severity(string $status): string + { + return array( + 'pass' => 'none', + 'warning' => 'warning', + 'fail' => 'error', + )[$status] ?? 'none'; + } + + /** + * @param array $sourceProbe + * @param array{property: string, source: string, target: string} $delta + * @return array + */ + private function deltaFinding(array $sourceProbe, array $delta, int $sequence): array + { + $target = '' === $delta['target'] ? 'no declared value' : $delta['target']; + + return array( + 'id' => 'static-parity-' . $delta['property'] . '-' . $sequence, + 'severity' => 'warning', + 'category' => 'visual-style', + 'summary' => sprintf('%s %s changed from "%s" to "%s".', $this->selector($sourceProbe), $delta['property'], $delta['source'], $target), + 'kind' => 'generic', + 'property' => $delta['property'], + 'source_value' => $delta['source'], + 'target_value' => $delta['target'], + 'selector' => $this->selector($sourceProbe), + ); + } + + /** + * @param array $sourceProbe + * @return array + */ + private function missingElementFinding(array $sourceProbe, int $sequence): array + { + return array( + 'id' => 'static-parity-missing-' . $sequence, + 'severity' => 'warning', + 'category' => 'visual-style', + 'summary' => sprintf('Source element %s has no matching candidate element.', $this->selector($sourceProbe)), + 'kind' => 'generic', + 'property' => 'presence', + 'selector' => $this->selector($sourceProbe), + ); + } + + /** + * @param array $finding + * @return array + */ + private function recommendationFor(array $finding, int $sequence): array + { + $property = (string) ($finding['property'] ?? 'visual-style'); + $summary = 'presence' === $property + ? 'Preserve the source element so candidate structure matches.' + : sprintf('Align the candidate %s with the source value.', $property); + + return array( + 'id' => 'rec-static-parity-' . $sequence, + 'priority' => 'medium', + 'summary' => $summary, + 'finding_ids' => array((string) $finding['id']), + ); + } + + /** + * @param array $probe + * @return array + */ + private function style(array $probe): array + { + $style = $probe['style'] ?? array(); + return is_array($style) ? array_filter($style, 'is_string') : array(); + } + + /** + * @param array $style + * @return array + */ + private static function sortedKeys(array $style): array + { + $keys = array_keys($style); + sort($keys); + return $keys; + } + + private function normalizeValue(string $property, string $value): string + { + $value = strtolower(trim(preg_replace('/\s+/', ' ', $value) ?? $value)); + if ( '' === $value ) { + return ''; + } + + if ( 'font-family' === $property ) { + $value = str_replace(array('"', "'"), '', $value); + return preg_replace('/\s*,\s*/', ',', $value) ?? $value; + } + + if ( 'font-weight' === $property ) { + return array('normal' => '400', 'bold' => '700', 'lighter' => '300', 'bolder' => '700')[$value] ?? $value; + } + + // Light, deterministic color normalization: drop spaces inside rgb()/rgba() + // and lowercase hex so equivalent declarations compare equal. + $value = preg_replace('/\s*,\s*/', ',', $value) ?? $value; + $value = preg_replace('/\(\s+/', '(', $value) ?? $value; + $value = preg_replace('/\s+\)/', ')', $value) ?? $value; + + return $value; + } + + /** @param array $probe */ + private function selector(array $probe): string + { + $selector = trim((string) ($probe['selector'] ?? '')); + return '' !== $selector ? $selector : (string) ($probe['tag'] ?? ''); + } + + /** @param array $probe */ + private function structuralPath(array $probe): string + { + return trim((string) ($probe['structural_path'] ?? '')); + } + + /** @param array $probe */ + private function tag(array $probe): string + { + return strtolower(trim((string) ($probe['tag'] ?? ''))); + } + + /** + * @param array $probe + * @return array + */ + private function classes(array $probe): array + { + $classes = $probe['classes'] ?? array(); + if ( ! is_array($classes) ) { + return array(); + } + $classes = array_values(array_filter($classes, 'is_string')); + sort($classes); + return $classes; + } + + /** @param array $probe */ + private function text(array $probe): string + { + return strtolower(trim(preg_replace('/\s+/', ' ', (string) ($probe['text'] ?? '')) ?? '')); + } + + /** + * @param array $probe + * @return array + */ + private function candidateSummary(array $probe): array + { + return array_filter(array( + 'id' => $probe['id'] ?? null, + 'tag' => $probe['tag'] ?? null, + 'selector' => $probe['selector'] ?? null, + 'text' => $probe['text'] ?? null, + ), static fn ($value): bool => null !== $value && '' !== $value); + } +} diff --git a/php-transformer/src/VisualParity/StaticStyleParityProbe.php b/php-transformer/src/VisualParity/StaticStyleParityProbe.php new file mode 100644 index 00000000..66ee46ed --- /dev/null +++ b/php-transformer/src/VisualParity/StaticStyleParityProbe.php @@ -0,0 +1,280 @@ + + */ + public function extract(string $html, string $extraCss = ''): array + { + $document = new DOMDocument(); + $previous = libxml_use_internal_errors(true); + $sourceHtml = preg_match('/<(?:!doctype|html|head|body)\b/i', $html) ? $html : '' . $html . ''; + $loaded = $document->loadHTML('' . $sourceHtml, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + if ( ! $loaded ) { + return $this->failed(); + } + + $body = $document->getElementsByTagName('body')->item(0); + if ( ! $body instanceof DOMElement ) { + return $this->result(array()); + } + + $cascade = new StaticCssCascade($document, $extraCss); + $xpath = new DOMXPath($document); + $nodes = $xpath->query('.//*', $body); + $probes = array(); + $seen = array(); + + if ( false !== $nodes ) { + foreach ( $nodes as $node ) { + if ( ! $node instanceof DOMElement ) { + continue; + } + $tag = strtolower($node->tagName); + if ( in_array($tag, self::SKIP_TAGS, true) || $this->isInsideSkippedTag($node) ) { + continue; + } + + $style = $cascade->resolve($node, self::TRACKED_PROPERTIES, self::INHERITABLE_PROPERTIES); + $hasIdentity = $node->hasAttribute('class') || $node->hasAttribute('id') || $node->hasAttribute('style'); + if ( array() === $style && ! $hasIdentity ) { + continue; + } + + $selector = $this->selector($node); + if ( isset($seen[$selector]) ) { + continue; + } + $seen[$selector] = true; + + $classes = $this->tokens($node->hasAttribute('class') ? $node->getAttribute('class') : ''); + sort($classes); + + $probe = array( + 'id' => 'static-parity-probe-' . ( count($probes) + 1 ), + 'tag' => $tag, + 'selector' => $selector, + 'structural_path' => $this->structuralPath($node), + 'classes' => $classes, + 'text' => mb_substr($this->normalizedText($node), 0, 80), + 'style' => $style, + ); + $probes[] = $probe; + } + } + + return $this->result($probes); + } + + /** + * @param array> $probes + * @return array + */ + private function result(array $probes): array + { + $byTag = array(); + $styledCount = 0; + foreach ( $probes as $probe ) { + $tag = (string) ($probe['tag'] ?? ''); + $byTag[$tag] = ($byTag[$tag] ?? 0) + 1; + if ( array() !== ($probe['style'] ?? array()) ) { + ++$styledCount; + } + } + ksort($byTag); + + return array( + 'schema' => self::SCHEMA, + 'status' => 'success', + 'probes' => $probes, + 'summary' => array( + 'total' => count($probes), + 'styled_total' => $styledCount, + 'by_tag' => $byTag, + 'tracked_properties' => self::TRACKED_PROPERTIES, + ), + 'diagnostics' => array(), + ); + } + + /** + * @return array + */ + private function failed(): array + { + return array( + 'schema' => self::SCHEMA, + 'status' => 'failed', + 'probes' => array(), + 'summary' => array( + 'total' => 0, + 'styled_total' => 0, + 'by_tag' => array(), + 'tracked_properties' => self::TRACKED_PROPERTIES, + ), + 'diagnostics' => array( + array('code' => 'html_parse_failed', 'message' => 'Unable to parse HTML for static style parity probes.'), + ), + ); + } + + /** + * Stable, human-readable selector path: tag + id (anchors and stops) or + * tag + up to two classes + :nth-of-type when needed, walked to . + */ + private function selector(DOMElement $element): string + { + $segments = array(); + for ( $node = $element; $node instanceof DOMElement && 'body' !== strtolower($node->tagName); $node = $node->parentNode ) { + $segment = strtolower($node->tagName); + if ( $node->hasAttribute('id') && '' !== trim($node->getAttribute('id')) ) { + $segment .= '#' . trim($node->getAttribute('id')); + array_unshift($segments, $segment); + break; + } + $classes = $this->tokens($node->hasAttribute('class') ? $node->getAttribute('class') : ''); + sort($classes); + if ( array() !== $classes ) { + $segment .= '.' . implode('.', array_slice($classes, 0, 2)); + } + $index = $this->elementIndex($node); + if ( $index > 1 ) { + $segment .= ':nth-of-type(' . $index . ')'; + } + array_unshift($segments, $segment); + } + + return implode(' > ', $segments); + } + + /** + * Class-free structural identity: tag + :nth-of-type chain to . Used as + * the final deterministic matching tier when classes/text drift. + */ + private function structuralPath(DOMElement $element): string + { + $segments = array(); + for ( $node = $element; $node instanceof DOMElement && 'body' !== strtolower($node->tagName); $node = $node->parentNode ) { + $segments[] = strtolower($node->tagName) . ':' . $this->elementIndex($node); + } + + return implode('/', array_reverse($segments)); + } + + private function elementIndex(DOMElement $element): int + { + $index = 1; + for ( $node = $element->previousSibling; $node instanceof DOMNode; $node = $node->previousSibling ) { + if ( $node instanceof DOMElement && strtolower($node->tagName) === strtolower($element->tagName) ) { + ++$index; + } + } + + return $index; + } + + private function normalizedText(DOMElement $element): string + { + return trim(preg_replace('/\s+/', ' ', html_entity_decode((string) $element->textContent, ENT_QUOTES | ENT_HTML5)) ?? ''); + } + + /** + * @return array + */ + private function tokens(string $value): array + { + return array_values(array_filter(preg_split('/\s+/', trim($value)) ?: array(), static fn (string $token): bool => '' !== $token)); + } + + private function isInsideSkippedTag(DOMElement $element): bool + { + for ( $node = $element->parentNode; $node instanceof DOMElement; $node = $node->parentNode ) { + if ( in_array(strtolower($node->tagName), self::SKIP_TAGS, true) ) { + return true; + } + } + + return false; + } +} diff --git a/php-transformer/src/VisualParity/StaticStyleParityRunner.php b/php-transformer/src/VisualParity/StaticStyleParityRunner.php new file mode 100644 index 00000000..5d48c981 --- /dev/null +++ b/php-transformer/src/VisualParity/StaticStyleParityRunner.php @@ -0,0 +1,67 @@ + blocks) to produce the CANDIDATE, then compares the two with + * {@see StaticStyleParityProbe} + {@see StaticStyleParityComparator} under the + * SAME author CSS so the only variable is the transformed DOM. Pure PHP: no + * browser, no screenshots, no network. Same inputs -> byte-identical report. + * + * This is the deterministic replacement for full-page pixelmatch as the primary + * parity signal: it answers "after the transform, does the same effective + * styling still apply to the same content?" and names the exact CSS property on + * the exact selector that diverged. + */ +final class StaticStyleParityRunner +{ + private HtmlTransformer $transformer; + private StaticStyleParityProbe $probe; + private StaticStyleParityComparator $comparator; + + public function __construct( + ?HtmlTransformer $transformer = null, + ?StaticStyleParityProbe $probe = null, + ?StaticStyleParityComparator $comparator = null + ) { + $this->transformer = $transformer ?? new HtmlTransformer(); + $this->probe = $probe ?? new StaticStyleParityProbe(); + $this->comparator = $comparator ?? new StaticStyleParityComparator(); + } + + /** + * Compare a source document against its own transform output. + * + * @param string $sourceHtml Source HTML document. + * @param string $authorCss Author CSS (inline

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; +}