/* Maintains packages/os/site-kernel-handoff/src/behavior-snapshot/behavior-snapshot-commands.ts as an authored site-kernel-handoff authored module so agents can evolve it without rediscovering local boundaries. Do not introduce app-specific runtime composition or deployment behavior into this reusable package source file. RFC-0595: detect redirect pages, set contentHash: null + redirectTarget. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt RFC-1254: drop unused io-migration leftovers RFC-0357, RFC-0379, RFC-0585, RFC-0588, RFC-0592 */ import path from "node:path"; import crypto from "node:crypto"; import type { KernelCommandInput, KernelCommandResult, KernelRuntimeContext, } from "@warpgogol/werkstatt-engine/kernel"; import { collectFiles } from "@warpgogol/werkstatt-shared/node/fs"; import { parseRedirectRules, extractRedirectTarget, type RedirectRule, } from "@warpgogol/werkstatt-shared/redirects"; import { isHtmlRedirectPage } from "@warpgogol/werkstatt-shared/semantic"; import { hashHtml } from "@warpgogol/werkstatt-engine/fingerprint"; import type { RouteFact } from "@warpgogol/werkstatt-engine/schemas"; import { type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; function flagString(input: KernelCommandInput, key: string): string | undefined { const v = input.flags[key]; return typeof v === "string" ? v : undefined; } interface BehaviorSnapshot { routes: RouteFact[]; routeCount: number; sitemapHash: string; llmsHashes: Record; robotsDirectives: string; headersHash: string; redirectsHash: string; } export interface BehaviorSnapshotWrapper { schemaVersion: string; systemId: string; releaseId: string | null; buildKind: string; capturedAt: string; behaviorSnapshot: BehaviorSnapshot; behaviorSnapshotHash: string; } async function hashContent(content: string): Promise { return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`; } export function isRouteRedirected(routePath: string, rules: RedirectRule[]): boolean { return rules.some((rule) => { if (rule.status !== 301 && rule.status !== 308) return false; const escaped = rule.from.replace(/[.+?^${}()|[\]\\]/g, "\\$&"); const pattern = escaped.replace(/\/\*$/, "(/.*)?$").replace(/\*/g, ".*"); const regex = new RegExp(`^${pattern}$`); return regex.test(routePath); }); } export async function collectRoutes( distDir: string, redirectRules: RedirectRule[] = [], io: WorkspaceIO, ): Promise { const routes: RouteFact[] = []; if (!(await io.exists(distDir))) return routes; for (const fullPath of await collectFiles(distDir, { extensions: [".html"] })) { if (path.basename(fullPath) !== "index.html") continue; const relPath = path.relative(distDir, fullPath).replace(/\\/g, "/"); const routePath = "/" + relPath.replace(/index\.html$/, "").replace(/\/$/, ""); if (isRouteRedirected(routePath || "/", redirectRules)) continue; const html = await io.readFile(fullPath); if (isHtmlRedirectPage(html)) { const redirectTarget = extractRedirectTarget(html) ?? "unknown"; routes.push({ path: routePath || "/", contentHash: null, redirectTarget }); } else { const contentHash = hashHtml(html); routes.push({ path: routePath || "/", contentHash }); } } routes.sort((a, b) => a.path.localeCompare(b.path)); return routes; } async function hashFileIfExists(filePath: string, io: WorkspaceIO): Promise { if (!(await io.exists(filePath))) return "sha256:absent"; const content = await io.readFile(filePath); return `sha256:${crypto.createHash("sha256").update(content).digest("hex")}`; } // §6.5: behavior.snapshot.capture export interface BehaviorSnapshotCaptureData { systemId: string; buildKind: "readable" | "production"; releaseId: string | null; behaviorSnapshotHash: string; routeCount: number; capturedAt: string; wrapper: BehaviorSnapshotWrapper; } export async function runBehaviorSnapshotCapture( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot, logger } = context; const distPath = flagString(input, "dist"); const systemId = flagString(input, "system"); const buildKind = flagString(input, "build-kind") as "readable" | "production" | undefined; const releaseId = flagString(input, "release"); if (!distPath || !systemId || !buildKind) { throw new Error("[behavior.snapshot.capture] --dist, --system, and --build-kind are required"); } if (buildKind !== "readable" && buildKind !== "production") { throw new Error( `[behavior.snapshot.capture] --build-kind must be 'readable' or 'production', got '${buildKind}'`, ); } const distDir = path.resolve(workspaceRoot, distPath); if (!(await context.io.exists(distDir))) { throw new Error(`[behavior.snapshot.capture] dist directory not found: ${distDir}`); } const redirectsPath = path.join(distDir, "_redirects"); const redirectsContent = (await context.io.exists(redirectsPath)) ? await context.io.readFile(redirectsPath) : ""; const redirectRules = redirectsContent ? parseRedirectRules(redirectsContent) : []; const routes = await collectRoutes(distDir, redirectRules, context.io); const sitemapPath = path.join(distDir, "sitemap.xml"); const sitemapHash = await hashFileIfExists(sitemapPath, context.io); const robotsPath = path.join(distDir, "robots.txt"); const robotsContent = (await context.io.exists(robotsPath)) ? await context.io.readFile(robotsPath) : ""; const headersPath = path.join(distDir, "_headers"); const snapshot: BehaviorSnapshot = { routes, routeCount: routes.length, sitemapHash, llmsHashes: {}, robotsDirectives: robotsContent, headersHash: await hashFileIfExists(headersPath, context.io), redirectsHash: redirectsContent ? await hashContent(redirectsContent) : "sha256:absent", }; const now = new Date().toISOString(); const wrapper = { schemaVersion: "1.0.0", systemId, releaseId: releaseId ?? null, buildKind, capturedAt: now, behaviorSnapshot: snapshot, behaviorSnapshotHash: await hashContent(JSON.stringify(snapshot, null, 2)), }; logger.info(` Routes: ${snapshot.routeCount}, sitemap: ${snapshot.sitemapHash.slice(0, 16)}...`); return { data: { systemId, buildKind, releaseId: releaseId ?? null, behaviorSnapshotHash: wrapper.behaviorSnapshotHash, routeCount: snapshot.routeCount, capturedAt: now, wrapper, }, exitCode: 0, summary: `[behavior.snapshot.capture] ${systemId} (${buildKind}): ${snapshot.routeCount} routes`, nextSteps: [ { action: `Prepare a release: pnpm exec werkstatt run release.prepare --mission `, kind: "optional", }, ], }; } // §6.6: behavior.snapshot.diff export interface BehaviorSnapshotDiffData { verdict: "pass" | "fail"; baselineHash: string; candidateHash: string; differences: Array<{ field: string; kind: "added" | "removed" | "changed"; detail: string }>; } export async function runBehaviorSnapshotDiff( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot, logger } = context; const baselinePath = flagString(input, "baseline"); const candidatePath = flagString(input, "candidate"); if (!baselinePath || !candidatePath) { throw new Error("[behavior.snapshot.diff] --baseline and --candidate are required"); } const baselineFile = path.resolve(workspaceRoot, baselinePath); const candidateFile = path.resolve(workspaceRoot, candidatePath); if (!(await context.io.exists(baselineFile))) { throw new Error(`[behavior.snapshot.diff] baseline not found: ${baselineFile}`); } if (!(await context.io.exists(candidateFile))) { throw new Error(`[behavior.snapshot.diff] candidate not found: ${candidateFile}`); } const baselineRaw = JSON.parse(await context.io.readFile(baselineFile)); const candidateRaw = JSON.parse(await context.io.readFile(candidateFile)); const baseline = baselineRaw.behaviorSnapshot ?? baselineRaw; const candidate = candidateRaw.behaviorSnapshot ?? candidateRaw; const baselineHash = baselineRaw.behaviorSnapshotHash ?? (await hashContent(JSON.stringify(baseline, null, 2))); const candidateHash = candidateRaw.behaviorSnapshotHash ?? (await hashContent(JSON.stringify(candidate, null, 2))); const differences: Array<{ field: string; kind: "added" | "removed" | "changed"; detail: string; }> = []; // Compare route count if (baseline.routeCount !== candidate.routeCount) { differences.push({ field: "routeCount", kind: "changed", detail: `baseline: ${baseline.routeCount}, candidate: ${candidate.routeCount}`, }); } // Compare route sets const baselineRoutes = new Map((baseline.routes ?? []).map((r: RouteFact) => [r.path, r])); const candidateRoutes = new Map((candidate.routes ?? []).map((r: RouteFact) => [r.path, r])); for (const [routePath] of baselineRoutes) { if (!candidateRoutes.has(routePath)) { differences.push({ field: "routes", kind: "removed", detail: `route '${routePath}' present in baseline but missing in candidate`, }); } } for (const [routePath] of candidateRoutes) { if (!baselineRoutes.has(routePath)) { differences.push({ field: "routes", kind: "added", detail: `route '${routePath}' present in candidate but missing in baseline`, }); } } // Compare sitemap hash if (baseline.sitemapHash !== candidate.sitemapHash) { differences.push({ field: "sitemapHash", kind: "changed", detail: `baseline: ${baseline.sitemapHash}, candidate: ${candidate.sitemapHash}`, }); } // Compare headers if (baseline.headersHash !== candidate.headersHash) { differences.push({ field: "headers", kind: "changed", detail: `headers differ: ${baseline.headersHash} vs ${candidate.headersHash}`, }); } // Compare redirects if (baseline.redirectsHash !== candidate.redirectsHash) { differences.push({ field: "redirects", kind: "changed", detail: `redirects differ: ${baseline.redirectsHash} vs ${candidate.redirectsHash}`, }); } const verdict = differences.length === 0 ? "pass" : "fail"; if (verdict === "pass") { logger.success(`[behavior.snapshot.diff] pass — 0 structural differences`); } else { logger.error(`[behavior.snapshot.diff] FAIL — ${differences.length} structural differences`); for (const diff of differences) { logger.error(` ${diff.kind} ${diff.field}: ${diff.detail}`); } } return { data: { verdict, baselineHash, candidateHash, differences }, exitCode: verdict === "pass" ? 0 : 1, summary: `[behavior.snapshot.diff] ${verdict} — ${differences.length} structural differences`, nextSteps: verdict === "fail" ? [ { action: `Review the ${differences.length} structural difference(s) above and decide whether to accept or fix them, then re-run: pnpm exec werkstatt run behavior.snapshot.diff`, kind: "required", }, ] : undefined, }; }