4.8 KiB
name, description
| name | description |
|---|---|
| monolith-split-under-dist-pin | Use when cutting a monolithic source file into modules behind a black-box test suite whose committed build artifact (dist) must stay byte-identical to src; one verified commit per cut, red-proof against the previous bundle. Not for feature work or doc-only edits. |
Monolith split under a committed-bundle pin
Procedure that landed a 1354-line index.ts as 9 modules + 301 lines of wiring across 9 commits with zero red commits. Assumes: black-box test suite (tests drive the built bundle, not internals) and a pin test asserting the committed dist/ equals a fresh build of src/.
Per-cut loop (one commit per cut, always in this order)
bun run build(or project equivalent) before staging — commitdist/in the same commit as the movedsrc/, otherwise the pin test fails ("committed dist is byte-identical to a fresh build") and you must amend. Amending is fine only while the branch is unpushed.tsc --noEmit, then run the full suite async (bats tests/ > /tmp/x.log 2>&1; grep -E '^1\.\.').- Never write to
src/while the suite runs.setup()rebuilds the bundle fromsrc/, so a concurrent edit attributes someone else's half-written state to your run. Read-only prep (planning the next cut's exact line ranges) is the correct thing to do while waiting. - Gate green → next cut. Gate red → fix, rebuild, amend, re-gate.
Cutting mechanics
- Cut leaf/pure modules first (grammar, transcript rules, URL resolution, pure segmenter), then stateful ones (config, IO, pipeline, UI). Never let a module import
index— if the new module needs something fromindex, that thing is a leaf and must move too (this is what forcedsegment.tsout early). - Extract in verbatim bodies: read the exact ranges, write them into the new file, then delete the same ranges. Only module docs, export keywords, and injected dependencies change. Keeps each commit reviewable as a move.
- Edit tools that require "displayed lines" from the latest read snapshot: re-read the exact ranges under the current tag, then issue one edit call with all ops listed bottom-up (original line numbers throughout). Adjacent ranges merge into one
CUT a=b; leaving a doubled blank line is the usual scar — one extraCUTnext turn. - Mutable config must reach a long-lived object as getters, not a construction-time snapshot: menu handlers mutate
cfgafter construction, and a snapshot freezes the settings the UI exists to change. - After moving code, grep the old file for the moved names: stale call sites compile under bundlers that skip typechecking (
stop()vsspeaker.stop()was caught only bytsc, not bybun build).
Proving a new assertion is red-capable (before trusting it green)
Build the old bundle and point the test harness at it — no stash, no worktree juggling:
git show HEAD:dist/index.js > /tmp/old-dist.js # bundle is self-contained
# rebuild the throwaway harness by extracting the driver the suite writes
sed -n "<heredoc-body-range>p" tests/behavior.bats > "$W/driver.ts"
DV_EXT=/tmp/old-dist.js <same env as the test> bun "$W/driver.ts" <scenario> # expect RED
DV_EXT="$PWD/dist/index.js" <same env> bun "$W/driver.ts" <scenario> # expect GREEN
Copy the project's real player/server stub verbatim — a simplified stub (e.g. one that skips writing the log file the driver counts) prints a false PLAYS 0 and looks like a failing fix. Pin the scratch dir to a fixed path; ls -dt /tmp/tmp.* picks up another session's directory.
Black-box test hygiene learned the hard way
- A test that counts artifacts in the shared temp directory observes every other process on the box (live agent sessions synthesize into the same
dankvoice-*dirs). Give the run a privateTMPDIR="$WORK/scratch-tmp"and assert there; the assertion's sensitivity is proven by the fact it caught a real leftover while scoped globally. - Let preseeds reference the ephemeral fixture port with a placeholder (
__PORT__) substituted by the driver, so a test can preseed a URL pointing at its own fixture server. - Refactor-only commits get no new test: re-asserting unchanged labels/order after a commit whose point is that they are unchanged is a tautology; the existing suite is the gate. New tests belong to behavior changes, red-first.
- Tests must observe output, never internals: assert
PLAYS/CALLS/CALL <url>/NOTIFY <text>, not that a variable was set.
Docs after a split (same branch, own commit)
Move each explanation to the file that now owns it (knob list → the module that reads env; state-machine prose → the module that implements it) and leave the entry point a one-line-per-module map. Then grep for pointers into the old file — a README saying "see header of src/index.ts" becomes a lie the moment the knobs move.