Files
msd-core/docs/json-errors.md
Tom Boucher 955655407c fix(#2979): document ExitError plain-text carve-out in json-errors.md (#3093)
* 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>
2026-08-05 18:57:10 -04:00

5.0 KiB

JSON Error Mode — gsd-tools Structured Errors

Overview

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"). Usage errors are an intentional exception — see the ExitError carve-out below.

Activating

Either flag or env var activates the mode:

# Flag (preferred in test code):
node gsd-tools.cjs --json-errors <command> [args]

# Env var (preferred for shell wrappers and CI):
GSD_JSON_ERRORS=1 node gsd-tools.cjs <command> [args]

Wire format

On any error, exactly one JSON line is written to stderr and the process exits with code 1:

{ "ok": false, "reason": "<error_code>", "message": "<human text>" }

Fields:

Field Type Description
ok false Always false for error objects.
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 ERROR_REASON. Tests must assert on reason values (stable), not message text (unstable).

Dispatch errors (gsd-tools routing layer)

Code When emitted
sdk_unknown_command Unknown top-level command (gsd-tools bogus-cmd)
sdk_unknown_command Unknown dotted command (gsd-tools foo.bar where foo is not a known command)
sdk_unknown_command Unknown subcommand within a domain (e.g. gsd-tools intel bogus-sub)
sdk_missing_arg Required argument omitted by an SDK-level guard
sdk_fail_fast SDK fail-fast policy triggered

Usage / flag errors

Code When emitted
usage --pick flag used without a following value
usage Version flag (--version, -v) which gsd-tools never accepts
usage Top-level no-args invocation (usage text)

Config errors (config-get, config-set, config-ensure-section)

Code When emitted
config_key_not_found config-get for a key that is absent from the config file
config_no_file Config operation when .planning/config.json does not exist
config_parse_failed Config file exists but is not valid JSON
config_invalid_key config-set for a key outside the allowed whitelist

Phase / workflow errors

Code When emitted
phase_not_found Phase directory lookup returns no match
summary_no_planning Summary operation when no .planning/ directory exists

Graphify errors

Code When emitted
graphify_no_graph Graphify query or diff when no graph has been built
graphify_invalid_query Graphify query with a malformed query string

Hook / security errors

Code When emitted
hooks_opt_out Hooks are disabled via opt-out config
security_scan_failed Security scan produced a finding that blocks the operation

Fallback

Code When emitted
unknown All other errors without a specific reason code assigned

Writing tests

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.

// CORRECT: parse then assert on typed field
const result = runGsdTools(['--json-errors', 'bogus-command'], tmpDir);
assert.strictEqual(result.success, false);
const err = JSON.parse(result.error);
assert.strictEqual(err.ok, false);
assert.strictEqual(err.reason, 'sdk_unknown_command');

// WRONG: text matching (banned by lint-no-source-grep policy)
// assert.ok(result.error.includes('Unknown command'));

Adding a new error code

  1. Add the constant to ERROR_REASON in gsd-core/bin/lib/core.cjs (snake_case, prefixed by subsystem).
  2. Pass it as the second argument to error() at the call site.
  3. Add a row to this document.
  4. Add a test asserting the new reason code via JSON.parse.