- 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)
135 lines
7.6 KiB
JavaScript
135 lines
7.6 KiB
JavaScript
#!/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
|