From 7bcae924c834675ba0dfb83aad7c33a2456133b9 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Thu, 30 Apr 2026 02:55:03 -0400 Subject: [PATCH 1/5] =?UTF-8?q?ts(B-0086):=20port=201=20budget=20script=20?= =?UTF-8?q?(.sh=E2=86=92.ts)=20=E2=80=94=20slice=2018=20of=20TS/Bun=20migr?= =?UTF-8?q?ation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ts(slice-18, wip 1/N): port budget/daily-cost-report (.sh→.ts) Daily cost-monitoring entry point. Wraps snapshot-burn.sh + project-runway.sh and writes human-readable projection to docs/budget-history/latest-report.md (visibility surface for Aaron's #287 deadline). Note: this wrapper still spawns the bash siblings (snapshot-burn.sh + project-runway.sh), NOT the TS port — the bash versions are the soak-period reference until they retire. Once project-runway is also TS-ported, this wrapper can switch to spawning the .ts versions. Mechanical changes: - bash arg-parse → parseArgs with ParsedArgs | ArgError | help - bash 'cat > "$report_path" < 0 ? ` ${args.join(" ")}` : ""; + process.stdout.write(`==> snapshot-burn.sh${argsSuffix}\n`); + + const path = resolve(scriptDir(), "snapshot-burn.sh"); + const result = spawnSync(path, args, { + stdio: "inherit", + maxBuffer: SPAWN_MAX_BUFFER, + }); + return { exitCode: result.status ?? 1 }; +} + +function runProjectRunway(): { exitCode: number; output: string } { + const path = resolve(scriptDir(), "project-runway.sh"); + // Capture combined stdout+stderr; bash original uses + // `$("$script_dir/project-runway.sh" 2>&1)`. + const result = spawnSync(path, [], { + encoding: "utf8", + maxBuffer: SPAWN_MAX_BUFFER, + stdio: ["inherit", "pipe", "pipe"], + }); + return { + exitCode: result.status ?? 1, + output: `${result.stdout}${result.stderr}`, + }; +} + +function buildReport(args: { ts: string; gitSha: string; projection: string }): string { + return `# Latest cost projection — auto-generated + +**Generated:** \`${args.ts}\` +**Factory git SHA:** \`${args.gitSha}\` +**Source:** \`tools/budget/daily-cost-report.ts\` (wraps snapshot-burn.sh + project-runway.sh) + +This file is **OVERWRITTEN** on each daily run. Historical snapshots live in +\`docs/budget-history/snapshots.jsonl\` (append-only); historical projections +can be reconstructed from any snapshot subset via \`tools/budget/project-runway.sh\`. + +--- + +## Projection text + +\`\`\`text +${args.projection} +\`\`\` + +--- + +## How to read this + +- **\`Actions billable_ms cumulative\`** — cumulative GitHub-Actions billable runtime across captured snapshots. On public repos this is typically 0 (included minutes); meaningful for macOS / private-repo / Enterprise-plan accounts. +- **\`Per-PR Actions ms (naive)\`** — rolling-window estimate of per-merged-PR Actions cost. Caveats in the projection text below; treat as proxy until \`N \\geq 3\` cumulative snapshots exist. +- **\`Actions fit\`** — whether projected Stages 1-4 burn fits the configured free-tier allowance. If \`EXCEEDS\`, the gate-conditions section names escape valves. +- **\`Copilot projected USD\`** — assumed-30-day span at the current seat count and rate. Re-run with \`--copilot-rate\` to model rate changes. + +--- + +## Source data + +- Snapshots: \`docs/budget-history/snapshots.jsonl\` +- Methodology: \`docs/budget-history/README.md\` +- Wrapper: \`tools/budget/daily-cost-report.ts\` (this run) +- Capture script: \`tools/budget/snapshot-burn.sh\` +- Projection script: \`tools/budget/project-runway.sh\` +`; +} + +export function main(argv: readonly string[]): number { + const parsed = parseArgs(argv); + if ("help" in parsed) { + emitHelp(); + return 0; + } + if ("error" in parsed) { + process.stderr.write(`${parsed.error}\n`); + return parsed.exitCode; + } + + const root = repoRoot(); + const reportPath = resolve(root, "docs", "budget-history", "latest-report.md"); + const snapshotsPath = resolve(root, "docs", "budget-history", "snapshots.jsonl"); + + // Step 1 — capture snapshot (unless skipped) + if (!parsed.skipSnapshot) { + const burn = runSnapshotBurn(parsed.dryRun); + if (burn.exitCode !== 0) { + process.stderr.write(`error: snapshot-burn.sh failed (exit ${String(burn.exitCode)})\n`); + return 1; + } + } else { + process.stdout.write("==> snapshot-burn.sh SKIPPED per --skip-snapshot\n"); + } + + // Step 2 — run projection (text mode) + let projection: string; + if (!existsSync(snapshotsPath)) { + process.stdout.write( + "==> project-runway.sh SKIPPED (no snapshots yet); writing bootstrap report\n", + ); + projection = + "No snapshots captured yet. The first snapshot-burn.sh run will append a baseline row to docs/budget-history/snapshots.jsonl. Once N >= 2 snapshots exist across LFG merges, projection becomes available."; + } else { + process.stdout.write("==> project-runway.sh\n"); + const runway = runProjectRunway(); + if (runway.exitCode !== 0) { + process.stderr.write(`error: project-runway.sh failed (exit ${String(runway.exitCode)})\n`); + return 1; + } + projection = runway.output; + } + + // Step 3 — write the report (overwrite, not append) + const report = buildReport({ + ts: nowIsoUtc(), + gitSha: gitHeadSha(root), + projection, + }); + writeFileSync(reportPath, report); + process.stdout.write(`==> wrote ${reportPath}\n`); + process.stdout.write("OK: daily cost report regenerated\n"); + return 0; +} + +if (import.meta.main) { + process.exit(main(process.argv.slice(2))); +} From 9d8bdf8249089e6fdf8d72c2f208b1dfc8b7bc5c Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Thu, 30 Apr 2026 02:59:14 -0400 Subject: [PATCH 2/5] review(slice-18): use statSync.isFile() instead of existsSync per Codex P2 Codex P2: existsSync returns true for directories and other non-regular paths; the bash original uses -f which checks regular-file existence. If snapshots.jsonl were ever a directory, existsSync would skip the bootstrap branch and the wrapper would try to spawn project-runway.sh against a non-file. Switched to statSync.isFile() with try/catch fallback to false. --- tools/budget/daily-cost-report.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tools/budget/daily-cost-report.ts b/tools/budget/daily-cost-report.ts index 751cac502..71f90a16e 100644 --- a/tools/budget/daily-cost-report.ts +++ b/tools/budget/daily-cost-report.ts @@ -31,7 +31,7 @@ // also TS-ported and the cluster soaks clean, this wrapper can // switch to spawning the .ts versions. -import { existsSync, writeFileSync } from "node:fs"; +import { statSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; @@ -186,9 +186,16 @@ export function main(argv: readonly string[]): number { process.stdout.write("==> snapshot-burn.sh SKIPPED per --skip-snapshot\n"); } - // Step 2 — run projection (text mode) + // Step 2 — run projection (text mode). Match bash `-f` semantics + // (regular-file check; existsSync would also accept directories). + let isRegularFile: boolean; + try { + isRegularFile = statSync(snapshotsPath).isFile(); + } catch { + isRegularFile = false; + } let projection: string; - if (!existsSync(snapshotsPath)) { + if (!isRegularFile) { process.stdout.write( "==> project-runway.sh SKIPPED (no snapshots yet); writing bootstrap report\n", ); From 787214ea5111141173db99cd4bfd1abc28ad9ba3 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Thu, 30 Apr 2026 03:03:46 -0400 Subject: [PATCH 3/5] =?UTF-8?q?review(slice-18):=20address=20Copilot=20P0+?= =?UTF-8?q?P0+P2=20=E2=80=94=20spawn=20classification=20+=20null-stream=20?= =?UTF-8?q?guards=20+=20header=20phrasing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Copilot findings on #901: P0 — spawnSync launch failures collapsed into exitCode 1: Added classifySpawnFailure helper (4-case: status set / ENOENT → 127 / signal / other) reused from PRs #887, #898, #900. Both runSnapshotBurn and runProjectRunway now report a contextual note when the child can't start (e.g., 'snapshot-burn.sh: command not found on PATH (ENOENT)'). P0 — null stdout/stderr could yield 'nullnull': When a child fails to start, result.stdout / result.stderr can be null even with encoding: 'utf8'. Guarded with `?? ''` in runProjectRunway so the projection block doesn't end up as the literal string 'nullnull'. P2 — Header comment phrasing: Reworded 'snapshot-burn.sh ported in #894' to 'TS port snapshot-burn.ts landed in #894 but this wrapper still spawns the .sh during the soak period' to avoid implying the .sh file itself is the ported artifact. --- tools/budget/daily-cost-report.ts | 66 ++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/tools/budget/daily-cost-report.ts b/tools/budget/daily-cost-report.ts index 71f90a16e..a08a7b6cd 100644 --- a/tools/budget/daily-cost-report.ts +++ b/tools/budget/daily-cost-report.ts @@ -19,7 +19,9 @@ // 2 on CLI-argument errors // // Composes with: -// - tools/budget/snapshot-burn.sh (data-capture primitive; ported in #894) +// - tools/budget/snapshot-burn.sh (data-capture primitive — bash; +// TS port `snapshot-burn.ts` landed in #894 but this wrapper still +// spawns the .sh during the soak period) // - tools/budget/project-runway.sh (projection primitive; bash-only) // - docs/budget-history/snapshots.jsonl (append-only data store) // - docs/budget-history/latest-report.md (visibility surface; @@ -92,7 +94,37 @@ function gitHeadSha(root: string): string { return result.status === 0 ? result.stdout.trim() : "unknown"; } -function runSnapshotBurn(dryRun: boolean): { exitCode: number } { +interface SpawnError { + readonly code?: string; +} + +interface ChildOutcome { + readonly status: number; + readonly note: string; +} + +function classifySpawnFailure( + status: number | null, + signal: string | null, + error: SpawnError | undefined, +): ChildOutcome { + // 4-case helper (status set / ENOENT / signal / other) reused from + // PRs #887, #898, #900. Distinguishes ENOENT/permission/signal from + // a normal non-zero exit so callers see the actual failure mode. + if (status !== null) return { status, note: "" }; + if (error?.code === "ENOENT") { + return { status: 127, note: "command not found on PATH (ENOENT)" }; + } + if (error?.code !== undefined) { + return { status: 1, note: `spawn failed (${error.code})` }; + } + if (signal !== null) { + return { status: 1, note: `terminated by signal ${signal}` }; + } + return { status: 1, note: "terminated without exit code" }; +} + +function runSnapshotBurn(dryRun: boolean): { exitCode: number; note: string } { const args = dryRun ? ["--dry-run"] : []; const argsSuffix = args.length > 0 ? ` ${args.join(" ")}` : ""; process.stdout.write(`==> snapshot-burn.sh${argsSuffix}\n`); @@ -102,10 +134,15 @@ function runSnapshotBurn(dryRun: boolean): { exitCode: number } { stdio: "inherit", maxBuffer: SPAWN_MAX_BUFFER, }); - return { exitCode: result.status ?? 1 }; + const classified = classifySpawnFailure( + result.status, + result.signal, + result.error as SpawnError | undefined, + ); + return { exitCode: classified.status, note: classified.note }; } -function runProjectRunway(): { exitCode: number; output: string } { +function runProjectRunway(): { exitCode: number; output: string; note: string } { const path = resolve(scriptDir(), "project-runway.sh"); // Capture combined stdout+stderr; bash original uses // `$("$script_dir/project-runway.sh" 2>&1)`. @@ -114,9 +151,20 @@ function runProjectRunway(): { exitCode: number; output: string } { maxBuffer: SPAWN_MAX_BUFFER, stdio: ["inherit", "pipe", "pipe"], }); + // Defensive: result.stdout / result.stderr can be null when the + // child fails to start (Copilot P0 on #901). Guard with `?? ""` + // so we don't end up with a "nullnull" projection block. + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + const classified = classifySpawnFailure( + result.status, + result.signal, + result.error as SpawnError | undefined, + ); return { - exitCode: result.status ?? 1, - output: `${result.stdout}${result.stderr}`, + exitCode: classified.status, + output: `${stdout}${stderr}`, + note: classified.note, }; } @@ -179,6 +227,9 @@ export function main(argv: readonly string[]): number { if (!parsed.skipSnapshot) { const burn = runSnapshotBurn(parsed.dryRun); if (burn.exitCode !== 0) { + if (burn.note.length > 0) { + process.stderr.write(`snapshot-burn.sh: ${burn.note}\n`); + } process.stderr.write(`error: snapshot-burn.sh failed (exit ${String(burn.exitCode)})\n`); return 1; } @@ -205,6 +256,9 @@ export function main(argv: readonly string[]): number { process.stdout.write("==> project-runway.sh\n"); const runway = runProjectRunway(); if (runway.exitCode !== 0) { + if (runway.note.length > 0) { + process.stderr.write(`project-runway.sh: ${runway.note}\n`); + } process.stderr.write(`error: project-runway.sh failed (exit ${String(runway.exitCode)})\n`); return 1; } From 4a13e32d421d440f9de7ca49c9c0aa6bf2786baf Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Thu, 30 Apr 2026 03:04:55 -0400 Subject: [PATCH 4/5] review(slice-18) round-2: extract step-helpers + suppress no-unnecessary-condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extracted runSnapshotStep + runProjectionStep helpers to drop main() under cognitive-complexity 15. - Added eslint-disable on stdout/stderr ?? '' guards (typings claim string when encoding is set, but the runtime can return null when spawn fails — same pattern as #898). Lint clean: tsc + eslint strictTypeChecked + sonarjs all pass. --- tools/budget/daily-cost-report.ts | 102 ++++++++++++++++++------------ 1 file changed, 61 insertions(+), 41 deletions(-) diff --git a/tools/budget/daily-cost-report.ts b/tools/budget/daily-cost-report.ts index a08a7b6cd..498796a7a 100644 --- a/tools/budget/daily-cost-report.ts +++ b/tools/budget/daily-cost-report.ts @@ -153,8 +153,12 @@ function runProjectRunway(): { exitCode: number; output: string; note: string } }); // Defensive: result.stdout / result.stderr can be null when the // child fails to start (Copilot P0 on #901). Guard with `?? ""` - // so we don't end up with a "nullnull" projection block. + // so we don't end up with a "nullnull" projection block. The + // typings claim string when encoding is set, but the runtime + // doesn't always cooperate. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition const stdout = result.stdout ?? ""; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition const stderr = result.stderr ?? ""; const classified = classifySpawnFailure( result.status, @@ -208,6 +212,57 @@ ${args.projection} `; } +function isRegularFileSafe(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +interface StepResult { + readonly ok: boolean; + readonly projection?: string; +} + +function runSnapshotStep(skipSnapshot: boolean, dryRun: boolean): StepResult { + if (skipSnapshot) { + process.stdout.write("==> snapshot-burn.sh SKIPPED per --skip-snapshot\n"); + return { ok: true }; + } + const burn = runSnapshotBurn(dryRun); + if (burn.exitCode !== 0) { + if (burn.note.length > 0) { + process.stderr.write(`snapshot-burn.sh: ${burn.note}\n`); + } + process.stderr.write(`error: snapshot-burn.sh failed (exit ${String(burn.exitCode)})\n`); + return { ok: false }; + } + return { ok: true }; +} + +const BOOTSTRAP_PROJECTION = + "No snapshots captured yet. The first snapshot-burn.sh run will append a baseline row to docs/budget-history/snapshots.jsonl. Once N >= 2 snapshots exist across LFG merges, projection becomes available."; + +function runProjectionStep(snapshotsPath: string): StepResult { + if (!isRegularFileSafe(snapshotsPath)) { + process.stdout.write( + "==> project-runway.sh SKIPPED (no snapshots yet); writing bootstrap report\n", + ); + return { ok: true, projection: BOOTSTRAP_PROJECTION }; + } + process.stdout.write("==> project-runway.sh\n"); + const runway = runProjectRunway(); + if (runway.exitCode !== 0) { + if (runway.note.length > 0) { + process.stderr.write(`project-runway.sh: ${runway.note}\n`); + } + process.stderr.write(`error: project-runway.sh failed (exit ${String(runway.exitCode)})\n`); + return { ok: false }; + } + return { ok: true, projection: runway.output }; +} + export function main(argv: readonly string[]): number { const parsed = parseArgs(argv); if ("help" in parsed) { @@ -223,47 +278,12 @@ export function main(argv: readonly string[]): number { const reportPath = resolve(root, "docs", "budget-history", "latest-report.md"); const snapshotsPath = resolve(root, "docs", "budget-history", "snapshots.jsonl"); - // Step 1 — capture snapshot (unless skipped) - if (!parsed.skipSnapshot) { - const burn = runSnapshotBurn(parsed.dryRun); - if (burn.exitCode !== 0) { - if (burn.note.length > 0) { - process.stderr.write(`snapshot-burn.sh: ${burn.note}\n`); - } - process.stderr.write(`error: snapshot-burn.sh failed (exit ${String(burn.exitCode)})\n`); - return 1; - } - } else { - process.stdout.write("==> snapshot-burn.sh SKIPPED per --skip-snapshot\n"); - } + const snapshotStep = runSnapshotStep(parsed.skipSnapshot, parsed.dryRun); + if (!snapshotStep.ok) return 1; - // Step 2 — run projection (text mode). Match bash `-f` semantics - // (regular-file check; existsSync would also accept directories). - let isRegularFile: boolean; - try { - isRegularFile = statSync(snapshotsPath).isFile(); - } catch { - isRegularFile = false; - } - let projection: string; - if (!isRegularFile) { - process.stdout.write( - "==> project-runway.sh SKIPPED (no snapshots yet); writing bootstrap report\n", - ); - projection = - "No snapshots captured yet. The first snapshot-burn.sh run will append a baseline row to docs/budget-history/snapshots.jsonl. Once N >= 2 snapshots exist across LFG merges, projection becomes available."; - } else { - process.stdout.write("==> project-runway.sh\n"); - const runway = runProjectRunway(); - if (runway.exitCode !== 0) { - if (runway.note.length > 0) { - process.stderr.write(`project-runway.sh: ${runway.note}\n`); - } - process.stderr.write(`error: project-runway.sh failed (exit ${String(runway.exitCode)})\n`); - return 1; - } - projection = runway.output; - } + const projectionStep = runProjectionStep(snapshotsPath); + if (!projectionStep.ok) return 1; + const projection = projectionStep.projection ?? ""; // Step 3 — write the report (overwrite, not append) const report = buildReport({ From 42b5c41bc9cda16543eb973d0a596c1b8b6f1f90 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Thu, 30 Apr 2026 03:11:19 -0400 Subject: [PATCH 5/5] review(slice-18): preserve stdout/stderr ordering via shell-side 2>&1 per Copilot P1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot caught: concatenating result.stdout + result.stderr does NOT preserve the original chronological ordering of merged streams. The bash original $(... 2>&1) merges at the kernel pipe level — if project-runway.sh emits warnings on stderr while writing success output to stdout, the messages interleave correctly. Switched to /bin/bash -c 'path 2>&1' so the merge happens shell-side (matches bash original semantics). Single stdout pipe = correct ordering. result.stderr is now unused (the inherit pipe still receives nothing). --- tools/budget/daily-cost-report.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tools/budget/daily-cost-report.ts b/tools/budget/daily-cost-report.ts index 498796a7a..f46518f9c 100644 --- a/tools/budget/daily-cost-report.ts +++ b/tools/budget/daily-cost-report.ts @@ -144,22 +144,22 @@ function runSnapshotBurn(dryRun: boolean): { exitCode: number; note: string } { function runProjectRunway(): { exitCode: number; output: string; note: string } { const path = resolve(scriptDir(), "project-runway.sh"); - // Capture combined stdout+stderr; bash original uses - // `$("$script_dir/project-runway.sh" 2>&1)`. - const result = spawnSync(path, [], { + // Capture combined stdout+stderr in-order via shell-side `2>&1` + // (matches bash original `$("$script_dir/project-runway.sh" 2>&1)` + // which preserves chronological interleaving). Concatenating + // `result.stdout + result.stderr` would reorder warnings vs + // success output (Copilot P1 on #901). + // The path is constructed from import.meta.url + dirname, not from + // user input, so shell-quoting safety isn't an issue here. + const result = spawnSync("/bin/bash", ["-c", `"${path}" 2>&1`], { encoding: "utf8", maxBuffer: SPAWN_MAX_BUFFER, stdio: ["inherit", "pipe", "pipe"], }); - // Defensive: result.stdout / result.stderr can be null when the - // child fails to start (Copilot P0 on #901). Guard with `?? ""` - // so we don't end up with a "nullnull" projection block. The - // typings claim string when encoding is set, but the runtime - // doesn't always cooperate. + // Defensive: result.stdout can be null when the child fails to + // start (Copilot P0 on #901). Guard with `?? ""`. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - const stdout = result.stdout ?? ""; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - const stderr = result.stderr ?? ""; + const output = result.stdout ?? ""; const classified = classifySpawnFailure( result.status, result.signal, @@ -167,7 +167,7 @@ function runProjectRunway(): { exitCode: number; output: string; note: string } ); return { exitCode: classified.status, - output: `${stdout}${stderr}`, + output, note: classified.note, }; }