import type { IR, IRNode } from "../normalize/ir.js"; import { isTextChild, irContentExtent } from "../normalize/ir.js"; import type { PageSnapshot } from "../capture/walker.js"; import type { GenNode } from "./render.js"; import { indexByCid } from "./render.js"; import type { AssetGraph } from "../infer/assets.js"; import type { FontGraph } from "../infer/fonts.js"; import type { Section } from "../infer/sections.js"; import type { CaptureResult } from "../capture/capture.js"; import { WALL_RE } from "../util/captureFailure.js"; export type GateResult = { gate: string; pass: boolean; metrics: Record; issues: string[]; }; // ---------- helpers ---------- function normText(s: string): string { return s.replace(/\s+/g, " ").trim(); } function pxNum(v: string | undefined): number { if (!v) return NaN; // include an optional exponent so saturating values like `rounded-full`'s 3.35544e+07px parse // as the full magnitude (not just the mantissa 3.35) — pill-radius equivalence depends on it. const m = /(-?\d+(?:\.\d+)?(?:e[+-]?\d+)?)/i.exec(v); return m ? parseFloat(m[1]!) : NaN; } function withinAbs(a: number, b: number, tol: number): boolean { return Math.abs(a - b) <= tol; } function withinPct(a: number, b: number, pct: number): boolean { const m = Math.max(Math.abs(a), Math.abs(b)); return m === 0 ? true : Math.abs(a - b) / m <= pct; } function normColor(v: string | undefined): string { return (v ?? "").replace(/\s+/g, ""); } function parseRgb(v: string | undefined): [number, number, number, number] | null { const m = (v ?? "").match(/rgba?\(([^)]+)\)/i); if (!m) return null; const p = m[1]!.split(/[,\/]/).map((s) => parseFloat(s.trim())); if (p.length < 3 || p.slice(0, 3).some(Number.isNaN)) return null; return [p[0]!, p[1]!, p[2]!, p.length >= 4 && !Number.isNaN(p[3]!) ? p[3]! : 1]; } /** Colors are equivalent within a small per-channel tolerance: re-emitting a * computed color (often defined in oklch/wide-gamut on modern SaaS sites) can * round ±1 per channel. Imperceptible, but a real color difference still fails. */ function colorsClose(a: string | undefined, b: string | undefined): boolean { const ca = parseRgb(a), cb = parseRgb(b); if (!ca || !cb) return normColor(a) === normColor(b); return Math.abs(ca[0] - cb[0]) <= 2 && Math.abs(ca[1] - cb[1]) <= 2 && Math.abs(ca[2] - cb[2]) <= 2 && Math.abs(ca[3] - cb[3]) <= 0.04; } function normFontFamily(v: string | undefined): string { // Compare effective font stacks: drop quotes/case/whitespace, then dedup tokens // preserving order. Sites that build font-family from CSS variables often emit // the fallback list twice (e.g. `mono, …, monospace, mono, …, monospace`); the // clone resolves it once. Both name the same fonts in the same priority order, // so they are equivalent — a real primary-font difference is still caught. const toks = (v ?? "").replace(/['"]/g, "").replace(/\s+/g, " ").toLowerCase() .split(",").map((s) => s.trim()).filter(Boolean); const seen = new Set(); const out: string[] = []; for (const t of toks) if (!seen.has(t)) { seen.add(t); out.push(t); } return out.join(","); } type SrcNode = { node: IRNode; computed: Record; bbox: { x: number; y: number; width: number; height: number }; visible: boolean; directText: string }; function collectSrcNodes(ir: IR, vp: number): SrcNode[] { const out: SrcNode[] = []; const walk = (node: IRNode): void => { const computed = node.computedByVp[vp]; const bbox = node.bboxByVp[vp]; if (computed && bbox) { let directText = ""; for (const c of node.children) if (isTextChild(c)) directText += c.text; out.push({ node, computed, bbox, visible: !!node.visibleByVp[vp], directText }); } for (const c of node.children) if (!isTextChild(c)) walk(c); }; walk(ir.root); return out; } function hasVisibleElementChild(node: IRNode, vp: number): boolean { for (const c of node.children) { if (isTextChild(c)) continue; if (c.visibleByVp[vp]) return true; } return false; } /** A source text node that the capture painted VISIBLE but the clone rendered HIDDEN at the same * viewport is a high-signal regression: the words are in the markup yet fall in an invisible gen * subtree (off-viewport-shifted, banded-hidden, or width-frozen off-screen). Cheap to detect — * the gen node exists (matched by cid) but reports `visible === false`. Counts DISTINCT source * cids over the run (a node hidden at several viewports counts once) so the number reads as * "how many text nodes went dark", not "hidden-node×viewport". Non-blocking: reported as a metric * only. Pure + input-driven → deterministic. */ export function countVisibleInCaptureHiddenInClone( ir: IR, genSnaps: Record, viewports: number[], ): number { const hiddenCids = new Set(); for (const vp of viewports) { if (!genSnaps[vp]) continue; const gen = indexByCid(genSnaps[vp]!); for (const s of collectSrcNodes(ir, vp)) { if (!s.visible) continue; if (normText(s.directText).length === 0) continue; const g = gen.get(s.node.id); if (g && !g.visible) hiddenCids.add(s.node.id); } } return hiddenCids.size; } // ---------- Pollution gate (stage 2): is the captured page degenerate? ---------- // A clone can pass every structural gate while faithfully reproducing the WRONG // page: an egress/bot wall, a near-empty shell, or a cookie/consent modal that was // never dismissed (the clone then reproduces the modal, so the perceptual gate is // fooled too). This gate flags those captures so a "perfect" score can't hide them. // WALL_RE lives in util/captureFailure.ts — shared with the capture-side fast-fail so // the abort and the grade can never drift on what counts as a wall. export function gatePollution(ir: IR, capture: CaptureResult, viewports: number[]): GateResult { const issues: string[] = []; const nodeCount = ir.doc.nodeCount; // Collect all visible source text once. let textChars = 0; const textParts: string[] = []; const walk = (node: IRNode): void => { const anyVisible = Object.values(node.visibleByVp).some(Boolean); for (const c of node.children) { if (isTextChild(c)) { if (anyVisible) { const t = normText(c.text); if (t) { textParts.push(t); textChars += t.length; } } } else walk(c); } }; walk(ir.root); const allText = textParts.join(" "); const wall = WALL_RE.test(allText); // Overlay remaining after dismissal (max across viewports), and shortest page. // `blocking` = a full-viewport overlay that STILL scroll-locks the page (a modal // we could not clear); mere overlay presence (a legit fixed hero/app banner) is // tracked but not failed, to avoid false positives on real fixed content. let overlaysRemaining = 0; let blocking = capture.dismissal?.blocking ?? false; for (const pv of capture.perViewport) { overlaysRemaining = Math.max(overlaysRemaining, pv.overlaysRemaining ?? 0); blocking = blocking || !!pv.blocking; } let minHeightRatio = Infinity; let maxHeightRatio = 0; for (const pv of capture.perViewport) { if (pv.height > 0) { const ratio = pv.scrollHeight / pv.height; minHeightRatio = Math.min(minHeightRatio, ratio); maxHeightRatio = Math.max(maxHeightRatio, ratio); } } if (!Number.isFinite(minHeightRatio)) minHeightRatio = 1; // Scroll-locked-capture contradiction: an email-capture/promo popup that sets // body{overflow:hidden;height:100vh} collapses `document.scrollHeight` to EXACTLY the viewport // height at EVERY width (ratio ~1.0 across the board) — yet the real page's IN-FLOW content // (the IR's sections) still lays out several viewports tall. A genuine one-screen landing page // has content extent ~= its scrollHeight, so this only fires when the two disagree: captured // scrollHeight pinned to one viewport WHILE the IR content spans multiple. That is a // scroll-locked, polluted capture — the overlay detector should have caught it, so fail loudly. let maxContentRatio = 0; for (const pv of capture.perViewport) { if (pv.height > 0) maxContentRatio = Math.max(maxContentRatio, irContentExtent(ir.root, pv.viewport) / pv.height); } // scrollHeight never exceeds ~1 viewport at any width, but the IR content is 2+ viewports tall. const scrollLockedContradiction = maxHeightRatio > 0 && maxHeightRatio < 1.15 && maxContentRatio >= 2; // Degenerate signals. Calibrated against real captures: an egress/bot wall is // ~3 nodes / ~24 chars; the most minimal legitimate page in the suite // (michaelcole.me) is 26 nodes / 640 chars. Thresholds sit safely between. if (nodeCount < 12) issues.push(`degenerate DOM: only ${nodeCount} nodes`); if (wall && nodeCount < 220) issues.push("bot/egress wall text on a small page"); if (textChars < 60 && nodeCount < 50) issues.push(`near-empty page: ${textChars} visible text chars, ${nodeCount} nodes`); if (blocking) issues.push("a full-viewport modal still scroll-locks the page after dismissal"); if (scrollLockedContradiction) issues.push(`scroll-locked capture: scrollHeight pinned to ~1 viewport at every width while IR content spans ${round2(maxContentRatio)} viewports`); return { gate: "pollution", pass: issues.length === 0, metrics: { nodeCount, visibleTextChars: textChars, wallTextDetected: wall, overlaysRemaining, blocking, minScrollHeightRatio: round2(minHeightRatio), maxScrollHeightRatio: round2(maxHeightRatio), maxContentExtentRatio: round2(maxContentRatio), dismissedCount: capture.dismissal?.dismissed.length ?? 0, overlaysRemoved: capture.dismissal?.removed ?? 0, videoStills: capture.dismissal?.videoStills ?? 0, }, issues, }; } // ---------- Gate 1: capture completeness ---------- export function gate1Capture(ir: IR, viewports: number[], artifacts: { screenshots: Record; assetsPresent: boolean; fontsPresent: boolean }): GateResult { const issues: string[] = []; let rootOk = ir.root && ir.root.tag === "body"; for (const vp of viewports) { if (!ir.doc.perViewport[vp]) issues.push(`missing perViewport ${vp}`); if (!artifacts.screenshots[vp]) issues.push(`missing screenshot ${vp}`); if (!((ir.doc.perViewport[vp]?.scrollHeight ?? 0) > 0)) issues.push(`no scrollHeight ${vp}`); } if (!rootOk) issues.push("no visible DOM root"); if (!artifacts.assetsPresent) issues.push("assets-discovered missing"); if (!artifacts.fontsPresent) issues.push("fonts-discovered missing"); return { gate: "capture", pass: issues.length === 0, metrics: { viewports }, issues }; } // ---------- Gate 2: asset/font equivalence ---------- export function gate2Assets(assetGraph: AssetGraph, fontGraph: FontGraph, gen: { remoteRefs: string[]; failed404: string[] }): GateResult { const issues: string[] = []; let unclassified = 0, zeroByte = 0, skippedNoReason = 0; for (const e of assetGraph.entries) { if (e.type === "css") continue; if (e.classification !== "downloaded" && e.classification !== "skipped") unclassified++; if (e.classification === "downloaded" && e.bytes <= 0) zeroByte++; if (e.classification === "skipped" && !e.reason) skippedNoReason++; } if (unclassified > 0) issues.push(`${unclassified} unclassified assets`); if (zeroByte > 0) issues.push(`${zeroByte} zero-byte downloaded assets`); if (skippedNoReason > 0) issues.push(`${skippedNoReason} skipped assets without reason`); if (gen.remoteRefs.length > 0) issues.push(`${gen.remoteRefs.length} generated refs point to remote origin`); if (gen.failed404.length > 0) issues.push(`${gen.failed404.length} generated asset refs missing (HTTP >= 400 or file absent from export)`); const fontsResolvedOrFallback = fontGraph.entries.every((f) => f.status === "resolved" || (f.status === "fallback" && f.reason)); if (!fontsResolvedOrFallback) issues.push("font declarations not resolved/fallback-recorded"); return { gate: "asset_font", pass: issues.length === 0, metrics: { total: assetGraph.entries.filter((e) => e.type !== "css").length, downloaded: assetGraph.entries.filter((e) => e.classification === "downloaded" && e.type !== "css").length, skipped: assetGraph.entries.filter((e) => e.classification === "skipped" && e.type !== "css").length, remoteRefs: gen.remoteRefs.length, failed404: gen.failed404.length, failed404Sample: [...new Set(gen.failed404)].slice(0, 6), fonts: fontGraph.entries.length, }, issues, }; } // ---------- Gate 3: rendered DOM equivalence ---------- export function gate3Dom(ir: IR, genSnaps: Record, viewports: number[], sourceOrigin: string): GateResult { const issues: string[] = []; let totalVisible = 0, matched = 0; let textTotal = 0, textPresent = 0; let linksTotal = 0, linksOk = 0; let mediaTotal = 0, mediaOk = 0; let inventedText = 0; // All source text (visible or not) — the deterministic compiler only ever // replays captured text, so this is the authoritative "not invented" set. const allSrcText = new Set(); for (const vp of viewports) { for (const s of collectSrcNodes(ir, vp)) { const t = normText(s.directText); if (t.length > 0) allSrcText.add(t); } } for (const vp of viewports) { const gen = genSnaps[vp] ? indexByCid(genSnaps[vp]!) : new Map(); const srcNodes = collectSrcNodes(ir, vp); const genTextAll = normText([...gen.values()].filter((g) => g.visible).map((g) => g.text).join(" ")); const srcTextSet = new Set(); for (const s of srcNodes) { if (!s.visible) continue; totalVisible++; const g = gen.get(s.node.id); if (g && (g.tag === s.node.tag || isValidRetag(s.node.tag, g.tag))) matched++; const t = normText(s.directText); if (t.length > 0) { textTotal++; srcTextSet.add(t); if (genTextAll.includes(t)) textPresent++; } if (s.node.tag === "a" && s.node.attrs.href) { linksTotal++; const srcHref = normHref(s.node.attrs.href, sourceOrigin); const genHref = g ? normHref(g.attrs.href ?? "", sourceOrigin) : ""; if (srcHref === genHref) linksOk++; } if (/^(img|video|svg)$/.test(s.node.tag)) { mediaTotal++; if (s.node.tag === "svg") { if (g) mediaOk++; } else { const src = g?.attrs.src ?? ""; if (g && (src === "" || !src.startsWith("http") || src.includes("127.0.0.1") || src.startsWith("data:"))) mediaOk++; } } } // invented visible text > 20 chars not present anywhere in the source DOM for (const g of gen.values()) { if (!g.visible) continue; const t = normText(g.text); if (t.length > 20 && !allSrcText.has(t)) { const inAnySrc = [...allSrcText].some((s) => s.includes(t) || t.includes(s)); if (!inAnySrc) inventedText++; } } } // Non-blocking, high-signal diagnostic: source text the capture painted VISIBLE that the clone // renders HIDDEN at the same viewport (off-screen-shifted / banded / width-frozen subtree). Does // NOT gate pass — text presence already covers markup fidelity — but surfaces the "went dark" // count so a banner/feed row vanishing is legible in the report instead of hiding inside a 99.x. const textHiddenInClone = countVisibleInCaptureHiddenInClone(ir, genSnaps, viewports); const matchPct = totalVisible ? matched / totalVisible : 1; const textPct = textTotal ? textPresent / textTotal : 1; const linkPct = linksTotal ? linksOk / linksTotal : 1; const mediaPct = mediaTotal ? mediaOk / mediaTotal : 1; if (textPct < 0.999) issues.push(`text presence ${(textPct * 100).toFixed(1)}% (< 100%)`); if (matchPct < 0.98) issues.push(`node match ${(matchPct * 100).toFixed(1)}% (< 98%)`); if (linkPct < 0.999) issues.push(`link href preserve ${(linkPct * 100).toFixed(1)}%`); if (mediaPct < 0.999) issues.push(`media mapping ${(mediaPct * 100).toFixed(1)}%`); if (inventedText > 0) issues.push(`${inventedText} invented visible text nodes > 20 chars`); return { gate: "dom", pass: issues.length === 0, metrics: { nodeMatchPct: round4(matchPct), textPresentPct: round4(textPct), linkPct: round4(linkPct), mediaPct: round4(mediaPct), totalVisible, matched, textTotal, textPresent, linksTotal, mediaTotal, inventedText, textHiddenInClone, }, issues, }; } // The generator deterministically retags certain source elements to a neutral // div/span for valid HTML / hydration correctness (a