Files
msd-core/docs/features/raw-terminator-banned-by-construction.md
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

3.4 KiB

id, title, group
id title group
3910 The Raw Terminator Is Banned by Construction v1.7.0 Features

Purpose: Make a bare process.exit(...) a lint error everywhere it matters, so the "nothing fails with success" defect class ADR-3889 exists to close cannot silently reopen through a new call site.

What changed (ADR-3889 Phase 6, #3910):

  • New rule local/require-registered-exit (eslint-rules/require-registered-exit.cjs) flags any CallExpression shaped exactly like process.exit(...). It does not flag process.exitCode = N — that assignment is the correct drain-then-exit pattern runMain itself uses, and the two are structurally distinct (an assignment target is never a CallExpression).
  • Registered on four globs: src/**/*.cts, scripts/**/*.cjs, hooks/**/*.js, msd-core/bin/**/*.cjs (eslint.config.mjs:420-426,545-547,574-576,601). Registering on src/**/*.cts — not only the emitted msd-core/bin/lib/*.cjs mirrors, which are globally eslint-ignored (ADR-457) — is load-bearing: a rule registered only on the emitted surface is blind to the real sources, the same way n/no-process-exit went invisible (#3496).
  • The dead n/no-process-exit: 'off' carve-out for hooks/** is deleted: Phase 7 (#3911) migrated every enforcement hook onto terminateNow, so it protected nothing.
  • Exactly two allowlist entries, repo-wide:
    1. The body of terminateNow in src/cli-exit.cts — detected structurally (any process.exit() lexically nested inside a function named terminateNow, and the file's basename is cli-exit.cts), not by path+line, so it does not rot when the function moves.
    2. msd-core/bin/msd-tools.cjs's ensureRuntimeBuild bootstrap-failure path, via an inline // eslint-disable-next-line local/require-registered-exit with a stated reason — it runs before ./lib/cli-exit.cjs is even required, so the registered-exit seam does not exist yet at that point in the process's lifetime.
  • The last raw terminators in src/**/*.cts were migrated onto the seam, most notably src/io.cts's error(): it changed from an uncatchable process.exit(1) to a catchable throw new ExitError(1) (stderr output is byte-identical; runMain projects the exit code). terminateNow could not serve this site — ADR-3889 §1 makes exit codes 0 and 1 unallocatable, so nameForExitCode(1) throws. That control-flow change required three interceptor fixes so an ExitError reaches runMain: command-routing-hub's dispatch() now rethrows it, and the profile-pipeline router's detached .catch() no longer calls error() — it writes stderr and sets exitCode in place.
  • Known limits (documented and test-pinned, not endorsed): the rule matches the literal process.exit(...) shape only, with no scope/flow analysis. It does not catch process['exit'](0) (computed member access), const e = process.exit; e(1) (aliasing to a local binding before calling), or process.exit.call(...)/.apply(...) (indirect invocation). Catching these needs binding/scope-aware analysis, out of scope for this issue; pinning tests in tests/eslint-rules.test.cjs assert today's non-detection so a future widening is a visible choice, not a silent one.

See Resolve a raw-terminator finding for what to do when this rule fires, and ADR-3889 for the exit-code registry the seam is layered over.