Files
ditto.site/compiler/src/validate/validate.ts
T
Samraaj BathandClaude Fable 5 fae44aab93 Validation waits for webfonts before measuring; sticky wrappers keep fluid width
- The render walk and screenshots now await document.fonts.ready plus a
  bounded state-based poll until every declared face is terminal
  (loaded/error); pending families surface as a font_wait_warning event
  instead of skewing text metrics with fallback-font boxes
- position:sticky full-width wrappers share the static/relative
  fluid-fill branch (a sticky box fills its containing block like any
  in-flow box) instead of freezing at canonical px; fixed-width sticky
  rails still freeze

447 tests pass (12 new), typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 06:21:07 -07:00

289 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { mkdtempSync, rmSync, readFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";
import { buildIR } from "../normalize/ir.js";
import { detectSections } from "../infer/sections.js";
import { buildAssetGraph } from "../infer/assets.js";
import { buildFontGraph } from "../infer/fonts.js";
import { generateAll } from "../generate/pipeline.js";
import { COMPILER_VERSION } from "../generate/manifest.js";
import { interactionRejectedArtifact } from "../generate/interactive.js";
import { buildApp, serveStatic, renderApp, measureProbeWidths, findRemoteRefs } from "./render.js";
import { driveInteractionGate } from "./interactionGate.js";
import { driveMotionGate, motionExpected } from "./motionGate.js";
import {
gate1Capture, gate2Assets, gate3Dom, gate4Style, gate5Layout, gateResponsive, gatePollution, type GateResult,
} from "./gates.js";
/** Widths the capture never sampled — the midpoint of every adjacent captured pair (inside each
* media band) plus one beyond the widest. The responsive gate renders the clone here to catch
* baked-px output that stairsteps between bands or off-centres on a wider monitor. */
export function probeWidthsFor(viewports: number[]): number[] {
const sorted = [...viewports].sort((a, b) => a - b);
const mids: number[] = [];
for (let i = 0; i < sorted.length - 1; i++) mids.push(Math.round((sorted[i]! + sorted[i + 1]!) / 2));
const widest = sorted[sorted.length - 1] ?? 1920;
return [...mids, Math.round(widest * 4 / 3)];
}
import { buildReport, reportToMarkdown, type Report } from "./report.js";
import { readJSON, writeJSON, writeText, ensureDir, fileExists } from "../util/fsx.js";
import type { CaptureResult } from "../capture/capture.js";
const DEFAULT_HARNESS = fileURLToPath(new URL("../../.harness", import.meta.url));
const DETERMINISM_FILES = [
"manifest.json", "sections.json", "tokens.json", "assets.json", "fonts.json", "components.json", "recipes.json", "recipes.md", "interaction-recipes.json", "interaction-recipes.md", "seo.json", "seo.md", "code-quality.json", "code-quality.md",
"app/src/app/page.tsx", "app/src/app/ditto.css", "app/src/app/globals.css", "app/src/app/layout.tsx",
"app/AGENTS.md", "app/ARCHITECTURE.md",
"app/src/app/content.ts", // Stage 6 — present only when extraction promoted components
"app/src/app/_cids.ts", // internal data-cid arrays (present only when extraction promoted components)
"app/src/app/_styles.ts", // internal per-instance class overrides (present only when a component's className varies)
"app/package.json", "app/next.config.mjs", "app/tsconfig.json",
];
const VITE_DETERMINISM_FILES = [
"manifest.json", "sections.json", "tokens.json", "assets.json", "fonts.json", "components.json", "recipes.json", "recipes.md", "interaction-recipes.json", "interaction-recipes.md", "seo.json", "seo.md", "code-quality.json", "code-quality.md",
"app/index.html", "app/vite.config.ts", "app/src/page.tsx", "app/src/main.tsx", "app/src/ditto.css", "app/src/globals.css",
"app/AGENTS.md", "app/ARCHITECTURE.md",
"app/src/content.ts",
"app/src/_cids.ts",
"app/src/_styles.ts",
"app/public/robots.txt", "app/public/sitemap.xml", "app/public/llms.txt",
"app/package.json", "app/tsconfig.json",
];
export async function validateRun(runDir: string, opts?: { harnessDir?: string; tier?: string; log?: (e: Record<string, unknown>) => void }): Promise<Report> {
const log = opts?.log ?? (() => {});
const harnessDir = opts?.harnessDir ?? DEFAULT_HARNESS;
const sourceDir = join(runDir, "source");
const generatedDir = join(runDir, "generated");
const appDir = join(generatedDir, "app");
const renderedDir = join(runDir, "rendered");
const validationDir = join(runDir, "validation");
ensureDir(validationDir);
const input = readJSON<{ url: string; viewports: number[]; siteId: string }>(join(runDir, "input.json"));
const url = input.url;
// Opt-in reflow trade: the generator flowed geometry that re-positions content (a deliberate
// fidelity-for-cleanliness trade), so the layout gate uses a wider leaf-position tolerance — the
// perceptual + size + section-structure + page-height gates remain the strict visual bar.
const reflowOpt = fileExists(join(sourceDir, "clone-options.json"))
&& !!readJSON<{ reflow?: boolean }>(join(sourceDir, "clone-options.json")).reflow;
const viewports = input.viewports;
const origin = new URL(url).origin;
const capture = readJSON<CaptureResult>(join(sourceDir, "capture", "capture-result.json"));
const rejPath = join(sourceDir, "interaction-rejected.json");
// Always validate the current interaction runtime first. Otherwise a stale rejection file can
// suppress DittoWire during generation, causing the gate to re-reject an interaction that now works.
generateAll({ sourceDir, capture, viewports, sampleViewports: capture.viewports, url, outDir: generatedDir, ignoreRejectedInteractions: true });
// Stage 5: match generation — carry @keyframes only when motion was captured, so the
// motion gate's expectations line up with what the clone actually emitted.
const ir = buildIR(sourceDir, viewports, { motion: !!capture.motion });
const sections = detectSections(ir);
const assetGraph = buildAssetGraph(capture);
const fontGraph = buildFontGraph(capture.fontFaces, assetGraph, url);
// ---- Gate 0: build + render ----
log({ event: "build_start" });
const build = buildApp(appDir, harnessDir);
log({ event: "build_done", ok: build.ok, ms: build.durationMs });
let snapshots: Record<number, import("../capture/walker.js").PageSnapshot> = {};
let runtimeErrors: string[] = [];
let httpStatus = 0;
let failedResources: string[] = [];
// Stage 4: interaction gate (N/A auto-pass when no interactions were captured).
let interactionGate: GateResult = { gate: "interaction", pass: true, metrics: { patterns: 0, na: true }, issues: [] };
// Stage 5: motion gate (N/A auto-pass when no motion was captured/expected).
let motionGate: GateResult = { gate: "motion", pass: true, metrics: { animations: 0, na: true }, issues: [] };
// Responsive gate: clone snapshots at widths BETWEEN/BEYOND the captured set.
let probeSnaps: Record<number, import("../capture/walker.js").PageSnapshot> = {};
if (build.ok && build.outDir) {
const server = await serveStatic(build.outDir);
try {
const r = await renderApp({ url: server.url + "/", viewports, renderedDir });
snapshots = r.snapshots; runtimeErrors = r.runtimeErrors; httpStatus = r.httpStatus; failedResources = r.failedResources;
// Non-fatal: webfonts that never loaded before the render walk (text was measured in a fallback
// face). Surfaced as a warning event, NOT a runtime error, so it never fails gate 0.
if (r.fontWarnings.length) log({ event: "font_wait_warning", families: r.fontWarnings });
probeSnaps = await measureProbeWidths({ url: server.url + "/", widths: probeWidthsFor(viewports) });
if (capture.interaction) {
interactionGate = await driveInteractionGate({ url: server.url + "/", viewports, ir, interaction: capture.interaction });
log({ event: "interaction_gate", pass: interactionGate.pass, metrics: interactionGate.metrics });
// Feed the gate's verdict back: patterns that didn't reproduce are recorded so
// generation leaves them static (rather than shipping a broken interaction).
const rejected = (interactionGate.metrics.rejected as string[] | undefined) ?? [];
if (rejected.length) writeJSON(rejPath, interactionRejectedArtifact(rejected));
else rmSync(rejPath, { force: true });
}
if (capture.motion && motionExpected(ir, capture.motion)) {
motionGate = await driveMotionGate({ url: server.url + "/", viewports, ir, motion: capture.motion });
log({ event: "motion_gate", pass: motionGate.pass, metrics: motionGate.metrics });
}
} catch (e) {
// A render crash (e.g. a page.goto timeout on trickling media, or a Playwright fault)
// must NOT escape reportless — otherwise validation/ stays empty and the run looks
// un-validated. Record the error as a runtime error so httpStatus stays 0, gate 0 fails
// with a visible issue, and the normal downstream path writes report.json as usual.
runtimeErrors.push(`render error: ${e instanceof Error ? e.message : String(e)}`);
log({ event: "render_error", error: e instanceof Error ? e.message : String(e) });
} finally {
await server.close();
}
}
// Video/audio elements stream and their requests are aborted ("failed") when the
// render snapshot is taken before the stream finishes — the file is materialized,
// so this is not an asset failure. Only count non-streaming assets.
const assetFailed = failedResources.filter(
(f) => f.includes("/assets/") && !/\.(mp4|webm|mov|m4v|ogv|ogg|mp3|wav|m3u8)(\?|$)/i.test(f),
);
const artifactsPresent = ["manifest.json", "sections.json", "tokens.json", "assets.json", "fonts.json"].every((f) => fileExists(join(generatedDir, f)));
const gate0Issues: string[] = [];
if (!build.ok) gate0Issues.push("build failed: " + build.stderr.split("\n").filter(Boolean).slice(-3).join(" | "));
if (httpStatus !== 200) gate0Issues.push(`http status ${httpStatus}`);
if (runtimeErrors.length) gate0Issues.push(`${runtimeErrors.length} runtime errors`);
if (!artifactsPresent) gate0Issues.push("missing required artifacts");
// De-duplicate runtime errors and keep a sample so the report is debuggable.
const uniqueErrors = [...new Set(runtimeErrors)].slice(0, 5);
const gate0: GateResult = {
gate: "build",
pass: build.ok && httpStatus === 200 && runtimeErrors.length === 0 && artifactsPresent,
metrics: { buildOk: build.ok, http200: httpStatus === 200, noRuntimeErrors: runtimeErrors.length === 0, artifactsPresent, buildMs: build.durationMs, runtimeErrorSample: uniqueErrors },
issues: gate0Issues,
};
// ---- Gate 1: capture completeness ----
const screenshots: Record<number, boolean> = {};
for (const vp of viewports) screenshots[vp] = fileExists(join(sourceDir, "screenshots", `${vp}.png`));
const gate1 = gate1Capture(ir, viewports, {
screenshots,
assetsPresent: fileExists(join(sourceDir, "assets-discovered.json")),
fontsPresent: fileExists(join(sourceDir, "fonts-discovered.json")),
});
// ---- Gate 2: asset/font ----
const remoteRefs = findRemoteRefs(snapshots);
const gate2 = gate2Assets(assetGraph, fontGraph, { remoteRefs, failed404: assetFailed });
// ---- Gates 3,4,5 ----
const gate3 = gate3Dom(ir, snapshots, viewports, origin);
const gate4 = gate4Style(ir, snapshots, viewports);
const gate5 = gate5Layout(ir, snapshots, sections, viewports, reflowOpt);
// ---- Gate 6: determinism ----
const gate6 = checkDeterminism(sourceDir, capture, viewports, url);
// ---- Stage 2 gates: pollution + perceptual ----
const pollution = gatePollution(ir, capture, viewports);
const perceptual = screenshotDiff(sourceDir, renderedDir, viewports, validationDir, opts?.tier);
// ---- Responsive gate: fluidity/centering at non-captured widths (diagnostic; not in 06) ----
const responsive = gateResponsive(ir, probeSnaps, viewports);
const gates: Record<string, GateResult> = {
build: gate0, capture: gate1, asset_font: gate2, dom: gate3, style: gate4,
layout: gate5, determinism: gate6, pollution, perceptual, responsive, interaction: interactionGate, motion: motionGate,
};
const report = buildReport({ sourceUrl: url, tier: opts?.tier ?? "unknown", compilerVersion: COMPILER_VERSION, gates });
writeJSON(join(validationDir, "report.json"), report);
writeText(join(validationDir, "report.md"), reportToMarkdown(report));
if (build.stderr && !build.ok) writeText(join(runDir, "logs", "build.log"), build.stderr);
log({ event: "validated", status: report.status, score: report.scorecard.total, gates0to6: report.gates0to6Pass });
// Prune: if the interaction gate rejected any patterns, regenerate the deliverable
// app with them excluded (left static) — so the shipped clone never reproduces an
// interaction that the gate proved doesn't work. Base DOM/CSS is unchanged (only
// DittoWire wiring drops), so the graded gates still hold.
const rejectedN = ((interactionGate.metrics.rejected as string[] | undefined) ?? []).length;
if (rejectedN) {
generateAll({ sourceDir, capture, viewports, sampleViewports: capture.viewports, url, outDir: generatedDir });
log({ event: "interaction_pruned", patterns: rejectedN });
}
return report;
}
function checkDeterminism(sourceDir: string, capture: CaptureResult, viewports: number[], url: string): GateResult {
const a = mkdtempSync(join(tmpdir(), "det-a-"));
const b = mkdtempSync(join(tmpdir(), "det-b-"));
const issues: string[] = [];
const cloneOpts = fileExists(join(sourceDir, "clone-options.json"))
? readJSON<{ framework?: "next" | "vite" }>(join(sourceDir, "clone-options.json"))
: {};
const files = cloneOpts.framework === "vite" ? VITE_DETERMINISM_FILES : DETERMINISM_FILES;
try {
generateAll({ sourceDir, capture, viewports, sampleViewports: capture.viewports, url, outDir: join(a, "generated"), ignoreRejectedInteractions: true });
generateAll({ sourceDir, capture, viewports, sampleViewports: capture.viewports, url, outDir: join(b, "generated"), ignoreRejectedInteractions: true });
for (const f of files) {
const pa = join(a, "generated", f), pb = join(b, "generated", f);
const ea = existsSync(pa), eb = existsSync(pb);
if (!ea && !eb) continue; // optional file (e.g. content.ts) not generated by either run
if (ea !== eb) { issues.push(`nondeterministic presence: ${f}`); continue; }
if (readFileSync(pa, "utf8") !== readFileSync(pb, "utf8")) issues.push(`differs: ${f}`);
}
} catch (e) {
issues.push("determinism run error: " + String(e));
} finally {
rmSync(a, { recursive: true, force: true });
rmSync(b, { recursive: true, force: true });
}
return { gate: "determinism", pass: issues.length === 0, metrics: { filesCompared: files.length, mismatches: issues.length }, issues };
}
function readPng(path: string): PNG | null {
try { return PNG.sync.read(readFileSync(path)); } catch { return null; }
}
// Perceptual pass threshold (fraction of differing pixels at the worst viewport).
// Heavier tiers carry more legitimately-irreproducible pixels — hero photography,
// gradients, residual animation/video frames — so the bar loosens with difficulty.
export const PERCEPTUAL_THRESHOLD: Record<string, number> = { easy: 0.1, medium: 0.12, hard: 0.14, stage2: 0.16 };
export function screenshotDiff(sourceDir: string, renderedDir: string, viewports: number[], validationDir: string, tier?: string): GateResult {
const perVp: Record<number, number> = {};
const issues: string[] = [];
let worst = 0;
for (const vp of viewports) {
const srcPng = readPng(join(sourceDir, "screenshots", `${vp}.png`));
const genPng = readPng(join(renderedDir, "screenshots", `${vp}.png`));
if (!srcPng || !genPng) { perVp[vp] = 1; worst = 1; issues.push(`vp${vp} missing screenshot`); continue; }
const w = Math.min(srcPng.width, genPng.width);
const h = Math.min(srcPng.height, genPng.height);
const a = cropTo(srcPng, w, h);
const b = cropTo(genPng, w, h);
const diff = new PNG({ width: w, height: h });
const diffPx = pixelmatch(a.data, b.data, diff.data, w, h, { threshold: 0.1 });
const ratio = diffPx / (w * h);
perVp[vp] = Math.round(ratio * 10000) / 10000;
worst = Math.max(worst, ratio);
// include height mismatch penalty signal
if (Math.abs(srcPng.height - genPng.height) / Math.max(srcPng.height, 1) > 0.05) {
issues.push(`vp${vp} screenshot height mismatch ${srcPng.height} vs ${genPng.height}`);
}
}
const threshold = PERCEPTUAL_THRESHOLD[tier ?? "hard"] ?? 0.14;
const worstPct = Math.round(worst * 10000) / 10000;
if (worstPct > threshold) issues.push(`worst viewport diff ${(worstPct * 100).toFixed(1)}% (> ${(threshold * 100).toFixed(0)}%)`);
return { gate: "perceptual", pass: worst <= threshold, metrics: { perViewport: perVp, worstDiffPct: worstPct, threshold }, issues };
}
function cropTo(png: PNG, w: number, h: number): PNG {
if (png.width === w && png.height === h) return png;
const out = new PNG({ width: w, height: h });
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const si = (png.width * y + x) << 2;
const di = (w * y + x) << 2;
out.data[di] = png.data[si]!; out.data[di + 1] = png.data[si + 1]!;
out.data[di + 2] = png.data[si + 2]!; out.data[di + 3] = png.data[si + 3]!;
}
}
return out;
}