diff --git a/scripts/affected-tests-lib.cjs b/scripts/affected-tests-lib.cjs index c0ba24d5b..5d4c39060 100644 --- a/scripts/affected-tests-lib.cjs +++ b/scripts/affected-tests-lib.cjs @@ -4,6 +4,7 @@ const { execFileSync } = require('node:child_process'); const { readdirSync, readFileSync, existsSync } = require('node:fs'); const path = require('node:path'); +const { ExitError } = require('./lib/cli-exit.cjs'); const { suiteOf } = require('./run-tests.cjs'); const CRITICAL_PATHS = [ @@ -409,7 +410,7 @@ function runNodeTestFiles(repoRoot, files) { if (firstFailure === 0) firstFailure = code; } } - if (firstFailure !== 0) process.exit(firstFailure); + if (firstFailure !== 0) throw new ExitError(firstFailure); } function runSuite(repoRoot, suite) { diff --git a/scripts/changeset/cli.cjs b/scripts/changeset/cli.cjs index ee8749ccf..283120679 100755 --- a/scripts/changeset/cli.cjs +++ b/scripts/changeset/cli.cjs @@ -17,6 +17,7 @@ const fs = require('node:fs'); const path = require('node:path'); +const { ExitError, runMain } = require('../lib/cli-exit.cjs'); const { parseFragment } = require('./parse.cjs'); const { renderChangelog } = require('./render.cjs'); const { serializeChangelog, parseChangelog } = require('./serialize.cjs'); @@ -475,29 +476,26 @@ function main() { if (!parsed.ok) { process.stderr.write(`${parsed.error}\n`); process.stderr.write(usage()); - process.exit(2); + throw new ExitError(2); } const { opts } = parsed; if (opts.cmd !== 'render' && opts.cmd !== 'github-release-notes' && opts.cmd !== 'extract' && opts.cmd !== 'verify') { process.stderr.write(usage()); - process.exit(1); + throw new ExitError(1); } if (opts.cmd === 'render' && (!opts.version || !opts.date)) { - process.stderr.write('--version and --date are required for render\n'); - process.exit(2); + throw new ExitError(2, '--version and --date are required for render'); } if (opts.cmd === 'github-release-notes' && (!opts.fromRef || !opts.toRef)) { - process.stderr.write('--from and --to are required for github-release-notes\n'); - process.exit(2); + throw new ExitError(2, '--from and --to are required for github-release-notes'); } if (opts.cmd === 'extract' && (!opts.fromRef || !opts.toRef)) { process.stderr.write('--from and --to are required for extract\n'); process.stderr.write(usage()); - process.exit(1); + throw new ExitError(1); } if (opts.cmd === 'verify' && !opts.version) { - process.stderr.write('--version is required for verify\n'); - process.exit(2); + throw new ExitError(2, '--version is required for verify'); } if (opts.cmd === 'extract') { @@ -509,7 +507,7 @@ function main() { } else if (exitCode === 2) { process.stderr.write(`no releases found in range (from=${report.from}, to=${report.to})\n`); } - process.exit(exitCode); + return exitCode; } if (opts.cmd === 'verify') { @@ -521,7 +519,7 @@ function main() { } else { process.stderr.write(report.error + '\n'); } - process.exit(exitCode); + return exitCode; } 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 }; diff --git a/scripts/changeset/lint.cjs b/scripts/changeset/lint.cjs index 6b9a2aa35..4704147dd 100755 --- a/scripts/changeset/lint.cjs +++ b/scripts/changeset/lint.cjs @@ -60,6 +60,8 @@ function evaluateLint({ changedFiles, labels }) { return { ok: false, reason: LINT_REASON.FAIL_MISSING_FRAGMENT }; } +const { ExitError, runMain } = require('../lib/cli-exit.cjs'); + function main() { const fs = require('node:fs'); const cp = require('node:child_process'); @@ -87,8 +89,7 @@ function main() { ); changedFiles = out.split('\n').filter(Boolean); } catch (e) { - process.stderr.write(`could not compute diff: ${e.message}\n`); - process.exit(2); + throw new ExitError(2, `could not compute diff: ${e.message}`); } 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(`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 }; diff --git a/scripts/changeset/new.cjs b/scripts/changeset/new.cjs index 3df75d023..ea88a534a 100755 --- a/scripts/changeset/new.cjs +++ b/scripts/changeset/new.cjs @@ -13,6 +13,7 @@ const fs = require('node:fs'); const path = require('node:path'); +const { ExitError, runMain } = require('../lib/cli-exit.cjs'); // Small word lists — keep the function simple and dependency-free. // Together this gives ~40 * 40 * 40 = 64,000 distinct names. The lint @@ -121,17 +122,16 @@ function main() { if (!parsed.ok) { process.stderr.write(`${parsed.error}\n`); process.stderr.write('usage: changeset/new.cjs --type --pr NNNN --body "..."\n'); - process.exit(2); + throw new ExitError(2); } const { opts } = parsed; if (!opts.type || !opts.pr || !opts.body) { - process.stderr.write('usage: changeset/new.cjs --type --pr NNNN --body "..."\n'); - process.exit(2); + throw new ExitError(2, 'usage: changeset/new.cjs --type --pr NNNN --body "..."'); } const file = scaffoldFragment(opts); 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 }; diff --git a/scripts/check-alias-drift.cjs b/scripts/check-alias-drift.cjs index cdc82ceb1..d5c3e2784 100644 --- a/scripts/check-alias-drift.cjs +++ b/scripts/check-alias-drift.cjs @@ -3,12 +3,14 @@ const fs = require('node:fs'); const path = require('node:path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + const ROOT = path.resolve(__dirname, '..'); const aliasesPath = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'command-aliases.cjs'); function fail(message) { process.stderr.write(`${message}\n`); - process.exit(1); + throw new ExitError(1); } function ensureArray(value, name) { @@ -27,82 +29,86 @@ function assertNoDuplicates(values, label) { } } -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}`, - ); +function main() { + if (!fs.existsSync(aliasesPath)) { + fail(`check:alias-drift: missing ${path.relative(ROOT, aliasesPath)}`); } - for (let i = 0; i < derivedSubcommands.length; i++) { - if (derivedSubcommands[i] !== subcommands[i]) { + 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}[${i}] = "${subcommands[i]}" ` + - `does not match ${family.commandAliases}[${i}].subcommand = "${derivedSubcommands[i]}"`, + `check:alias-drift: ${family.subcommands} length ${subcommands.length} does not match ` + + `${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'); - 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'); } -process.stdout.write('check:alias-drift ok\n'); +runMain(main); diff --git a/scripts/check-env.cjs b/scripts/check-env.cjs index 2ce274764..704ed1908 100644 --- a/scripts/check-env.cjs +++ b/scripts/check-env.cjs @@ -29,65 +29,16 @@ const fs = require('fs'); const path = require('path'); 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 // shell:true requires the exact filename including extension. 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 // --------------------------------------------------------------------------- -/** @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`? * Constraint forms: >=X.Y.Z, >X.Y.Z, <=X.Y.Z, } */ + 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"); } - } 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 { - 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 -// --------------------------------------------------------------------------- -const overallPass = checks.every(c => c.status !== 'fail'); + // --------------------------------------------------------------------------- + // 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'); -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`); + 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'; + } } - process.stdout.write('\n'); - if (overallPass) { - process.stdout.write('Result: ALL CHECKS PASSED\n'); + + 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 { - 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); diff --git a/scripts/check-npm-integrity.cjs b/scripts/check-npm-integrity.cjs index 8e08bd991..651c37429 100644 --- a/scripts/check-npm-integrity.cjs +++ b/scripts/check-npm-integrity.cjs @@ -16,62 +16,7 @@ const fs = require('fs'); const path = require('path'); -// ---- Argument parsing ------------------------------------------------------- - -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; - } -} +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); // ---- Minimal semver satisfies ----------------------------------------------- @@ -132,78 +77,139 @@ function satisfies(installed, range) { return installed === range; } -// ---- Walk packages map ------------------------------------------------------ +function main() { + // ---- Argument parsing ------------------------------------------------------- -const invalids = []; -const missings = []; -const extraneousFound = []; + let ignoreExtraneous = false; -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; + 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' + ); + return 0; + } else { + process.stderr.write(`ERROR: Unknown argument: ${arg}\n`); + throw new ExitError(2); + } } - if (!declaredRanges[pkgName]) continue; + // ---- Locate lockfile -------------------------------------------------------- - if (!satisfies(installedVersion, declaredRanges[pkgName])) { - invalids.push({ name: pkgName, version: installedVersion, declared: declaredRanges[pkgName] }); + 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`); + throw new ExitError(2); } -} -for (const name of Object.keys(declaredRanges)) { - if (!packages[`node_modules/${name}`]) { - missings.push({ name, required: declaredRanges[name] }); + // ---- 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`); + throw new ExitError(2); } -} -// ---- 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'); - 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}`); + 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' + ); + throw new ExitError(2); } - lines.push(''); -} -if (failMissing) { - lines.push(' MISSING (declared but absent from lockfile packages map):'); - for (const { name, required } of missings) { - lines.push(` ${name}@${required}`); + 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; + } } - 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}`); + // ---- Walk packages map ------------------------------------------------------ + + const invalids = []; + 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'); -process.stderr.write(lines.join('\n') + '\n'); -process.exit(1); +runMain(main); diff --git a/scripts/ci-guard-runner.cjs b/scripts/ci-guard-runner.cjs index 2ca864dae..7a1c8c56a 100644 --- a/scripts/ci-guard-runner.cjs +++ b/scripts/ci-guard-runner.cjs @@ -6,11 +6,17 @@ // Exit 0 = github-hosted runner confirmed. // 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 || ''; -if (env !== 'github-hosted') { - process.stderr.write( - `::error::Expected github-hosted runner. RUNNER_ENVIRONMENT=${env || 'unset'}\n` - ); - process.exit(1); +function main() { + if (env !== 'github-hosted') { + throw new ExitError( + 1, + `::error::Expected github-hosted runner. RUNNER_ENVIRONMENT=${env || 'unset'}` + ); + } } + +runMain(main); diff --git a/scripts/ci-prepare-test-scope.cjs b/scripts/ci-prepare-test-scope.cjs index 1759cdbe9..66309db1a 100644 --- a/scripts/ci-prepare-test-scope.cjs +++ b/scripts/ci-prepare-test-scope.cjs @@ -14,33 +14,38 @@ const fs = require('fs'); const path = require('path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + const scope = process.env.TEST_SCOPE || ''; const targeted = process.env.TARGETED_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'; -let selected; -if (scope === 'windows') { - selected = windows; -} else if (scope === 'targeted') { - selected = targeted; -} else { - process.stderr.write(`::error::Unknown test scope: ${scope}\n`); - process.exit(1); +function main() { + let selected; + if (scope === 'windows') { + selected = windows; + } else if (scope === 'targeted') { + selected = targeted; + } else { + 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. -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); +runMain(main); diff --git a/scripts/ci-rebase-check.cjs b/scripts/ci-rebase-check.cjs index 6cfcbb04c..d7a83f344 100644 --- a/scripts/ci-rebase-check.cjs +++ b/scripts/ci-rebase-check.cjs @@ -13,6 +13,8 @@ const { execFileSync } = require('child_process'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + function run(cmd, args, opts) { try { execFileSync(cmd, args, { stdio: 'inherit', ...opts }); @@ -26,8 +28,7 @@ function runOrThrow(cmd, args, label) { try { execFileSync(cmd, args, { stdio: 'inherit' }); } catch (e) { - process.stderr.write(`::error::${label} failed\n`); - process.exit(1); + throw new ExitError(1, `::error::${label} failed`); } } @@ -35,48 +36,51 @@ const token = process.env.GITHUB_TOKEN || ''; const baseBranch = process.env.GITHUB_BASE_REF || 'main'; const repo = process.env.GITHUB_REPOSITORY || ''; -// Configure git identity (needed for merge commit). -runOrThrow('git', ['config', 'user.email', 'ci@gsd-redux'], 'git config user.email'); -runOrThrow('git', ['config', 'user.name', 'CI Rebase Check'], 'git config user.name'); +function main() { + // Configure git identity (needed for merge commit). + 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. -if (token && repo) { - runOrThrow( - 'git', - ['remote', 'set-url', 'origin', `https://x-access-token:${token}@github.com/${repo}.git`], - '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; + // Set authenticated remote URL. + if (token && repo) { + runOrThrow( + 'git', + ['remote', 'set-url', 'origin', `https://x-access-token:${token}@github.com/${repo}.git`], + 'git remote set-url' + ); } - if (attempt === 3) { - process.stderr.write(`::error::git fetch origin ${baseBranch} failed after 3 attempts.\n`); - process.exit(1); + + // 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) { + 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. -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); -} +runMain(main); diff --git a/scripts/ci-test-scope.cjs b/scripts/ci-test-scope.cjs index f81c384a2..977c9d5c4 100644 --- a/scripts/ci-test-scope.cjs +++ b/scripts/ci-test-scope.cjs @@ -4,6 +4,8 @@ const { execFileSync } = require('child_process'); const { existsSync, readdirSync, appendFileSync } = require('fs'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + const RULES = [ { name: 'workflow automation', @@ -197,7 +199,7 @@ function parseArgs(argv) { if (!out.files) throw new Error('--files requires a value'); } else if (arg === '--help' || arg === '-h') { console.log(usage()); - process.exit(0); + throw new ExitError(0); } else { throw new Error(`unknown argument: ${arg}`); } @@ -317,10 +319,11 @@ function main() { writeOutputs(result); console.log(JSON.stringify(result, null, 2)); } catch (error) { + if (error instanceof ExitError) throw error; console.error(`ci-test-scope: ${error.message}`); console.error(usage()); - process.exit(2); + throw new ExitError(2); } } -main(); +runMain(main); diff --git a/scripts/diff-touches-shipped-paths.cjs b/scripts/diff-touches-shipped-paths.cjs index 5b51b49d9..da0943d60 100644 --- a/scripts/diff-touches-shipped-paths.cjs +++ b/scripts/diff-touches-shipped-paths.cjs @@ -43,6 +43,7 @@ const fs = require('node:fs'); const path = require('node:path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const EXIT_SHIPPED = 0; const EXIT_NOT_SHIPPED = 1; @@ -88,60 +89,67 @@ function isPushBlocking(diffPath) { return diffPath.replace(/\\/g, '/').startsWith('.github/workflows/'); } -function fail(message, err) { - process.stderr.write(`diff-touches-shipped-paths: ${message}\n`); - if (err && err.stack) process.stderr.write(`${err.stack}\n`); - process.exit(EXIT_ERROR); +function readStdin() { + return new Promise((resolve, reject) => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { data += chunk; }); + process.stdin.on('end', () => resolve(data)); + process.stdin.on('error', reject); + }); } -function main() { - // Surface ANY uncaught failure as exit 2 (classifier error) rather - // than letting Node's default-1 shadow the legitimate - // "no shipped paths" result. Bug #2983. - process.on('uncaughtException', (err) => fail('uncaught exception', err)); - process.on('unhandledRejection', (err) => fail('unhandled rejection', err)); - - let shipPrefixes; +async function main() { try { - const pkgPath = path.resolve(process.cwd(), 'package.json'); - shipPrefixes = loadShipPrefixes(pkgPath); - } catch (err) { - return fail(`failed to read package.json from ${process.cwd()}`, err); - } - - let buf = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('error', (err) => fail('stdin read error', err)); - process.stdin.on('data', (chunk) => { - buf += chunk; - }); - process.stdin.on('end', () => { + let shipPrefixes; try { - const paths = buf.split('\n').map((s) => s.trim()).filter(Boolean); - // #2980 still wins over #3621: any commit touching .github/workflows/* - // is unpickable regardless of other content because the push step - // fails on workflow scope rejection. Check this first. - if (paths.some(isPushBlocking)) { - process.exit(EXIT_NOT_SHIPPED); - } - if (paths.some((p) => isShipped(p, shipPrefixes))) { - process.exit(EXIT_SHIPPED); - } - // #3621: a commit whose only relevant paths are CI-gating tests is - // still pickable — it can change whether the hotfix CI passes even - // though it doesn't change what the npm tarball ships. - if (paths.some(isCiGating)) { - process.exit(EXIT_SHIPPED); - } - process.exit(EXIT_NOT_SHIPPED); + const pkgPath = path.resolve(process.cwd(), 'package.json'); + shipPrefixes = loadShipPrefixes(pkgPath); } catch (err) { - fail('classification failed', err); + process.stderr.write(`diff-touches-shipped-paths: failed to read package.json from ${process.cwd()}\n`); + if (err && err.stack) process.stderr.write(`${err.stack}\n`); + throw new ExitError(EXIT_ERROR); } - }); + + let buf; + try { + buf = await readStdin(); + } catch (err) { + process.stderr.write(`diff-touches-shipped-paths: stdin read error\n`); + if (err && err.stack) process.stderr.write(`${err.stack}\n`); + throw new ExitError(EXIT_ERROR); + } + + const paths = buf.split('\n').map((s) => s.trim()).filter(Boolean); + // #2980 still wins over #3621: any commit touching .github/workflows/* + // is unpickable regardless of other content because the push step + // fails on workflow scope rejection. Check this first. + if (paths.some(isPushBlocking)) { + return EXIT_NOT_SHIPPED; + } + if (paths.some((p) => isShipped(p, shipPrefixes))) { + return EXIT_SHIPPED; + } + // #3621: a commit whose only relevant paths are CI-gating tests is + // still pickable — it can change whether the hotfix CI passes even + // though it doesn't change what the npm tarball ships. + if (paths.some(isCiGating)) { + return EXIT_SHIPPED; + } + return EXIT_NOT_SHIPPED; + } catch (e) { + // Re-throw ExitError unchanged; map any unexpected error to EXIT_ERROR=2 + // (Node's default uncaught-exception code is 1, which is indistinguishable + // from the legitimate EXIT_NOT_SHIPPED result — bug #2983). + if (e instanceof ExitError) throw e; + process.stderr.write(`diff-touches-shipped-paths: classification failed\n`); + if (e && e.stack) process.stderr.write(`${e.stack}\n`); + throw new ExitError(EXIT_ERROR); + } } if (require.main === module) { - main(); + runMain(main); } module.exports = { loadShipPrefixes, isShipped, isCiGating, isPushBlocking, EXIT_SHIPPED, EXIT_NOT_SHIPPED, EXIT_ERROR }; diff --git a/scripts/gen-inventory-manifest.cjs b/scripts/gen-inventory-manifest.cjs index dcc81683c..40a859c7c 100644 --- a/scripts/gen-inventory-manifest.cjs +++ b/scripts/gen-inventory-manifest.cjs @@ -16,6 +16,8 @@ const fs = require('node:fs'); const path = require('node:path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + const ROOT = path.resolve(__dirname, '..'); const MANIFEST_PATH = path.join(ROOT, 'docs', 'INVENTORY-MANIFEST.json'); @@ -70,40 +72,44 @@ function buildManifest() { return manifest; } -const [, , flag] = process.argv; +function main() { + const [, , flag] = process.argv; -if (flag === '--check') { - const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); - const live = buildManifest(); - // Strip the generated date for comparison - delete committed.generated; - delete live.generated; - const committedStr = JSON.stringify(committed, null, 2); - const liveStr = JSON.stringify(live, null, 2); - if (committedStr !== liveStr) { - process.stderr.write( - 'docs/INVENTORY-MANIFEST.json is stale. Run:\n' + - ' node scripts/gen-inventory-manifest.cjs --write\n' + - 'then add a matching row in docs/INVENTORY.md for each new entry.\n\n', - ); - // Show diff-friendly output - for (const family of Object.keys(live.families)) { - const liveSet = new Set(live.families[family]); - const committedSet = new Set((committed.families || {})[family] || []); - for (const name of liveSet) { - if (!committedSet.has(name)) process.stderr.write(' + ' + family + '/' + name + '\n'); - } - for (const name of committedSet) { - if (!liveSet.has(name)) process.stderr.write(' - ' + family + '/' + name + '\n'); + if (flag === '--check') { + const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); + const live = buildManifest(); + // Strip the generated date for comparison + delete committed.generated; + delete live.generated; + const committedStr = JSON.stringify(committed, null, 2); + const liveStr = JSON.stringify(live, null, 2); + if (committedStr !== liveStr) { + process.stderr.write( + 'docs/INVENTORY-MANIFEST.json is stale. Run:\n' + + ' node scripts/gen-inventory-manifest.cjs --write\n' + + 'then add a matching row in docs/INVENTORY.md for each new entry.\n\n', + ); + // Show diff-friendly output + for (const family of Object.keys(live.families)) { + const liveSet = new Set(live.families[family]); + const committedSet = new Set((committed.families || {})[family] || []); + for (const name of liveSet) { + if (!committedSet.has(name)) process.stderr.write(' + ' + family + '/' + name + '\n'); + } + for (const name of committedSet) { + 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); diff --git a/scripts/gen-research-agents.cjs b/scripts/gen-research-agents.cjs index 6ff6de3ea..98a4aa10d 100644 --- a/scripts/gen-research-agents.cjs +++ b/scripts/gen-research-agents.cjs @@ -24,6 +24,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { PROFILES } = require('./research-profiles.cjs'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const ROOT = path.resolve(__dirname, '..'); const AGENTS_DIR = path.join(ROOT, 'agents'); @@ -225,8 +226,7 @@ function runWrite() { for (const profile of PROFILES) { const agentPath = path.join(AGENTS_DIR, profile.name + '.md'); if (!fs.existsSync(agentPath)) { - process.stderr.write('ERROR: agent file not found: ' + agentPath + '\n'); - process.exit(1); + throw new ExitError(1, 'ERROR: agent file not found: ' + agentPath); } writeAgent(profile); process.stdout.write(' wrote ' + profile.name + '.md\n'); @@ -241,7 +241,7 @@ module.exports = { PROFILES, checkAgent, runCheck, parseAgentFile }; // ─── CLI entry point ────────────────────────────────────────────────────────── -if (require.main === module) { +function main() { const flag = process.argv[2] || '--check'; if (flag === '--write') { @@ -251,7 +251,7 @@ if (require.main === module) { const ok = runCheck(); if (!ok) { 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'); } else if (flag === '--check') { @@ -263,12 +263,14 @@ if (require.main === module) { '\nTo regenerate frontmatter from profiles:\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'); } else { - process.stderr.write('Unknown flag: ' + flag + '\n'); - process.stderr.write('Usage: node scripts/gen-research-agents.cjs [--check|--write]\n'); - process.exit(1); + throw new ExitError(1, 'Unknown flag: ' + flag + '\nUsage: node scripts/gen-research-agents.cjs [--check|--write]'); } } + +if (require.main === module) { + runMain(main); +} diff --git a/scripts/lib/cli-exit.cjs b/scripts/lib/cli-exit.cjs new file mode 100644 index 000000000..709e8dc23 --- /dev/null +++ b/scripts/lib/cli-exit.cjs @@ -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)} 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 }; diff --git a/scripts/lint-command-contract.cjs b/scripts/lint-command-contract.cjs index daa515ea8..13c2a52f4 100644 --- a/scripts/lint-command-contract.cjs +++ b/scripts/lint-command-contract.cjs @@ -28,6 +28,8 @@ const { executionContextRefs: extractExecutionContextRefs, } = require('./command-contract-helpers.cjs'); +const { runMain } = require('./lib/cli-exit.cjs'); + // ─── check one file ─────────────────────────────────────────────────────────── function check(filePath) { @@ -79,30 +81,34 @@ function check(filePath) { // ─── run ───────────────────────────────────────────────────────────────────── -const commandFiles = fs - .readdirSync(COMMANDS_DIR) - .filter(f => f.endsWith('.md')) - .map(f => path.join(COMMANDS_DIR, f)); +function main() { + const commandFiles = fs + .readdirSync(COMMANDS_DIR) + .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) { - console.log( - `ok lint-command-contract: ${commandFiles.length} command files checked, 0 violations`, - ); - process.exit(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`); + if (results.length === 0) { + console.log( + `ok lint-command-contract: ${commandFiles.length} command files checked, 0 violations`, + ); + return 0; } - 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); diff --git a/scripts/lint-descriptions.cjs b/scripts/lint-descriptions.cjs index 893006b3a..0adad0c6a 100644 --- a/scripts/lint-descriptions.cjs +++ b/scripts/lint-descriptions.cjs @@ -15,6 +15,7 @@ const fs = require('fs'); const path = require('path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const MAX_LENGTH = 100; const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); @@ -46,38 +47,41 @@ function getFiles() { .map(f => path.join(COMMANDS_DIR, f)); } -const files = getFiles(); -const violations = []; +function main() { + const files = getFiles(); + const violations = []; -for (const filePath of files) { - let content; - try { - content = fs.readFileSync(filePath, 'utf-8'); - } catch (err) { - process.stderr.write(`ERROR: Cannot read file: ${filePath}\n ${err.message}\n`); - process.exit(1); + for (const filePath of files) { + let content; + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch (err) { + throw new ExitError(1, `ERROR: Cannot read file: ${filePath}\n ${err.message}`); + } + + 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 (description === null) continue; - - if (description.length > MAX_LENGTH) { - violations.push({ filePath, length: description.length, description }); + if (violations.length === 0) { + const checked = files.length; + process.stdout.write(`ok lint-descriptions: ${checked} file(s) checked, 0 violations\n`); + return 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`); + return 1; } -if (violations.length === 0) { - 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); +runMain(main); diff --git a/scripts/lint-docs-required.cjs b/scripts/lint-docs-required.cjs index 752685b03..a6580cb4a 100755 --- a/scripts/lint-docs-required.cjs +++ b/scripts/lint-docs-required.cjs @@ -14,6 +14,7 @@ */ const { parseFragment, FRAGMENT_ERROR } = require('./changeset/parse.cjs'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const LINT_REASON = Object.freeze({ OK_NO_TRIGGERING_FRAGMENTS: 'ok_no_triggering_fragments', @@ -161,8 +162,7 @@ function main() { ); changedFiles = out.split('\n').filter(Boolean); } catch (e) { - process.stderr.write(`could not compute diff: ${e.message}\n`); - process.exit(2); + throw new ExitError(2, `could not compute diff: ${e.message}`); } const { fragments, malformed } = readFragmentsFromDisk(changedFiles, rootDir); @@ -204,10 +204,10 @@ function main() { `exemption via \`\` 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 = { evaluateLint, diff --git a/scripts/lint-legacy-dir-name.cjs b/scripts/lint-legacy-dir-name.cjs index 4d7cb1199..c5223f3f1 100644 --- a/scripts/lint-legacy-dir-name.cjs +++ b/scripts/lint-legacy-dir-name.cjs @@ -33,6 +33,7 @@ const { execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); // Constructed with split to avoid self-match when this script is scanned. 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. -let trackedFiles; -try { - 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; +function main() { + // Enumerate tracked files via git ls-files so only committed/staged source is checked. + let trackedFiles; try { - content = fs.readFileSync(fullPath, 'utf8'); - } catch { - // Unreadable files (permissions, etc.) — skip silently. - continue; + trackedFiles = execFileSync('git', ['ls-files'], { cwd: REPO_ROOT, encoding: 'utf8' }) + .split('\n') + .map((f) => f.trim()) + .filter(Boolean); + } catch (err) { + throw new ExitError(1, 'ERROR lint-legacy-dir-name: git ls-files failed: ' + err.message); } - 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; + const violations = []; - 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], - }); + 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 { + 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) { - 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); +runMain(main); diff --git a/scripts/lint-pr-check-project-dir.cjs b/scripts/lint-pr-check-project-dir.cjs index b3126528b..e9a65bfac 100644 --- a/scripts/lint-pr-check-project-dir.cjs +++ b/scripts/lint-pr-check-project-dir.cjs @@ -4,6 +4,8 @@ const fs = require('fs'); const path = require('path'); +const { runMain } = require('./lib/cli-exit.cjs'); + const ROOT = path.join(__dirname, '..'); const DEFAULT_RELATIVE_FILES = [ @@ -84,7 +86,7 @@ function main(argv = process.argv.slice(2)) { } if (require.main === module) { - process.exit(main()); + runMain(main); } module.exports = { diff --git a/scripts/lint-shell-command-projection-drift.cjs b/scripts/lint-shell-command-projection-drift.cjs index 46f740925..acc1aa733 100644 --- a/scripts/lint-shell-command-projection-drift.cjs +++ b/scripts/lint-shell-command-projection-drift.cjs @@ -13,19 +13,9 @@ const fs = require('fs'); const path = require('path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); 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 = [ { @@ -42,16 +32,31 @@ const forbidden = [ }, ]; -const matches = forbidden.filter((rule) => rule.pattern.test(content)); -if (matches.length === 0) { - process.stdout.write(`ok shell-projection-drift: ${rel}\n`); - process.exit(0); +function main() { + 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) { + 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`); -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); +runMain(main); diff --git a/scripts/lint-skill-deps.cjs b/scripts/lint-skill-deps.cjs index e0daf6ca9..73380b32e 100644 --- a/scripts/lint-skill-deps.cjs +++ b/scripts/lint-skill-deps.cjs @@ -25,6 +25,7 @@ const fs = require('fs'); 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, loadSkillsManifest, resolveProfile } = require(PROFILES_MODULE); @@ -142,39 +143,43 @@ function checkProfileClosure(manifest) { // Main // --------------------------------------------------------------------------- -const manifest = loadSkillsManifest(commandsDir); -const allStems = new Set( - [...manifest.keys()].filter((k) => !k.startsWith('_calls_agents_')) -); +function main() { + const manifest = loadSkillsManifest(commandsDir); + const allStems = new Set( + [...manifest.keys()].filter((k) => !k.startsWith('_calls_agents_')) + ); -const consistencyViolations = checkFrontmatterBodyConsistency(manifest, allStems); -const closureViolations = checkProfileClosure(manifest); + const consistencyViolations = checkFrontmatterBodyConsistency(manifest, allStems); + const closureViolations = checkProfileClosure(manifest); -const totalViolations = consistencyViolations.length + closureViolations.length; + const totalViolations = consistencyViolations.length + closureViolations.length; -if (totalViolations === 0) { - const checked = manifest.size; - process.stdout.write('ok lint-skill-deps: ' + checked + ' skill(s) checked, 0 violations\n'); - process.exit(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'); + if (totalViolations === 0) { + const checked = manifest.size; + process.stdout.write('ok lint-skill-deps: ' + checked + ' skill(s) checked, 0 violations\n'); + return 0; } - process.stderr.write('Fix: add missing deps to requires: in the skill frontmatter.\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('\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'); } - 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); diff --git a/scripts/lint-test-file-count.cjs b/scripts/lint-test-file-count.cjs index 7a5d6ced8..69c99ebea 100644 --- a/scripts/lint-test-file-count.cjs +++ b/scripts/lint-test-file-count.cjs @@ -17,6 +17,7 @@ const fs = require('fs'); const path = require('path'); const { assertWithinAllowlist } = require('./lib/allowlist-ratchet.cjs'); +const { runMain } = require('./lib/cli-exit.cjs'); const ROOT = path.join(__dirname, '..'); const PROD_DIRS = [ @@ -201,14 +202,14 @@ function run() { if (jsonMode) { 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) { 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` + (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`); @@ -231,7 +232,7 @@ function run() { } 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.exit(1); + return 1; } module.exports = { @@ -242,4 +243,4 @@ module.exports = { _loadAllowlist: loadAllowlist, }; -if (require.main === module) run(); +if (require.main === module) runMain(run); diff --git a/scripts/mutation-matrix.cjs b/scripts/mutation-matrix.cjs index a215e88bd..562c8995c 100644 --- a/scripts/mutation-matrix.cjs +++ b/scripts/mutation-matrix.cjs @@ -32,6 +32,8 @@ const { execFileSync } = require('child_process'); const { readFileSync } = require('fs'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + // ── Single source of truth: covered modules ─────────────────────────────────── // Each entry: { cjs: '', tests: ['tests/...', ...] } // A module is "covered" iff its tests are wired into the Stryker command runner @@ -121,7 +123,7 @@ function parseArgs(argv) { ' --base Git ref to diff against (default: origin/${GITHUB_BASE_REF:-next})', ' --print Human-readable output instead of JSON', ].join('\n')); - process.exit(0); + throw new ExitError(0); } else { throw new Error(`unknown argument: ${arg}`); } @@ -211,9 +213,10 @@ function main() { console.log(JSON.stringify(result, null, 2)); } } catch (err) { + if (err instanceof ExitError) throw err; console.error(`mutation-matrix: ${err.message}`); - process.exit(2); + throw new ExitError(2); } } -main(); +runMain(main); diff --git a/scripts/release-notes/format-github-release-notes.cjs b/scripts/release-notes/format-github-release-notes.cjs index 85c7733cf..2116f5902 100644 --- a/scripts/release-notes/format-github-release-notes.cjs +++ b/scripts/release-notes/format-github-release-notes.cjs @@ -4,6 +4,7 @@ const path = require('path'); const os = require('os'); const fs = require('fs'); 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'. @@ -155,7 +156,7 @@ function formatReleaseNotes({ generatedBody, version, prerelease, packageName }) } // CLI entry point -if (require.main === module) { +function main() { try { const argv = process.argv.slice(2); @@ -248,9 +249,13 @@ if (require.main === module) { process.stdout.write(formatted + '\n'); } } catch (err) { - process.stderr.write((err.message || String(err)) + '\n'); - process.exit(1); + if (err instanceof ExitError) throw err; + throw new ExitError(1, err && err.message ? err.message : String(err)); } } +if (require.main === module) { + runMain(main); +} + module.exports = { formatReleaseNotes, classifyTitle }; diff --git a/scripts/release-tarball-smoke.cjs b/scripts/release-tarball-smoke.cjs index 0c7a8ead7..6f20d21e3 100644 --- a/scripts/release-tarball-smoke.cjs +++ b/scripts/release-tarball-smoke.cjs @@ -46,6 +46,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); 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 // `npm install -g` with a 1499-file tarball took ~120 s exactly, causing // 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'); cleanup(packDir, installPrefix, fixtureDir); - process.exit(1); + throw new ExitError(1); } } } catch (err) { + if (err instanceof ExitError) throw err; const result = { code: SMOKE.PACK_FAILED, details: { error: err.message, stderr: err.stderr }, }; if (isJson) process.stdout.write(JSON.stringify(result) + '\n'); cleanup(packDir, installPrefix, fixtureDir); - process.exit(1); + throw new ExitError(1); } const result = runSmoke({ tarballPath, installPrefix, expectedVersion, fixtureDir }); if (isJson) process.stdout.write(JSON.stringify(result) + '\n'); cleanup(packDir, installPrefix, fixtureDir); - process.exit(result.code === SMOKE.OK ? 0 : 1); + return result.code === SMOKE.OK ? 0 : 1; } function cleanup(...dirs) { @@ -623,5 +625,5 @@ function cleanup(...dirs) { module.exports = { SMOKE, runSmoke, binInvocation }; if (require.main === module) { - cliMain(); + runMain(cliMain); } diff --git a/scripts/run-affected-tests.cjs b/scripts/run-affected-tests.cjs index 224919e34..fc911bde5 100644 --- a/scripts/run-affected-tests.cjs +++ b/scripts/run-affected-tests.cjs @@ -2,5 +2,6 @@ 'use strict'; const { runAffectedTests } = require('./affected-tests-lib.cjs'); +const { runMain } = require('./lib/cli-exit.cjs'); -runAffectedTests(); +runMain(runAffectedTests); diff --git a/scripts/run-cross-platform-tests.cjs b/scripts/run-cross-platform-tests.cjs index 72eb3b1cd..31a8f4a8e 100644 --- a/scripts/run-cross-platform-tests.cjs +++ b/scripts/run-cross-platform-tests.cjs @@ -1,6 +1,7 @@ 'use strict'; const { spawnSync } = require('child_process'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const CROSS_PLATFORM_TEST_REASON = Object.freeze({ PASS: 'pass', @@ -50,14 +51,17 @@ function runCrossPlatformTests(options = {}, deps = {}) { } if (require.main === module) { - const result = runCrossPlatformTests(); - const line = `[cross-platform-tests] reason=${result.reason} exit=${result.exitCode}`; - if (result.ok) { - process.stdout.write(`${line}\n`); - process.exit(0); + function main() { + const result = runCrossPlatformTests(); + const line = `[cross-platform-tests] reason=${result.reason} exit=${result.exitCode}`; + if (result.ok) { + process.stdout.write(`${line}\n`); + return 0; + } + process.stderr.write(`${line}\n`); + throw new ExitError(result.exitCode); } - process.stderr.write(`${line}\n`); - process.exit(result.exitCode); + runMain(main); } module.exports = { CROSS_PLATFORM_TEST_REASON, runCrossPlatformTests }; diff --git a/scripts/run-tests.cjs b/scripts/run-tests.cjs index 337e1d2eb..810438534 100644 --- a/scripts/run-tests.cjs +++ b/scripts/run-tests.cjs @@ -23,6 +23,7 @@ const { readdirSync, existsSync } = require('fs'); const { join } = require('path'); const { execFileSync } = require('child_process'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const SUITES = ['all', 'unit', 'integration', 'install', 'security', 'slow']; @@ -189,13 +190,13 @@ function main() { if (parsed.error) { console.error(`run-tests: ${parsed.error}`); console.error(`Valid suites: ${SUITES.join(', ')}`); - process.exit(2); + throw new ExitError(2); } const suite = parsed.suite; if (suite !== null && !SUITES.includes(suite)) { console.error(`run-tests: unknown suite "${suite}"`); console.error(`Valid suites: ${SUITES.join(', ')}`); - process.exit(2); + throw new ExitError(2); } const testDir = process.env.GSD_TEST_DIR @@ -208,7 +209,7 @@ function main() { if (allFiles.length === 0) { console.error(`No test files found in ${testDir}`); - process.exit(1); + throw new ExitError(1); } let selectedNames; @@ -216,7 +217,7 @@ function main() { const explicit = selectExplicitFiles(allFiles, parsed.files, parsed.filesFrom); if (explicit.error) { console.error(`run-tests: ${explicit.error}`); - process.exit(2); + throw new ExitError(2); } selectedNames = explicit.files; } else { @@ -229,7 +230,7 @@ function main() { // adversarial tests land) don't gate CI. CI consumers wanting strictness // can grep stderr for "no tests in suite". 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. @@ -304,11 +305,11 @@ function main() { if (firstFailureExit === 0) firstFailureExit = code; } } - if (firstFailureExit !== 0) process.exit(firstFailureExit); + if (firstFailureExit !== 0) return firstFailureExit; } if (require.main === module) { - main(); + runMain(main); } module.exports = { suiteOf }; diff --git a/scripts/verify-npm-publish.cjs b/scripts/verify-npm-publish.cjs index e0c998a1e..ff754a8dc 100644 --- a/scripts/verify-npm-publish.cjs +++ b/scripts/verify-npm-publish.cjs @@ -8,6 +8,7 @@ */ const cp = require('node:child_process'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); // ---- Constants --------------------------------------------------------------- @@ -137,67 +138,57 @@ function parseArgs(argv) { ' --json emit structured JSON output\n' + ' --help, -h show this help\n' ); - process.exit(0); + throw new ExitError(0); } else if (arg === '--package') { const val = args.shift(); if (!val || val.startsWith('-')) { - process.stderr.write('error: --package requires a value\n'); - process.exit(2); + throw new ExitError(2, 'error: --package requires a value'); } opts.pkg = val; } else if (arg === '--version') { const val = args.shift(); if (!val || val.startsWith('-')) { - process.stderr.write('error: --version requires a value\n'); - process.exit(2); + throw new ExitError(2, 'error: --version requires a value'); } opts.version = val; } else if (arg === '--dist-tag') { const val = args.shift(); if (!val || val.startsWith('-')) { - process.stderr.write('error: --dist-tag requires a value\n'); - process.exit(2); + throw new ExitError(2, 'error: --dist-tag requires a value'); } opts.distTag = val; } else if (arg === '--max-attempts') { const val = args.shift(); if (!val || val.startsWith('-')) { - process.stderr.write('error: --max-attempts requires a value\n'); - process.exit(2); + throw new ExitError(2, 'error: --max-attempts requires a value'); } const n = parseInt(val, 10); if (isNaN(n) || n < 1) { - process.stderr.write('error: --max-attempts must be a positive integer\n'); - process.exit(2); + throw new ExitError(2, 'error: --max-attempts must be a positive integer'); } opts.maxAttempts = n; } else if (arg === '--interval-ms') { const val = args.shift(); if (!val || val.startsWith('-')) { - process.stderr.write('error: --interval-ms requires a value\n'); - process.exit(2); + throw new ExitError(2, 'error: --interval-ms requires a value'); } const n = parseInt(val, 10); if (isNaN(n) || n < 0) { - process.stderr.write('error: --interval-ms must be a non-negative integer\n'); - process.exit(2); + throw new ExitError(2, 'error: --interval-ms must be a non-negative integer'); } opts.intervalMs = n; } else if (arg === '--json') { opts.json = true; } else { - process.stderr.write(`unknown argument: ${arg}\n`); - process.exit(2); + throw new ExitError(2, `unknown argument: ${arg}`); } } if (!opts.pkg) { - process.stderr.write('error: --package is required\n'); - process.exit(2); + throw new ExitError(2, 'error: --package is required'); } if (!opts.version) { - process.stderr.write('error: --version is required\n'); - process.exit(2); + throw new ExitError(2, 'error: --version is required'); } return opts; @@ -237,16 +228,13 @@ async function main() { } } - process.exit(result.ok ? 0 : 1); + return result.ok ? 0 : 1; } // ---- Guard ------------------------------------------------------------------- if (require.main === module) { - main().catch((err) => { - process.stderr.write(String((err && err.stack) || err) + '\n'); - process.exit(1); - }); + runMain(main); } module.exports = { verifyPublish, parseArgs, REASON, defaultFetchVersion, defaultFetchDistTag }; diff --git a/tests/cli-exit.test.cjs b/tests/cli-exit.test.cjs new file mode 100644 index 000000000..75a52c6a5 --- /dev/null +++ b/tests/cli-exit.test.cjs @@ -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; + } + }); +});