refactor(scripts): replace process.exit() with ExitError + runMain handler (#739) (#740)

Part 1 of 2 of the n/no-process-exit cleanup (umbrella #738): convert every
process.exit() call in standalone scripts/** CLIs to the rule-compliant pattern.

- New shared helper scripts/lib/cli-exit.cjs: ExitError(code,message) + runMain()
  which translates a thrown ExitError / returned number into process.exitCode
  (never process.exit()), flushing output and still firing process.on('exit').
- main()-based entrypoints: throw new ExitError(code) for errors, return <code>
  for verdicts; invoked via runMain(main). Child exit codes preserved via return.
- top-level-only scripts: imperative body extracted into main() so mid-flow
  aborts (throw ExitError) actually halt; pure consts/helpers stay at module scope.
- diff-touches-shipped-paths.cjs: stdin event handling restructured to an async
  read so the whole flow runs under runMain; uncaughtException/unhandledRejection
  nets replaced by an in-band catch that preserves EXIT_ERROR=2.

Exit codes verified unchanged for every converted script (success/error/help and
the 0/1/2 semantic codes in diff-touches). Rule stays warn here; flipped to error
in part 2 (#738) once gsd-core/bin/** is also clean.

Refs #739

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-06 16:13:13 -04:00
committed by GitHub
parent dfbb684843
commit f729101eec
31 changed files with 1074 additions and 774 deletions

View File

@@ -43,6 +43,7 @@
const fs = require('node:fs');
const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const EXIT_SHIPPED = 0;
const EXIT_NOT_SHIPPED = 1;
@@ -88,60 +89,67 @@ function isPushBlocking(diffPath) {
return diffPath.replace(/\\/g, '/').startsWith('.github/workflows/');
}
function fail(message, err) {
process.stderr.write(`diff-touches-shipped-paths: ${message}\n`);
if (err && err.stack) process.stderr.write(`${err.stack}\n`);
process.exit(EXIT_ERROR);
function readStdin() {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { data += chunk; });
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
}
function main() {
// Surface ANY uncaught failure as exit 2 (classifier error) rather
// than letting Node's default-1 shadow the legitimate
// "no shipped paths" result. Bug #2983.
process.on('uncaughtException', (err) => fail('uncaught exception', err));
process.on('unhandledRejection', (err) => fail('unhandled rejection', err));
let shipPrefixes;
async function main() {
try {
const pkgPath = path.resolve(process.cwd(), 'package.json');
shipPrefixes = loadShipPrefixes(pkgPath);
} catch (err) {
return fail(`failed to read package.json from ${process.cwd()}`, err);
}
let buf = '';
process.stdin.setEncoding('utf8');
process.stdin.on('error', (err) => fail('stdin read error', err));
process.stdin.on('data', (chunk) => {
buf += chunk;
});
process.stdin.on('end', () => {
let shipPrefixes;
try {
const paths = buf.split('\n').map((s) => s.trim()).filter(Boolean);
// #2980 still wins over #3621: any commit touching .github/workflows/*
// is unpickable regardless of other content because the push step
// fails on workflow scope rejection. Check this first.
if (paths.some(isPushBlocking)) {
process.exit(EXIT_NOT_SHIPPED);
}
if (paths.some((p) => isShipped(p, shipPrefixes))) {
process.exit(EXIT_SHIPPED);
}
// #3621: a commit whose only relevant paths are CI-gating tests is
// still pickable — it can change whether the hotfix CI passes even
// though it doesn't change what the npm tarball ships.
if (paths.some(isCiGating)) {
process.exit(EXIT_SHIPPED);
}
process.exit(EXIT_NOT_SHIPPED);
const pkgPath = path.resolve(process.cwd(), 'package.json');
shipPrefixes = loadShipPrefixes(pkgPath);
} catch (err) {
fail('classification failed', err);
process.stderr.write(`diff-touches-shipped-paths: failed to read package.json from ${process.cwd()}\n`);
if (err && err.stack) process.stderr.write(`${err.stack}\n`);
throw new ExitError(EXIT_ERROR);
}
});
let buf;
try {
buf = await readStdin();
} catch (err) {
process.stderr.write(`diff-touches-shipped-paths: stdin read error\n`);
if (err && err.stack) process.stderr.write(`${err.stack}\n`);
throw new ExitError(EXIT_ERROR);
}
const paths = buf.split('\n').map((s) => s.trim()).filter(Boolean);
// #2980 still wins over #3621: any commit touching .github/workflows/*
// is unpickable regardless of other content because the push step
// fails on workflow scope rejection. Check this first.
if (paths.some(isPushBlocking)) {
return EXIT_NOT_SHIPPED;
}
if (paths.some((p) => isShipped(p, shipPrefixes))) {
return EXIT_SHIPPED;
}
// #3621: a commit whose only relevant paths are CI-gating tests is
// still pickable — it can change whether the hotfix CI passes even
// though it doesn't change what the npm tarball ships.
if (paths.some(isCiGating)) {
return EXIT_SHIPPED;
}
return EXIT_NOT_SHIPPED;
} catch (e) {
// Re-throw ExitError unchanged; map any unexpected error to EXIT_ERROR=2
// (Node's default uncaught-exception code is 1, which is indistinguishable
// from the legitimate EXIT_NOT_SHIPPED result — bug #2983).
if (e instanceof ExitError) throw e;
process.stderr.write(`diff-touches-shipped-paths: classification failed\n`);
if (e && e.stack) process.stderr.write(`${e.stack}\n`);
throw new ExitError(EXIT_ERROR);
}
}
if (require.main === module) {
main();
runMain(main);
}
module.exports = { loadShipPrefixes, isShipped, isCiGating, isPushBlocking, EXIT_SHIPPED, EXIT_NOT_SHIPPED, EXIT_ERROR };