diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml index e20f433..5c068a8 100644 --- a/.github/workflows/contract.yml +++ b/.github/workflows/contract.yml @@ -43,3 +43,9 @@ jobs: - name: Run error drift script tests run: node --test scripts/check-round-errors.test.mjs + + - name: Check committed snapshot health + run: node scripts/check-snapshots.mjs + + - name: Run snapshot health script tests + run: node --test scripts/check-snapshots.test.mjs diff --git a/.github/workflows/snapshots.yml b/.github/workflows/snapshots.yml new file mode 100644 index 0000000..61c3da8 --- /dev/null +++ b/.github/workflows/snapshots.yml @@ -0,0 +1,40 @@ +name: Snapshot health check + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + snapshots: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: pnpm/action-setup@v4 + with: + version: 10.13.1 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + + - name: Install JS dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Run snapshot health unit tests + run: pnpm snapshot:test + + - name: Check committed contract snapshots and dashboard evidence + run: pnpm snapshot:check diff --git a/README.md b/README.md index 0d6402a..84cb5df 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ pnpm install pnpm contract:test # 14 Rust tests pnpm bindings:generate # generate TS bindings from the contract pnpm bindings:check # verify committed bindings match the contract +pnpm snapshot:check # verify committed contract snapshots & dashboard evidence pnpm web:dev # jury UI — works without .env pnpm agents:e2e # testnet full agent proof (needs stellar keys) pnpm mainnet:verify # mainnet read-only proof diff --git a/docs/CI.md b/docs/CI.md index dabddd1..3f102fb 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -166,3 +166,12 @@ policy locally: pnpm rust:audit:test pnpm rust:audit:check ``` + +## Snapshot health check + +GitHub Actions workflow: `.github/workflows/snapshots.yml` (and `.github/workflows/contract.yml`) + +Runs `pnpm snapshot:test` (`node --test scripts/check-snapshots.test.mjs`) and `pnpm snapshot:check` (`node scripts/check-snapshots.mjs`) on pushes to `main` and on pull requests. The check fails when: + +1. Any required contract test snapshot category under `contracts/round/test_snapshots/test` is missing, or any committed `.json` snapshot fails Soroban payload validation (`generators`, `auth`, `ledger`, `events`). +2. Committed dashboard demo evidence (`apps/web/src/demo/demo-trace.generated.ts` and `apps/web/src/demo/demo-trace.checksum.ts`) is missing, has a stale `LIFE_CYCLE_PHASES_CHECKSUM`, omits required lifecycle milestones or bidder/auditor evidence blobs, or contains a secret seed. diff --git a/package.json b/package.json index 6a6794c..d883702 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "web:test": "pnpm --filter @sub-rosa/web test", "web:typecheck": "pnpm --filter @sub-rosa/web typecheck", "snapshot:check": "node scripts/check-snapshots.mjs", + "snapshot:test": "node --test scripts/check-snapshots.test.mjs", "docs:check": "node scripts/check-deploy-docs.mjs", "docs:check-links": "node scripts/check-links.js", "coverage:test": "node --test scripts/check-rust-audit.test.mjs && node scripts/check-rust-audit.mjs && node --test scripts/run-ts-coverage.test.mjs && node scripts/run-ts-coverage.mjs", diff --git a/scripts/check-snapshots.mjs b/scripts/check-snapshots.mjs index ea719dd..a77fbcc 100644 --- a/scripts/check-snapshots.mjs +++ b/scripts/check-snapshots.mjs @@ -1,41 +1,50 @@ #!/usr/bin/env node import { normalizeError } from "../packages/logging/src/errors.cjs"; // Copyright (c) 2026 Sub Rosa contributors -import { createLogger } from '../packages/logging/src/index.cjs'; +import { createLogger } from "../packages/logging/src/index.cjs"; const diagnostics = createLogger("scripts.check-snapshots"); // check-snapshots.mjs // -// CI guard for Issue #120 — contract snapshot inventory check. +// CI guard for Issue #120 & #521 — contract snapshot & dashboard evidence check. // -// Scans contracts/round/test_snapshots/test for .json files and verifies -// that every required snapshot category is still present. The check is -// purely filename-based: no snapshot payloads are parsed. +// Verifies that: +// 1. Every required contract test snapshot category is present in +// contracts/round/test_snapshots/test and every committed .json snapshot +// parses as a valid Soroban snapshot object (generators, auth, ledger, events). +// 2. Committed dashboard demo evidence (apps/web/src/demo/demo-trace.generated.ts +// and apps/web/src/demo/demo-trace.checksum.ts) is present, matches the +// canonical lifecycle milestone checksum, includes all required lifecycle +// phases/bidders/agents/keeper/settlement/auditor blobs, and contains no +// secret seeds. // // Usage (from repo root): // node scripts/check-snapshots.mjs -// pnpm snapshot:check ← added to root package.json +// pnpm snapshot:check // // Exit codes: -// 0 all required categories present -// 1 one or more categories missing +// 0 all required contract snapshots and dashboard evidence are healthy +// 1 missing, corrupted, or stale contract snapshot or dashboard evidence -import { readdirSync } from "node:fs"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import { resolve, dirname, join, relative } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const SNAPSHOT_DIR = resolve( +export const DEFAULT_SNAPSHOT_DIR = resolve( __dirname, "../contracts/round/test_snapshots/test", ); +export const DEFAULT_DEMO_TRACE_PATH = resolve( + __dirname, + "../apps/web/src/demo/demo-trace.generated.ts", +); +export const DEFAULT_CHECKSUM_PATH = resolve( + __dirname, + "../apps/web/src/demo/demo-trace.checksum.ts", +); -// --------------------------------------------------------------------------- -// Required snapshot categories. -// -// Each entry is a prefix that must be matched by at least one .json file in -// the snapshot directory. They are grouped by lifecycle phase for readability. -// --------------------------------------------------------------------------- -const REQUIRED_CATEGORIES = [ +export const REQUIRED_CATEGORIES = [ // ── Create ────────────────────────────────────────────────────────────── "create_round_happy_path", "create_round_rejects_commit_after_reveal", @@ -104,44 +113,361 @@ const REQUIRED_CATEGORIES = [ "seeded_case_7_lowest_bid_reproducible", ]; -// --------------------------------------------------------------------------- -// Check -// --------------------------------------------------------------------------- +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} -let files; -try { - files = readdirSync(SNAPSHOT_DIR).filter((f) => f.endsWith(".json")); -} catch (err) { - diagnostics.error("cannot-read-snapshot-directory", `\n✗ Cannot read snapshot directory: ${SNAPSHOT_DIR}`); - diagnostics.error("progress", ` ${normalizeError(err).message}`); - process.exit(1); +function collectJsonFiles(dir) { + const results = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...collectJsonFiles(full)); + } else if (entry.name.endsWith(".json")) { + results.push(full); + } + } + return results; } -// Strip the trailing ..json suffix to get the bare category name. -const presentCategories = new Set( - files.map((f) => f.replace(/\.\d+\.json$/, "")), -); +export function validateSnapshotPayload(rawText, label) { + let parsed; + try { + parsed = JSON.parse(rawText); + } catch (err) { + return `${label}: invalid JSON (${normalizeError(err).message})`; + } + if (!isRecord(parsed)) { + return `${label}: snapshot root must be a JSON object`; + } + if (!isRecord(parsed.generators)) { + return `${label}: missing or invalid 'generators' object`; + } + if (!Array.isArray(parsed.auth)) { + return `${label}: missing or invalid 'auth' array`; + } + if (!isRecord(parsed.ledger)) { + return `${label}: missing or invalid 'ledger' object`; + } + if (!Array.isArray(parsed.events)) { + return `${label}: missing or invalid 'events' array`; + } + return null; +} -const missing = REQUIRED_CATEGORIES.filter( - (cat) => !presentCategories.has(cat), -); +export function checkContractSnapshots(snapshotDir = DEFAULT_SNAPSHOT_DIR) { + let topEntries; + try { + topEntries = readdirSync(snapshotDir, { withFileTypes: true }); + } catch (err) { + return { + ok: false, + dirError: normalizeError(err).message, + filesFound: 0, + categoriesChecked: REQUIRED_CATEGORIES.length, + missingCategories: [], + invalidSnapshots: [], + }; + } + + const topFiles = topEntries + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => entry.name); + + const presentCategories = new Set( + topFiles.map((f) => f.replace(/\.\d+\.json$/, "")), + ); + + const missingCategories = REQUIRED_CATEGORIES.filter( + (cat) => !presentCategories.has(cat), + ); + + const allJsonFiles = collectJsonFiles(snapshotDir); + const invalidSnapshots = []; + for (const fullPath of allJsonFiles) { + const relLabel = relative(snapshotDir, fullPath).replaceAll("\\", "/"); + try { + const content = readFileSync(fullPath, "utf8"); + const issue = validateSnapshotPayload(content, relLabel); + if (issue) invalidSnapshots.push(issue); + } catch (err) { + invalidSnapshots.push( + `${relLabel}: unreadable snapshot (${normalizeError(err).message})`, + ); + } + } + + return { + ok: missingCategories.length === 0 && invalidSnapshots.length === 0, + dirError: null, + filesFound: topFiles.length, + categoriesChecked: REQUIRED_CATEGORIES.length, + missingCategories, + invalidSnapshots, + }; +} + +export function parseDemoTraceSource(source) { + const match = source.match( + /export\s+const\s+DEMO_TRACE\s*=\s*(\{[\s\S]*\})\s*as\s+const\s*;/, + ); + if (!match) { + throw new Error("Could not locate `export const DEMO_TRACE = { ... } as const;`"); + } + return JSON.parse(match[1]); +} + +export function parseChecksumSource(source) { + const milestonesMatch = source.match( + /export\s+const\s+REQUIRED_MILESTONES\s*=\s*\[([\s\S]*?)\]\s*as\s+const\s*;/, + ); + if (!milestonesMatch) { + throw new Error("Could not locate `REQUIRED_MILESTONES` in checksum file"); + } + const milestones = [...milestonesMatch[1].matchAll(/"([^"]+)"/g)].map( + (m) => m[1], + ); + const checksumMatch = source.match( + /export\s+const\s+LIFE_CYCLE_PHASES_CHECKSUM\s*=\s*"([0-9a-f]{64})"\s*;/, + ); + if (!checksumMatch) { + throw new Error( + "Could not locate `LIFE_CYCLE_PHASES_CHECKSUM` in checksum file", + ); + } + return { + requiredMilestones: milestones, + expectedChecksum: checksumMatch[1], + }; +} + +export function checkDashboardEvidence({ + demoTracePath = DEFAULT_DEMO_TRACE_PATH, + checksumPath = DEFAULT_CHECKSUM_PATH, +} = {}) { + const issues = []; + let traceSource; + let checksumSource; + try { + traceSource = readFileSync(demoTracePath, "utf8"); + } catch (err) { + return { + ok: false, + issues: [ + `Cannot read demo trace (${demoTracePath}): ${normalizeError(err).message}`, + ], + }; + } + try { + checksumSource = readFileSync(checksumPath, "utf8"); + } catch (err) { + return { + ok: false, + issues: [ + `Cannot read demo trace checksum (${checksumPath}): ${normalizeError(err).message}`, + ], + }; + } -const total = files.length; -const required = REQUIRED_CATEGORIES.length; + let trace; + let checksumConfig; + try { + trace = parseDemoTraceSource(traceSource); + } catch (err) { + return { + ok: false, + issues: [`Invalid demo trace file: ${normalizeError(err).message}`], + }; + } + try { + checksumConfig = parseChecksumSource(checksumSource); + } catch (err) { + return { + ok: false, + issues: [`Invalid demo checksum file: ${normalizeError(err).message}`], + }; + } + + const canonical = [...checksumConfig.requiredMilestones].sort().join("|"); + const computedChecksum = createHash("sha256").update(canonical).digest("hex"); + if (computedChecksum !== checksumConfig.expectedChecksum) { + issues.push( + `LIFE_CYCLE_PHASES_CHECKSUM mismatch: expected ${checksumConfig.expectedChecksum}, computed ${computedChecksum}`, + ); + } + + if (/\bS[A-Z2-7]{55}\b/.test(JSON.stringify(trace))) { + issues.push("DEMO_TRACE must not contain a Stellar secret seed"); + } + + if ( + !isRecord(trace.meta) || + typeof trace.meta.contractId !== "string" || + trace.meta.contractId.trim() === "" || + typeof trace.meta.recordedAt !== "string" || + trace.meta.recordedAt.trim() === "" || + typeof trace.meta.roundStatus !== "string" || + trace.meta.roundStatus.trim() === "" + ) { + issues.push("DEMO_TRACE.meta is missing required contractId, recordedAt, or roundStatus"); + } + + if (!Array.isArray(trace.lifecycle) || trace.lifecycle.length === 0) { + issues.push("DEMO_TRACE.lifecycle must be a non-empty array"); + } else { + const phases = trace.lifecycle + .map((item) => (isRecord(item) && typeof item.phase === "string" ? item.phase : null)) + .filter(Boolean); + const missingMilestones = checksumConfig.requiredMilestones.filter( + (m) => !phases.includes(m), + ); + if (missingMilestones.length > 0) { + issues.push( + `DEMO_TRACE.lifecycle is missing required milestones: ${missingMilestones.join(", ")}`, + ); + } + const settleCount = phases.filter((p) => p === "settle").length; + if (settleCount !== 1 || phases[phases.length - 1] !== "settle") { + issues.push("DEMO_TRACE.lifecycle must end with a single settle phase"); + } + } -diagnostics.info("contract-snapshot-inventory", `\nContract snapshot inventory`); -diagnostics.info("directory", ` Directory : ${SNAPSHOT_DIR}`); -diagnostics.info("files-found", ` Files found : ${total}`); -diagnostics.info("categories-checked", ` Categories checked : ${required}`); + const bidderLabels = + Array.isArray(trace.bidders) && trace.bidders.length > 0 + ? trace.bidders + .map((b) => (isRecord(b) && typeof b.label === "string" ? b.label.trim() : "")) + .filter(Boolean) + : []; + if (bidderLabels.length === 0) { + issues.push("DEMO_TRACE.bidders must contain at least one bidder"); + } -if (missing.length === 0) { - diagnostics.info("all", `\n✓ All ${required} required snapshot categories are present.\n`); - process.exit(0); -} else { - diagnostics.error("progress-2", `\n✗ ${missing.length} required snapshot category/categories missing:\n`); - for (const cat of missing) { - diagnostics.error("progress-3", ` - ${cat}`); + const agentNames = + Array.isArray(trace.agents) && trace.agents.length > 0 + ? trace.agents + .map((a) => (isRecord(a) && typeof a.name === "string" ? a.name.trim() : "")) + .filter(Boolean) + : []; + for (const label of bidderLabels) { + if (!agentNames.includes(label)) { + issues.push(`DEMO_TRACE bidder "${label}" is missing a matching agent record`); + } } - diagnostics.error("regenerate-snapshots-with-cargo-test-p-sub-rosa-round", `\n Regenerate snapshots with: cargo test -p sub-rosa-round\n`); + + if (!isRecord(trace.settlement)) { + issues.push("DEMO_TRACE.settlement receipt section is missing"); + } + + if (!isRecord(trace.auditor) || !isRecord(trace.auditor.blobs)) { + issues.push("DEMO_TRACE.auditor.blobs is missing"); + } else { + for (const label of bidderLabels) { + const blob = trace.auditor.blobs[label]; + if (typeof blob !== "string" || blob.trim() === "") { + issues.push(`DEMO_TRACE.auditor.blobs.${label} must be a non-empty string`); + } + } + } + + return { ok: issues.length === 0, issues }; +} + +export function checkSnapshots({ + snapshotDir = DEFAULT_SNAPSHOT_DIR, + demoTracePath = DEFAULT_DEMO_TRACE_PATH, + checksumPath = DEFAULT_CHECKSUM_PATH, +} = {}) { + const contract = checkContractSnapshots(snapshotDir); + const dashboard = checkDashboardEvidence({ demoTracePath, checksumPath }); + return { + ok: contract.ok && dashboard.ok, + contract, + dashboard, + }; +} + +function main() { + const snapshotDir = process.argv[2] + ? resolve(process.cwd(), process.argv[2]) + : DEFAULT_SNAPSHOT_DIR; + const demoTracePath = process.argv[3] + ? resolve(process.cwd(), process.argv[3]) + : DEFAULT_DEMO_TRACE_PATH; + const checksumPath = process.argv[4] + ? resolve(process.cwd(), process.argv[4]) + : DEFAULT_CHECKSUM_PATH; + + const { ok, contract, dashboard } = checkSnapshots({ + snapshotDir, + demoTracePath, + checksumPath, + }); + + if (contract.dirError) { + diagnostics.error( + "cannot-read-snapshot-directory", + `\n✗ Cannot read snapshot directory: ${snapshotDir}`, + ); + diagnostics.error("progress", ` ${contract.dirError}`); + process.exit(1); + } + + diagnostics.info("contract-snapshot-inventory", "\nContract snapshot inventory"); + diagnostics.info("directory", ` Directory : ${snapshotDir}`); + diagnostics.info("files-found", ` Files found : ${contract.filesFound}`); + diagnostics.info( + "categories-checked", + ` Categories checked : ${contract.categoriesChecked}`, + ); + + if (contract.missingCategories.length > 0) { + diagnostics.error( + "progress-2", + `\n✗ ${contract.missingCategories.length} required snapshot category/categories missing:\n`, + ); + for (const cat of contract.missingCategories) { + diagnostics.error("progress-3", ` - ${cat}`); + } + diagnostics.error( + "regenerate-snapshots-with-cargo-test-p-sub-rosa-round", + "\n Regenerate snapshots with: cargo test -p sub-rosa-round\n", + ); + } + + if (contract.invalidSnapshots.length > 0) { + diagnostics.error( + "invalid-snapshot-payloads", + `\n✗ ${contract.invalidSnapshots.length} snapshot file(s) failed payload validation:\n`, + ); + for (const issue of contract.invalidSnapshots) { + diagnostics.error("invalid-snapshot-detail", ` - ${issue}`); + } + } + + if (!dashboard.ok) { + diagnostics.error( + "stale-dashboard-evidence", + `\n✗ Dashboard demo evidence failed health check:\n`, + ); + for (const issue of dashboard.issues) { + diagnostics.error("stale-dashboard-detail", ` - ${issue}`); + } + } + + if (ok) { + diagnostics.info( + "all", + `\n✓ All ${contract.categoriesChecked} required snapshot categories and dashboard evidence are healthy.\n`, + ); + process.exit(0); + } + process.exit(1); } + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + main(); +} diff --git a/scripts/check-snapshots.test.mjs b/scripts/check-snapshots.test.mjs new file mode 100644 index 0000000..6f5d6a6 --- /dev/null +++ b/scripts/check-snapshots.test.mjs @@ -0,0 +1,171 @@ +// Copyright (c) 2026 Sub Rosa contributors +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { + checkContractSnapshots, + checkDashboardEvidence, + checkSnapshots, + DEFAULT_CHECKSUM_PATH, + DEFAULT_DEMO_TRACE_PATH, + REQUIRED_CATEGORIES, +} from "./check-snapshots.mjs"; + +const SCRIPT_PATH = fileURLToPath( + new URL("./check-snapshots.mjs", import.meta.url), +); +const REPO_ROOT = fileURLToPath(new URL("..", import.meta.url)); +const WORKFLOW_PATH = fileURLToPath( + new URL("../.github/workflows/snapshots.yml", import.meta.url), +); +const CONTRACT_WORKFLOW_PATH = fileURLToPath( + new URL("../.github/workflows/contract.yml", import.meta.url), +); +const CI_DOCS_PATH = fileURLToPath(new URL("../docs/CI.md", import.meta.url)); + +const VALID_SNAPSHOT_JSON = JSON.stringify({ + generators: { address: 1, nonce: 0 }, + auth: [], + ledger: { protocol_version: 22, sequence_number: 1 }, + events: [], +}); + +function createSnapshotFixture({ omitCategory = null, corruptCategory = null } = {}) { + const dir = mkdtempSync(join(tmpdir(), "sub-rosa-snapshots-")); + for (const category of REQUIRED_CATEGORIES) { + if (category === omitCategory) continue; + const payload = + category === corruptCategory + ? JSON.stringify({ generators: {}, auth: "not-an-array" }) + : VALID_SNAPSHOT_JSON; + writeFileSync(join(dir, `${category}.1.json`), payload); + } + return dir; +} + +describe("check-snapshots", () => { + it("passes against committed repository contract snapshots and dashboard evidence", () => { + const result = checkSnapshots(); + assert.equal(result.ok, true); + assert.equal(result.contract.missingCategories.length, 0); + assert.equal(result.contract.invalidSnapshots.length, 0); + assert.equal(result.dashboard.issues.length, 0); + + const cli = spawnSync(process.execPath, [SCRIPT_PATH], { + cwd: REPO_ROOT, + encoding: "utf8", + }); + assert.equal(cli.status, 0, cli.stderr); + assert.match(cli.stdout, /required snapshot categories and dashboard evidence are healthy/); + }); + + it("fails when the snapshot directory is missing", () => { + const missingDir = join(tmpdir(), "nonexistent-sub-rosa-snapshots-dir"); + const result = checkContractSnapshots(missingDir); + assert.equal(result.ok, false); + assert.ok(result.dirError); + }); + + it("fails when a required contract snapshot category is missing", () => { + const dir = createSnapshotFixture({ omitCategory: "settle_exact_payouts_table" }); + try { + const result = checkContractSnapshots(dir); + assert.equal(result.ok, false); + assert.deepEqual(result.missingCategories, ["settle_exact_payouts_table"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("fails when a committed snapshot file has a corrupted or incomplete payload", () => { + const dir = createSnapshotFixture({ + corruptCategory: "create_round_happy_path", + }); + try { + const result = checkContractSnapshots(dir); + assert.equal(result.ok, false); + assert.equal(result.invalidSnapshots.length, 1); + assert.match( + result.invalidSnapshots[0], + /create_round_happy_path\.1\.json: missing or invalid 'auth' array/, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("fails when generated dashboard evidence is stale or missing required milestones", () => { + const dir = mkdtempSync(join(tmpdir(), "sub-rosa-demo-evidence-")); + try { + const traceSource = readFileSync(DEFAULT_DEMO_TRACE_PATH, "utf8"); + const staleTracePath = join(dir, "demo-trace.generated.ts"); + const staleSource = traceSource.replace('"phase": "open_reveal"', '"phase": "stale_phase"'); + writeFileSync(staleTracePath, staleSource); + + const result = checkDashboardEvidence({ + demoTracePath: staleTracePath, + checksumPath: DEFAULT_CHECKSUM_PATH, + }); + assert.equal(result.ok, false); + assert.ok( + result.issues.some((issue) => + issue.includes("missing required milestones: open_reveal"), + ), + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("fails when the milestone checksum diverges from REQUIRED_MILESTONES", () => { + const dir = mkdtempSync(join(tmpdir(), "sub-rosa-checksum-drift-")); + try { + const checksumSource = readFileSync(DEFAULT_CHECKSUM_PATH, "utf8"); + const driftedChecksumPath = join(dir, "demo-trace.checksum.ts"); + writeFileSync( + driftedChecksumPath, + checksumSource.replace(/[0-9a-f]{64}/, "0".repeat(64)), + ); + + const result = checkDashboardEvidence({ + demoTracePath: DEFAULT_DEMO_TRACE_PATH, + checksumPath: driftedChecksumPath, + }); + assert.equal(result.ok, false); + assert.ok( + result.issues.some((issue) => + issue.includes("LIFE_CYCLE_PHASES_CHECKSUM mismatch"), + ), + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("enforces snapshot:check in CI workflows and documents the gate", () => { + const workflow = readFileSync(WORKFLOW_PATH, "utf8"); + assert.match(workflow, /^ push:\n branches: \[main\]$/m); + assert.match(workflow, /^ pull_request:$/m); + assert.doesNotMatch(workflow, /\bcontinue-on-error\s*:/); + assert.match(workflow, /pnpm snapshot:test/); + assert.match(workflow, /pnpm snapshot:check/); + + const contractWorkflow = readFileSync(CONTRACT_WORKFLOW_PATH, "utf8"); + assert.match(contractWorkflow, /node scripts\/check-snapshots\.mjs/); + assert.match(contractWorkflow, /node --test scripts\/check-snapshots\.test\.mjs/); + + const docs = readFileSync(CI_DOCS_PATH, "utf8"); + assert.match(docs, /snapshots\.yml/); + assert.match(docs, /pnpm snapshot:check/); + }); +});