TypeScript CLI House Rules
Eight rules. Each exists because breaking it produced a real bug on a real
CI runner or real user machine. Copy the templates from scripts/ and
references/.
Rule 1 — Test scripts build first
{ "test": "npm run build && node run-tests.mjs" }
A test script that skips the build happily tests yesterday's build output. This produced a false "fix verified" (27 green tests against stale output) and a 3-retry CI loop. Build, then test. Always.
Rule 2 — Portable node --test launcher (no glob args)
node --test "dist/test/*.test.js" — glob arguments need Node 21+; Node 20
treats the pattern as a literal filename and fails with
Could not find 'dist\test\*.test.js'. Use the launcher in
scripts/run-tests.mjs: it enumerates the files itself and passes an explicit
list — works on every Node ≥ 20, both OSes, no shell globbing involved.
Rule 3 — Pin LF via .gitattributes
* text=auto eol=lf
CI checkouts on Windows may convert to CRLF, and any test that splits on
"---\n" or compares literal text breaks only on the Windows runner.
Commit .gitattributes (see references/) and still make parsers CRLF-tolerant
(Rule 4) — other people's checkouts are not yours.
Rule 4 — CRLF-tolerant parsing
Normalize before structural operations:
const text = (await readFile(p, "utf8")).replace(/\r\n/g, "\n");
Frontmatter splitters, diff parsers, and line-number assertions all bit us on this. Treat CRLF as an input format, not an error.
Rule 5 — POSIX kills the process GROUP
Spawning through a shell on Linux means your child is /bin/sh; killing only
it orphans the real worker, which holds the output pipes and hangs the runner
(observed: an 11-minute wait for a 0.5s timeout test). Pattern:
const child = spawn(cmd, { shell: true, detached: process.platform !== "win32" });
// on timeout:
process.kill(-child.pid, "SIGTERM"); // negative pid = whole group
Windows: taskkill /pid <pid> /T /F. Both branches, tested both places.
Rule 6 — Strip CI-injected env in test launchers
CI runners export GITHUB_EVENT_PATH, GITHUB_TOKEN, GH_TOKEN into every
step; a CLI that switches modes on those variables silently changes behavior
under test. Strip them (and your own mode-switch vars) in run-tests.mjs
before spawning — see the launcher template.
Rule 7 — Quote-context-aware substitution
When substituting user text into a shell command line containing
"... {{var}} ..." placeholders: if the placeholder is adjacent to a double
quote, escape in place and keep the template's quotes; only wrap in new
quotes when the placeholder is bare. Blind re-quoting produced ""path""
→ POSIX sh split a multi-line prompt into executed commands (exit 127).
Flatten newlines in inlined prompts; deliver the full text via an env var.
Rule 8 — Fixtures: no network, isolated git
Tests must run offline in os.tmpdir(). For git fixtures: GIT_CONFIG_NOSYSTEM=1,
GIT_CONFIG_GLOBAL=<empty file>, identity via -c user.email=… -c user.name=….
A test that needs the network is not a test; a git fixture that reads the
user's global config is a flake waiting for their insteadOf rewrite.
CI matrix
references/templates.md ships the matrix — ubuntu + windows × node
20/22/24, npm ci + npm test. If your suite passes only one OS, you have
not tested it (Rule 5's bug passed Windows for weeks).