Files
msd-core/scripts/lib/cli-exit.cjs
Tom Boucher 8edace40d5 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>
2026-08-26 21:24:34 -04:00

106 lines
4.6 KiB
JavaScript

// 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 };
};
/**
* 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.
*
* 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 {
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 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`);
process.exitCode = err.code;
return;
}
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, setJsonErrorMode, getJsonErrorMode, EXIT_ENVELOPE_REASON };