- route-cpu: RE-RUNS routing/scripts/measure-route-cpu.mjs as a child process; p95 printed against the 8 ms product budget + 10 ms free ceiling; the child's GATE FAIL line carried verbatim; the accepted c3 band has a 200 ms drift guard — uncharted regression fails - bundle-budget: BA9 route-payload gz sum + whole-app bundle ceiling (500000 gz bytes); Worker bundle honest NOT MEASURED when bot/dist is absent (never zero) - kv-writes: 4 write sites inventoried by family, every write TTL-named (PG1); the cron zero-write law is source-proven — runGuidanceCronTick's body must contain no .put(/.delete( - subrequests: per-request-class external-subrequest inventory derived from the route table; uninventoried classes FAIL; source-derived honesty sentence verbatim; never a fabricated zero - cache-headers: reuses cloudflare-probes' runAssetHeaderProbe (imported, never forked) - golden-corpus: RE-RUNS routing:corpus + search:corpus as child processes (SC4.a/SC4.b)
535 lines
28 KiB
JavaScript
535 lines
28 KiB
JavaScript
#!/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> ms (<km> km / ...)` line per
|
||
* pair, an optional `unreachable — honest null` marker per dead-end probe,
|
||
* and the summary `p95: <n> ms (max <n> ms, mean <n> ms, n=<k>)` 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: <CONSTANT>`.
|
||
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 };
|
||
}
|