Skip to content

ts(B-0086): port 1 budget script (.sh→.ts) — slice 19 of TS/Bun migration#902

Merged
AceHack merged 1 commit intomainfrom
lane-b/ts-bun-slice-19-project-runway-2026-04-30
Apr 30, 2026
Merged

ts(B-0086): port 1 budget script (.sh→.ts) — slice 19 of TS/Bun migration#902
AceHack merged 1 commit intomainfrom
lane-b/ts-bun-slice-19-project-runway-2026-04-30

Conversation

@AceHack
Copy link
Copy Markdown
Member

@AceHack AceHack commented Apr 30, 2026

Summary

Slice 19 of the TS/Bun migration (B-0086). Ports tools/budget/project-runway.sh (297 lines bash) → tools/budget/project-runway.ts (430 lines TS) — the projection layer over docs/budget-history/snapshots.jsonl.

Closes the budget cluster. Once this lands, all three primitives are TS:

Primitive Slice PR State
snapshot-burn 14 #894 merged
daily-cost-report 18 #901 merged
project-runway 19 this in flight

daily-cost-report.ts can then switch from spawning the .sh siblings to spawning the .ts versions (follow-up slice).

Behavioural improvements (deliberate, not drift)

  1. statSync().isFile() over existsSync() — bash -f rejects directories, existsSync accepts them. Slice-18 mirror.
  2. Native JSONL parsingreadFileSync + split + JSON.parse rather than per-line jq spawn-out. Projection reads already-persisted JSON; jq is overkill for a structurally typed reduce. (snapshot-burn.ts still needs gh api for capture; this is projection only.)
  3. requireInt validation — matches bash case '$val' in ''|*[!0-9]*) ... exactly: same exit code 2, same wording (Codex P2 NM59qF00 + NM59qH2H, Copilot P1 NM59qGJ- on the bash original).

Verification

diff <(bun tools/budget/project-runway.ts) \
     <(./tools/budget/project-runway.sh)        # empty
diff <(bun tools/budget/project-runway.ts --json) \
     <(./tools/budget/project-runway.sh --json) # empty

Both modes byte-equivalent on this repo state (snapshots.jsonl with N=4).

Error-path equivalence sampled:

Input TS Bash
--stages abc exit 2 + matching message exit 2 + matching message
--file /tmp/nonexistent exit 1 + matching message exit 1 + matching message
--bogus exit 2 + matching message exit 2 + matching message

bun --bun tsc --noEmit -p tsconfig.json clean (the new lint (tsc tools) gate from #890 will verify in CI).

Test plan

  • tsc --noEmit clean locally
  • Help/usage text emits cleanly
  • text-mode byte-equivalent with bash original on live snapshot file
  • --json mode byte-equivalent with bash original
  • Error paths byte-equivalent (3 sampled)
  • CI: lint (tsc tools) gate passes
  • CI: gate.yml matrix passes (ubuntu-24.04, ubuntu-24.04-arm, macos-26)
  • CodeQL clean (no shell-out → no js/indirect-command-line-injection candidates this slice)

Composes with

🤖 Generated with Claude Code

…tion

Closes the budget cluster: snapshot-burn (slice 14) +
daily-cost-report (slice 18) + project-runway (this slice) are
now all TS. Once this lands, daily-cost-report.ts can switch
from spawning project-runway.sh to project-runway.ts.

Behavioural improvements over bash original (deliberate, not drift):

- File-existence check uses statSync().isFile() + try/catch
  rather than existsSync — bash `-f` rejects directories,
  existsSync accepts them (slice-18 mirror).
- JSONL parsing is native (readFileSync + split + JSON.parse)
  rather than per-line jq spawn-out — projection script reads
  already-persisted JSON, so jq is a heavy dependency for what
  is structurally a typed reduce. snapshot-burn.ts still needs
  gh api for capture; this is projection only.
- requireInt validation matches bash `case '$val' in
  ''|*[!0-9]*) ...` with TS `requireInt(flag, val)` returning
  `number | ArgError` — same exit code 2, same error wording
  (Codex P2 NM59qF00 + NM59qH2H, Copilot P1 NM59qGJ- on the
  bash original).

Byte-equivalence verified on this repo state:
  diff <(bun tools/budget/project-runway.ts) \
       <(./tools/budget/project-runway.sh)        # empty
  diff <(bun tools/budget/project-runway.ts --json) \
       <(./tools/budget/project-runway.sh --json) # empty

Error paths verified equivalent: --stages abc → exit 2 with
matching message; --file <missing> → exit 1; --bogus → exit 2.

Tools used: tsc --noEmit clean; eslint clean per the existing
tsc-tools CI gate (#890).

Composes with:
  - tools/budget/snapshot-burn.ts (slice 14, #894)
  - tools/budget/daily-cost-report.ts (slice 18, #901)
  - docs/trajectories/typescript-bun-migration/RESUME.md
  - docs/trajectories/typescript-bun-migration/slice-audits.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 30, 2026 07:21
@AceHack AceHack enabled auto-merge (squash) April 30, 2026 07:21
@AceHack AceHack merged commit bfdadd9 into main Apr 30, 2026
26 checks passed
@AceHack AceHack deleted the lane-b/ts-bun-slice-19-project-runway-2026-04-30 branch April 30, 2026 07:24
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Ports the budget “projection” primitive from bash to TypeScript/Bun so the budget cluster can be fully TS-based (reading docs/budget-history/snapshots.jsonl and emitting text/JSON runway projections).

Changes:

  • Added tools/budget/project-runway.ts (TS/Bun port of project-runway.sh) with native JSONL parsing and CLI validation.
  • Appended Slice 19 audit notes to the TS/Bun migration trajectory.
  • Updated the TS/Bun migration RESUME dashboard to reflect slice progress.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
tools/budget/project-runway.ts New TS/Bun implementation of the runway projection script (CLI parsing, JSONL parsing, projection + emitters).
docs/trajectories/typescript-bun-migration/slice-audits.md Adds Slice 19 audit entry describing the port and equivalence checks.
docs/trajectories/typescript-bun-migration/RESUME.md Updates trajectory status/milestone text for slice 19 in-flight.

Comment on lines +115 to +117
if (a === "--stages") return applyIntFlag("--stages", next, (n) => { state.stages = n; });
if (a === "--copilot-rate") return applyIntFlag("--copilot-rate", next, (n) => { state.copilotRate = n; });
if (a === "--actions-free-ms") return applyIntFlag("--actions-free-ms", next, (n) => { state.actionsFreeMs = n; });
Comment on lines +214 to +216
// Match bash `wc -l` semantics over JSONL: count non-empty lines.
// Empty trailing newline shouldn't inflate the count.
return raw.split("\n").filter((line) => line.length > 0);

### Code-pattern audit (per-port)

- **`project-runway.ts`** (297 → 430 lines): JSONL parsing entirely native (`readFileSync` + split + `JSON.parse`) — no jq spawn-out, since this is a pure data-projection script (snapshot-burn.ts still needs `gh api` for capture, but projection is over already-persisted JSON). Argument parsing splits flag classification (`classifyFlag`) from int-flag application (`applyIntFlag`) so each function stays under cognitive-complexity 15. Validation mirror: bash `case '$val' in ''|*[!0-9]*) ...` → TS `requireInt(flag, val)` returning `number | ArgError` discriminated-union — same semantics (non-empty + digits-only), same exit code 2, same error-message wording. The jq path expressions like `([.repos[].agg.total_duration_ms // 0] | add) // 0` map cleanly to typed `RepoEntryLike[].map(...).reduce(...)` once you express the snapshot shape as `SnapshotLike` with optional fields throughout (`?:`). Default-zero (`?? 0`) matches jq `// 0` per-element + `add // 0` empty-array semantics.
AceHack added a commit that referenced this pull request Apr 30, 2026
…sure (#903)

Per the consolidated-row-pattern (rows from 03:41Z + 05:01Z arc
closures): when a session lands many small commits across multiple
ticks, a single consolidated row summarizing the arc is more
signal-dense than N minimal rows.

Covers the slices 15-19 arc that landed after the 05:43Z slice-14 row:
  - #896 slice 15 (grok.ts) — peer-call cluster opens
  - #898 slice 16 (gemini.ts) — peer-call sibling
  - #899 backport to grok from #898 review-cycle findings
  - #900 slice 17 (codex.ts) — peer-call cluster closes
  - #897 B-0107 row — CodeQL dismissal pattern
  - #901 slice 18 (daily-cost-report.ts) — budget wrapper
  - #902 slice 19 (project-runway.ts) — budget cluster closes (in flight)

Three new substrate observations recorded for future-Otto:
  - sibling-port-cost decreases monotonically (round-2/3 fixes
    bake into later siblings proactively)
  - kernel-pipe vs JS-space stream interleaving distinction
    (bash `2>&1` merges shell-side; `result.stdout + result.stderr`
    in JS does not preserve chronological ordering)
  - fix-the-bug + file-the-row + implement-the-row + closeout pattern
    is the durable shape (B-0106 + B-0107 are both worked examples)

Cron 98fc7424 still armed.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 319e38521a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +239 to +240
const repos = obj.repos ?? [];
const totalMs = sumOptional(repos.map((r) => r.agg?.total_duration_ms));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject malformed snapshot rows instead of defaulting to zero

This parser silently treats missing/non-object repos as an empty array, so corrupted JSONL rows can pass as valid and produce zeroed totals instead of failing. In the previous shell version, equivalent jq queries (.repos[]) error on missing/null repos, which correctly surfaces malformed history files; here the script can emit a plausible-looking projection from bad input and mislead budgeting decisions. Please validate the snapshot shape (at least require repos to be an array) and return the documented malformed-file exit path when that contract is violated.

Useful? React with 👍 / 👎.

AceHack added a commit that referenced this pull request Apr 30, 2026
#907)

Last git-cluster port: tools/git/batch-resolve-pr-threads.{sh→ts}.
Slice 13 (push-with-retry) + slice 20 (batch-resolve-pr-threads)
together complete the git-cluster.

Behavioural improvements over bash original (deliberate, not drift):

- jq pipelines replaced with native JSON parse + array operations;
  drops jq from runtime deps (only `gh` required now).
- Pattern classification via TypeScript pattern arrays + `.some()`
  rather than bash `for pat in ; do [[ ]]; done` — same semantics,
  more type-safe with `Classification = "dangling-ref" |
  "name-attribution" | "unknown"` discriminated union.
- ResolveError discriminated record with `stage: "reply" | "resolve"`
  so failure mode is visible in error messages.

Byte-equivalence verified on this repo state:
- Three argument-validation paths byte-equivalent (no args / bad
  pr-number / unknown second arg) — same exit code + same message.
- Live dry-run on PR #902 (4 unresolved threads) byte-equivalent —
  empty diff against bash original.
- Apply mode not exercised (would mutate live PR state); code path
  verified by inspection.

All bash safety rails from PR #199 (Copilot/Codex) preserved:
positive-integer validation, exact-`--apply` second-arg check,
GraphQL errors array inspection, null-pullRequest detection,
paginated thread fetch (50 per page) + paginated per-thread comments
(50 per thread with truncation warning), positional -F args
(avoids parameter-expansion-quote pitfall), printf-not-echo for
review-comment bodies (handled via TypeScript string operations).

Bucket B 2 → 1 (only tools/pr-preservation/archive-pr.sh 674L
remains; bash+Python mix — slice 21).

Composes with:
- tools/git/push-with-retry.ts (slice 13, #892) — git-cluster siblings
- docs/trajectories/typescript-bun-migration/RESUME.md updated
- docs/trajectories/typescript-bun-migration/slice-audits.md slice 20 audit

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
AceHack added a commit that referenced this pull request Apr 30, 2026
…Bun migration (#908)

* ts(B-0086): port 1 pr-preservation script (.sh→.ts) — slice 21 of TS/Bun migration

Last Bucket B file: tools/pr-preservation/archive-pr.{sh→ts}.
After this PR merges, **Bucket B is empty** — every file flagged
for TS port has been ported. The trajectory transitions from
"porting" phase to "soak + bash retirement" phase.

Behavioural improvements over bash original (deliberate, not drift):

- Drops Python from runtime deps entirely. Bash original was
  217 lines bash + ~457 lines embedded Python (GraphQL fetcher +
  Markdown formatter); TS port collapses both into single Bun
  runtime. No more bash/Python boundary, no more mktemp + trap
  cleanup, no `set +e` to capture Python exit code.

- Native JSON parsing replaces all jq/Python json shells.

- Generic `paginateTopLevel<T>` helper with type-safe extractor
  handles the cursor loop for all 3 top-level connections
  (reviewThreads/reviews/comments). `paginateThreadComments`
  handles the per-thread case.

- `detectFenceMarker` preserves CommonMark §4.5 fence rules
  strictly: leading-space-count ≤ 3 + no tab in prefix; closing
  fence same marker char + length ≥ opener. This matches the
  Python original's nuanced fence detection (Otto-241 etc).

- `yamlQuote` post-processes `JSON.stringify` output to escape
  non-ASCII codepoints as \uXXXX, matching Python's
  `json.dumps(ensure_ascii=True)` wire-format default. Without
  this, titles with → / — would diverge from bash output.

Byte-equivalence verified on this repo state:
- 2 argument-validation paths byte-equivalent (no args / bad
  PR number) — same exit code 1 + same message.
- Live archive run on PR #902 (4 threads, 2 reviews, 0 comments)
  byte-equivalent EXCEPT `archived_at` (timestamp) + `archive_tool`
  (.sh vs .ts — deliberate self-reference). Title with non-ASCII
  chars escapes correctly via the yamlQuote fix.

All bash safety rails preserved: positive-integer PR validation,
GH_REPO env-var preference + `gh repo view` fallback, 2/3-segment
NWO parsing with Enterprise HOST validation (dot required),
slash-injection defence on owner/name, paginated GraphQL fetch
(top-level + per-thread), GraphQL `errors` array inspection,
null-pullRequest detection, idempotent archive path via
PR-<NNNN>-* glob (Otto-235), CommonMark §4.5 fence detection
(Otto-241), trailing-newline-only rstrip (preserves
two-space markdown hard-line-breaks).

Bucket B 1 → 0. Bucket C: 2 (gh-api-heavy scripts pending
maintainer decision). Bucket A: 14 (stays bash by design).

Composes with:
- tools/git/batch-resolve-pr-threads.ts (slice 20, #907) — same
  GraphQL pagination shape
- docs/trajectories/typescript-bun-migration/RESUME.md updated
- docs/trajectories/typescript-bun-migration/slice-audits.md
  slice 21 audit appended

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(ts-bun-audits): MD038 — rephrase to avoid leading-space inside code spans

* fix(ts-bun-audits): address #908 review threads — line counts + ensure_ascii framing + usage-line carve-out

Round-1 fixes for PR #908 (4 Copilot P1 threads):

- Line-count drift (Copilot P1): I claimed "674 → 590" but actual is
  "674 → 806". Updated and added a one-line note explaining why TS
  is larger than bash (explicit type interfaces replace Python's
  untyped dict navigation).

- ensure_ascii=True framing was wrong (Copilot P1, twice): Python's
  json.dumps with ensure_ascii=True does NOT emit literal `→`/`—` —
  it emits \uXXXX escapes. My audit prose said "Python escapes to
  `→`" which contradicted both the byte-equivalence goal and what
  yamlQuote actually implements. Rewrote to clearly say Python emits
  \uXXXX form (e.g. → for right-arrow, — for em-dash) and
  the TS yamlQuote post-processes JSON.stringify to match.

- Usage-line not byte-equivalent (Copilot P1, twice): bash echoes
  `$0` showing the actual `./tools/...sh` path; TS hard-codes
  `bun tools/...ts` so the user sees the form they should run.
  Reframed equivalence claim to be honest: same exit code + same
  error-body, but usage-line script-path is intentionally NOT
  byte-equivalent — same carve-out as the `archive_tool` YAML
  self-reference.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants