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:
@@ -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) {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
56
scripts/lib/cli-exit.cjs
Normal 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 };
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
161
tests/cli-exit.test.cjs
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user