diff --git a/scripts/lib/budget-gates.mjs b/scripts/lib/budget-gates.mjs new file mode 100644 index 0000000..3e6199b --- /dev/null +++ b/scripts/lib/budget-gates.mjs @@ -0,0 +1,534 @@ +#!/usr/bin/env node +/* + * budget-gates.mjs — the measurement gates for the consolidated budget audit + * (07-04, D-06). + * --------------------------------------------------------------------------- + * A MAINTAINER-TIME REPO TOOL — NOT a runtime dependency; nothing in bot/src, + * web/src, routing/src, or search/src imports it. The free plan gives a Worker + * 10 ms of CPU per request (Error 1102 beyond it) and 50 subrequests per + * request, so every budget here is MEASURED AND PRINTED, never hoped for + * ("budget gates print their measurement" — the BA9 precedent). + * + * Gate contract (07-RESEARCH Pattern 4): every gate is an async function + * returning { id, pass, evidence, violations? } — and every gate carries an + * honest NOT-RUN path that is NEVER a pass ({ pass:false, notRun:true }). + * + * The gates, in audit order: + * runCpuLadderGate() id: route-cpu — RE-RUNS routing/scripts/measure-route-cpu.mjs as a child process (never re-implemented) + * runBundleBudgetGate() id: bundle-budget — BA9's route-payload gzip sum + the whole-app bundle ceiling + * runKvWriteClassGate() id: kv-writes — the write-class inventory + the cron's ZERO-write law (source-proven) + * runSubrequestClassGate() id: subrequests — the per-request-class external-subrequest inventory (source-derived) + * runCacheHeaderGate() id: cache-headers — the LIVE header probe (re-used from cloudflare-probes.mjs) + * runGoldenCorpusGate() id: golden-corpus — RE-RUNS `routing:corpus` + `search:corpus` as child processes + * + * The route-cpu verdict law (the c3 class, decided in Phase 6 / 06-04): the + * full-sample ladder p95 (25 pairs incl. the 10 adversarial quadrant worst + * cases) MEASURED 38–93 ms across runs — over the 8 ms product budget, and + * the mitigation ladder was exhausted WITHIN the $0 law ((a) rejected on + * correctness — 12/25 pairs unreachable, (b) bidirectional A* shipped, + * (c) Workers Paid rejected on the $0 mandate). The DECIDED posture is the + * honest degradation: cross-metro CPU exhaust → Error 1102 → fail-honest + * null → the client's router-pending state; the committed upgrade lever is + * ALT landmark preprocessing (next milestone). This gate therefore checks + * the measured p95 against the DOCUMENTED accepted band (a 200 ms drift + * guard ≈ 2x the worst documented run): a p95 inside the band is the + * documented state (the record still prints it against the 8 ms budget with + * negative headroom — nothing is hidden); a p95 beyond it is UNCHARTED + * regression and FAILS. + * + * Test injectability (the privacy-audit suite precedent): every gate accepts + * opts-only path overrides; production callers pass nothing. + */ +import { spawnSync, execFileSync } from "node:child_process"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { gzipSync } from "node:zlib"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { runAssetHeaderProbe } from "./cloudflare-probes.mjs"; + +const MODULE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const REPO_ROOT = process.env.PRIVACY_AUDIT_ROOT ?? MODULE_ROOT; + +/** The ladder's own budget constant — 8 ms, 20% under the 10 ms free ceiling. */ +export const P95_BUDGET_MS = 8; +/** The BA9 route-payload ceiling (D-11): 200 KiB gz. */ +export const ROUTE_PAYLOAD_BUDGET_BYTES = 204800; +/** The whole-app bundle ceiling (D-06's record row): 500 KB gz for ALL of web/dist/assets/*.js. */ +export const APP_BUNDLE_BUDGET_BYTES = 500000; +/** The drift guard for the accepted c3 band: the documented accepted p95 tops + * at 93.6 ms (06-04's worst shared-box run); beyond 200 ms nothing the + * escalation examined applies — uncharted regression. */ +export const ROUTE_CPU_DRIFT_GUARD_MS = 200; + +/** A missing environment is the exit-2 class: pass:false + notRun:true. */ +function notRun(id, reason) { + return { id, pass: false, notRun: true, evidence: reason }; +} + +/* ══════════════════════════ route-cpu ══════════════════════════ */ + +/** + * RE-RUN the existing A* ladder as a child process and gate its measurement. + * The child's stdout carries one ` label: ms ( km / ...)` line per + * pair, an optional `unreachable — honest null` marker per dead-end probe, + * and the summary `p95: ms (max ms, mean ms, n=)` line. + * opts: scriptPath, graphPath, ladderOutput (pre-captured child stdout — + * tests), cwd. + */ +export function runCpuLadderGate(opts = {}) { + const id = "route-cpu"; + const script = opts.scriptPath ?? path.join(REPO_ROOT, "routing", "scripts", "measure-route-cpu.mjs"); + const graphPath = opts.graphPath ?? path.join(REPO_ROOT, "routing", "data", "kl-roadgraph.v1.bin"); + + if (!existsSync(graphPath)) { + return notRun(id, "kl-roadgraph.v1.bin is absent — build it (node routing/scripts/build-road-graph.mjs) and re-run"); + } + if (!existsSync(script)) return notRun(id, "routing/scripts/measure-route-cpu.mjs is absent — the ladder cannot be re-run"); + + let out; + if (opts.ladderOutput !== undefined) { + out = opts.ladderOutput; // test injection — the captured child stdout + } else { + try { + out = execFileSync(process.execPath, [script], { + cwd: opts.cwd ?? REPO_ROOT, + encoding: "utf8", + timeout: 120000, + maxBuffer: 1 << 24, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (err) { + // The ladder exits 1 on its own gate failure — its stdout is still the + // measurement (the GATE FAIL line prints to stderr and is merged here: + // the mitigation-ladder text is part of the evidence). + if (err.stdout === undefined) return notRun(id, `the ladder could not run (${err.code === "ETIMEDOUT" ? "timed out" : "spawn failure"})`); + out = err.stdout + "\n" + (err.stderr ?? ""); + } + } + + // The child's own verdict lines — carried VERBATIM (the mitigation-ladder + // text is part of the evidence, per the plan). + const childP95Line = out.match(/^p95: .*$/m)?.[0] ?? null; + const childGateLine = out.match(/^GATE (?:FAIL|PASS): .*$/m)?.[0] ?? null; + const p95 = Number(out.match(/^p95: (\d+(?:\.\d+)?) ms/m)?.[1] ?? NaN); + if (!Number.isFinite(p95) || childP95Line === null) { + return notRun(id, "the ladder produced no parsable p95 line — the measurement is unavailable, never assumed"); + } + + const overBudget = p95 > P95_BUDGET_MS; + const uncharted = p95 > ROUTE_CPU_DRIFT_GUARD_MS; + + // The verdict: inside the documented accepted c3 band = the documented + // state (see the header's verdict law); beyond the drift guard = FAIL. + const pass = !uncharted; + const verdict = uncharted + ? `UNCHARTED REGRESSION — p95 beyond the ${ROUTE_CPU_DRIFT_GUARD_MS} ms drift guard (the documented accepted c3 band tops at ~93 ms)` + : overBudget + ? `over the ${P95_BUDGET_MS} ms product budget — the DOCUMENTED, ACCEPTED c3 class (cross-metro honest degradation: 1102 → fail-honest null → router-pending; mitigation ladder exhausted within the $0 law; the ALT landmark lever is a next-milestone item)` + : `within the ${P95_BUDGET_MS} ms product budget`; + const evidence = + `p95 ${p95.toFixed(3)} ms <= 8 ms budget (10 ms free-plan ceiling) — NOT satisfied as stated: ` + + `${verdict}. Full-sample ladder (corpus + quadrant worst cases): ${childP95Line}. ` + + `Child's own gate line: ${childGateLine ?? "(none printed)"}. ` + + `LADDER HISTORY context is cited in docs/BUDGETS.md, not re-printed here.`; + return { id, pass, evidence, p95 }; +} + +/* ══════════════════════════ bundle-budget ══════════════════════════ */ + +/** The BA9 input set (web/test/build-artifact.test.mjs lines 204–209). */ +function collectTsFiles(dir) { + const out = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...collectTsFiles(full)); + else if (entry.name.endsWith(".ts")) out.push(full); + } + return out; +} + +/** + * The BA9 measurement, re-implemented in the library (the inputs, the gzip + * sum, the 204800 ceiling), PLUS the whole-app bundle measurement and the + * Worker bundle size (NOT MEASURED when bot/dist is absent — never zero). + * opts: distDir, routeDirs, routeFixturesFile, botDistDir. + */ +export function runBundleBudgetGate(opts = {}) { + const id = "bundle-budget"; + const webRoot = opts.webRoot ?? path.join(REPO_ROOT, "web"); + const distDir = opts.distDir ?? path.join(webRoot, "dist"); + const routeDirs = opts.routeDirs ?? [ + path.join(REPO_ROOT, "routing", "src"), + path.join(webRoot, "src", "route"), + path.join(webRoot, "src", "guidance"), + ]; + const routeFixturesFile = opts.routeFixturesFile ?? path.join(REPO_ROOT, "routing", "src", "data", "route-fixtures.json"); + + const measurements = []; + const violations = []; + + // — BA9: the route payload's gz bytes (source gz ≈ conservative vs minified+gz). + if (!existsSync(routeFixturesFile)) return notRun(id, "route-fixtures.json is absent — the BA9 inputs are incomplete"); + const missingDirs = routeDirs.filter((d) => !existsSync(d)); + if (missingDirs.length > 0) return notRun(id, `route-payload input dir(s) absent: ${missingDirs.map((d) => path.relative(REPO_ROOT, d)).join(", ")}`); + const inputs = [routeFixturesFile, ...routeDirs.flatMap((dir) => collectTsFiles(dir))]; + const routePayloadGz = inputs.reduce((sum, f) => sum + gzipSync(readFileSync(f)).length, 0); + measurements.push(`route bundle budget: ${routePayloadGz} / ${ROUTE_PAYLOAD_BUDGET_BYTES} gz bytes`); + if (routePayloadGz > ROUTE_PAYLOAD_BUDGET_BYTES) { + violations.push(`route payload is ${routePayloadGz} gz bytes — over the ${ROUTE_PAYLOAD_BUDGET_BYTES}-byte (200 KiB) D-11 budget by ${routePayloadGz - ROUTE_PAYLOAD_BUDGET_BYTES}; shrink the fixtures (simplified geometry) or the route code before shipping (T-05-12)`); + } + + // — The whole-app bundle: every hashed JS asset in web/dist, gz-summed. + if (!existsSync(distDir)) return notRun(id, "web/dist is absent — run npm run build -w web and re-run"); + const assetsDir = path.join(distDir, "assets"); + if (!existsSync(assetsDir)) return notRun(id, "web/dist/assets is absent — a build without hashed assets is not the shipped shape"); + const jsAssets = readdirSync(assetsDir).filter((f) => f.endsWith(".js")); + if (jsAssets.length === 0) return notRun(id, "web/dist/assets carries no .js file — the build is broken"); + const appBundleGz = jsAssets.reduce((sum, f) => sum + gzipSync(readFileSync(path.join(assetsDir, f))).length, 0); + measurements.push(`app bundle budget: ${appBundleGz} / ${APP_BUNDLE_BUDGET_BYTES} gz bytes (${jsAssets.length} hashed js asset(s))`); + if (appBundleGz > APP_BUNDLE_BUDGET_BYTES) { + violations.push(`app bundle is ${appBundleGz} gz bytes — over the ${APP_BUNDLE_BUDGET_BYTES}-byte (500 KB gz) D-06 record ceiling by ${appBundleGz - APP_BUNDLE_BUDGET_BYTES}`); + } + + // — The Worker bundle's uncompressed size (honest NOT MEASURED, never zero). + const botDistDir = opts.botDistDir ?? path.join(REPO_ROOT, "bot", "dist"); + if (existsSync(botDistDir)) { + // A flat byte-sum over the directory's files (wrangler emits a bundle dir). + let workerBytesTotal = 0; + const walkSum = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walkSum(full); + else workerBytesTotal += readFileSync(full).length; + } + }; + walkSum(botDistDir); + measurements.push(`Worker bundle: ${workerBytesTotal} uncompressed bytes (bot/dist)`); + } else { + measurements.push("Worker bundle: NOT MEASURED — bot/dist is absent (wrangler builds in memory on deploy; the number is not fabricated)"); + } + + const evidence = + violations.length === 0 + ? `${measurements.join(" | ")}` + : `${measurements.join(" | ")} — ${violations.length} violation(s): ${violations.join("; ")}`; + return { id, pass: violations.length === 0, evidence, violations: violations.length }; +} + +/* ══════════════════════════ kv-writes ══════════════════════════ */ + +/** The key families (the KV storage contract — shared with cloudflare-probes). */ +const KEY_FAMILIES = ["session:", "chat:", "loc:", "guidance:"]; + +/** + * The KV write-class inventory (T-07-18). Three laws: + * 1. every `.put(` call site in bot/src writes a key whose family is one of + * the four TTL-only families AND carries a NAMED TTL constant on the + * same line (the PG1 law, re-checked from the budget side); + * 2. the cron tick (`runGuidanceCronTick`'s body) contains NO `.put(` and + * NO `.delete(` — the zero-write law is SOURCE-PROVEN, not claimed; + * 3. the per-family TTLs are READ from the constants (never hard-coded). + * opts: botRoot (defaults REPO_ROOT/bot). + */ +export function runKvWriteClassGate(opts = {}) { + const id = "kv-writes"; + const botRoot = opts.botRoot ?? path.join(REPO_ROOT, "bot"); + const srcDir = path.join(botRoot, "src"); + if (!existsSync(srcDir)) return notRun(id, "bot/src is absent — the write classes cannot be inventoried"); + + // — The write-class inventory: every .put( call site in bot/src. + const putSites = []; + const walk = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith(".ts")) { + const lines = readFileSync(full, "utf8").split("\n"); + lines.forEach((line, i) => { + if (line.includes(".put(")) { + putSites.push({ file: path.relative(botRoot, full), line: i + 1, text: line.trim() }); + } + }); + } + } + }; + walk(srcDir); + + const classViolations = []; + const classRows = []; + // Family ownership by module (the key schemes are each module's own law, + // documented in their headers — sessions.ts owns session:/chat:, etc.). + // A .put( in a file OUTSIDE the owned modules must carry a family literal + // on its own line, or it is a new, unclassified write family. + const FILE_FAMILY = (rel) => + rel.startsWith("src/kv/sessions.ts") + ? "session: + chat:" + : rel.startsWith("src/kv/location-pin.ts") + ? "loc:" + : rel.startsWith("src/kv/guidance-sessions.ts") + ? "guidance:" + : null; + for (const site of putSites) { + const owned = FILE_FAMILY(site.file); + const literalFamily = KEY_FAMILIES.find((f) => site.text.includes(f)); + const family = owned ?? literalFamily; + // The named-TTL law: the call line carries `expirationTtl: `. + const namedTtl = /expirationTtl:\s*[A-Z][A-Z0-9_]+/.test(site.text); + if (family === undefined || family === null) { + classViolations.push(`${site.file}:${site.line} — a .put( whose key family is not one of ${KEY_FAMILIES.join("/")} and whose module owns none`); + } else { + classRows.push(`${site.file}:${site.line} ${family}`); + } + if (!namedTtl) { + classViolations.push(`${site.file}:${site.line} — a .put( WITHOUT a NAMED TTL constant on the same line (PG1)`); + } + } + + // — The TTL table, READ from the constants (never hard-coded). + const ttlRows = []; + const ttlConstants = [ + ["SESSION_TTL_SECONDS", "src/kv/sessions.ts", "session: + chat:"], + ["PIN_TTL_SECONDS", "src/kv/location-pin.ts", "loc:"], + ["GUIDANCE_SESSION_TTL_SECONDS", "src/kv/guidance-sessions.ts", "guidance:"], + ]; + for (const [name, file, families] of ttlConstants) { + const full = path.join(botRoot, file); + if (!existsSync(full)) { + ttlRows.push(`${name}: NOT FOUND (${file} absent)`); + classViolations.push(`${file} is absent — the ${name} constant cannot be read`); + continue; + } + const value = readFileSync(full, "utf8").match(new RegExp(`export const ${name} = (\\d+);`))?.[1]; + if (value === undefined) { + ttlRows.push(`${name}: NOT FOUND in ${file}`); + classViolations.push(`${file} no longer exports ${name} — the TTL table cannot be read from source`); + } else { + ttlRows.push(`${name} = ${value} (${families})`); + } + } + + // — THE CRON LAW: extract runGuidanceCronTick's body and count writes. + const webhookPath = path.join(srcDir, "webhook.ts"); + let cronWriteCount = null; + if (!existsSync(webhookPath)) { + classViolations.push("bot/src/webhook.ts is absent — the cron zero-write law cannot be source-proven"); + } else { + const src = readFileSync(webhookPath, "utf8"); + const start = src.indexOf("async function runGuidanceCronTick("); + if (start === -1) { + classViolations.push("runGuidanceCronTick is absent from bot/src/webhook.ts — the cron law's subject is gone"); + } else { + // A simple brace counter from the function's first { to its match. + let depth = 0; + let end = -1; + for (let i = src.indexOf("{", start); i < src.length; i++) { + if (src[i] === "{") depth += 1; + else if (src[i] === "}") { + depth -= 1; + if (depth === 0) { + end = i; + break; + } + } + } + if (end === -1) { + classViolations.push("runGuidanceCronTick's body did not balance — the extraction failed"); + } else { + const body = src.slice(start, end + 1); + cronWriteCount = (body.match(/\.put\(/g) ?? []).length + (body.match(/\.delete\(/g) ?? []).length; + if (cronWriteCount !== 0) { + classViolations.push(`the cron tick performs ${cronWriteCount} KV write/delete call(s) — the free-plan zero-write law (T-07-18) is violated in runGuidanceCronTick`); + } + } + } + } + + const cronEvidence = + cronWriteCount === null ? "cron write count: UNKNOWN (extraction failed)" : `cron write count: ${cronWriteCount} (runGuidanceCronTick scans for .put(/.delete( — zero; the tick lists, reads, and sends only)`; + + const evidence = + classViolations.length === 0 + ? `${putSites.length} KV write site(s) [${[...new Set(classRows)].join("; ")}] — every write carries a NAMED TTL constant; ${ttlRows.join("; ")}; ${cronEvidence}` + : `${putSites.length} write site(s); ${cronEvidence}; ${classViolations.length} violation(s): ${classViolations.join("; ")}`; + return { id, pass: classViolations.length === 0, evidence, violations: classViolations.length }; +} + +/* ══════════════════════════ subrequests ══════════════════════════ */ + +/** + * The per-request-class external-subrequest inventory (T-07-23b). + * + * HONESTY REQUIREMENT: this gate is a SOURCE-DERIVED inventory, not a live + * Workers Logs metric. Each row's count is derived from the Worker's route + * table (bot/src/webhook.ts) and the code paths behind it; the live Workers + * Logs subrequest metric is NOT available outside --live and when it cannot + * be produced the row reads `not measured outside --live` — never a + * fabricated number. + * + * The derived facts (each with its source site): + * /telegram/webhook ≤ 2 per update — ctx.reply / replyWithPhoto with a + * text fallback (bot/src/bot/commands/start.ts); + * the count is update-dependent — not measured live + * /api/session 0 external — HMAC validation is local; KV ops are + * NOT subrequests (bot/src/webhook.ts handleSession) + * /api/location 0 external — KV reads only (handleLocation) + * /api/location-fallback 1 external — bot.api.sendMessage + * (bot/src/bot/handlers/location-fallback.ts) + * /api/guidance 0 external — KV only (handleGuidance) + * /api/route 0 external — the graph comes from the R2/KV + * binding, never fetched; the A* search is pure + * CPU (bot/src/routing/worker-router.ts) + * scheduled (cron tick) 1 per DUE envelope (api.sendPhoto, + * bot/src/webhook.ts) — the ONLY recurring + * external-subrequest class; the per-tick count + * depends on the number of live due envelopes — + * not measured + * + * KV operations are NOT subrequests — Cloudflare counts them against the + * separate KV-operations ceiling (1,000/invocation) — so they are reported + * by the kv-writes gate and this gate SAYS SO explicitly. + * + * The gate derives the route table FROM webhook.ts (`url.pathname === "…"` + * entries) and FAILS on any class the inventory does not cover — the table + * must follow the code, not the reverse. + * opts: webhookPath, plus per-class overrides for the fixture tests. + */ +export function runSubrequestClassGate(opts = {}) { + const id = "subrequests"; + const webhookPath = opts.webhookPath ?? path.join(REPO_ROOT, "bot", "src", "webhook.ts"); + if (!existsSync(webhookPath)) return notRun(id, "bot/src/webhook.ts is absent — the route table cannot be derived"); + + const src = readFileSync(webhookPath, "utf8"); + // Derive the route classes from the Worker's own route table. + const routes = [...src.matchAll(/url\.pathname === "([^"]+)"/g)].map((m) => m[1]); + if (src.includes("scheduled(") || src.includes("runGuidanceCronTick")) routes.push("scheduled (cron tick)"); + + // The inventory — the source-derived facts, keyed by route class. + const INVENTORY = { + "/telegram/webhook": { + count: "≤ 2 per update (reply / photo-with-text-fallback — update-dependent)", + source: "bot/src/bot/commands/start.ts ctx.reply/replyWithPhoto", + }, + "/api/session": { count: "0 external subrequests", source: "bot/src/webhook.ts handleSession — HMAC validation is local; KV ops are not subrequests" }, + "/api/location": { count: "0 external subrequests", source: "bot/src/webhook.ts handleLocation — KV reads only" }, + "/api/location-fallback": { count: "1 external subrequest (bot.api.sendMessage)", source: "bot/src/bot/handlers/location-fallback.ts" }, + "/api/guidance": { count: "0 external subrequests", source: "bot/src/webhook.ts handleGuidance — KV only" }, + "/api/route": { count: "0 external subrequests", source: "bot/src/routing/worker-router.ts — the graph comes from the R2/KV binding, never fetched; the A* search is pure CPU" }, + "scheduled (cron tick)": { + count: "1 per DUE envelope (api.sendPhoto); the per-tick total depends on the number of live due envelopes — not measured", + source: "bot/src/webhook.ts runGuidanceCronTick — the ONLY recurring external-subrequest class", + }, + }; + + const violations = []; + const rows = []; + for (const route of routes) { + const row = INVENTORY[route]; + if (row === undefined) { + violations.push(`uninventoried request class: ${route} — the subrequest inventory must be updated beside the route table`); + continue; + } + rows.push(`${route} → ${row.count} [${row.source}]`); + } + // A documented inventory row whose route vanished from the table is drift too. + for (const route of Object.keys(INVENTORY)) { + if (!routes.includes(route)) violations.push(`inventoried class ${route} is no longer in the route table — update the inventory`); + } + + // Re-verify the two MANDATORY derived facts from the source (they are the + // record's load-bearing rows — a silent regression would empty them). + const fallbackSrc = path.join(REPO_ROOT, "bot", "src", "bot", "handlers", "location-fallback.ts"); + if (opts.verifyFacts !== false) { + if (existsSync(fallbackSrc) && !readFileSync(fallbackSrc, "utf8").includes("api.sendMessage")) { + violations.push("location-fallback no longer calls api.sendMessage — the inventory's 1-subrequest row is stale"); + } + if (existsSync(webhookPath)) { + const tickStart = src.indexOf("async function runGuidanceCronTick("); + const routeIdx = src.indexOf('url.pathname === "/api/route"'); + if (tickStart !== -1 && !src.slice(tickStart).includes("api.sendPhoto")) { + violations.push("the cron tick no longer sends via api.sendPhoto — the only-recurring-class fact is stale"); + } + if (routeIdx !== -1 && /url\.pathname === "\/api\/route"[\s\S]{0,2000}?\bfetch\(/.test(src.slice(routeIdx, src.indexOf("return new Response(\"Not found\"")))) { + violations.push("/api/route now appears to fetch — its 0-external-subrequest fact is stale"); + } + } + } + + const table = rows.join(" | "); + const liveNote = "live Workers Logs subrequest metric: not measured outside --live (and not producible by this gate — see the record)"; + const kvNote = "KV operations are NOT subrequests — they count against the separate KV-operations ceiling (1,000/invocation) and are tracked by the kv-writes gate"; + const evidence = + violations.length === 0 + ? `source-derived inventory, not a live Workers Logs metric — ${routes.length} request class(es): ${table} | budget 50/request per class | ${liveNote} | ${kvNote}` + : `source-derived inventory; ${violations.length} violation(s): ${violations.join("; ")}`; + return { id, pass: violations.length === 0, evidence, violations: violations.length }; +} + +/* ══════════════════════════ cache-headers ══════════════════════════ */ + +/** + * The LIVE header probe — RE-USED from scripts/lib/cloudflare-probes.mjs + * (imported, never forked). Without a deployed origin the probe degrades to + * notRun. The probe quotes the observed cache-control / cf-cache-status + * VERBATIM (cf-cache-status is probabilistic per Cloudflare's own footnote — + * quoted, never interpreted as a guarantee). + * opts: baseUrl (default: the shared probe's own resolution — MINI_APP_URL / + * bot/wrangler.jsonc), forceMissingOrigin (tests). + */ +export function runCacheHeaderGate(opts = {}) { + const id = "cache-headers"; + if (opts.forceMissingOrigin || opts.baseUrl === null) { + return notRun(id, "live cache-header probe not run — no deployed origin or credentials"); + } + const result = opts.baseUrl !== undefined ? runAssetHeaderProbe({ baseUrl: opts.baseUrl }) : runAssetHeaderProbe(); + // The shared probe carries id "asset-headers"; the budget audit's row is + // "cache-headers" — same measurement, one id per audit family. + return { ...result, id }; +} + +/* ══════════════════════════ golden-corpus ══════════════════════════ */ + +/** + * RE-RUN the golden route + search corpora as child processes (cwd = repo + * root) — RE-RUN, never re-implemented — so the ONE budget-audit command + * that produces the record also proves the corpora are green (SC4.a/SC4.b). + * opts: commands (Array<[id, argv]> overrides — tests). + */ +export function runGoldenCorpusGate(opts = {}) { + const id = "golden-corpus"; + const commands = + opts.commands ?? [ + ["routing:corpus", ["npm", "run", "routing:corpus"]], + ["search:corpus", ["npm", "run", "search:corpus"]], + ]; + + const parts = []; + const failures = []; + for (const [corpusId, argv] of commands) { + let res; + try { + res = spawnSync(argv[0], argv.slice(1), { + cwd: opts.cwd ?? REPO_ROOT, + encoding: "utf8", + timeout: 300000, + maxBuffer: 1 << 26, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (err) { + failures.push(`${corpusId} could not run (${err.code === "ETIMEDOUT" ? "timed out" : "spawn failure"})`); + continue; + } + if (res.status !== 0) { + const tail = ((res.stdout ?? "") + (res.stderr ?? "")).trim().split("\n").slice(-3).join(" | "); + failures.push(`${corpusId} exited ${res.status ?? "signal"} — its own failure output: ${tail}`); + } else { + parts.push(`${corpusId} exit 0`); + } + } + + const evidence = + failures.length === 0 + ? `golden corpora re-run by THIS gate (child processes): ${parts.join("; ")} — SC4.a + SC4.b proven by the same command that produces the record` + : `${failures.join("; ")}`; + return { id, pass: failures.length === 0, evidence, violations: failures.length }; +}