diff --git a/.changeset/kind-sloths-climb.md b/.changeset/kind-sloths-climb.md new file mode 100644 index 000000000..5269c7130 --- /dev/null +++ b/.changeset/kind-sloths-climb.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3093 +--- +**`docs/json-errors.md` now documents the ExitError plain-text carve-out** — the page previously claimed every CLI error emits a structured JSON envelope on stderr, but usage errors (ExitError) intentionally emit plain text with their own exit code. The structured-envelope guidance is now scoped to non-usage failures, with the carve-out stated explicitly and a characterization test pinning both paths. (#2979) diff --git a/docs/json-errors.md b/docs/json-errors.md index c12c058c6..2da5a9869 100644 --- a/docs/json-errors.md +++ b/docs/json-errors.md @@ -2,11 +2,12 @@ ## Overview -`gsd-tools` supports a **JSON error mode** that emits all errors as structured +`gsd-tools` supports a **JSON error mode** that emits most errors as structured JSON objects on stderr instead of free-form text. This is the recommended surface for tests and tooling that need to assert on error types without grepping raw text (see `CONTRIBUTING.md` — "Prohibited: Raw Text Matching on -Test Outputs"). +Test Outputs"). Usage errors are an intentional exception — see the +`ExitError` carve-out below. ## Activating @@ -37,6 +38,20 @@ Fields: | `reason` | string | Typed reason code from the taxonomy below. | | `message` | string | Human-readable description (may change; do not assert on it). | +### `ExitError` carve-out (plain text, not JSON) + +Usage errors and explicit exit-code signals take a **different path**: they +throw `ExitError` (`src/cli-exit.cts`), which `runMain` catches *before* the +JSON-envelope branch. An `ExitError` writes its `message` as **plain text** +to stderr (not a JSON object) and exits with the error's own `code` (which +may differ from 1). This is intentional — usage messages are operator-facing +prose, not structured failures. + +If you are testing a usage/flag error, do **not** parse stderr as JSON; +assert on the exit code and (if needed) the plain-text message. The +"parse stderr as JSON" guidance below applies only to the structured-envelope +branch (non-`ExitError` failures). + ## Error code taxonomy Codes are frozen constants in `gsd-core/bin/lib/core.cjs` under @@ -99,8 +114,9 @@ text (unstable). ## Writing tests -Always parse stderr with `JSON.parse` and assert on typed fields. Never use -`.includes()`, `.match()`, or regex on the raw error string. +For **non-usage** errors (the structured-envelope branch), parse stderr with +`JSON.parse` and assert on typed fields. Never use `.includes()`, `.match()`, +or regex on the raw error string. ```js // CORRECT: parse then assert on typed field diff --git a/tests/cli-exit.test.cjs b/tests/cli-exit.test.cjs index 03a1de939..21823bd14 100644 --- a/tests/cli-exit.test.cjs +++ b/tests/cli-exit.test.cjs @@ -183,11 +183,18 @@ describe('runMain', () => { describe('regressions', () => { /** Spawn a one-shot script that sets json-error mode and calls runMain with a throwing handler. */ function spawnJsonErrorRun({ jsonMode, errorType = 'TypeError', message = 'unexpected boom' } = {}) { + // ExitError lives in the same module as runMain; import it when the test + // wants to exercise the ExitError carve-out path. ExitError takes (code, message). + const isExitError = errorType === 'ExitError'; + const destructure = isExitError ? '{ runMain, ExitError }' : '{ runMain }'; + const throwExpr = isExitError + ? `new ExitError(1, ${JSON.stringify(message)})` + : `new ${errorType}(${JSON.stringify(message)})`; const script = ` const io = require(${JSON.stringify(IO_PATH)}); - const { runMain } = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)}); + const ${destructure} = require(${JSON.stringify(BUILT_CLI_EXIT_PATH)}); io.setJsonErrorMode(${jsonMode ? 'true' : 'false'}); - runMain(() => { throw new ${errorType}(${JSON.stringify(message)}); }); + runMain(() => { throw ${throwExpr}; }); setImmediate(() => {}); `; return spawnSync(process.execPath, ['-e', script], { encoding: 'utf-8' }); @@ -245,5 +252,35 @@ describe('regressions', () => { `expected "unexpected boom" in stderr, got: ${stderrTrimmed.slice(0, 200)}` ); }); + + // #2979: characterization test pinning the two error paths under json-errors + // mode. The structured envelope covers non-ExitError failures; ExitError + // (usage errors) intentionally emits plain text with its own exit code. + // Both halves asserted together so the code cannot drift toward the doc's + // prior overstated claim that EVERY error emits JSON. + test('#2979: ExitError emits plain text (not JSON) even under --json-errors; non-ExitError emits the envelope', () => { + // ExitError path: plain text, own exit code, NOT a JSON object. + const exitResult = spawnJsonErrorRun({ + jsonMode: true, + errorType: 'ExitError', + message: 'Usage: gsd-tools [args]', + }); + assert.strictEqual(exitResult.status, 1, 'ExitError exits with its code'); + const exitStderr = exitResult.stderr.trim(); + let exitParsed = null; + try { exitParsed = JSON.parse(exitStderr); } catch { /* expected — plain text */ } + assert.strictEqual(exitParsed, null, + `ExitError must emit plain text, not JSON; got: ${exitStderr.slice(0, 200)}`); + assert.ok(exitStderr.includes('Usage'), + `ExitError plain-text message must reach stderr; got: ${exitStderr.slice(0, 200)}`); + + // Non-ExitError path: structured JSON envelope. + const envResult = spawnJsonErrorRun({ jsonMode: true }); + assert.strictEqual(envResult.status, 1); + const envParsed = JSON.parse(envResult.stderr.trim()); + assert.strictEqual(envParsed.ok, false); + assert.strictEqual(envParsed.reason, 'sdk_fail_fast'); + assert.ok(envParsed.message, 'envelope must carry a message'); + }); }); });