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

@@ -4,6 +4,7 @@ const { execFileSync } = require('node:child_process');
const { readdirSync, readFileSync, existsSync } = require('node:fs'); const { readdirSync, readFileSync, existsSync } = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { ExitError } = require('./lib/cli-exit.cjs');
const { suiteOf } = require('./run-tests.cjs'); const { suiteOf } = require('./run-tests.cjs');
const CRITICAL_PATHS = [ const CRITICAL_PATHS = [
@@ -409,7 +410,7 @@ function runNodeTestFiles(repoRoot, files) {
if (firstFailure === 0) firstFailure = code; if (firstFailure === 0) firstFailure = code;
} }
} }
if (firstFailure !== 0) process.exit(firstFailure); if (firstFailure !== 0) throw new ExitError(firstFailure);
} }
function runSuite(repoRoot, suite) { function runSuite(repoRoot, suite) {

View File

@@ -17,6 +17,7 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { ExitError, runMain } = require('../lib/cli-exit.cjs');
const { parseFragment } = require('./parse.cjs'); const { parseFragment } = require('./parse.cjs');
const { renderChangelog } = require('./render.cjs'); const { renderChangelog } = require('./render.cjs');
const { serializeChangelog, parseChangelog } = require('./serialize.cjs'); const { serializeChangelog, parseChangelog } = require('./serialize.cjs');
@@ -475,29 +476,26 @@ function main() {
if (!parsed.ok) { if (!parsed.ok) {
process.stderr.write(`${parsed.error}\n`); process.stderr.write(`${parsed.error}\n`);
process.stderr.write(usage()); process.stderr.write(usage());
process.exit(2); throw new ExitError(2);
} }
const { opts } = parsed; const { opts } = parsed;
if (opts.cmd !== 'render' && opts.cmd !== 'github-release-notes' && opts.cmd !== 'extract' && opts.cmd !== 'verify') { if (opts.cmd !== 'render' && opts.cmd !== 'github-release-notes' && opts.cmd !== 'extract' && opts.cmd !== 'verify') {
process.stderr.write(usage()); process.stderr.write(usage());
process.exit(1); throw new ExitError(1);
} }
if (opts.cmd === 'render' && (!opts.version || !opts.date)) { if (opts.cmd === 'render' && (!opts.version || !opts.date)) {
process.stderr.write('--version and --date are required for render\n'); throw new ExitError(2, '--version and --date are required for render');
process.exit(2);
} }
if (opts.cmd === 'github-release-notes' && (!opts.fromRef || !opts.toRef)) { if (opts.cmd === 'github-release-notes' && (!opts.fromRef || !opts.toRef)) {
process.stderr.write('--from and --to are required for github-release-notes\n'); throw new ExitError(2, '--from and --to are required for github-release-notes');
process.exit(2);
} }
if (opts.cmd === 'extract' && (!opts.fromRef || !opts.toRef)) { if (opts.cmd === 'extract' && (!opts.fromRef || !opts.toRef)) {
process.stderr.write('--from and --to are required for extract\n'); process.stderr.write('--from and --to are required for extract\n');
process.stderr.write(usage()); process.stderr.write(usage());
process.exit(1); throw new ExitError(1);
} }
if (opts.cmd === 'verify' && !opts.version) { if (opts.cmd === 'verify' && !opts.version) {
process.stderr.write('--version is required for verify\n'); throw new ExitError(2, '--version is required for verify');
process.exit(2);
} }
if (opts.cmd === 'extract') { if (opts.cmd === 'extract') {
@@ -509,7 +507,7 @@ function main() {
} else if (exitCode === 2) { } else if (exitCode === 2) {
process.stderr.write(`no releases found in range (from=${report.from}, to=${report.to})\n`); process.stderr.write(`no releases found in range (from=${report.from}, to=${report.to})\n`);
} }
process.exit(exitCode); return exitCode;
} }
if (opts.cmd === 'verify') { if (opts.cmd === 'verify') {
@@ -521,7 +519,7 @@ function main() {
} else { } else {
process.stderr.write(report.error + '\n'); process.stderr.write(report.error + '\n');
} }
process.exit(exitCode); return exitCode;
} }
const { exitCode, report } = opts.cmd === 'render' ? cmdRender(opts) : cmdGithubReleaseNotes(opts); const { exitCode, report } = opts.cmd === 'render' ? cmdRender(opts) : cmdGithubReleaseNotes(opts);
@@ -541,9 +539,9 @@ function main() {
} }
} }
} }
process.exit(exitCode); return exitCode;
} }
if (require.main === module) main(); if (require.main === module) runMain(main);
module.exports = { cmdRender, cmdExtract, cmdVerify, cmdGithubReleaseNotes, parseArgs, splitChangelog, assembleChangelog, listFragmentFiles, usage }; module.exports = { cmdRender, cmdExtract, cmdVerify, cmdGithubReleaseNotes, parseArgs, splitChangelog, assembleChangelog, listFragmentFiles, usage };

View File

@@ -60,6 +60,8 @@ function evaluateLint({ changedFiles, labels }) {
return { ok: false, reason: LINT_REASON.FAIL_MISSING_FRAGMENT }; return { ok: false, reason: LINT_REASON.FAIL_MISSING_FRAGMENT };
} }
const { ExitError, runMain } = require('../lib/cli-exit.cjs');
function main() { function main() {
const fs = require('node:fs'); const fs = require('node:fs');
const cp = require('node:child_process'); const cp = require('node:child_process');
@@ -87,8 +89,7 @@ function main() {
); );
changedFiles = out.split('\n').filter(Boolean); changedFiles = out.split('\n').filter(Boolean);
} catch (e) { } catch (e) {
process.stderr.write(`could not compute diff: ${e.message}\n`); throw new ExitError(2, `could not compute diff: ${e.message}`);
process.exit(2);
} }
const verdict = evaluateLint({ changedFiles, labels }); const verdict = evaluateLint({ changedFiles, labels });
@@ -102,9 +103,9 @@ function main() {
process.stderr.write(`Run \`npm run changeset\` to create one, or add the \`${OPT_OUT_LABEL}\` label\n`); process.stderr.write(`Run \`npm run changeset\` to create one, or add the \`${OPT_OUT_LABEL}\` label\n`);
process.stderr.write(`if this PR genuinely has no user-facing impact (test refactor, CI tweak, etc.).\n`); process.stderr.write(`if this PR genuinely has no user-facing impact (test refactor, CI tweak, etc.).\n`);
} }
process.exit(verdict.ok ? 0 : 1); return verdict.ok ? 0 : 1;
} }
if (require.main === module) main(); if (require.main === module) runMain(main);
module.exports = { evaluateLint, LINT_REASON, OPT_OUT_LABEL, isUserFacing, isFragment }; module.exports = { evaluateLint, LINT_REASON, OPT_OUT_LABEL, isUserFacing, isFragment };

View File

@@ -13,6 +13,7 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { ExitError, runMain } = require('../lib/cli-exit.cjs');
// Small word lists — keep the function simple and dependency-free. // Small word lists — keep the function simple and dependency-free.
// Together this gives ~40 * 40 * 40 = 64,000 distinct names. The lint // Together this gives ~40 * 40 * 40 = 64,000 distinct names. The lint
@@ -121,17 +122,16 @@ function main() {
if (!parsed.ok) { if (!parsed.ok) {
process.stderr.write(`${parsed.error}\n`); process.stderr.write(`${parsed.error}\n`);
process.stderr.write('usage: changeset/new.cjs --type <Fixed|Added|...> --pr NNNN --body "..."\n'); process.stderr.write('usage: changeset/new.cjs --type <Fixed|Added|...> --pr NNNN --body "..."\n');
process.exit(2); throw new ExitError(2);
} }
const { opts } = parsed; const { opts } = parsed;
if (!opts.type || !opts.pr || !opts.body) { if (!opts.type || !opts.pr || !opts.body) {
process.stderr.write('usage: changeset/new.cjs --type <Fixed|Added|...> --pr NNNN --body "..."\n'); throw new ExitError(2, 'usage: changeset/new.cjs --type <Fixed|Added|...> --pr NNNN --body "..."');
process.exit(2);
} }
const file = scaffoldFragment(opts); const file = scaffoldFragment(opts);
process.stdout.write(`${path.relative(process.cwd(), file)}\n`); process.stdout.write(`${path.relative(process.cwd(), file)}\n`);
} }
if (require.main === module) main(); if (require.main === module) runMain(main);
module.exports = { generateFragmentName, scaffoldFragment, parseArgs, ALLOWED_TYPES }; module.exports = { generateFragmentName, scaffoldFragment, parseArgs, ALLOWED_TYPES };

View File

@@ -3,12 +3,14 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const ROOT = path.resolve(__dirname, '..'); const ROOT = path.resolve(__dirname, '..');
const aliasesPath = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'command-aliases.cjs'); const aliasesPath = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'command-aliases.cjs');
function fail(message) { function fail(message) {
process.stderr.write(`${message}\n`); process.stderr.write(`${message}\n`);
process.exit(1); throw new ExitError(1);
} }
function ensureArray(value, name) { function ensureArray(value, name) {
@@ -27,82 +29,86 @@ function assertNoDuplicates(values, label) {
} }
} }
if (!fs.existsSync(aliasesPath)) { function main() {
fail(`check:alias-drift: missing ${path.relative(ROOT, aliasesPath)}`); if (!fs.existsSync(aliasesPath)) {
} fail(`check:alias-drift: missing ${path.relative(ROOT, aliasesPath)}`);
const aliases = require(aliasesPath);
const families = [
{
commandAliases: 'STATE_COMMAND_ALIASES',
subcommands: 'STATE_SUBCOMMANDS',
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'state-command-router.cjs'),
},
{
commandAliases: 'VERIFY_COMMAND_ALIASES',
subcommands: 'VERIFY_SUBCOMMANDS',
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'verify-command-router.cjs'),
},
{
commandAliases: 'INIT_COMMAND_ALIASES',
subcommands: 'INIT_SUBCOMMANDS',
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'init-command-router.cjs'),
},
{
commandAliases: 'PHASE_COMMAND_ALIASES',
subcommands: 'PHASE_SUBCOMMANDS',
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phase-command-router.cjs'),
},
{
commandAliases: 'PHASES_COMMAND_ALIASES',
subcommands: 'PHASES_SUBCOMMANDS',
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phases-command-router.cjs'),
},
{
commandAliases: 'VALIDATE_COMMAND_ALIASES',
subcommands: 'VALIDATE_SUBCOMMANDS',
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'validate-command-router.cjs'),
},
{
commandAliases: 'ROADMAP_COMMAND_ALIASES',
subcommands: 'ROADMAP_SUBCOMMANDS',
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'roadmap-command-router.cjs'),
},
];
for (const family of families) {
const commandAliases = aliases[family.commandAliases];
const subcommands = aliases[family.subcommands];
ensureArray(commandAliases, family.commandAliases);
ensureArray(subcommands, family.subcommands);
const derivedSubcommands = commandAliases.map((entry) => entry && entry.subcommand);
assertNoDuplicates(derivedSubcommands, `${family.commandAliases}.subcommand`);
if (derivedSubcommands.length !== subcommands.length) {
fail(
`check:alias-drift: ${family.subcommands} length ${subcommands.length} does not match ` +
`${family.commandAliases} length ${derivedSubcommands.length}`,
);
} }
for (let i = 0; i < derivedSubcommands.length; i++) { const aliases = require(aliasesPath);
if (derivedSubcommands[i] !== subcommands[i]) {
const families = [
{
commandAliases: 'STATE_COMMAND_ALIASES',
subcommands: 'STATE_SUBCOMMANDS',
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'state-command-router.cjs'),
},
{
commandAliases: 'VERIFY_COMMAND_ALIASES',
subcommands: 'VERIFY_SUBCOMMANDS',
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'verify-command-router.cjs'),
},
{
commandAliases: 'INIT_COMMAND_ALIASES',
subcommands: 'INIT_SUBCOMMANDS',
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'init-command-router.cjs'),
},
{
commandAliases: 'PHASE_COMMAND_ALIASES',
subcommands: 'PHASE_SUBCOMMANDS',
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phase-command-router.cjs'),
},
{
commandAliases: 'PHASES_COMMAND_ALIASES',
subcommands: 'PHASES_SUBCOMMANDS',
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phases-command-router.cjs'),
},
{
commandAliases: 'VALIDATE_COMMAND_ALIASES',
subcommands: 'VALIDATE_SUBCOMMANDS',
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'validate-command-router.cjs'),
},
{
commandAliases: 'ROADMAP_COMMAND_ALIASES',
subcommands: 'ROADMAP_SUBCOMMANDS',
routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'roadmap-command-router.cjs'),
},
];
for (const family of families) {
const commandAliases = aliases[family.commandAliases];
const subcommands = aliases[family.subcommands];
ensureArray(commandAliases, family.commandAliases);
ensureArray(subcommands, family.subcommands);
const derivedSubcommands = commandAliases.map((entry) => entry && entry.subcommand);
assertNoDuplicates(derivedSubcommands, `${family.commandAliases}.subcommand`);
if (derivedSubcommands.length !== subcommands.length) {
fail( fail(
`check:alias-drift: ${family.subcommands}[${i}] = "${subcommands[i]}" ` + `check:alias-drift: ${family.subcommands} length ${subcommands.length} does not match ` +
`does not match ${family.commandAliases}[${i}].subcommand = "${derivedSubcommands[i]}"`, `${family.commandAliases} length ${derivedSubcommands.length}`,
);
}
for (let i = 0; i < derivedSubcommands.length; i++) {
if (derivedSubcommands[i] !== subcommands[i]) {
fail(
`check:alias-drift: ${family.subcommands}[${i}] = "${subcommands[i]}" ` +
`does not match ${family.commandAliases}[${i}].subcommand = "${derivedSubcommands[i]}"`,
);
}
}
const routerSource = fs.readFileSync(family.routerPath, 'utf8');
if (!routerSource.includes(family.subcommands)) {
fail(
`check:alias-drift: ${path.relative(ROOT, family.routerPath)} does not reference ${family.subcommands}`,
); );
} }
} }
const routerSource = fs.readFileSync(family.routerPath, 'utf8'); process.stdout.write('check:alias-drift ok\n');
if (!routerSource.includes(family.subcommands)) {
fail(
`check:alias-drift: ${path.relative(ROOT, family.routerPath)} does not reference ${family.subcommands}`,
);
}
} }
process.stdout.write('check:alias-drift ok\n'); runMain(main);

View File

@@ -29,65 +29,16 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { spawnSync } = require('child_process'); const { spawnSync } = require('child_process');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
// On Windows, npm ships as npm.cmd (a batch wrapper); spawnSync without // On Windows, npm ships as npm.cmd (a batch wrapper); spawnSync without
// shell:true requires the exact filename including extension. // shell:true requires the exact filename including extension.
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
let jsonMode = false;
for (const arg of process.argv.slice(2)) {
if (arg === '--json') {
jsonMode = true;
} else if (arg === '--help' || arg === '-h') {
process.stdout.write(
'scripts/check-env.cjs — Environment parity validator for contributors (issue #117).\n' +
'\n' +
'Checks that the developer\'s environment matches project requirements before\n' +
'running tests or audits. Designed to catch mismatches early rather than\n' +
'through cryptic test failures.\n' +
'\n' +
'Exit codes:\n' +
' 0 All checks passed\n' +
' 1 One or more checks failed\n' +
' 2 Tool error (missing required tool, corrupt package.json, etc.)\n' +
'\n' +
'Usage:\n' +
' node scripts/check-env.cjs # Human-readable report\n' +
' node scripts/check-env.cjs --json # Structured JSON report\n' +
' node scripts/check-env.cjs --help # This message\n'
);
process.exit(0);
} else {
process.stderr.write(`Unknown option: ${arg}\n`);
process.exit(2);
}
}
// ---------------------------------------------------------------------------
// Locate the project root (directory containing package.json)
// ---------------------------------------------------------------------------
const PROJECT_ROOT = process.cwd();
const PACKAGE_JSON = path.join(PROJECT_ROOT, 'package.json');
if (!fs.existsSync(PACKAGE_JSON)) {
process.stderr.write(`ERROR: package.json not found in ${PROJECT_ROOT}\n`);
process.exit(2);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** @type {Array<{name: string, status: 'pass'|'fail'|'skip', message: string}>} */
const checks = [];
function addCheck(name, status, message) {
checks.push({ name, status, message });
}
/** /**
* Semver comparison: does `version` satisfy `constraint`? * Semver comparison: does `version` satisfy `constraint`?
* Constraint forms: >=X.Y.Z, >X.Y.Z, <=X.Y.Z, <X.Y.Z, =X.Y.Z, X.Y.Z * Constraint forms: >=X.Y.Z, >X.Y.Z, <=X.Y.Z, <X.Y.Z, =X.Y.Z, X.Y.Z
@@ -138,7 +89,7 @@ function satisfiesConstraint(version, constraint) {
* Returns the string value or empty string if absent. * Returns the string value or empty string if absent.
* Uses './package.json' so Node resolves relative to CWD on all platforms. * Uses './package.json' so Node resolves relative to CWD on all platforms.
*/ */
function pkgField(fieldPath) { function pkgField(fieldPath, PROJECT_ROOT) {
try { try {
const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8')); const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
let val = pkg; let val = pkg;
@@ -152,155 +103,210 @@ function pkgField(fieldPath) {
} }
} }
// --------------------------------------------------------------------------- function main() {
// Check 1: Node version vs engines.node // ---------------------------------------------------------------------------
// --------------------------------------------------------------------------- // Argument parsing
const enginesNode = pkgField('engines.node'); // ---------------------------------------------------------------------------
let currentNode = ''; let jsonMode = false;
try {
currentNode = process.version.replace(/^v/, '');
} catch { /* ignore */ }
if (!currentNode) { for (const arg of process.argv.slice(2)) {
addCheck('node-version', 'fail', 'node binary not found on PATH'); if (arg === '--json') {
} else if (!enginesNode) { jsonMode = true;
addCheck('node-version', 'fail', 'engines.node missing from package.json — add it (see D2 in docs/contributing/bootstrap.md)'); } else if (arg === '--help' || arg === '-h') {
} else { process.stdout.write(
if (satisfiesConstraint(currentNode, enginesNode)) { 'scripts/check-env.cjs — Environment parity validator for contributors (issue #117).\n' +
addCheck('node-version', 'pass', `Node ${currentNode} satisfies ${enginesNode}`); '\n' +
} else { 'Checks that the developer\'s environment matches project requirements before\n' +
addCheck('node-version', 'fail', `Node ${currentNode} does NOT satisfy engines.node ${enginesNode}`); 'running tests or audits. Designed to catch mismatches early rather than\n' +
} 'through cryptic test failures.\n' +
} '\n' +
'Exit codes:\n' +
// --------------------------------------------------------------------------- ' 0 All checks passed\n' +
// Check 2: npm version vs engines.npm (skip if field absent) ' 1 One or more checks failed\n' +
// --------------------------------------------------------------------------- ' 2 Tool error (missing required tool, corrupt package.json, etc.)\n' +
const enginesNpm = pkgField('engines.npm'); '\n' +
let currentNpm = ''; 'Usage:\n' +
try { ' node scripts/check-env.cjs # Human-readable report\n' +
const res = spawnSync(npmCmd, ['--version'], { encoding: 'utf8', timeout: 10_000, shell: process.platform === 'win32' }); ' node scripts/check-env.cjs --json # Structured JSON report\n' +
if (res.status === 0 && res.stdout) { ' node scripts/check-env.cjs --help # This message\n'
currentNpm = res.stdout.trim(); );
} return 0;
} catch { /* ignore */ }
if (!enginesNpm) {
addCheck('npm-version', 'skip', 'engines.npm not set in package.json — skipping');
} else if (!currentNpm) {
addCheck('npm-version', 'fail', 'npm binary not found on PATH');
} else {
if (satisfiesConstraint(currentNpm, enginesNpm)) {
addCheck('npm-version', 'pass', `npm ${currentNpm} satisfies ${enginesNpm}`);
} else {
addCheck('npm-version', 'fail', `npm ${currentNpm} does NOT satisfy engines.npm ${enginesNpm}`);
}
}
// ---------------------------------------------------------------------------
// Check 3: Lockfile presence
// ---------------------------------------------------------------------------
const LOCKFILE = path.join(PROJECT_ROOT, 'package-lock.json');
if (fs.existsSync(LOCKFILE)) {
addCheck('lockfile-present', 'pass', 'package-lock.json exists');
} else {
addCheck('lockfile-present', 'fail', "package-lock.json missing — run 'npm install' to generate it");
}
// ---------------------------------------------------------------------------
// Check 4: Lockfile sync (npm ci --dry-run)
// ---------------------------------------------------------------------------
if (fs.existsSync(LOCKFILE)) {
try {
// --ignore-scripts: this is a lockfile-vs-package.json sync check, not a
// build. Without it, npm would run the `prepare` lifecycle (build:lib via
// tsc) — which fails when check:env runs before deps are installed (tsc
// absent), misreporting an out-of-sync lockfile. ADR-457 build-at-publish.
const res = spawnSync(npmCmd, ['ci', '--dry-run', '--ignore-scripts'], {
cwd: PROJECT_ROOT,
encoding: 'utf8',
shell: process.platform === 'win32',
});
if (res.status === 0) {
addCheck('lockfile-sync', 'pass', 'package-lock.json is in sync with package.json');
} else { } else {
process.stderr.write(`Unknown option: ${arg}\n`);
throw new ExitError(2);
}
}
// ---------------------------------------------------------------------------
// Locate the project root (directory containing package.json)
// ---------------------------------------------------------------------------
const PROJECT_ROOT = process.cwd();
const PACKAGE_JSON = path.join(PROJECT_ROOT, 'package.json');
if (!fs.existsSync(PACKAGE_JSON)) {
process.stderr.write(`ERROR: package.json not found in ${PROJECT_ROOT}\n`);
throw new ExitError(2);
}
/** @type {Array<{name: string, status: 'pass'|'fail'|'skip', message: string}>} */
const checks = [];
function addCheck(name, status, message) {
checks.push({ name, status, message });
}
// ---------------------------------------------------------------------------
// Check 1: Node version vs engines.node
// ---------------------------------------------------------------------------
const enginesNode = pkgField('engines.node', PROJECT_ROOT);
let currentNode = '';
try {
currentNode = process.version.replace(/^v/, '');
} catch { /* ignore */ }
if (!currentNode) {
addCheck('node-version', 'fail', 'node binary not found on PATH');
} else if (!enginesNode) {
addCheck('node-version', 'fail', 'engines.node missing from package.json — add it (see D2 in docs/contributing/bootstrap.md)');
} else {
if (satisfiesConstraint(currentNode, enginesNode)) {
addCheck('node-version', 'pass', `Node ${currentNode} satisfies ${enginesNode}`);
} else {
addCheck('node-version', 'fail', `Node ${currentNode} does NOT satisfy engines.node ${enginesNode}`);
}
}
// ---------------------------------------------------------------------------
// Check 2: npm version vs engines.npm (skip if field absent)
// ---------------------------------------------------------------------------
const enginesNpm = pkgField('engines.npm', PROJECT_ROOT);
let currentNpm = '';
try {
const res = spawnSync(npmCmd, ['--version'], { encoding: 'utf8', timeout: 10_000, shell: process.platform === 'win32' });
if (res.status === 0 && res.stdout) {
currentNpm = res.stdout.trim();
}
} catch { /* ignore */ }
if (!enginesNpm) {
addCheck('npm-version', 'skip', 'engines.npm not set in package.json — skipping');
} else if (!currentNpm) {
addCheck('npm-version', 'fail', 'npm binary not found on PATH');
} else {
if (satisfiesConstraint(currentNpm, enginesNpm)) {
addCheck('npm-version', 'pass', `npm ${currentNpm} satisfies ${enginesNpm}`);
} else {
addCheck('npm-version', 'fail', `npm ${currentNpm} does NOT satisfy engines.npm ${enginesNpm}`);
}
}
// ---------------------------------------------------------------------------
// Check 3: Lockfile presence
// ---------------------------------------------------------------------------
const LOCKFILE = path.join(PROJECT_ROOT, 'package-lock.json');
if (fs.existsSync(LOCKFILE)) {
addCheck('lockfile-present', 'pass', 'package-lock.json exists');
} else {
addCheck('lockfile-present', 'fail', "package-lock.json missing — run 'npm install' to generate it");
}
// ---------------------------------------------------------------------------
// Check 4: Lockfile sync (npm ci --dry-run)
// ---------------------------------------------------------------------------
if (fs.existsSync(LOCKFILE)) {
try {
// --ignore-scripts: this is a lockfile-vs-package.json sync check, not a
// build. Without it, npm would run the `prepare` lifecycle (build:lib via
// tsc) — which fails when check:env runs before deps are installed (tsc
// absent), misreporting an out-of-sync lockfile. ADR-457 build-at-publish.
const res = spawnSync(npmCmd, ['ci', '--dry-run', '--ignore-scripts'], {
cwd: PROJECT_ROOT,
encoding: 'utf8',
shell: process.platform === 'win32',
});
if (res.status === 0) {
addCheck('lockfile-sync', 'pass', 'package-lock.json is in sync with package.json');
} else {
addCheck('lockfile-sync', 'fail', "package-lock.json is out of sync — run 'npm ci' to restore");
}
} catch {
addCheck('lockfile-sync', 'fail', "package-lock.json is out of sync — run 'npm ci' to restore"); addCheck('lockfile-sync', 'fail', "package-lock.json is out of sync — run 'npm ci' to restore");
} }
} catch {
addCheck('lockfile-sync', 'fail', "package-lock.json is out of sync — run 'npm ci' to restore");
}
} else {
addCheck('lockfile-sync', 'skip', 'skipped — lockfile missing');
}
// ---------------------------------------------------------------------------
// Check 5: Version manager pin vs active Node
// Looks for .nvmrc, .node-version, or .tool-versions at project root.
// ---------------------------------------------------------------------------
const NVMRC = path.join(PROJECT_ROOT, '.nvmrc');
const NODE_VERSION_FILE = path.join(PROJECT_ROOT, '.node-version');
const TOOL_VERSIONS = path.join(PROJECT_ROOT, '.tool-versions');
let pinnedMajor = '';
let pinSource = '';
if (fs.existsSync(NVMRC)) {
const content = fs.readFileSync(NVMRC, 'utf8').split('\n')[0].trim().replace(/^v/, '');
pinnedMajor = content.split('.')[0];
pinSource = '.nvmrc';
} else if (fs.existsSync(NODE_VERSION_FILE)) {
const content = fs.readFileSync(NODE_VERSION_FILE, 'utf8').split('\n')[0].trim().replace(/^v/, '');
pinnedMajor = content.split('.')[0];
pinSource = '.node-version';
} else if (fs.existsSync(TOOL_VERSIONS)) {
const lines = fs.readFileSync(TOOL_VERSIONS, 'utf8').split('\n');
const nodeLine = lines.find(l => /^nodejs\s+/.test(l));
if (nodeLine) {
const ver = nodeLine.split(/\s+/)[1] || '';
pinnedMajor = ver.replace(/^v/, '').split('.')[0];
pinSource = '.tool-versions';
}
}
if (!pinnedMajor) {
addCheck('version-manager-pin', 'skip', 'no .nvmrc, .node-version, or .tool-versions found — skipping');
} else if (process.env.CI === 'true') {
addCheck('version-manager-pin', 'skip', 'CI=true — version-manager pin check skipped (matrix tests multiple Node majors)');
} else {
const activeMajor = process.version.replace(/^v/, '').split('.')[0];
if (activeMajor === pinnedMajor) {
addCheck('version-manager-pin', 'pass', `Active Node major (${activeMajor}) matches ${pinSource} pin (${pinnedMajor})`);
} else { } else {
addCheck('version-manager-pin', 'fail', `Active Node major (${activeMajor}) does NOT match ${pinSource} pin (${pinnedMajor}) — run 'nvm use' or equivalent`); addCheck('lockfile-sync', 'skip', 'skipped — lockfile missing');
} }
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Output // Check 5: Version manager pin vs active Node
// --------------------------------------------------------------------------- // Looks for .nvmrc, .node-version, or .tool-versions at project root.
const overallPass = checks.every(c => c.status !== 'fail'); // ---------------------------------------------------------------------------
const NVMRC = path.join(PROJECT_ROOT, '.nvmrc');
const NODE_VERSION_FILE = path.join(PROJECT_ROOT, '.node-version');
const TOOL_VERSIONS = path.join(PROJECT_ROOT, '.tool-versions');
if (jsonMode) { let pinnedMajor = '';
// Structured JSON: {pass: bool, checks: [{name, status, message}]} let pinSource = '';
const out = {
pass: overallPass, if (fs.existsSync(NVMRC)) {
checks: checks.map(c => ({ name: c.name, status: c.status, message: c.message })), const content = fs.readFileSync(NVMRC, 'utf8').split('\n')[0].trim().replace(/^v/, '');
}; pinnedMajor = content.split('.')[0];
process.stdout.write(JSON.stringify(out, null, 2) + '\n'); pinSource = '.nvmrc';
} else { } else if (fs.existsSync(NODE_VERSION_FILE)) {
// Human-readable report const content = fs.readFileSync(NODE_VERSION_FILE, 'utf8').split('\n')[0].trim().replace(/^v/, '');
process.stdout.write('=== Environment Check ===\n'); pinnedMajor = content.split('.')[0];
for (const { name, status, message } of checks) { pinSource = '.node-version';
const icon = status === 'pass' ? '[PASS]' : status === 'fail' ? '[FAIL]' : '[SKIP]'; } else if (fs.existsSync(TOOL_VERSIONS)) {
const namePadded = name.padEnd(25); const lines = fs.readFileSync(TOOL_VERSIONS, 'utf8').split('\n');
process.stdout.write(` ${icon} ${namePadded} ${message}\n`); const nodeLine = lines.find(l => /^nodejs\s+/.test(l));
if (nodeLine) {
const ver = nodeLine.split(/\s+/)[1] || '';
pinnedMajor = ver.replace(/^v/, '').split('.')[0];
pinSource = '.tool-versions';
}
} }
process.stdout.write('\n');
if (overallPass) { if (!pinnedMajor) {
process.stdout.write('Result: ALL CHECKS PASSED\n'); addCheck('version-manager-pin', 'skip', 'no .nvmrc, .node-version, or .tool-versions found — skipping');
} else if (process.env.CI === 'true') {
addCheck('version-manager-pin', 'skip', 'CI=true — version-manager pin check skipped (matrix tests multiple Node majors)');
} else { } else {
process.stdout.write('Result: ONE OR MORE CHECKS FAILED — see above\n'); const activeMajor = process.version.replace(/^v/, '').split('.')[0];
if (activeMajor === pinnedMajor) {
addCheck('version-manager-pin', 'pass', `Active Node major (${activeMajor}) matches ${pinSource} pin (${pinnedMajor})`);
} else {
addCheck('version-manager-pin', 'fail', `Active Node major (${activeMajor}) does NOT match ${pinSource} pin (${pinnedMajor}) — run 'nvm use' or equivalent`);
}
} }
// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
const overallPass = checks.every(c => c.status !== 'fail');
if (jsonMode) {
// Structured JSON: {pass: bool, checks: [{name, status, message}]}
const out = {
pass: overallPass,
checks: checks.map(c => ({ name: c.name, status: c.status, message: c.message })),
};
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
} else {
// Human-readable report
process.stdout.write('=== Environment Check ===\n');
for (const { name, status, message } of checks) {
const icon = status === 'pass' ? '[PASS]' : status === 'fail' ? '[FAIL]' : '[SKIP]';
const namePadded = name.padEnd(25);
process.stdout.write(` ${icon} ${namePadded} ${message}\n`);
}
process.stdout.write('\n');
if (overallPass) {
process.stdout.write('Result: ALL CHECKS PASSED\n');
} else {
process.stdout.write('Result: ONE OR MORE CHECKS FAILED — see above\n');
}
}
return overallPass ? 0 : 1;
} }
process.exit(overallPass ? 0 : 1); runMain(main);

View File

@@ -16,62 +16,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
// ---- Argument parsing ------------------------------------------------------- const { ExitError, runMain } = require('./lib/cli-exit.cjs');
let ignoreExtraneous = false;
for (const arg of process.argv.slice(2)) {
if (arg === '--ignore-extraneous') {
ignoreExtraneous = true;
} else if (arg === '--help' || arg === '-h') {
process.stdout.write(
'Usage: node scripts/check-npm-integrity.cjs [--ignore-extraneous]\n'
);
process.exit(0);
} else {
process.stderr.write(`ERROR: Unknown argument: ${arg}\n`);
process.exit(2);
}
}
// ---- Locate lockfile --------------------------------------------------------
const lockfilePath = path.join(process.cwd(), 'package-lock.json');
if (!fs.existsSync(lockfilePath)) {
process.stderr.write(`ERROR: package-lock.json not found in ${process.cwd()}\n`);
process.exit(2);
}
// ---- Parse lockfile ---------------------------------------------------------
let lock;
try {
lock = JSON.parse(fs.readFileSync(lockfilePath, 'utf-8'));
} catch (e) {
process.stderr.write(`ERROR: Failed to parse package-lock.json: ${e.message}\n`);
process.exit(2);
}
const lockVersion = lock.lockfileVersion || 1;
if (lockVersion < 2) {
process.stderr.write(
`ERROR: package-lock.json lockfileVersion ${lockVersion} is not supported. ` +
'Run `npm install` to upgrade to v3.\n'
);
process.exit(2);
}
const packages = lock.packages || {};
const rootEntry = packages[''] || {};
// Collect declared dependency ranges from root entry.
const declaredRanges = {};
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
for (const [name, range] of Object.entries(rootEntry[field] || {})) {
if (!declaredRanges[name]) declaredRanges[name] = range;
}
}
// ---- Minimal semver satisfies ----------------------------------------------- // ---- Minimal semver satisfies -----------------------------------------------
@@ -132,78 +77,139 @@ function satisfies(installed, range) {
return installed === range; return installed === range;
} }
// ---- Walk packages map ------------------------------------------------------ function main() {
// ---- Argument parsing -------------------------------------------------------
const invalids = []; let ignoreExtraneous = false;
const missings = [];
const extraneousFound = [];
for (const [key, entry] of Object.entries(packages)) { for (const arg of process.argv.slice(2)) {
if (!key.startsWith('node_modules/')) continue; if (arg === '--ignore-extraneous') {
const rest = key.slice('node_modules/'.length); ignoreExtraneous = true;
const isScoped = rest[0] === '@'; } else if (arg === '--help' || arg === '-h') {
const slashCount = (rest.match(/\//g) || []).length; process.stdout.write(
if (isScoped && slashCount > 1) continue; 'Usage: node scripts/check-npm-integrity.cjs [--ignore-extraneous]\n'
if (!isScoped && slashCount > 0) continue; );
return 0;
const pkgName = rest; } else {
const installedVersion = entry.version || ''; process.stderr.write(`ERROR: Unknown argument: ${arg}\n`);
throw new ExitError(2);
if (entry.extraneous) { }
extraneousFound.push({ name: pkgName, version: installedVersion });
continue;
} }
if (!declaredRanges[pkgName]) continue; // ---- Locate lockfile --------------------------------------------------------
if (!satisfies(installedVersion, declaredRanges[pkgName])) { const lockfilePath = path.join(process.cwd(), 'package-lock.json');
invalids.push({ name: pkgName, version: installedVersion, declared: declaredRanges[pkgName] });
if (!fs.existsSync(lockfilePath)) {
process.stderr.write(`ERROR: package-lock.json not found in ${process.cwd()}\n`);
throw new ExitError(2);
} }
}
for (const name of Object.keys(declaredRanges)) { // ---- Parse lockfile ---------------------------------------------------------
if (!packages[`node_modules/${name}`]) {
missings.push({ name, required: declaredRanges[name] }); let lock;
try {
lock = JSON.parse(fs.readFileSync(lockfilePath, 'utf-8'));
} catch (e) {
process.stderr.write(`ERROR: Failed to parse package-lock.json: ${e.message}\n`);
throw new ExitError(2);
} }
}
// ---- Verdict ---------------------------------------------------------------- const lockVersion = lock.lockfileVersion || 1;
if (lockVersion < 2) {
const failInvalid = invalids.length > 0; process.stderr.write(
const failMissing = missings.length > 0; `ERROR: package-lock.json lockfileVersion ${lockVersion} is not supported. ` +
const failExtra = !ignoreExtraneous && extraneousFound.length > 0; 'Run `npm install` to upgrade to v3.\n'
);
if (!failInvalid && !failMissing && !failExtra) { throw new ExitError(2);
process.stderr.write('check-npm-integrity.cjs: clean\n');
process.exit(0);
}
const lines = ['FAIL: dependency integrity drift detected', ''];
if (failInvalid) {
lines.push(' INVALID (installed version does not satisfy declared range):');
for (const { name, declared, version } of invalids) {
lines.push(` ${name}: declared=${declared} installed=${version}`);
} }
lines.push('');
}
if (failMissing) { const packages = lock.packages || {};
lines.push(' MISSING (declared but absent from lockfile packages map):'); const rootEntry = packages[''] || {};
for (const { name, required } of missings) {
lines.push(` ${name}@${required}`); // Collect declared dependency ranges from root entry.
const declaredRanges = {};
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
for (const [name, range] of Object.entries(rootEntry[field] || {})) {
if (!declaredRanges[name]) declaredRanges[name] = range;
}
} }
lines.push('');
}
if (failExtra) { // ---- Walk packages map ------------------------------------------------------
lines.push(' EXTRANEOUS (in lockfile but not declared as a dependency):');
for (const { name, version } of extraneousFound) { const invalids = [];
lines.push(` ${name}@${version}`); const missings = [];
const extraneousFound = [];
for (const [key, entry] of Object.entries(packages)) {
if (!key.startsWith('node_modules/')) continue;
const rest = key.slice('node_modules/'.length);
const isScoped = rest[0] === '@';
const slashCount = (rest.match(/\//g) || []).length;
if (isScoped && slashCount > 1) continue;
if (!isScoped && slashCount > 0) continue;
const pkgName = rest;
const installedVersion = entry.version || '';
if (entry.extraneous) {
extraneousFound.push({ name: pkgName, version: installedVersion });
continue;
}
if (!declaredRanges[pkgName]) continue;
if (!satisfies(installedVersion, declaredRanges[pkgName])) {
invalids.push({ name: pkgName, version: installedVersion, declared: declaredRanges[pkgName] });
}
} }
lines.push('');
for (const name of Object.keys(declaredRanges)) {
if (!packages[`node_modules/${name}`]) {
missings.push({ name, required: declaredRanges[name] });
}
}
// ---- Verdict ----------------------------------------------------------------
const failInvalid = invalids.length > 0;
const failMissing = missings.length > 0;
const failExtra = !ignoreExtraneous && extraneousFound.length > 0;
if (!failInvalid && !failMissing && !failExtra) {
process.stderr.write('check-npm-integrity.cjs: clean\n');
return 0;
}
const lines = ['FAIL: dependency integrity drift detected', ''];
if (failInvalid) {
lines.push(' INVALID (installed version does not satisfy declared range):');
for (const { name, declared, version } of invalids) {
lines.push(` ${name}: declared=${declared} installed=${version}`);
}
lines.push('');
}
if (failMissing) {
lines.push(' MISSING (declared but absent from lockfile packages map):');
for (const { name, required } of missings) {
lines.push(` ${name}@${required}`);
}
lines.push('');
}
if (failExtra) {
lines.push(' EXTRANEOUS (in lockfile but not declared as a dependency):');
for (const { name, version } of extraneousFound) {
lines.push(` ${name}@${version}`);
}
lines.push('');
}
lines.push('Remediation: rm -rf node_modules && npm ci');
process.stderr.write(lines.join('\n') + '\n');
throw new ExitError(1);
} }
lines.push('Remediation: rm -rf node_modules && npm ci'); runMain(main);
process.stderr.write(lines.join('\n') + '\n');
process.exit(1);

View File

@@ -6,11 +6,17 @@
// Exit 0 = github-hosted runner confirmed. // Exit 0 = github-hosted runner confirmed.
// Exit 1 = not a github-hosted runner (emits GitHub Actions error annotation). // Exit 1 = not a github-hosted runner (emits GitHub Actions error annotation).
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const env = process.env.RUNNER_ENVIRONMENT || ''; const env = process.env.RUNNER_ENVIRONMENT || '';
if (env !== 'github-hosted') { function main() {
process.stderr.write( if (env !== 'github-hosted') {
`::error::Expected github-hosted runner. RUNNER_ENVIRONMENT=${env || 'unset'}\n` throw new ExitError(
); 1,
process.exit(1); `::error::Expected github-hosted runner. RUNNER_ENVIRONMENT=${env || 'unset'}`
);
}
} }
runMain(main);

View File

@@ -14,33 +14,38 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const scope = process.env.TEST_SCOPE || ''; const scope = process.env.TEST_SCOPE || '';
const targeted = process.env.TARGETED_TESTS || ''; const targeted = process.env.TARGETED_TESTS || '';
const windows = process.env.WINDOWS_TESTS || ''; const windows = process.env.WINDOWS_TESTS || '';
const FALLBACK = 'tests/command-contract.test.cjs tests/commands.test.cjs tests/core.test.cjs tests/package-manifest.test.cjs'; const FALLBACK = 'tests/command-contract.test.cjs tests/commands.test.cjs tests/core.test.cjs tests/package-manifest.test.cjs';
let selected; function main() {
if (scope === 'windows') { let selected;
selected = windows; if (scope === 'windows') {
} else if (scope === 'targeted') { selected = windows;
selected = targeted; } else if (scope === 'targeted') {
} else { selected = targeted;
process.stderr.write(`::error::Unknown test scope: ${scope}\n`); } else {
process.exit(1); throw new ExitError(1, `::error::Unknown test scope: ${scope}`);
}
// Trim and fall back to default set if empty.
if (!selected.trim()) {
selected = FALLBACK;
}
// Split on whitespace, filter blanks, join with newlines.
const lines = selected.split(/\s+/).filter(Boolean);
const content = lines.join('\n') + '\n';
const outPath = path.join(process.cwd(), '.ci-selected-tests.txt');
fs.writeFileSync(outPath, content, 'utf-8');
process.stdout.write('Scoped tests:\n');
process.stdout.write(content);
} }
// Trim and fall back to default set if empty. runMain(main);
if (!selected.trim()) {
selected = FALLBACK;
}
// Split on whitespace, filter blanks, join with newlines.
const lines = selected.split(/\s+/).filter(Boolean);
const content = lines.join('\n') + '\n';
const outPath = path.join(process.cwd(), '.ci-selected-tests.txt');
fs.writeFileSync(outPath, content, 'utf-8');
process.stdout.write('Scoped tests:\n');
process.stdout.write(content);

View File

@@ -13,6 +13,8 @@
const { execFileSync } = require('child_process'); const { execFileSync } = require('child_process');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
function run(cmd, args, opts) { function run(cmd, args, opts) {
try { try {
execFileSync(cmd, args, { stdio: 'inherit', ...opts }); execFileSync(cmd, args, { stdio: 'inherit', ...opts });
@@ -26,8 +28,7 @@ function runOrThrow(cmd, args, label) {
try { try {
execFileSync(cmd, args, { stdio: 'inherit' }); execFileSync(cmd, args, { stdio: 'inherit' });
} catch (e) { } catch (e) {
process.stderr.write(`::error::${label} failed\n`); throw new ExitError(1, `::error::${label} failed`);
process.exit(1);
} }
} }
@@ -35,48 +36,51 @@ const token = process.env.GITHUB_TOKEN || '';
const baseBranch = process.env.GITHUB_BASE_REF || 'main'; const baseBranch = process.env.GITHUB_BASE_REF || 'main';
const repo = process.env.GITHUB_REPOSITORY || ''; const repo = process.env.GITHUB_REPOSITORY || '';
// Configure git identity (needed for merge commit). function main() {
runOrThrow('git', ['config', 'user.email', 'ci@gsd-redux'], 'git config user.email'); // Configure git identity (needed for merge commit).
runOrThrow('git', ['config', 'user.name', 'CI Rebase Check'], 'git config user.name'); runOrThrow('git', ['config', 'user.email', 'ci@gsd-redux'], 'git config user.email');
runOrThrow('git', ['config', 'user.name', 'CI Rebase Check'], 'git config user.name');
// Set authenticated remote URL. // Set authenticated remote URL.
if (token && repo) { if (token && repo) {
runOrThrow( runOrThrow(
'git', 'git',
['remote', 'set-url', 'origin', `https://x-access-token:${token}@github.com/${repo}.git`], ['remote', 'set-url', 'origin', `https://x-access-token:${token}@github.com/${repo}.git`],
'git remote set-url' 'git remote set-url'
); );
}
// Fetch base branch with retry.
for (let attempt = 1; attempt <= 3; attempt++) {
const result = run('git', ['fetch', 'origin', baseBranch]);
if (result) {
break;
} }
if (attempt === 3) {
process.stderr.write(`::error::git fetch origin ${baseBranch} failed after 3 attempts.\n`); // Fetch base branch with retry.
process.exit(1); for (let attempt = 1; attempt <= 3; attempt++) {
const result = run('git', ['fetch', 'origin', baseBranch]);
if (result) {
break;
}
if (attempt === 3) {
throw new ExitError(1, `::error::git fetch origin ${baseBranch} failed after 3 attempts.`);
}
// Wait before retry: attempt * 4 seconds.
const waitMs = attempt * 4000;
const deadline = Date.now() + waitMs;
while (Date.now() < deadline) { /* busy wait, acceptable in CI */ }
}
// Attempt merge.
try {
execFileSync('git', ['merge', '--no-edit', '--no-ff', `origin/${baseBranch}`], { stdio: 'inherit' });
} catch (e) {
process.stderr.write(
`::error::This PR cannot cleanly merge origin/${baseBranch}. Rebase your branch onto current ${baseBranch} and push again.\n`
);
process.stderr.write('::error::Conflicting files:\n');
try {
execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], { stdio: 'inherit' });
} catch (_) { /* ignore */ }
try {
execFileSync('git', ['merge', '--abort'], { stdio: 'inherit' });
} catch (_) { /* ignore */ }
throw new ExitError(1);
} }
// Wait before retry: attempt * 4 seconds.
const waitMs = attempt * 4000;
const deadline = Date.now() + waitMs;
while (Date.now() < deadline) { /* busy wait, acceptable in CI */ }
} }
// Attempt merge. runMain(main);
try {
execFileSync('git', ['merge', '--no-edit', '--no-ff', `origin/${baseBranch}`], { stdio: 'inherit' });
} catch (e) {
process.stderr.write(
`::error::This PR cannot cleanly merge origin/${baseBranch}. Rebase your branch onto current ${baseBranch} and push again.\n`
);
process.stderr.write('::error::Conflicting files:\n');
try {
execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], { stdio: 'inherit' });
} catch (_) { /* ignore */ }
try {
execFileSync('git', ['merge', '--abort'], { stdio: 'inherit' });
} catch (_) { /* ignore */ }
process.exit(1);
}

View File

@@ -4,6 +4,8 @@
const { execFileSync } = require('child_process'); const { execFileSync } = require('child_process');
const { existsSync, readdirSync, appendFileSync } = require('fs'); const { existsSync, readdirSync, appendFileSync } = require('fs');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const RULES = [ const RULES = [
{ {
name: 'workflow automation', name: 'workflow automation',
@@ -197,7 +199,7 @@ function parseArgs(argv) {
if (!out.files) throw new Error('--files requires a value'); if (!out.files) throw new Error('--files requires a value');
} else if (arg === '--help' || arg === '-h') { } else if (arg === '--help' || arg === '-h') {
console.log(usage()); console.log(usage());
process.exit(0); throw new ExitError(0);
} else { } else {
throw new Error(`unknown argument: ${arg}`); throw new Error(`unknown argument: ${arg}`);
} }
@@ -317,10 +319,11 @@ function main() {
writeOutputs(result); writeOutputs(result);
console.log(JSON.stringify(result, null, 2)); console.log(JSON.stringify(result, null, 2));
} catch (error) { } catch (error) {
if (error instanceof ExitError) throw error;
console.error(`ci-test-scope: ${error.message}`); console.error(`ci-test-scope: ${error.message}`);
console.error(usage()); console.error(usage());
process.exit(2); throw new ExitError(2);
} }
} }
main(); runMain(main);

View File

@@ -43,6 +43,7 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const EXIT_SHIPPED = 0; const EXIT_SHIPPED = 0;
const EXIT_NOT_SHIPPED = 1; const EXIT_NOT_SHIPPED = 1;
@@ -88,60 +89,67 @@ function isPushBlocking(diffPath) {
return diffPath.replace(/\\/g, '/').startsWith('.github/workflows/'); return diffPath.replace(/\\/g, '/').startsWith('.github/workflows/');
} }
function fail(message, err) { function readStdin() {
process.stderr.write(`diff-touches-shipped-paths: ${message}\n`); return new Promise((resolve, reject) => {
if (err && err.stack) process.stderr.write(`${err.stack}\n`); let data = '';
process.exit(EXIT_ERROR); process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { data += chunk; });
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
} }
function main() { async 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;
try { try {
const pkgPath = path.resolve(process.cwd(), 'package.json'); let shipPrefixes;
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', () => {
try { try {
const paths = buf.split('\n').map((s) => s.trim()).filter(Boolean); const pkgPath = path.resolve(process.cwd(), 'package.json');
// #2980 still wins over #3621: any commit touching .github/workflows/* shipPrefixes = loadShipPrefixes(pkgPath);
// 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);
} catch (err) { } 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) { if (require.main === module) {
main(); runMain(main);
} }
module.exports = { loadShipPrefixes, isShipped, isCiGating, isPushBlocking, EXIT_SHIPPED, EXIT_NOT_SHIPPED, EXIT_ERROR }; module.exports = { loadShipPrefixes, isShipped, isCiGating, isPushBlocking, EXIT_SHIPPED, EXIT_NOT_SHIPPED, EXIT_ERROR };

View File

@@ -16,6 +16,8 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const ROOT = path.resolve(__dirname, '..'); const ROOT = path.resolve(__dirname, '..');
const MANIFEST_PATH = path.join(ROOT, 'docs', 'INVENTORY-MANIFEST.json'); const MANIFEST_PATH = path.join(ROOT, 'docs', 'INVENTORY-MANIFEST.json');
@@ -70,40 +72,44 @@ function buildManifest() {
return manifest; return manifest;
} }
const [, , flag] = process.argv; function main() {
const [, , flag] = process.argv;
if (flag === '--check') { if (flag === '--check') {
const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
const live = buildManifest(); const live = buildManifest();
// Strip the generated date for comparison // Strip the generated date for comparison
delete committed.generated; delete committed.generated;
delete live.generated; delete live.generated;
const committedStr = JSON.stringify(committed, null, 2); const committedStr = JSON.stringify(committed, null, 2);
const liveStr = JSON.stringify(live, null, 2); const liveStr = JSON.stringify(live, null, 2);
if (committedStr !== liveStr) { if (committedStr !== liveStr) {
process.stderr.write( process.stderr.write(
'docs/INVENTORY-MANIFEST.json is stale. Run:\n' + 'docs/INVENTORY-MANIFEST.json is stale. Run:\n' +
' node scripts/gen-inventory-manifest.cjs --write\n' + ' node scripts/gen-inventory-manifest.cjs --write\n' +
'then add a matching row in docs/INVENTORY.md for each new entry.\n\n', 'then add a matching row in docs/INVENTORY.md for each new entry.\n\n',
); );
// Show diff-friendly output // Show diff-friendly output
for (const family of Object.keys(live.families)) { for (const family of Object.keys(live.families)) {
const liveSet = new Set(live.families[family]); const liveSet = new Set(live.families[family]);
const committedSet = new Set((committed.families || {})[family] || []); const committedSet = new Set((committed.families || {})[family] || []);
for (const name of liveSet) { for (const name of liveSet) {
if (!committedSet.has(name)) process.stderr.write(' + ' + family + '/' + name + '\n'); if (!committedSet.has(name)) process.stderr.write(' + ' + family + '/' + name + '\n');
} }
for (const name of committedSet) { for (const name of committedSet) {
if (!liveSet.has(name)) process.stderr.write(' - ' + family + '/' + name + '\n'); if (!liveSet.has(name)) process.stderr.write(' - ' + family + '/' + name + '\n');
}
} }
throw new ExitError(1);
} }
process.exit(1); process.stdout.write('docs/INVENTORY-MANIFEST.json is up to date.\n');
} else if (flag === '--write') {
const manifest = buildManifest();
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n');
process.stdout.write('Wrote ' + MANIFEST_PATH + '\n');
} else {
process.stdout.write(JSON.stringify(buildManifest(), null, 2) + '\n');
} }
process.stdout.write('docs/INVENTORY-MANIFEST.json is up to date.\n');
} else if (flag === '--write') {
const manifest = buildManifest();
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n');
process.stdout.write('Wrote ' + MANIFEST_PATH + '\n');
} else {
process.stdout.write(JSON.stringify(buildManifest(), null, 2) + '\n');
} }
runMain(main);

View File

@@ -24,6 +24,7 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { PROFILES } = require('./research-profiles.cjs'); const { PROFILES } = require('./research-profiles.cjs');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const ROOT = path.resolve(__dirname, '..'); const ROOT = path.resolve(__dirname, '..');
const AGENTS_DIR = path.join(ROOT, 'agents'); const AGENTS_DIR = path.join(ROOT, 'agents');
@@ -225,8 +226,7 @@ function runWrite() {
for (const profile of PROFILES) { for (const profile of PROFILES) {
const agentPath = path.join(AGENTS_DIR, profile.name + '.md'); const agentPath = path.join(AGENTS_DIR, profile.name + '.md');
if (!fs.existsSync(agentPath)) { if (!fs.existsSync(agentPath)) {
process.stderr.write('ERROR: agent file not found: ' + agentPath + '\n'); throw new ExitError(1, 'ERROR: agent file not found: ' + agentPath);
process.exit(1);
} }
writeAgent(profile); writeAgent(profile);
process.stdout.write(' wrote ' + profile.name + '.md\n'); process.stdout.write(' wrote ' + profile.name + '.md\n');
@@ -241,7 +241,7 @@ module.exports = { PROFILES, checkAgent, runCheck, parseAgentFile };
// ─── CLI entry point ────────────────────────────────────────────────────────── // ─── CLI entry point ──────────────────────────────────────────────────────────
if (require.main === module) { function main() {
const flag = process.argv[2] || '--check'; const flag = process.argv[2] || '--check';
if (flag === '--write') { if (flag === '--write') {
@@ -251,7 +251,7 @@ if (require.main === module) {
const ok = runCheck(); const ok = runCheck();
if (!ok) { if (!ok) {
process.stderr.write('\nERROR: --check failed after --write. Fix serialization.\n'); process.stderr.write('\nERROR: --check failed after --write. Fix serialization.\n');
process.exit(1); throw new ExitError(1);
} }
process.stdout.write('\nAll agents match their profiles.\n'); process.stdout.write('\nAll agents match their profiles.\n');
} else if (flag === '--check') { } else if (flag === '--check') {
@@ -263,12 +263,14 @@ if (require.main === module) {
'\nTo regenerate frontmatter from profiles:\n' + '\nTo regenerate frontmatter from profiles:\n' +
' node scripts/gen-research-agents.cjs --write\n', ' node scripts/gen-research-agents.cjs --write\n',
); );
process.exit(1); throw new ExitError(1);
} }
process.stdout.write('\nAll 7 agents match their profiles.\n'); process.stdout.write('\nAll 7 agents match their profiles.\n');
} else { } else {
process.stderr.write('Unknown flag: ' + flag + '\n'); throw new ExitError(1, 'Unknown flag: ' + flag + '\nUsage: node scripts/gen-research-agents.cjs [--check|--write]');
process.stderr.write('Usage: node scripts/gen-research-agents.cjs [--check|--write]\n');
process.exit(1);
} }
} }
if (require.main === module) {
runMain(main);
}

56
scripts/lib/cli-exit.cjs Normal file
View File

@@ -0,0 +1,56 @@
'use strict';
/**
* Error that carries 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.
*
* @param {number} code exit code (default 1)
* @param {string} [message] optional human message; when set and code != 0 it is
* written to stderr by runMain before the process exits.
*/
class ExitError extends Error {
constructor(code = 1, message) {
super(message === undefined ? `process exit ${code}` : message);
this.name = 'ExitError';
this.code = code;
// Whether runMain should print this.message to stderr (only when a real
// message was provided, not the synthetic default).
this.hasUserMessage = message !== undefined;
}
}
/**
* Run a CLI main function and translate its outcome into process.exitCode
* (never process.exit(), so n/no-process-exit stays satisfied). Supports sync or
* async main.
* - main returns a number -> process.exitCode = that number
* - main throws/rejects ExitError -> process.exitCode = err.code, and if
* err.hasUserMessage && err.code !== 0, err.message is written to stderr
* - main throws/rejects anything else -> the stack is written to stderr and
* process.exitCode = 1
* Letting the event loop drain (vs process.exit) means buffered stdout/stderr is
* flushed and process.on('exit') cleanup handlers still fire.
*
* @param {() => (number|void|Promise<number|void>)} main
*/
function runMain(main) {
Promise.resolve()
.then(() => main())
.then((code) => {
if (typeof code === 'number') process.exitCode = code;
})
.catch((err) => {
if (err instanceof ExitError) {
if (err.hasUserMessage && err.code !== 0) {
process.stderr.write(`${err.message}\n`);
}
process.exitCode = err.code;
return;
}
process.stderr.write(`${err && err.stack ? err.stack : String(err)}\n`);
process.exitCode = 1;
});
}
module.exports = { ExitError, runMain };

View File

@@ -28,6 +28,8 @@ const {
executionContextRefs: extractExecutionContextRefs, executionContextRefs: extractExecutionContextRefs,
} = require('./command-contract-helpers.cjs'); } = require('./command-contract-helpers.cjs');
const { runMain } = require('./lib/cli-exit.cjs');
// ─── check one file ─────────────────────────────────────────────────────────── // ─── check one file ───────────────────────────────────────────────────────────
function check(filePath) { function check(filePath) {
@@ -79,30 +81,34 @@ function check(filePath) {
// ─── run ───────────────────────────────────────────────────────────────────── // ─── run ─────────────────────────────────────────────────────────────────────
const commandFiles = fs function main() {
.readdirSync(COMMANDS_DIR) const commandFiles = fs
.filter(f => f.endsWith('.md')) .readdirSync(COMMANDS_DIR)
.map(f => path.join(COMMANDS_DIR, f)); .filter(f => f.endsWith('.md'))
.map(f => path.join(COMMANDS_DIR, f));
const results = commandFiles.map(check).filter(Boolean); const results = commandFiles.map(check).filter(Boolean);
if (results.length === 0) { if (results.length === 0) {
console.log( console.log(
`ok lint-command-contract: ${commandFiles.length} command files checked, 0 violations`, `ok lint-command-contract: ${commandFiles.length} command files checked, 0 violations`,
); );
process.exit(0); return 0;
}
const total = results.reduce((n, r) => n + r.violations.length, 0);
process.stderr.write(
`\nERROR lint-command-contract: ${total} violation(s) across ${results.length} file(s)\n\n`,
);
for (const r of results) {
process.stderr.write(` ${r.file}\n`);
for (const v of r.violations) {
process.stderr.write(` - ${v}\n`);
} }
process.stderr.write('\n');
const total = results.reduce((n, r) => n + r.violations.length, 0);
process.stderr.write(
`\nERROR lint-command-contract: ${total} violation(s) across ${results.length} file(s)\n\n`,
);
for (const r of results) {
process.stderr.write(` ${r.file}\n`);
for (const v of r.violations) {
process.stderr.write(` - ${v}\n`);
}
process.stderr.write('\n');
}
process.stderr.write('See docs/adr/0002-command-contract-validation-module.md for the contract spec.\n\n');
return 1;
} }
process.stderr.write('See docs/adr/0002-command-contract-validation-module.md for the contract spec.\n\n');
process.exit(1); runMain(main);

View File

@@ -15,6 +15,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const MAX_LENGTH = 100; const MAX_LENGTH = 100;
const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
@@ -46,38 +47,41 @@ function getFiles() {
.map(f => path.join(COMMANDS_DIR, f)); .map(f => path.join(COMMANDS_DIR, f));
} }
const files = getFiles(); function main() {
const violations = []; const files = getFiles();
const violations = [];
for (const filePath of files) { for (const filePath of files) {
let content; let content;
try { try {
content = fs.readFileSync(filePath, 'utf-8'); content = fs.readFileSync(filePath, 'utf-8');
} catch (err) { } catch (err) {
process.stderr.write(`ERROR: Cannot read file: ${filePath}\n ${err.message}\n`); throw new ExitError(1, `ERROR: Cannot read file: ${filePath}\n ${err.message}`);
process.exit(1); }
const description = parseDescription(content);
if (description === null) continue;
if (description.length > MAX_LENGTH) {
violations.push({ filePath, length: description.length, description });
}
} }
const description = parseDescription(content); if (violations.length === 0) {
if (description === null) continue; const checked = files.length;
process.stdout.write(`ok lint-descriptions: ${checked} file(s) checked, 0 violations\n`);
if (description.length > MAX_LENGTH) { return 0;
violations.push({ filePath, length: description.length, description });
} }
process.stderr.write(`\nERROR lint-descriptions: ${violations.length} violation(s) found\n\n`);
for (const v of violations) {
const preview = v.description.length > 120 ? v.description.slice(0, 117) + '...' : v.description;
process.stderr.write(` ${v.filePath}\n`);
process.stderr.write(` Length : ${v.length} (max ${MAX_LENGTH})\n`);
process.stderr.write(` Desc : ${preview}\n\n`);
}
process.stderr.write(`Trim descriptions to <= ${MAX_LENGTH} chars. Flag docs belong in argument-hint:.\n\n`);
return 1;
} }
if (violations.length === 0) { runMain(main);
const checked = files.length;
process.stdout.write(`ok lint-descriptions: ${checked} file(s) checked, 0 violations\n`);
process.exit(0);
}
process.stderr.write(`\nERROR lint-descriptions: ${violations.length} violation(s) found\n\n`);
for (const v of violations) {
const preview = v.description.length > 120 ? v.description.slice(0, 117) + '...' : v.description;
process.stderr.write(` ${v.filePath}\n`);
process.stderr.write(` Length : ${v.length} (max ${MAX_LENGTH})\n`);
process.stderr.write(` Desc : ${preview}\n\n`);
}
process.stderr.write(`Trim descriptions to <= ${MAX_LENGTH} chars. Flag docs belong in argument-hint:.\n\n`);
process.exit(1);

View File

@@ -14,6 +14,7 @@
*/ */
const { parseFragment, FRAGMENT_ERROR } = require('./changeset/parse.cjs'); const { parseFragment, FRAGMENT_ERROR } = require('./changeset/parse.cjs');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const LINT_REASON = Object.freeze({ const LINT_REASON = Object.freeze({
OK_NO_TRIGGERING_FRAGMENTS: 'ok_no_triggering_fragments', OK_NO_TRIGGERING_FRAGMENTS: 'ok_no_triggering_fragments',
@@ -161,8 +162,7 @@ function main() {
); );
changedFiles = out.split('\n').filter(Boolean); changedFiles = out.split('\n').filter(Boolean);
} catch (e) { } catch (e) {
process.stderr.write(`could not compute diff: ${e.message}\n`); throw new ExitError(2, `could not compute diff: ${e.message}`);
process.exit(2);
} }
const { fragments, malformed } = readFragmentsFromDisk(changedFiles, rootDir); const { fragments, malformed } = readFragmentsFromDisk(changedFiles, rootDir);
@@ -204,10 +204,10 @@ function main() {
`exemption via \`<!-- docs-exempt: <reason> -->\` inside the fragment body also works.\n`, `exemption via \`<!-- docs-exempt: <reason> -->\` inside the fragment body also works.\n`,
); );
} }
process.exit(verdict.ok ? 0 : 1); return verdict.ok ? 0 : 1;
} }
if (require.main === module) main(); if (require.main === module) runMain(main);
module.exports = { module.exports = {
evaluateLint, evaluateLint,

View File

@@ -33,6 +33,7 @@
const { execFileSync } = require('child_process'); const { execFileSync } = require('child_process');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
// Constructed with split to avoid self-match when this script is scanned. // Constructed with split to avoid self-match when this script is scanned.
const FORBIDDEN = 'get-shit' + '-done'; const FORBIDDEN = 'get-shit' + '-done';
@@ -90,67 +91,70 @@ function isBinary(fullPath) {
} }
} }
// Enumerate tracked files via git ls-files so only committed/staged source is checked. function main() {
let trackedFiles; // Enumerate tracked files via git ls-files so only committed/staged source is checked.
try { let trackedFiles;
trackedFiles = execFileSync('git', ['ls-files'], { cwd: REPO_ROOT, encoding: 'utf8' })
.split('\n')
.map((f) => f.trim())
.filter(Boolean);
} catch (err) {
process.stderr.write('ERROR lint-legacy-dir-name: git ls-files failed: ' + err.message + '\n');
process.exit(1);
}
const violations = [];
for (const relPath of trackedFiles) {
if (isAllowlisted(relPath)) continue;
const fullPath = path.join(REPO_ROOT, relPath);
// Skip this guard script itself.
if (path.resolve(fullPath) === SELF_PATH) continue;
if (isBinary(fullPath)) continue;
let content;
try { try {
content = fs.readFileSync(fullPath, 'utf8'); trackedFiles = execFileSync('git', ['ls-files'], { cwd: REPO_ROOT, encoding: 'utf8' })
} catch { .split('\n')
// Unreadable files (permissions, etc.) — skip silently. .map((f) => f.trim())
continue; .filter(Boolean);
} catch (err) {
throw new ExitError(1, 'ERROR lint-legacy-dir-name: git ls-files failed: ' + err.message);
} }
const lines = content.split('\n'); const violations = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip lines that carry the explicit allow marker.
if (line.includes(ALLOW_MARKER)) continue;
FORBIDDEN_RE.lastIndex = 0; for (const relPath of trackedFiles) {
let match; if (isAllowlisted(relPath)) continue;
while ((match = FORBIDDEN_RE.exec(line)) !== null) {
violations.push({ const fullPath = path.join(REPO_ROOT, relPath);
file: relPath,
line: i + 1, // Skip this guard script itself.
col: match.index + 1, if (path.resolve(fullPath) === SELF_PATH) continue;
text: match[0],
}); if (isBinary(fullPath)) continue;
let content;
try {
content = fs.readFileSync(fullPath, 'utf8');
} catch {
// Unreadable files (permissions, etc.) — skip silently.
continue;
}
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip lines that carry the explicit allow marker.
if (line.includes(ALLOW_MARKER)) continue;
FORBIDDEN_RE.lastIndex = 0;
let match;
while ((match = FORBIDDEN_RE.exec(line)) !== null) {
violations.push({
file: relPath,
line: i + 1,
col: match.index + 1,
text: match[0],
});
}
} }
} }
if (violations.length === 0) {
process.stdout.write('ok lint-legacy-dir-name: ' + trackedFiles.length + ' tracked file(s) checked, 0 violations\n');
return 0;
}
process.stderr.write('\nERROR lint-legacy-dir-name: ' + violations.length + ' violation(s) found\n\n');
for (const v of violations) {
process.stderr.write(' ' + v.file + ':' + v.line + ':' + v.col + ' — ' + JSON.stringify(v.text) + '\n');
}
process.stderr.write('\n');
process.stderr.write('Fix: rename to gsd-core, or add `gsd-allow-legacy-name` marker on the line if the\n');
process.stderr.write(' use is intentional (migration modules, tests, guard, changeset).\n\n');
return 1;
} }
if (violations.length === 0) { runMain(main);
process.stdout.write('ok lint-legacy-dir-name: ' + trackedFiles.length + ' tracked file(s) checked, 0 violations\n');
process.exit(0);
}
process.stderr.write('\nERROR lint-legacy-dir-name: ' + violations.length + ' violation(s) found\n\n');
for (const v of violations) {
process.stderr.write(' ' + v.file + ':' + v.line + ':' + v.col + ' — ' + JSON.stringify(v.text) + '\n');
}
process.stderr.write('\n');
process.stderr.write('Fix: rename to gsd-core, or add `gsd-allow-legacy-name` marker on the line if the\n');
process.stderr.write(' use is intentional (migration modules, tests, guard, changeset).\n\n');
process.exit(1);

View File

@@ -4,6 +4,8 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { runMain } = require('./lib/cli-exit.cjs');
const ROOT = path.join(__dirname, '..'); const ROOT = path.join(__dirname, '..');
const DEFAULT_RELATIVE_FILES = [ const DEFAULT_RELATIVE_FILES = [
@@ -84,7 +86,7 @@ function main(argv = process.argv.slice(2)) {
} }
if (require.main === module) { if (require.main === module) {
process.exit(main()); runMain(main);
} }
module.exports = { module.exports = {

View File

@@ -13,19 +13,9 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const ROOT = path.resolve(__dirname, '..'); const ROOT = path.resolve(__dirname, '..');
const targetArg = process.argv[2] || path.join(ROOT, 'bin', 'install.js');
const target = path.resolve(targetArg);
const rel = path.relative(ROOT, target);
let content;
try {
content = fs.readFileSync(target, 'utf8');
} catch (error) {
process.stderr.write(`lint-shell-command-projection-drift: failed to read ${target}: ${error.message}\n`);
process.exit(1);
}
const forbidden = [ const forbidden = [
{ {
@@ -42,16 +32,31 @@ const forbidden = [
}, },
]; ];
const matches = forbidden.filter((rule) => rule.pattern.test(content)); function main() {
if (matches.length === 0) { const targetArg = process.argv[2] || path.join(ROOT, 'bin', 'install.js');
process.stdout.write(`ok shell-projection-drift: ${rel}\n`); const target = path.resolve(targetArg);
process.exit(0); const rel = path.relative(ROOT, target);
let content;
try {
content = fs.readFileSync(target, 'utf8');
} catch (error) {
throw new ExitError(1, `lint-shell-command-projection-drift: failed to read ${target}: ${error.message}`);
}
const matches = forbidden.filter((rule) => rule.pattern.test(content));
if (matches.length === 0) {
process.stdout.write(`ok shell-projection-drift: ${rel}\n`);
return 0;
}
process.stderr.write(`ERROR shell-projection-drift: inline serialized shim builders found in ${rel}\n`);
for (const match of matches) {
process.stderr.write(` - ${match.label}\n`);
}
process.stderr.write('Route shim/wrapper rendering through gsd-core/bin/lib/shell-command-projection.cjs\n');
process.stderr.write('Safe subprocess execution via spawnSync/execFileSync is intentionally allowed.\n');
return 1;
} }
process.stderr.write(`ERROR shell-projection-drift: inline serialized shim builders found in ${rel}\n`); runMain(main);
for (const match of matches) {
process.stderr.write(` - ${match.label}\n`);
}
process.stderr.write('Route shim/wrapper rendering through gsd-core/bin/lib/shell-command-projection.cjs\n');
process.stderr.write('Safe subprocess execution via spawnSync/execFileSync is intentionally allowed.\n');
process.exit(1);

View File

@@ -25,6 +25,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { runMain } = require('./lib/cli-exit.cjs');
const PROFILES_MODULE = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'install-profiles.cjs'); const PROFILES_MODULE = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'install-profiles.cjs');
const { PROFILES, loadSkillsManifest, resolveProfile } = require(PROFILES_MODULE); const { PROFILES, loadSkillsManifest, resolveProfile } = require(PROFILES_MODULE);
@@ -142,39 +143,43 @@ function checkProfileClosure(manifest) {
// Main // Main
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const manifest = loadSkillsManifest(commandsDir); function main() {
const allStems = new Set( const manifest = loadSkillsManifest(commandsDir);
[...manifest.keys()].filter((k) => !k.startsWith('_calls_agents_')) const allStems = new Set(
); [...manifest.keys()].filter((k) => !k.startsWith('_calls_agents_'))
);
const consistencyViolations = checkFrontmatterBodyConsistency(manifest, allStems); const consistencyViolations = checkFrontmatterBodyConsistency(manifest, allStems);
const closureViolations = checkProfileClosure(manifest); const closureViolations = checkProfileClosure(manifest);
const totalViolations = consistencyViolations.length + closureViolations.length; const totalViolations = consistencyViolations.length + closureViolations.length;
if (totalViolations === 0) { if (totalViolations === 0) {
const checked = manifest.size; const checked = manifest.size;
process.stdout.write('ok lint-skill-deps: ' + checked + ' skill(s) checked, 0 violations\n'); process.stdout.write('ok lint-skill-deps: ' + checked + ' skill(s) checked, 0 violations\n');
process.exit(0); return 0;
}
process.stderr.write('\nERROR lint-skill-deps: ' + totalViolations + ' violation(s) found\n\n');
if (consistencyViolations.length > 0) {
process.stderr.write('Frontmatter to body consistency violations (' + consistencyViolations.length + '):\n\n');
for (const v of consistencyViolations) {
process.stderr.write(' ' + v.filePath + '\n');
process.stderr.write(' ' + v.message + '\n\n');
} }
process.stderr.write('Fix: add missing deps to requires: in the skill frontmatter.\n\n');
}
if (closureViolations.length > 0) { process.stderr.write('\nERROR lint-skill-deps: ' + totalViolations + ' violation(s) found\n\n');
process.stderr.write('Profile closure violations (' + closureViolations.length + '):\n\n');
for (const v of closureViolations) { if (consistencyViolations.length > 0) {
process.stderr.write(' ' + v.message + '\n'); process.stderr.write('Frontmatter to body consistency violations (' + consistencyViolations.length + '):\n\n');
for (const v of consistencyViolations) {
process.stderr.write(' ' + v.filePath + '\n');
process.stderr.write(' ' + v.message + '\n\n');
}
process.stderr.write('Fix: add missing deps to requires: in the skill frontmatter.\n\n');
} }
process.stderr.write('\nFix: add the missing skills to the profile base set in install-profiles.cjs.\n\n');
if (closureViolations.length > 0) {
process.stderr.write('Profile closure violations (' + closureViolations.length + '):\n\n');
for (const v of closureViolations) {
process.stderr.write(' ' + v.message + '\n');
}
process.stderr.write('\nFix: add the missing skills to the profile base set in install-profiles.cjs.\n\n');
}
return 1;
} }
process.exit(1); runMain(main);

View File

@@ -17,6 +17,7 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { assertWithinAllowlist } = require('./lib/allowlist-ratchet.cjs'); const { assertWithinAllowlist } = require('./lib/allowlist-ratchet.cjs');
const { runMain } = require('./lib/cli-exit.cjs');
const ROOT = path.join(__dirname, '..'); const ROOT = path.join(__dirname, '..');
const PROD_DIRS = [ const PROD_DIRS = [
@@ -201,14 +202,14 @@ function run() {
if (jsonMode) { if (jsonMode) {
console.log(JSON.stringify({ ok: failures.length === 0, results, failures, hints: [] }, null, 2)); console.log(JSON.stringify({ ok: failures.length === 0, results, failures, hints: [] }, null, 2));
process.exit(failures.length > 0 ? 1 : 0); return failures.length > 0 ? 1 : 0;
} }
if (failures.length === 0) { if (failures.length === 0) {
const inAllowlist = results.filter(r => r.verdict === Verdict.OK_IN_ALLOWLIST).length; const inAllowlist = results.filter(r => r.verdict === Verdict.OK_IN_ALLOWLIST).length;
console.log(`ok lint-test-file-count: ${results.length} module(s) checked, 0 failures` + console.log(`ok lint-test-file-count: ${results.length} module(s) checked, 0 failures` +
(inAllowlist > 0 ? `, ${inAllowlist} allowlisted` : '')); (inAllowlist > 0 ? `, ${inAllowlist} allowlisted` : ''));
process.exit(0); return 0;
} }
process.stderr.write(`\nERROR lint-test-file-count: ${failures.length} module(s) exceed the test-file limit\n\n`); process.stderr.write(`\nERROR lint-test-file-count: ${failures.length} module(s) exceed the test-file limit\n\n`);
@@ -231,7 +232,7 @@ function run() {
} }
process.stderr.write('\nFix: consolidate test files per module (one primary + one integration).\n'); process.stderr.write('\nFix: consolidate test files per module (one primary + one integration).\n');
process.stderr.write('Or update scripts/lint-test-file-count.allowlist.json with PR justification.\n\n'); process.stderr.write('Or update scripts/lint-test-file-count.allowlist.json with PR justification.\n\n');
process.exit(1); return 1;
} }
module.exports = { module.exports = {
@@ -242,4 +243,4 @@ module.exports = {
_loadAllowlist: loadAllowlist, _loadAllowlist: loadAllowlist,
}; };
if (require.main === module) run(); if (require.main === module) runMain(run);

View File

@@ -32,6 +32,8 @@
const { execFileSync } = require('child_process'); const { execFileSync } = require('child_process');
const { readFileSync } = require('fs'); const { readFileSync } = require('fs');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
// ── Single source of truth: covered modules ─────────────────────────────────── // ── Single source of truth: covered modules ───────────────────────────────────
// Each entry: { cjs: '<built artifact>', tests: ['tests/...', ...] } // Each entry: { cjs: '<built artifact>', tests: ['tests/...', ...] }
// A module is "covered" iff its tests are wired into the Stryker command runner // A module is "covered" iff its tests are wired into the Stryker command runner
@@ -121,7 +123,7 @@ function parseArgs(argv) {
' --base <ref> Git ref to diff against (default: origin/${GITHUB_BASE_REF:-next})', ' --base <ref> Git ref to diff against (default: origin/${GITHUB_BASE_REF:-next})',
' --print Human-readable output instead of JSON', ' --print Human-readable output instead of JSON',
].join('\n')); ].join('\n'));
process.exit(0); throw new ExitError(0);
} else { } else {
throw new Error(`unknown argument: ${arg}`); throw new Error(`unknown argument: ${arg}`);
} }
@@ -211,9 +213,10 @@ function main() {
console.log(JSON.stringify(result, null, 2)); console.log(JSON.stringify(result, null, 2));
} }
} catch (err) { } catch (err) {
if (err instanceof ExitError) throw err;
console.error(`mutation-matrix: ${err.message}`); console.error(`mutation-matrix: ${err.message}`);
process.exit(2); throw new ExitError(2);
} }
} }
main(); runMain(main);

View File

@@ -4,6 +4,7 @@ const path = require('path');
const os = require('os'); const os = require('os');
const fs = require('fs'); const fs = require('fs');
const { execFileSync } = require('child_process'); const { execFileSync } = require('child_process');
const { runMain, ExitError } = require('../lib/cli-exit.cjs');
/** /**
* Classify a What's-Changed bullet line into 'Feature', 'Fix', or 'Enhancement'. * Classify a What's-Changed bullet line into 'Feature', 'Fix', or 'Enhancement'.
@@ -155,7 +156,7 @@ function formatReleaseNotes({ generatedBody, version, prerelease, packageName })
} }
// CLI entry point // CLI entry point
if (require.main === module) { function main() {
try { try {
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
@@ -248,9 +249,13 @@ if (require.main === module) {
process.stdout.write(formatted + '\n'); process.stdout.write(formatted + '\n');
} }
} catch (err) { } catch (err) {
process.stderr.write((err.message || String(err)) + '\n'); if (err instanceof ExitError) throw err;
process.exit(1); throw new ExitError(1, err && err.message ? err.message : String(err));
} }
} }
if (require.main === module) {
runMain(main);
}
module.exports = { formatReleaseNotes, classifyTitle }; module.exports = { formatReleaseNotes, classifyTitle };

View File

@@ -46,6 +46,7 @@ const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const { PACKAGE_NAME } = require('../gsd-core/bin/lib/package-identity.cjs'); const { PACKAGE_NAME } = require('../gsd-core/bin/lib/package-identity.cjs');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
// 120 s proved too tight on Windows GitHub-hosted runners: cold-cache // 120 s proved too tight on Windows GitHub-hosted runners: cold-cache
// `npm install -g` with a 1499-file tarball took ~120 s exactly, causing // `npm install -g` with a 1499-file tarball took ~120 s exactly, causing
// spawnSync to fire SIGTERM and return { status: null, stdout: '', stderr: '' } // spawnSync to fire SIGTERM and return { status: null, stdout: '', stderr: '' }
@@ -587,23 +588,24 @@ function cliMain() {
}; };
if (isJson) process.stdout.write(JSON.stringify(result) + '\n'); if (isJson) process.stdout.write(JSON.stringify(result) + '\n');
cleanup(packDir, installPrefix, fixtureDir); cleanup(packDir, installPrefix, fixtureDir);
process.exit(1); throw new ExitError(1);
} }
} }
} catch (err) { } catch (err) {
if (err instanceof ExitError) throw err;
const result = { const result = {
code: SMOKE.PACK_FAILED, code: SMOKE.PACK_FAILED,
details: { error: err.message, stderr: err.stderr }, details: { error: err.message, stderr: err.stderr },
}; };
if (isJson) process.stdout.write(JSON.stringify(result) + '\n'); if (isJson) process.stdout.write(JSON.stringify(result) + '\n');
cleanup(packDir, installPrefix, fixtureDir); cleanup(packDir, installPrefix, fixtureDir);
process.exit(1); throw new ExitError(1);
} }
const result = runSmoke({ tarballPath, installPrefix, expectedVersion, fixtureDir }); const result = runSmoke({ tarballPath, installPrefix, expectedVersion, fixtureDir });
if (isJson) process.stdout.write(JSON.stringify(result) + '\n'); if (isJson) process.stdout.write(JSON.stringify(result) + '\n');
cleanup(packDir, installPrefix, fixtureDir); cleanup(packDir, installPrefix, fixtureDir);
process.exit(result.code === SMOKE.OK ? 0 : 1); return result.code === SMOKE.OK ? 0 : 1;
} }
function cleanup(...dirs) { function cleanup(...dirs) {
@@ -623,5 +625,5 @@ function cleanup(...dirs) {
module.exports = { SMOKE, runSmoke, binInvocation }; module.exports = { SMOKE, runSmoke, binInvocation };
if (require.main === module) { if (require.main === module) {
cliMain(); runMain(cliMain);
} }

View File

@@ -2,5 +2,6 @@
'use strict'; 'use strict';
const { runAffectedTests } = require('./affected-tests-lib.cjs'); const { runAffectedTests } = require('./affected-tests-lib.cjs');
const { runMain } = require('./lib/cli-exit.cjs');
runAffectedTests(); runMain(runAffectedTests);

View File

@@ -1,6 +1,7 @@
'use strict'; 'use strict';
const { spawnSync } = require('child_process'); const { spawnSync } = require('child_process');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const CROSS_PLATFORM_TEST_REASON = Object.freeze({ const CROSS_PLATFORM_TEST_REASON = Object.freeze({
PASS: 'pass', PASS: 'pass',
@@ -50,14 +51,17 @@ function runCrossPlatformTests(options = {}, deps = {}) {
} }
if (require.main === module) { if (require.main === module) {
const result = runCrossPlatformTests(); function main() {
const line = `[cross-platform-tests] reason=${result.reason} exit=${result.exitCode}`; const result = runCrossPlatformTests();
if (result.ok) { const line = `[cross-platform-tests] reason=${result.reason} exit=${result.exitCode}`;
process.stdout.write(`${line}\n`); if (result.ok) {
process.exit(0); process.stdout.write(`${line}\n`);
return 0;
}
process.stderr.write(`${line}\n`);
throw new ExitError(result.exitCode);
} }
process.stderr.write(`${line}\n`); runMain(main);
process.exit(result.exitCode);
} }
module.exports = { CROSS_PLATFORM_TEST_REASON, runCrossPlatformTests }; module.exports = { CROSS_PLATFORM_TEST_REASON, runCrossPlatformTests };

View File

@@ -23,6 +23,7 @@
const { readdirSync, existsSync } = require('fs'); const { readdirSync, existsSync } = require('fs');
const { join } = require('path'); const { join } = require('path');
const { execFileSync } = require('child_process'); const { execFileSync } = require('child_process');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const SUITES = ['all', 'unit', 'integration', 'install', 'security', 'slow']; const SUITES = ['all', 'unit', 'integration', 'install', 'security', 'slow'];
@@ -189,13 +190,13 @@ function main() {
if (parsed.error) { if (parsed.error) {
console.error(`run-tests: ${parsed.error}`); console.error(`run-tests: ${parsed.error}`);
console.error(`Valid suites: ${SUITES.join(', ')}`); console.error(`Valid suites: ${SUITES.join(', ')}`);
process.exit(2); throw new ExitError(2);
} }
const suite = parsed.suite; const suite = parsed.suite;
if (suite !== null && !SUITES.includes(suite)) { if (suite !== null && !SUITES.includes(suite)) {
console.error(`run-tests: unknown suite "${suite}"`); console.error(`run-tests: unknown suite "${suite}"`);
console.error(`Valid suites: ${SUITES.join(', ')}`); console.error(`Valid suites: ${SUITES.join(', ')}`);
process.exit(2); throw new ExitError(2);
} }
const testDir = process.env.GSD_TEST_DIR const testDir = process.env.GSD_TEST_DIR
@@ -208,7 +209,7 @@ function main() {
if (allFiles.length === 0) { if (allFiles.length === 0) {
console.error(`No test files found in ${testDir}`); console.error(`No test files found in ${testDir}`);
process.exit(1); throw new ExitError(1);
} }
let selectedNames; let selectedNames;
@@ -216,7 +217,7 @@ function main() {
const explicit = selectExplicitFiles(allFiles, parsed.files, parsed.filesFrom); const explicit = selectExplicitFiles(allFiles, parsed.files, parsed.filesFrom);
if (explicit.error) { if (explicit.error) {
console.error(`run-tests: ${explicit.error}`); console.error(`run-tests: ${explicit.error}`);
process.exit(2); throw new ExitError(2);
} }
selectedNames = explicit.files; selectedNames = explicit.files;
} else { } else {
@@ -229,7 +230,7 @@ function main() {
// adversarial tests land) don't gate CI. CI consumers wanting strictness // adversarial tests land) don't gate CI. CI consumers wanting strictness
// can grep stderr for "no tests in suite". // can grep stderr for "no tests in suite".
console.error(`run-tests: no tests in suite "${suite || 'all'}"`); console.error(`run-tests: no tests in suite "${suite || 'all'}"`);
process.exit(0); return 0;
} }
// Build the gitignored bin/lib artifact if absent, before any test requires it. // Build the gitignored bin/lib artifact if absent, before any test requires it.
@@ -304,11 +305,11 @@ function main() {
if (firstFailureExit === 0) firstFailureExit = code; if (firstFailureExit === 0) firstFailureExit = code;
} }
} }
if (firstFailureExit !== 0) process.exit(firstFailureExit); if (firstFailureExit !== 0) return firstFailureExit;
} }
if (require.main === module) { if (require.main === module) {
main(); runMain(main);
} }
module.exports = { suiteOf }; module.exports = { suiteOf };

View File

@@ -8,6 +8,7 @@
*/ */
const cp = require('node:child_process'); const cp = require('node:child_process');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
// ---- Constants --------------------------------------------------------------- // ---- Constants ---------------------------------------------------------------
@@ -137,67 +138,57 @@ function parseArgs(argv) {
' --json emit structured JSON output\n' + ' --json emit structured JSON output\n' +
' --help, -h show this help\n' ' --help, -h show this help\n'
); );
process.exit(0); throw new ExitError(0);
} else if (arg === '--package') { } else if (arg === '--package') {
const val = args.shift(); const val = args.shift();
if (!val || val.startsWith('-')) { if (!val || val.startsWith('-')) {
process.stderr.write('error: --package requires a value\n'); throw new ExitError(2, 'error: --package requires a value');
process.exit(2);
} }
opts.pkg = val; opts.pkg = val;
} else if (arg === '--version') { } else if (arg === '--version') {
const val = args.shift(); const val = args.shift();
if (!val || val.startsWith('-')) { if (!val || val.startsWith('-')) {
process.stderr.write('error: --version requires a value\n'); throw new ExitError(2, 'error: --version requires a value');
process.exit(2);
} }
opts.version = val; opts.version = val;
} else if (arg === '--dist-tag') { } else if (arg === '--dist-tag') {
const val = args.shift(); const val = args.shift();
if (!val || val.startsWith('-')) { if (!val || val.startsWith('-')) {
process.stderr.write('error: --dist-tag requires a value\n'); throw new ExitError(2, 'error: --dist-tag requires a value');
process.exit(2);
} }
opts.distTag = val; opts.distTag = val;
} else if (arg === '--max-attempts') { } else if (arg === '--max-attempts') {
const val = args.shift(); const val = args.shift();
if (!val || val.startsWith('-')) { if (!val || val.startsWith('-')) {
process.stderr.write('error: --max-attempts requires a value\n'); throw new ExitError(2, 'error: --max-attempts requires a value');
process.exit(2);
} }
const n = parseInt(val, 10); const n = parseInt(val, 10);
if (isNaN(n) || n < 1) { if (isNaN(n) || n < 1) {
process.stderr.write('error: --max-attempts must be a positive integer\n'); throw new ExitError(2, 'error: --max-attempts must be a positive integer');
process.exit(2);
} }
opts.maxAttempts = n; opts.maxAttempts = n;
} else if (arg === '--interval-ms') { } else if (arg === '--interval-ms') {
const val = args.shift(); const val = args.shift();
if (!val || val.startsWith('-')) { if (!val || val.startsWith('-')) {
process.stderr.write('error: --interval-ms requires a value\n'); throw new ExitError(2, 'error: --interval-ms requires a value');
process.exit(2);
} }
const n = parseInt(val, 10); const n = parseInt(val, 10);
if (isNaN(n) || n < 0) { if (isNaN(n) || n < 0) {
process.stderr.write('error: --interval-ms must be a non-negative integer\n'); throw new ExitError(2, 'error: --interval-ms must be a non-negative integer');
process.exit(2);
} }
opts.intervalMs = n; opts.intervalMs = n;
} else if (arg === '--json') { } else if (arg === '--json') {
opts.json = true; opts.json = true;
} else { } else {
process.stderr.write(`unknown argument: ${arg}\n`); throw new ExitError(2, `unknown argument: ${arg}`);
process.exit(2);
} }
} }
if (!opts.pkg) { if (!opts.pkg) {
process.stderr.write('error: --package is required\n'); throw new ExitError(2, 'error: --package is required');
process.exit(2);
} }
if (!opts.version) { if (!opts.version) {
process.stderr.write('error: --version is required\n'); throw new ExitError(2, 'error: --version is required');
process.exit(2);
} }
return opts; return opts;
@@ -237,16 +228,13 @@ async function main() {
} }
} }
process.exit(result.ok ? 0 : 1); return result.ok ? 0 : 1;
} }
// ---- Guard ------------------------------------------------------------------- // ---- Guard -------------------------------------------------------------------
if (require.main === module) { if (require.main === module) {
main().catch((err) => { runMain(main);
process.stderr.write(String((err && err.stack) || err) + '\n');
process.exit(1);
});
} }
module.exports = { verifyPublish, parseArgs, REASON, defaultFetchVersion, defaultFetchDistTag }; module.exports = { verifyPublish, parseArgs, REASON, defaultFetchVersion, defaultFetchDistTag };

161
tests/cli-exit.test.cjs Normal file
View File

@@ -0,0 +1,161 @@
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const { ExitError, runMain } = require('../scripts/lib/cli-exit.cjs');
/** Settle the runMain promise chain before asserting. */
async function settle() {
await new Promise((r) => setImmediate(r));
}
describe('ExitError', () => {
test('default code is 1', () => {
const err = new ExitError();
assert.equal(err.code, 1);
});
test('name is ExitError', () => {
const err = new ExitError();
assert.equal(err.name, 'ExitError');
});
test('instanceof Error', () => {
assert.ok(new ExitError() instanceof Error);
});
test('hasUserMessage is false when no message passed', () => {
const err = new ExitError(1);
assert.equal(err.hasUserMessage, false);
});
test('hasUserMessage is true when message passed', () => {
const err = new ExitError(1, 'something went wrong');
assert.equal(err.hasUserMessage, true);
});
test('custom code is preserved', () => {
const err = new ExitError(42, 'boom');
assert.equal(err.code, 42);
});
test('message is set to user message when provided', () => {
const err = new ExitError(2, 'user msg');
assert.equal(err.message, 'user msg');
});
test('message is synthetic when no message provided', () => {
const err = new ExitError(3);
assert.equal(err.message, 'process exit 3');
});
});
describe('runMain', () => {
test('main returns a number sets process.exitCode', async () => {
const saved = process.exitCode;
try {
runMain(() => 42);
await settle();
assert.equal(process.exitCode, 42);
} finally {
process.exitCode = saved || 0;
}
});
test('main returns undefined leaves process.exitCode unchanged', async () => {
const saved = process.exitCode;
// Set a known value before calling
process.exitCode = 0;
try {
runMain(() => undefined);
await settle();
assert.equal(process.exitCode, 0);
} finally {
process.exitCode = saved || 0;
}
});
test('main throws ExitError sets process.exitCode to err.code', async () => {
const saved = process.exitCode;
try {
runMain(() => { throw new ExitError(2); });
await settle();
assert.equal(process.exitCode, 2);
} finally {
process.exitCode = saved || 0;
}
});
test('main rejects async ExitError(0) sets process.exitCode to 0', async () => {
const saved = process.exitCode;
try {
runMain(async () => { throw new ExitError(0); });
await settle();
assert.equal(process.exitCode, 0);
} finally {
process.exitCode = saved !== undefined ? saved : 0;
}
});
test('main throws generic Error sets process.exitCode to 1 and writes stderr', async () => {
const saved = process.exitCode;
const stderrChunks = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk, ...args) => {
stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString());
return origWrite(chunk, ...args);
};
try {
runMain(() => { throw new Error('kaboom'); });
await settle();
assert.equal(process.exitCode, 1);
const combined = stderrChunks.join('');
assert.ok(combined.includes('kaboom'), `expected "kaboom" in stderr: ${combined}`);
} finally {
process.stderr.write = origWrite;
process.exitCode = saved || 0;
}
});
test('ExitError with hasUserMessage and non-zero code writes to stderr', async () => {
const saved = process.exitCode;
const stderrChunks = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk, ...args) => {
stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString());
return origWrite(chunk, ...args);
};
try {
runMain(() => { throw new ExitError(1, 'user-visible error'); });
await settle();
assert.equal(process.exitCode, 1);
const combined = stderrChunks.join('');
assert.ok(combined.includes('user-visible error'), `expected message in stderr: ${combined}`);
} finally {
process.stderr.write = origWrite;
process.exitCode = saved || 0;
}
});
test('ExitError with hasUserMessage and code 0 does NOT write to stderr', async () => {
const saved = process.exitCode;
const stderrChunks = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = (chunk, ...args) => {
stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString());
return origWrite(chunk, ...args);
};
try {
runMain(() => { throw new ExitError(0, 'silent success'); });
await settle();
assert.equal(process.exitCode, 0);
const combined = stderrChunks.join('');
assert.equal(combined.includes('silent success'), false,
`did not expect message in stderr: ${combined}`);
} finally {
process.stderr.write = origWrite;
process.exitCode = saved !== undefined ? saved : 0;
}
});
});