enhance(#3904): one exit module — generate the scripts-side copy from a single source (#3917)

* test(#3904): failing-first coverage for the drifted scripts-side exit module

The scripts/ copy of the CLI exit seam has no json-error arm, so an unexpected throw prints a raw stack where the documented contract promises {ok:false,reason,message}. Adds the consumer-altitude reproduction plus the negative space it must not swallow, the one-cell assertions for json-error mode, and the standalone-load constraint. RED until the generator lands.

* enhance(#3904): generate the scripts-side exit module from one source

src/cli-exit.cts becomes the single source of truth and scripts/lib/cli-exit.cjs a generated artifact of its compiled output, byte-compared by a --check entry in lint:generated-sync. The two had drifted: only the .cts copy emitted the documented {ok:false,reason,message} envelope on an unexpected throw, so a scripts-side tool printed a raw stack where docs/json-errors.md promises structured output.

The generated file is committed and must load on an unbuilt clone (64+ consumers, incl. check-env.cjs), and gsd-core/bin/lib/cli-exit.cjs is gitignored tsc output that doubles as the build sentinel, so it cannot be required from there. The exit module therefore drops its io.cjs import: the json-error-mode accessors move into it and io.cts re-exports them, leaving its export surface unchanged. The flag lives in a Symbol-keyed cell on globalThis because one source emitted to two locations means two module instances, and a module-level flag would give them two independent values.

* chore(#3904): changeset for the generated scripts-side exit module

* docs(#3904): name which surfaces honor the json-error envelope contract

docs/json-errors.md described the structured envelope as what runMain does without saying which copies of runMain actually had the branch — a claim that was silently false for every scripts/-side tool. Also drops a redundant source-grep test whose marker grew the unverified allow-test-rule pool past its ceiling; the behavioral test beside it proves the same property through real module resolution.

* test(#3904): compare exit verdicts, not stderr bytes, across the two copies

The parity test asserted byte-identical stderr, which the plain-text path cannot satisfy: the generated copy carries an 11-line banner, so its stack frames report line numbers offset by exactly that much, and the path normalizer stopped at the colon. Byte-identical stack traces were never the contract - two files at two paths necessarily differ there. Now compares the parsed envelope under json mode, the first line and exit code on the stack path, and exact output for ExitError.

* chore(#3904): backfill changeset pr number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-26 21:24:34 -04:00
committed by GitHub
parent 4e8927b0b9
commit 8edace40d5
9 changed files with 692 additions and 65 deletions

View File

@@ -0,0 +1,185 @@
#!/usr/bin/env node
/**
* gen-scripts-cli-exit.cjs — generates scripts/lib/cli-exit.cjs from a fresh
* compile of src/cli-exit.cts.
*
* ADR-3889 Phase 0 (#3904): scripts/lib/cli-exit.cjs used to be a hand-written
* fork of gsd-core/bin/lib/cli-exit.cjs (the compiled artifact of
* src/cli-exit.cts). The two drifted — only the .cts copy routed a
* non-ExitError throw through getJsonErrorMode() to emit a structured
* { ok:false, reason, message } envelope. This script makes scripts/lib/cli-exit.cjs
* a generated artifact of the SAME source, so the two surfaces cannot diverge
* again.
*
* scripts/ runs straight from the repo checkout and must work on an unbuilt
* clone (scripts/check-env.cjs requires this file before any build runs), so
* the generated file is compiled to a THROWAWAY outDir rather than read from
* gsd-core/bin/lib/ — reading the tracked build output would let a stale build
* produce a false green.
*
* Usage:
* node scripts/gen-scripts-cli-exit.cjs # same as --write
* node scripts/gen-scripts-cli-exit.cjs --write # write scripts/lib/cli-exit.cjs
* node scripts/gen-scripts-cli-exit.cjs --check # exit 1 if committed file is stale
*/
'use strict';
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const REPO_ROOT = path.resolve(__dirname, '..');
const OUTPUT_PATH = path.join(REPO_ROOT, 'scripts', 'lib', 'cli-exit.cjs');
const COMPILE_TIMEOUT_MS = 60_000;
/** Frozen reason codes so tests assert on structure, not prose. */
const REASON = Object.freeze({
OK: 'ok_generated_sync',
DRIFTED: 'fail_generated_drifted',
BUILD_FAILED: 'fail_build_failed',
MISSING_EMIT: 'fail_missing_emit',
USAGE: 'fail_usage',
});
const USAGE_MESSAGE = [
'Usage: node scripts/gen-scripts-cli-exit.cjs [--write|--check]',
' (no flag) same as --write',
' --write write scripts/lib/cli-exit.cjs',
' --check exit 1 if the committed file is stale',
].join('\n');
const BANNER = [
'// GENERATED FILE — DO NOT EDIT BY HAND.',
'// Source of truth: src/cli-exit.cts. Regenerate with:',
'// node scripts/gen-scripts-cli-exit.cjs --write',
'// Byte-compared by `npm run lint:generated-sync` (#3904, ADR-3889 Phase 0).',
'//',
'// Why this copy exists: scripts/ runs straight from the repo checkout and must',
'// work on an unbuilt clone — 64+ scripts require this file, including',
'// check-env.cjs, which runs before any build. gsd-core/bin/lib/cli-exit.cjs is',
'// gitignored tsc output and doubles as the build sentinel, so it cannot be',
'// required from here. Hence one source, two emitted locations.',
'',
'',
].join('\n');
/** Compile the whole project to a throwaway outDir so the work tree is untouched. */
function compileToTemp() {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-scripts-cli-exit-'));
try {
execFileSync(
process.execPath,
[
path.join(REPO_ROOT, 'node_modules', 'typescript', 'bin', 'tsc'),
'-p', path.join(REPO_ROOT, 'tsconfig.build.json'),
'--outDir', tmp,
// A throwaway outDir must not reuse the in-tree incremental state, or
// tsc skips emit for files it believes are already current.
'--incremental', 'false',
'--tsBuildInfoFile', 'null',
],
{ cwd: REPO_ROOT, encoding: 'utf8', stdio: 'pipe', timeout: COMPILE_TIMEOUT_MS },
);
return { ok: true, dir: tmp };
} catch (err) {
fs.rmSync(tmp, { recursive: true, force: true });
const detail = [err.stdout, err.stderr].filter(Boolean).join('\n').trim();
return { ok: false, detail };
}
}
/**
* Compile src/cli-exit.cts to a throwaway outDir and return the expected
* generated content (banner + compiled bytes), or a failure descriptor.
*
* @returns {{ ok: true, content: string } | { ok: false, reason: string, detail?: string }}
*/
function buildExpectedContent() {
const build = compileToTemp();
if (!build.ok) {
return { ok: false, reason: REASON.BUILD_FAILED, detail: build.detail };
}
try {
const emitted = path.join(build.dir, 'cli-exit.cjs');
if (!fs.existsSync(emitted)) {
return { ok: false, reason: REASON.MISSING_EMIT, detail: `no emit produced at ${emitted} from src/cli-exit.cts` };
}
const compiled = fs.readFileSync(emitted, 'utf8');
return { ok: true, content: BANNER + compiled };
} finally {
fs.rmSync(build.dir, { recursive: true, force: true });
}
}
function doWrite() {
const result = buildExpectedContent();
if (!result.ok) {
console.error(`FAIL gen-scripts-cli-exit: ${result.reason}`);
if (result.detail) console.error(result.detail);
return 1;
}
fs.mkdirSync(path.dirname(OUTPUT_PATH), { recursive: true });
fs.writeFileSync(OUTPUT_PATH, result.content, 'utf8');
console.log(`ok gen-scripts-cli-exit: wrote ${path.relative(REPO_ROOT, OUTPUT_PATH)}`);
return 0;
}
function doCheck() {
const result = buildExpectedContent();
if (!result.ok) {
console.error(`FAIL gen-scripts-cli-exit: ${result.reason}`);
if (result.detail) console.error(result.detail);
return 1;
}
if (!fs.existsSync(OUTPUT_PATH)) {
console.error(`FAIL gen-scripts-cli-exit: ${REASON.MISSING_EMIT}`);
console.error(` ${path.relative(REPO_ROOT, OUTPUT_PATH)} does not exist. Run:`);
console.error(' node scripts/gen-scripts-cli-exit.cjs --write');
return 1;
}
const committed = fs.readFileSync(OUTPUT_PATH, 'utf8');
if (committed !== result.content) {
console.error(`FAIL gen-scripts-cli-exit: ${REASON.DRIFTED}`);
console.error(
` ${path.relative(REPO_ROOT, OUTPUT_PATH)} (${committed.length} bytes) != ` +
`compile of src/cli-exit.cts (${result.content.length} bytes)`,
);
console.error('');
console.error('Regenerate with:');
console.error(' node scripts/gen-scripts-cli-exit.cjs --write');
return 1;
}
console.log(`ok gen-scripts-cli-exit: ${path.relative(REPO_ROOT, OUTPUT_PATH)} matches src/cli-exit.cts`);
return 0;
}
function main() {
const flag = process.argv[2];
const extra = process.argv[3];
if (flag !== undefined && flag !== '--write' && flag !== '--check') {
console.error(`FAIL gen-scripts-cli-exit: ${REASON.USAGE}`);
console.error(` unrecognized argument: ${flag}`);
console.error(USAGE_MESSAGE);
return 1;
}
if (extra !== undefined) {
console.error(`FAIL gen-scripts-cli-exit: ${REASON.USAGE}`);
console.error(` unexpected extra argument: ${extra}`);
console.error(USAGE_MESSAGE);
return 1;
}
if (flag === '--check') return doCheck();
return doWrite();
}
if (require.main === module) process.exitCode = main();
module.exports = { REASON, buildExpectedContent, OUTPUT_PATH, BANNER };

View File

@@ -1,56 +1,105 @@
'use strict';
// GENERATED FILE — DO NOT EDIT BY HAND.
// Source of truth: src/cli-exit.cts. Regenerate with:
// node scripts/gen-scripts-cli-exit.cjs --write
// Byte-compared by `npm run lint:generated-sync` (#3904, ADR-3889 Phase 0).
//
// Why this copy exists: scripts/ runs straight from the repo checkout and must
// work on an unbuilt clone — 64+ scripts require this file, including
// check-env.cjs, which runs before any build. gsd-core/bin/lib/cli-exit.cjs is
// gitignored tsc output and doubles as the build sentinel, so it cannot be
// required from here. Hence one source, two emitted locations.
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
/**
* 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.
* Process-exit primitives (ExitError, runMain) plus the json-error-mode cell.
* Must import nothing but `node:fs` — this source is emitted to TWO locations,
* gsd-core/bin/lib/cli-exit.cjs (tsc build output) and scripts/lib/cli-exit.cjs
* (a generated, committed artifact regenerated by scripts/gen-scripts-cli-exit.cjs),
* and the latter must load on an unbuilt clone before anything under ./lib exists.
*/
const node_fs_1 = __importDefault(require("node:fs"));
/**
* The wire value `runMain` stamps into its structured envelope. Declared HERE,
* not in io.cts, because this module must not import anything (see the module
* header): io.cts builds ERROR_REASON.SDK_FAIL_FAST from this constant, so the
* two surfaces share ONE definition rather than two literals kept in step by a
* parity test.
*/
const EXIT_ENVELOPE_REASON = 'sdk_fail_fast';
/**
* Process-level flag: when true, error paths emit structured JSON to stderr
* instead of plain text. Set by gsd-tools.cjs when the CLI is invoked with
* `--json-errors`; re-exported by io.cts, which is where most callers reach it.
*
* @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.
* Held in a Symbol-keyed cell on globalThis rather than in module scope, and
* that is load-bearing: this module is emitted to TWO locations
* (gsd-core/bin/lib/cli-exit.cjs and the generated scripts/lib/cli-exit.cjs),
* so a process that loads both would get two independent module instances. A
* module-level `let` would give them two independent flags — one copy could
* think json mode is on while the other thought it was off, which is exactly
* the divergence class ADR-3889 exists to remove. One cell, keyed by a
* registry Symbol, makes that unrepresentable.
*/
const JSON_ERROR_MODE_KEY = Symbol.for('gsd.exit.jsonErrorMode');
function setJsonErrorMode(v) {
globalThis[JSON_ERROR_MODE_KEY] = !!v;
}
function getJsonErrorMode() {
return globalThis[JSON_ERROR_MODE_KEY] === true;
}
/**
* Error carrying a process exit code. CLI logic throws this instead of calling
* process.exit() (banned by n/no-process-exit); runMain() translates it into
* process.exitCode at the entrypoint.
*/
class ExitError extends Error {
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;
}
code;
hasUserMessage;
constructor(code = 1, message) {
super(message === undefined ? `process exit ${code}` : message);
this.name = 'ExitError';
this.code = code;
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
* Run a CLI main and translate its outcome into process.exitCode (never
* process.exit, so n/no-process-exit stays satisfied; output flushes and
* process.on('exit') cleanup still fires). main may be sync or async:
* number return -> process.exitCode = it
* thrown ExitError -> process.exitCode = err.code (+ stderr err.message if hasUserMessage && code!=0)
* other throw -> when json-error mode is active, emits structured { ok:false, reason, message }
* to stderr; otherwise writes raw stack trace. exit code = 1 in either case.
*/
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`);
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.exitCode = err.code;
return;
}
process.stderr.write(`${err && err.stack ? err.stack : String(err)}\n`);
process.exitCode = 1;
if (getJsonErrorMode()) {
const e = err;
const payload = JSON.stringify({
ok: false,
reason: EXIT_ENVELOPE_REASON,
message: (e && e.message) ? e.message : String(err),
}) + '\n';
node_fs_1.default.writeSync(2, payload);
}
else {
const e = err;
process.stderr.write(`${e && e.stack ? e.stack : String(err)}\n`);
}
process.exitCode = 1;
});
}
module.exports = { ExitError, runMain };
module.exports = { ExitError, runMain, setJsonErrorMode, getJsonErrorMode, EXIT_ENVELOPE_REASON };