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

@@ -1,7 +1,44 @@
/**
* 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.
*/
import fs from 'node:fs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ioModule = require('./io.cjs');
const { getJsonErrorMode, ERROR_REASON } = ioModule;
/**
* 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' as const;
/**
* 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: unknown): void {
(globalThis as unknown as Record<symbol, boolean>)[JSON_ERROR_MODE_KEY] = !!v;
}
function getJsonErrorMode(): boolean {
return (globalThis as unknown as Record<symbol, boolean>)[JSON_ERROR_MODE_KEY] === true;
}
/**
* Error carrying a process exit code. CLI logic throws this instead of calling
@@ -42,7 +79,7 @@ function runMain(main: () => number | void | Promise<number | void>): void {
const e = err as Error;
const payload = JSON.stringify({
ok: false,
reason: ERROR_REASON.SDK_FAIL_FAST,
reason: EXIT_ENVELOPE_REASON,
message: (e && e.message) ? e.message : String(err),
}) + '\n';
fs.writeSync(2, payload);
@@ -54,4 +91,4 @@ function runMain(main: () => number | void | Promise<number | void>): void {
});
}
export = { ExitError, runMain };
export = { ExitError, runMain, setJsonErrorMode, getJsonErrorMode, EXIT_ENVELOPE_REASON };

View File

@@ -12,6 +12,9 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import cliExitModule = require('./cli-exit.cjs');
const { setJsonErrorMode, getJsonErrorMode, EXIT_ENVELOPE_REASON } = cliExitModule;
// ─── Temp-file helpers (needed by output()) ──────────────────────────────────
@@ -184,7 +187,7 @@ const ERROR_REASON = Object.freeze({
CONFIG_PARSE_FAILED: 'config_parse_failed',
CONFIG_INVALID_KEY: 'config_invalid_key',
// SDK / gsd-tools dispatch
SDK_FAIL_FAST: 'sdk_fail_fast',
SDK_FAIL_FAST: EXIT_ENVELOPE_REASON,
SDK_UNKNOWN_COMMAND: 'sdk_unknown_command',
SDK_MISSING_ARG: 'sdk_missing_arg',
// workflow / phase
@@ -220,18 +223,8 @@ const ERROR_REASON = Object.freeze({
type ErrorReasonValue = typeof ERROR_REASON[keyof typeof ERROR_REASON];
/**
* Process-level flag: when true, error() emits structured JSON to stderr
* instead of plain "Error: <message>" text. Set by gsd-tools.cjs when the
* CLI is invoked with `--json-errors`. Tests opt in to typed-IR error
* assertions by passing that flag and parsing the JSON.
*
* Default off so existing callers and human operators keep their plain-text
* diagnostics. The structured form is opt-in for tooling and tests (#2974).
*/
let _jsonErrorMode = false;
function setJsonErrorMode(v: unknown): void { _jsonErrorMode = !!v; }
function getJsonErrorMode(): boolean { return _jsonErrorMode; }
// setJsonErrorMode / getJsonErrorMode now live in cli-exit.cts (imported above)
// and are re-exported here for the callers that already import them from io.
/**
* Emit an error and exit. When the second argument is provided it must be
@@ -248,7 +241,7 @@ function getJsonErrorMode(): boolean { return _jsonErrorMode; }
* message is the only thing an operator sees there.
*/
function error(message: string, reason: ErrorReasonValue = ERROR_REASON.UNKNOWN, extra?: Record<string, unknown>): never {
if (_jsonErrorMode) {
if (getJsonErrorMode()) {
const payload = JSON.stringify({ ok: false, reason, message, ...(extra || {}) }) + '\n';
writeAllSync(2, payload);
} else {