* fix(#2979): document ExitError plain-text carve-out in json-errors.md The JSON-errors doc claimed every error emits a structured JSON envelope, but usage errors (ExitError) intentionally emit plain text with their own exit code (src/cli-exit.cts:36-39 catches ExitError before the envelope branch). Anyone following the doc's 'always parse stderr as JSON' guidance against a usage error got a parse failure. Amended the Wire format + Overview + Writing tests sections to scope the structured envelope to non-ExitError failures, stated the carve-out with a pointer to cli-exit.cts, and scoped the JSON-parse instruction to the envelope branch. Added a characterization test pinning both paths together (ExitError -> plain text + own code; non-ExitError -> JSON envelope) so the code cannot drift toward the doc's prior overstated claim. No runtime change — the test passes before and after the doc edit. Re-scoped per maintainer triage: the smart-entry --json part is already satisfied (shipped payload exposes the command token); only the doc correction + characterization test remain. * chore(#2979): backfill changeset PR number 3093 --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/kind-sloths-climb.md
Normal file
5
.changeset/kind-sloths-climb.md
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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 <command> [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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user