/* RFC-0221: the golden validation pack — capture a site's significant properties (route list, sitemap hash, llms hashes, passport scores) from a build output, and diff a freshly built pack against the bundle's golden one so absorb can confirm the catch-up preserved them. Do not build the site — operate on an existing build output. Do not capture DOM/screenshots — structural facts only (advisory visuals are out of scope). RFC-0221: golden validation pack emit + diff. */ import path from "node:path"; import { sha256OfBytes } from "./bundle-io.ts"; import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; export interface ValidationPack { /** Output-relative POSIX paths of rendered HTML routes, sorted. */ routes: string[]; /** sha256 of sitemap.xml, or null when absent. */ sitemapHash: string | null; /** filename → sha256 for llms.txt / llms-full.txt. */ llmsHashes: Record; /** Passport nebula score + per-pillar scores, or null when no passport was emitted. */ scores: { nebula: number; pillars: Record } | null; /** True when no build output was found (pack is empty/non-authoritative). */ empty: boolean; } export interface ValidationDiff { routesAdded: string[]; routesRemoved: string[]; sitemapChanged: boolean; llmsChanged: string[]; scoreDeltas: { key: string; from: number | null; to: number | null }[]; /** True when nothing of significance changed. */ clean: boolean; } const LLMS_FILES = ["llms.txt", "llms-full.txt"]; async function exists(p: string, io: WorkspaceIO = createDefaultIO().io): Promise { try { await io.stat(p); return true; } catch { return false; } } /** The directory that holds the served site (Cloudflare adapter → dist/client; else dist). */ async function resolveOutputDir(appDir: string): Promise { const client = path.join(appDir, "dist", "client"); if (await exists(client)) return client; const dist = path.join(appDir, "dist"); if (await exists(dist)) return dist; return null; } async function walkHtml(root: string): Promise { const out: string[] = []; async function rec(dir: string, io: WorkspaceIO = createDefaultIO().io): Promise { let entries; try { entries = await io.readdir(dir); } catch { return; } for (const e of entries) { const abs = path.join(dir, e.name); if (e.isDirectory) { if (e.name === "_astro" || e.name === "_img" || e.name === "_video") continue; await rec(abs); } else if (e.name.endsWith(".html")) { out.push(path.relative(root, abs).replace(/\\/g, "/")); } } } await rec(root); return out.sort(); } async function hashIfExists( p: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { try { return sha256OfBytes(await io.readFile(p)); } catch { return null; } } async function readScores( outDir: string, appDir: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { for (const candidate of [ path.join(outDir, ".well-known", "cosmic-passport.json"), path.join(appDir, "dist", ".well-known", "cosmic-passport.json"), ]) { try { const raw = await io.readFile(candidate); const parsed = JSON.parse(raw) as { scores?: { nebula?: number; pillars?: Record }; }; if (!parsed.scores) continue; const pillars: Record = {}; for (const [k, v] of Object.entries(parsed.scores.pillars ?? {})) { if (typeof v?.score === "number") pillars[k] = v.score; } return { nebula: parsed.scores.nebula ?? 0, pillars }; } catch { /* try next candidate */ } } return null; } /** Read the significant properties of an app's current build output into a ValidationPack. */ export async function buildValidationPack(appDir: string): Promise { const outDir = await resolveOutputDir(appDir); if (!outDir) { return { routes: [], sitemapHash: null, llmsHashes: {}, scores: null, empty: true }; } const routes = await walkHtml(outDir); const sitemapHash = await hashIfExists(path.join(outDir, "sitemap.xml")); const llmsHashes: Record = {}; for (const name of LLMS_FILES) { const h = await hashIfExists(path.join(outDir, name)); if (h) llmsHashes[name] = h; } const scores = await readScores(outDir, appDir); return { routes, sitemapHash, llmsHashes, scores, empty: false }; } function diffStringSets(a: string[], b: string[]): { added: string[]; removed: string[] } { const setA = new Set(a); const setB = new Set(b); return { added: b.filter((x) => !setA.has(x)).sort(), removed: a.filter((x) => !setB.has(x)).sort(), }; } /** Diff a freshly built pack (`fresh`) against the bundle's golden pack (`golden`). */ export function diffValidationPacks(golden: ValidationPack, fresh: ValidationPack): ValidationDiff { const routes = diffStringSets(golden.routes, fresh.routes); const llmsChanged: string[] = []; const keys = new Set([...Object.keys(golden.llmsHashes), ...Object.keys(fresh.llmsHashes)]); for (const k of keys) { if (golden.llmsHashes[k] !== fresh.llmsHashes[k]) llmsChanged.push(k); } llmsChanged.sort(); const scoreDeltas: ValidationDiff["scoreDeltas"] = []; const gScores = golden.scores; const fScores = fresh.scores; if (gScores || fScores) { if ((gScores?.nebula ?? null) !== (fScores?.nebula ?? null)) { scoreDeltas.push({ key: "nebula", from: gScores?.nebula ?? null, to: fScores?.nebula ?? null, }); } const pillarKeys = new Set([ ...Object.keys(gScores?.pillars ?? {}), ...Object.keys(fScores?.pillars ?? {}), ]); for (const k of pillarKeys) { const from = gScores?.pillars[k] ?? null; const to = fScores?.pillars[k] ?? null; if (from !== to) scoreDeltas.push({ key: `pillar.${k}`, from, to }); } } const sitemapChanged = golden.sitemapHash !== fresh.sitemapHash; const clean = routes.added.length === 0 && routes.removed.length === 0 && !sitemapChanged && llmsChanged.length === 0 && scoreDeltas.length === 0; return { routesAdded: routes.added, routesRemoved: routes.removed, sitemapChanged, llmsChanged, scoreDeltas, clean, }; }