feat(07-04): the budget-audit CLI + the root npm script — orchestration, free-tier comparison table, 0/1/2 exit codes
- npm run budget-audit: 5 required gates + the --live cache-header probe; one evidence line per gate - the FREE-TIER COMPARISON table: every documented Cloudflare free-plan ceiling beside the current value where measured, `not measured` where not — the console form of docs/BUDGETS.md - exit 0 CLEAN / 1 FAILED / 2 NOT RUN — a skip is never a pass (T-07-22) - no secret, no coordinate in the output (D-14's category-only law, applied to budgets)
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"gallery": "npm run gallery --workspace theme",
|
||||
"audit": "npm run audit --workspace theme",
|
||||
"privacy-audit": "node scripts/privacy-audit.mjs",
|
||||
"budget-audit": "node scripts/budget-audit.mjs",
|
||||
"capture": "npm run capture --workspace theme",
|
||||
"bot:test": "npm test -w bot",
|
||||
"bot:typecheck": "npm run typecheck -w bot",
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* budget-audit.mjs — the ONE 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. GPL-3.0: a fork reruns
|
||||
* `npm run budget-audit` over its own trees before its FIRST driver — the
|
||||
* budget record doubles as the fork's free-tier onboarding check.
|
||||
*
|
||||
* D-06: the free-first mandate is only real if the numbers are MEASURED and
|
||||
* PRINTED, not assumed. This CLI runs the six measurement gates, prints one
|
||||
* evidence line per gate, prints the FREE-TIER COMPARISON TABLE (every
|
||||
* documented Cloudflare free-plan ceiling beside the current value where the
|
||||
* audit measured one, `not measured` where it did not), and writes nothing —
|
||||
* the human record is docs/BUDGETS.md, filled from THIS output.
|
||||
*
|
||||
* What it runs, in order (the privacy-audit shape, 07-03):
|
||||
* runCpuLadderGate() id: route-cpu — RE-RUNS the A* ladder (child process)
|
||||
* runBundleBudgetGate() id: bundle-budget — BA9 route-payload gz + whole-app bundle gz
|
||||
* runKvWriteClassGate() id: kv-writes — write classes + the cron ZERO-write law
|
||||
* runSubrequestClassGate() id: subrequests — the per-request-class subrequest inventory
|
||||
* runGoldenCorpusGate() id: golden-corpus — RE-RUNS routing:corpus + search:corpus
|
||||
* [--live] runCacheHeaderGate() id: cache-headers — LIVE cache headers (observed, verbatim)
|
||||
*
|
||||
* Exit codes (the privacy-audit contract — no silent passes):
|
||||
* 0 clean — every required gate PASSed (a gate's pass means its measurement
|
||||
* is inside its budget, OR matches a documented, decided escalation —
|
||||
* the accepted c3 class for route-cpu; the record carries both verdicts)
|
||||
* 1 a gate FAILED — each printed above as its evidence line
|
||||
* 2 NOT RUN — environment drift (a required measurement could not run: a
|
||||
* missing graph binary, a missing build, an unreachable/undeployed
|
||||
* origin). A NOT RUN is NEVER a pass (T-07-22).
|
||||
*
|
||||
* The output carries NO secret and NO coordinate: sizes, counts, timings and
|
||||
* header lines only — the D-14 category-only law, applied to budgets.
|
||||
*
|
||||
* PRIVACY_AUDIT_ROOT: repo root override (shared with the privacy audit's
|
||||
* gates) — the gates are otherwise cwd-INDEPENDENT (module-URL root).
|
||||
*
|
||||
* Usage: node scripts/budget-audit.mjs [--live] (the `budget-audit` npm script)
|
||||
*/
|
||||
import { runCpuLadderGate, runBundleBudgetGate, runKvWriteClassGate, runSubrequestClassGate, runCacheHeaderGate, runGoldenCorpusGate } from "./lib/budget-gates.mjs";
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const REPO_ROOT = process.env.PRIVACY_AUDIT_ROOT ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const live = process.argv.includes("--live");
|
||||
|
||||
/** The five required gates — always measured. */
|
||||
const results = [
|
||||
await runCpuLadderGate(),
|
||||
await runBundleBudgetGate(),
|
||||
await runKvWriteClassGate(),
|
||||
await runSubrequestClassGate(),
|
||||
await runGoldenCorpusGate(),
|
||||
];
|
||||
|
||||
if (live) {
|
||||
results.push(await runCacheHeaderGate());
|
||||
} else {
|
||||
// Flag-skip placeholder — visible, honest, and NEVER green: the skip is the
|
||||
// operator's explicit choice (no --live), excluded from the exit-2 class.
|
||||
results.push({ id: "cache-headers", pass: false, notRun: true, flagSkip: true, evidence: "(pass --live)" });
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
const status = result.pass ? "PASS" : result.notRun ? "NOT RUN" : "FAIL";
|
||||
console.log(`${result.id}: ${status} — ${result.evidence}`);
|
||||
}
|
||||
|
||||
/* ————— the FREE-TIER COMPARISON TABLE — the console form of docs/BUDGETS.md ————— */
|
||||
|
||||
/** Static-asset file count in web/dist (the 20,000 files/version row's current value). */
|
||||
function distFileCount() {
|
||||
const dist = path.join(REPO_ROOT, "web", "dist");
|
||||
if (!existsSync(dist)) return null;
|
||||
let n = 0;
|
||||
const walk = (dir) => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else n += 1;
|
||||
}
|
||||
};
|
||||
walk(dist);
|
||||
return n;
|
||||
}
|
||||
|
||||
const routeCpu = results.find((r) => r.id === "route-cpu");
|
||||
const bundle = results.find((r) => r.id === "bundle-budget");
|
||||
const kv = results.find((r) => r.id === "kv-writes");
|
||||
const sq = results.find((r) => r.id === "subrequests");
|
||||
const distFiles = distFileCount();
|
||||
|
||||
console.log("\nFREE-TIER COMPARISON (ceilings: Cloudflare Workers limits / KV limits / pricing —");
|
||||
console.log("developers.cloudflare.com/workers/platform/limits, /kv/platform/limits, /workers/platform/pricing):");
|
||||
console.log("| Resource | Free-plan ceiling | Current (this run) |");
|
||||
console.log("|---|---|---|");
|
||||
console.log(`| Worker HTTP CPU | 10 ms/invocation | ${routeCpu?.p95 !== undefined ? `ladder p95 ${routeCpu.p95.toFixed(3)} ms (local 25-pair ladder, full sample incl. cross-metro worst cases — the accepted c3 class; live Workers Logs CPU is a DIFFERENT measurement, not measured)` : "not measured"}`);
|
||||
console.log("| Cron CPU | 10 ms/invocation | not measured (no live cron metric outside --live) |");
|
||||
console.log(`| Worker subrequests | 50/request | ${sq?.pass ? "per-class inventory printed above (subrequests gate) — /api/route = 0 external; the cron's Telegram send = the only recurring class" : "not measured"}`);
|
||||
console.log("| Worker requests | 100,000/day | not measured (no live metric) |");
|
||||
console.log("| Worker memory | 128 MB/isolate | not measured |");
|
||||
console.log(`| Worker bundle | 64 MiB uncompressed | ${(bundle?.evidence ?? "").includes("NOT MEASURED") ? "NOT MEASURED — bot/dist absent (wrangler builds in memory on deploy)" : "see the bundle-budget gate's Worker bundle row"}`);
|
||||
console.log(`| Static assets | free unlimited requests; 20,000 files/version; 25 MiB/file | ${distFiles !== null ? `${distFiles} file(s) in web/dist` : "not measured (web/dist absent)"}`);
|
||||
console.log(`| KV reads | 100,000/day | not measured (derived bound in docs/BUDGETS.md) |`);
|
||||
console.log(`| KV writes to different keys | 1,000/day | ${kv?.pass ? "4 write site(s), class table above; the daily estimate is derived in docs/BUDGETS.md" : "not measured"}`);
|
||||
console.log("| KV writes to same key | 1/second | not measured |");
|
||||
console.log(`| KV list requests | 1,000/day | derived 1,440/day (the 60 s cron × 1 list per tick) — OVER the ceiling: a DISCOVERED FINDING, printed in docs/BUDGETS.md; not silently accepted |`);
|
||||
console.log("| KV storage | 1 GB/account and namespace | not measured |");
|
||||
console.log("| KV operations/invocation | 1,000 | not measured |");
|
||||
console.log("| Workers Logs Free | 200,000 events/day; 3-day retention | not measured |");
|
||||
|
||||
/* ————— summary + exit codes ————— */
|
||||
|
||||
const failed = results.filter((r) => !r.pass && !r.notRun);
|
||||
const notRun = results.filter((r) => !r.pass && r.notRun && !r.flagSkip);
|
||||
const passed = results.filter((r) => r.pass).length;
|
||||
|
||||
if (failed.length > 0) {
|
||||
console.log(`BUDGET AUDIT FAILED — ${failed.length} gate(s) over budget`);
|
||||
process.exit(1); // violations — each printed above
|
||||
}
|
||||
if (notRun.length > 0) {
|
||||
const reasons = notRun.map((r) => `${r.id}: ${r.evidence}`).join("; ");
|
||||
console.log(`BUDGET AUDIT NOT RUN — ${reasons} (environment drift; never a pass)`);
|
||||
process.exit(2); // environment drift — fix the environment and rerun
|
||||
}
|
||||
console.log(
|
||||
`BUDGET AUDIT CLEAN — ${passed} gate(s) within budget ` +
|
||||
`(printed findings carried to docs/BUDGETS.md: the accepted c3 CPU class and the discovered KV-list over-ceiling)`,
|
||||
);
|
||||
process.exit(0); // clean
|
||||
Reference in New Issue
Block a user