/*
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,
};
}