refactor(bin): replace process.exit() in CLI entrypoints + ratchet rule to error (#738) (#741)

Part 2 of 2 of the n/no-process-exit cleanup (completes umbrella #738; part 1
was #739/scripts). Converts the 20 flagged process.exit() calls in the three
hand-written gsd-core/bin CLI entrypoints and flips n/no-process-exit to error.

- New src/cli-exit.cts -> gsd-core/bin/lib/cli-exit.cjs (ExitError + runMain),
  the gsd-core-side equivalent of scripts/lib/cli-exit.cjs; registered in
  .gitignore, eslint ignores, and the inventory manifest like its siblings.
- gsd-tools.cjs: 13 apply-prompt-budget exits -> throw ExitError; main()->runMain.
- verify-reapply-patches.cjs: 6 exits -> throw ExitError / return verdict; runMain.
- check-latest-version.cjs: 1 exit -> return verdict; runMain.
- eslint.config.mjs: n/no-process-exit warn -> error.

Scope note: the gsd-core/bin/lib/*.cjs modules (core, state, profile-pipeline,
roadmap-command-router, adr-parser, ui-safety-gate) are tsc-generated and
eslint-ignored (ADR-457), so their process.exit calls were never flagged and are
intentionally left untouched. Only the linted hand-written entrypoints are in scope.

Exit codes verified unchanged for all three entrypoints.

Closes #738

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-06 16:27:05 -04:00
committed by GitHub
parent f729101eec
commit b79b767629
8 changed files with 74 additions and 42 deletions

1
.gitignore vendored
View File

@@ -71,6 +71,7 @@ build/
/gsd-core/bin/lib/package-legitimacy.cjs
/gsd-core/bin/lib/semver-compare.cjs
/gsd-core/bin/lib/config-types.cjs
/gsd-core/bin/lib/cli-exit.cjs
/gsd-core/bin/lib/code-review-flags.cjs
/gsd-core/bin/lib/context-utilization.cjs
/gsd-core/bin/lib/artifacts.cjs

View File

@@ -1,5 +1,5 @@
{
"generated": "2026-06-05",
"generated": "2026-06-06",
"families": {
"agents": [
"gsd-advisor-researcher",
@@ -272,6 +272,7 @@
"audit.cjs",
"check-command-router.cjs",
"cjs-command-router-adapter.cjs",
"cli-exit.cjs",
"clock.cjs",
"clusters.cjs",
"code-review-flags.cjs",

View File

@@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
---
## CLI Modules (85 shipped)
## CLI Modules (86 shipped)
Full listing: `gsd-core/bin/lib/*.cjs`.
@@ -382,6 +382,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `artifacts.cjs` | Canonical artifact registry — known `.planning/` root file names; used by `gsd-health` W019 lint |
| `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers |
| `check-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools check` |
| `cli-exit.cjs` | `ExitError` class and `runMain()` helper — CLI entrypoints throw `ExitError` instead of calling `process.exit()`; `runMain()` translates the outcome into `process.exitCode` so output flushes cleanly |
| `cjs-command-router-adapter.cjs` | Shared compatibility adapter for manifest-backed CJS command-family routers |
| `clock.cjs` | Injectable clock seam (now/sleep) for deterministic lock testing |
| `clusters.cjs` | Skill cluster definitions for the runtime surface module (ADR-0011 Phase 2) |

View File

@@ -35,6 +35,7 @@ export default tseslint.config(
'**/*.generated.cjs',
// ADR-457: tsc-generated runtime artifact — lint the src/*.cts source, not the emitted .cjs.
'gsd-core/bin/lib/semver-compare.cjs',
'gsd-core/bin/lib/cli-exit.cjs',
'gsd-core/bin/lib/code-review-flags.cjs',
'gsd-core/bin/lib/context-utilization.cjs',
'gsd-core/bin/lib/artifacts.cjs',
@@ -174,7 +175,7 @@ export default tseslint.config(
'no-useless-escape': 'warn',
'no-unsafe-finally': 'warn',
// eslint-plugin-n rules
'n/no-process-exit': 'warn',
'n/no-process-exit': 'error',
// Local rules — warn for now; flip to error after cleanup phases
'local/no-source-grep': 'warn',
},

View File

@@ -21,6 +21,7 @@
*/
const { execNpm } = require('./lib/shell-command-projection.cjs');
const { runMain } = require('./lib/cli-exit.cjs');
// Sourced from the single Package Identity seam (#498), not re-typed. The seam
// bakes the value from package.json at build time, so it is a code constant —
@@ -98,9 +99,9 @@ function main() {
} else {
process.stderr.write(`check-latest-version: ${r.reason}: ${r.detail}\n`);
}
process.exit(r.ok ? 0 : 1);
return r.ok ? 0 : 1;
}
if (require.main === module) main();
if (require.main === module) runMain(main);
module.exports = { checkLatestVersion, CHECK_REASON, PACKAGE_NAME };

View File

@@ -170,6 +170,7 @@
const fs = require('fs');
const path = require('path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const core = require('./lib/core.cjs');
const { error, ERROR_REASON } = core;
// Resolve findProjectRoot lazily at call time rather than binding it at module
@@ -1527,33 +1528,26 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
// ── Validate required args ─────────────────────────────────────────
if (!budgetStr) {
process.stderr.write('Error: --budget <N> is required\n');
process.exit(1);
throw new ExitError(1, 'Error: --budget <N> is required');
}
const budget = parseInt(budgetStr, 10);
if (!Number.isFinite(budget) || budget <= 0) {
process.stderr.write('Error: --budget must be a positive integer\n');
process.exit(1);
throw new ExitError(1, 'Error: --budget must be a positive integer');
}
if (!instructionsFile) {
process.stderr.write('Error: --instructions-file <path> is required\n');
process.exit(1);
throw new ExitError(1, 'Error: --instructions-file <path> is required');
}
if (!roadmapFile) {
process.stderr.write('Error: --roadmap-file <path> is required\n');
process.exit(1);
throw new ExitError(1, 'Error: --roadmap-file <path> is required');
}
if (planFiles.length === 0) {
process.stderr.write('Error: at least one --plan-file <path> is required\n');
process.exit(1);
throw new ExitError(1, 'Error: at least one --plan-file <path> is required');
}
if (!outputPromptFile) {
process.stderr.write('Error: --output-prompt <path> is required\n');
process.exit(1);
throw new ExitError(1, 'Error: --output-prompt <path> is required');
}
if (!outputMetadataFile) {
process.stderr.write('Error: --output-metadata <path> is required\n');
process.exit(1);
throw new ExitError(1, 'Error: --output-metadata <path> is required');
}
// ── Validate and read required files ──────────────────────────────
@@ -1563,11 +1557,9 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
return await fs.promises.readFile(resolved, 'utf8');
} catch (err) {
if (err && err.code === 'ENOENT') {
process.stderr.write(`Error: file not found for ${flagName}: ${resolved}\n`);
process.exit(1);
throw new ExitError(1, `Error: file not found for ${flagName}: ${resolved}`);
}
process.stderr.write(`Error: cannot read file for ${flagName}: ${resolved}\n`);
process.exit(1);
throw new ExitError(1, `Error: cannot read file for ${flagName}: ${resolved}`);
}
}
@@ -1578,8 +1570,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
return await fs.promises.readFile(resolved, 'utf8');
} catch (err) {
if (err && err.code === 'ENOENT') return null;
process.stderr.write(`Error: cannot read optional file: ${resolved}\n`);
process.exit(1);
throw new ExitError(1, `Error: cannot read optional file: ${resolved}`);
}
}
@@ -1592,11 +1583,9 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
return { file: path.basename(p), content };
} catch (err) {
if (err && err.code === 'ENOENT') {
process.stderr.write(`Error: plan file not found: ${resolved}\n`);
process.exit(1);
throw new ExitError(1, `Error: plan file not found: ${resolved}`);
}
process.stderr.write(`Error: cannot read plan file: ${resolved}\n`);
process.exit(1);
throw new ExitError(1, `Error: cannot read plan file: ${resolved}`);
}
}));
@@ -1625,7 +1614,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
await fs.promises.writeFile(path.resolve(outputPromptFile), prompt);
if (metadata.hardFailed) {
process.exit(2);
throw new ExitError(2);
}
break;
}
@@ -1895,4 +1884,4 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
}
}
main();
runMain(main);

View File

@@ -32,6 +32,7 @@
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const SIGNIFICANT_MIN_CHARS = 12;
const GSD_HOOK_VERSION_LINE_RE = /^(?:\/\/|#)\s*gsd-hook-version:\s*\S+\s*$/i;
@@ -48,10 +49,9 @@ function parseArgs(argv) {
process.stdout.write(
'usage: verify-reapply-patches.cjs --patches-dir <path> --config-dir <path> [--pristine-dir <path>] [--json]\n',
);
process.exit(0);
throw new ExitError(0);
} else {
process.stderr.write(`unknown argument: ${arg}\n`);
process.exit(2);
throw new ExitError(2, `unknown argument: ${arg}`);
}
}
return opts;
@@ -281,16 +281,13 @@ function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashe
function main() {
const opts = parseArgs(process.argv.slice(2));
if (!opts.patchesDir || !opts.configDir) {
process.stderr.write('--patches-dir and --config-dir are required\n');
process.exit(2);
throw new ExitError(2, '--patches-dir and --config-dir are required');
}
if (!fs.existsSync(opts.patchesDir)) {
process.stderr.write(`patches dir not found: ${opts.patchesDir}\n`);
process.exit(2);
throw new ExitError(2, `patches dir not found: ${opts.patchesDir}`);
}
if (!fs.existsSync(opts.configDir)) {
process.stderr.write(`config dir not found: ${opts.configDir}\n`);
process.exit(2);
throw new ExitError(2, `config dir not found: ${opts.configDir}`);
}
const files = walk(opts.patchesDir).filter((f) => !f.endsWith('backup-meta.json'));
@@ -342,11 +339,11 @@ function main() {
}
}
process.exit(failures.length > 0 ? 1 : 0);
return failures.length > 0 ? 1 : 0;
}
if (require.main === module) {
main();
runMain(main);
}
module.exports = { computeUserAddedLines, isSignificantLine, verifyFile, walk, REASON, readPristineHashes, sha256 };

41
src/cli-exit.cts Normal file
View File

@@ -0,0 +1,41 @@
/**
* Error carrying a process exit code. CLI logic throws this instead of calling
* process.exit() (banned by n/no-process-exit); runMain() translates it into
* process.exitCode at the entrypoint.
*/
class ExitError extends Error {
code: number;
hasUserMessage: boolean;
constructor(code = 1, message?: string) {
super(message === undefined ? `process exit ${code}` : message);
this.name = 'ExitError';
this.code = code;
this.hasUserMessage = message !== undefined;
}
}
/**
* Run a CLI main and translate its outcome into process.exitCode (never
* process.exit, so n/no-process-exit stays satisfied; output flushes and
* process.on('exit') cleanup still fires). main may be sync or async:
* number return -> process.exitCode = it
* thrown ExitError -> process.exitCode = err.code (+ stderr err.message if hasUserMessage && code!=0)
* other throw -> stderr stack + process.exitCode = 1
*/
function runMain(main: () => number | void | Promise<number | void>): void {
Promise.resolve()
.then(() => main())
.then((code) => { if (typeof code === 'number') process.exitCode = code; })
.catch((err: unknown) => {
if (err instanceof ExitError) {
if (err.hasUserMessage && err.code !== 0) process.stderr.write(`${err.message}\n`);
process.exitCode = err.code;
return;
}
const e = err as Error;
process.stderr.write(`${e && e.stack ? e.stack : String(err)}\n`);
process.exitCode = 1;
});
}
export = { ExitError, runMain };