From bbf003fa5d1ec47b0cb6641f0dfdbc80f0ba06e4 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sun, 25 Oct 2026 05:05:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(07-04):=20the=20budget-audit=20CLI=20+=20t?= =?UTF-8?q?he=20root=20npm=20script=20=E2=80=94=20orchestration,=20free-ti?= =?UTF-8?q?er=20comparison=20table,=200/1/2=20exit=20codes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- package.json | 1 + scripts/budget-audit.mjs | 134 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 scripts/budget-audit.mjs diff --git a/package.json b/package.json index 541e373..2e65b7d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/budget-audit.mjs b/scripts/budget-audit.mjs new file mode 100644 index 0000000..bda4ea5 --- /dev/null +++ b/scripts/budget-audit.mjs @@ -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