feat(3255): add --json-errors structured error mode to gsd-tools (#3304)

* test(3255): add red/green tests for --json-errors structured error mode

Ten tests covering the --json-errors mode contract:
- Unknown command → sdk_unknown_command
- Dotted unknown command → sdk_unknown_command
- Missing --pick value → usage
- Config key not found → config_key_not_found
- Unknown subcommand → sdk_unknown_command
- GSD_JSON_ERRORS=1 env var activation
- Successful command unaffected
- Stable error shape ({ok, reason, message})
- Single error line per invocation
- Unknown flag → usage

All assertions use JSON.parse on stderr captures, never .includes() on
text (#2974 / CONTRIBUTING.md "Prohibited: Raw Text Matching" rule).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(3255): add typed ERROR_REASON codes and GSD_JSON_ERRORS env var support

- Destructure ERROR_REASON from core in gsd-tools.cjs
- Add GSD_JSON_ERRORS=1 env var as alternative to --json-errors CLI flag
- Pass ERROR_REASON.SDK_UNKNOWN_COMMAND to unknown top-level command default path
- Pass ERROR_REASON.SDK_UNKNOWN_COMMAND to unknown intel subcommand path
- Pass ERROR_REASON.USAGE to --pick missing value error path
- Pass ERROR_REASON.USAGE to --version flag rejection path

All ten tests in feat-3255-json-errors-mode.test.cjs pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(3255): add json-errors taxonomy doc, changeset, and CHANGELOG entry

- docs/json-errors.md: full error code taxonomy, wire format spec, and
  test-authoring guidelines for the --json-errors mode
- .changeset/gentle-tigers-roar.md: changeset fragment (pr will be updated
  after PR is opened)
- CHANGELOG.md: Unreleased → Added entry for the new structured error mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: update changeset PR number to 3304

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(gsd-tools): document --json-errors in usage/help text (#3255)

Add [--json-errors] to the TOP_LEVEL_USAGE synopsis line and introduce a
"Global flags:" section describing all four global flags (--raw, --pick,
--cwd, --ws) plus --json-errors with its GSD_JSON_ERRORS=1 env-var
alternative, so operators can discover the flag via `gsd-tools --help`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: drop redundant CHANGELOG.md edit (use .changeset/ fragment per CONTRIBUTING.md)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-09 11:46:34 -04:00
committed by GitHub
parent 706ddb5ea5
commit 31e2c22309
4 changed files with 351 additions and 6 deletions

View File

@@ -0,0 +1,5 @@
---
type: Added
pr: 3304
---
gsd-tools --json-errors mode: all error paths now emit structured JSON ({ok, reason, message}) when invoked with --json-errors or GSD_JSON_ERRORS=1 — tests can assert on typed reason codes instead of grepping stderr text

123
docs/json-errors.md Normal file
View File

@@ -0,0 +1,123 @@
# JSON Error Mode — `gsd-tools` Structured Errors
## Overview
`gsd-tools` supports a **JSON error mode** that emits all 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").
## Activating
Either flag or env var activates the mode:
```bash
# 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:
```json
{ "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). |
## Error code taxonomy
Codes are frozen constants in `get-shit-done/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
Always 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
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
`get-shit-done/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`.

View File

@@ -172,7 +172,7 @@
const fs = require('fs');
const path = require('path');
const core = require('./lib/core.cjs');
const { error, findProjectRoot } = core;
const { error, findProjectRoot, ERROR_REASON } = core;
const { getActiveWorkstream } = require('./lib/planning-workspace.cjs');
const { resolveActiveWorkstream, applyResolvedWorkstreamEnv } = require('./lib/active-workstream-store.cjs');
const state = require('./lib/state.cjs');
@@ -304,6 +304,8 @@ async function main() {
if (jsonErrorsIdx !== -1) {
core.setJsonErrorMode(true);
args.splice(jsonErrorsIdx, 1);
} else if (process.env.GSD_JSON_ERRORS === '1') {
core.setJsonErrorMode(true);
}
// --pick <name>: extract a single field from JSON output (replaces jq dependency).
@@ -313,7 +315,7 @@ async function main() {
let pickField = null;
if (pickIdx !== -1) {
pickField = args[pickIdx + 1];
if (!pickField || pickField.startsWith('--')) error('Missing value for --pick');
if (!pickField || pickField.startsWith('--')) error('Missing value for --pick', ERROR_REASON.USAGE);
args.splice(pickIdx, 2);
}
@@ -359,7 +361,7 @@ async function main() {
// supported by the dispatcher so `--help` is actually useful for
// discovery; previously it was a partial subset that didn't include
// phase / roadmap / milestone / progress / etc.
const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>]\n' +
const TOP_LEVEL_USAGE = 'Usage: gsd-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>] [--json-errors]\n' +
'Commands: agent-skills, audit-open, audit-uat, check-commit, commit, commit-to-subrepo, ' +
'config-ensure-section, config-get, config-new-project, config-path, config-set, ' +
'current-timestamp, detect-custom-files, docs-init, extract-messages, find-phase, ' +
@@ -368,6 +370,12 @@ async function main() {
'learnings, list-todos, milestone, phase, phase-plan-index, phases, profile-questionnaire, ' +
'profile-sample, progress, requirements, resolve-model, roadmap, scaffold, state, ' +
'template, validate, verify, verify-path-exists, verify-summary, workstream\n\n' +
'Global flags:\n' +
' --raw Emit raw output without post-processing\n' +
' --pick <field> Extract a single field from JSON output (dot/bracket notation)\n' +
' --cwd <path> Override working directory for project-root resolution\n' +
' --ws <name> Override active workstream (or set GSD_WORKSTREAM)\n' +
' --json-errors Emit structured JSON error objects on stderr (or set GSD_JSON_ERRORS=1)\n\n' +
'For command-specific argument requirements, invoke the command without args ' +
'(e.g. `gsd-tools phase add`) — the resulting error lists what is required.';
@@ -396,7 +404,7 @@ async function main() {
const NEVER_VALID_FLAGS = new Set(['--version', '-v']);
for (const arg of args) {
if (NEVER_VALID_FLAGS.has(arg)) {
error(`Unknown flag: ${arg}\ngsd-tools does not accept version flags. Run "gsd-tools" with no arguments for usage.`);
error(`Unknown flag: ${arg}\ngsd-tools does not accept version flags. Run "gsd-tools" with no arguments for usage.`, ERROR_REASON.USAGE);
}
}
@@ -1011,7 +1019,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
const planningDir = path.join(cwd, '.planning');
core.output(intel.intelUpdate(planningDir), raw);
} else {
error('Unknown intel subcommand. Available: query, status, update, diff, snapshot, patch-meta, validate, extract-exports');
error('Unknown intel subcommand. Available: query, status, update, diff, snapshot, patch-meta, validate, extract-exports', ERROR_REASON.SDK_UNKNOWN_COMMAND);
}
break;
}
@@ -1192,7 +1200,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
const rest = originalCommand.slice(dotIdx + 1);
suggestion = ` — did you mean: "${head} ${rest}"?`;
}
error(`Unknown command: ${command}${suggestion}`);
error(`Unknown command: ${command}${suggestion}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
}
}
}

View File

@@ -0,0 +1,209 @@
/**
* Tests for the --json-errors mode added in #3255.
*
* When gsd-tools is invoked with --json-errors, all error() calls emit a
* structured JSON object to stderr:
*
* { ok: false, reason: "<error_code>", message: "<human text>" }
*
* This lets tests assert on typed reason codes instead of grepping free-form
* stderr text. All assertions below parse the captured stderr via JSON.parse
* and inspect typed fields — never result.error.includes() (#2974 / k001).
*
* Covered error paths (representative set, each exercises a different branch):
* 1. Unknown top-level command → reason: "sdk_unknown_command"
* 2. Unknown dotted command → reason: "sdk_unknown_command"
* 3. Missing required argument → reason: "usage" (--pick without value)
* 4. Config key not found → reason: "config_key_not_found"
* 5. Unknown subcommand → reason: "sdk_unknown_command"
* 6. GSD_JSON_ERRORS=1 env var → same structured output without --flag
* 7. Successful command unaffected
* 8. Error object shape is stable ({ok, reason, message})
* 9. Single error line per invocation
* 10. Unknown flag → reason: "usage"
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
// Helper: run gsd-tools with --json-errors and parse the structured stderr.
// Returns the parsed object, or throws if stderr is not valid JSON.
function runJsonErrors(args, tmpDir, env = {}) {
const allArgs = ['--json-errors', ...args];
const result = runGsdTools(allArgs, tmpDir, env);
// Must have failed
assert.strictEqual(result.success, false,
`Expected failure with --json-errors for args: ${args.join(' ')}\nstdout: ${result.output}\nstderr: ${result.error}`);
let parsed;
try {
parsed = JSON.parse(result.error);
} catch (e) {
throw new Error(
`--json-errors must emit valid JSON on stderr.\n` +
`Args: ${args.join(' ')}\n` +
`stderr: ${result.error}\n` +
`parse error: ${e.message}`
);
}
return parsed;
}
describe('feat #3255: --json-errors mode emits structured error objects', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
// ── 1. Unknown top-level command ─────────────────────────────────────────
test('unknown top-level command emits { ok: false, reason: "sdk_unknown_command" }', () => {
const parsed = runJsonErrors(['totally-unknown-command-xyzzy'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
`reason must be "sdk_unknown_command", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 2. Unknown dotted command ────────────────────────────────────────────
test('unknown dotted command (foo.bar) emits { ok: false, reason: "sdk_unknown_command" }', () => {
const parsed = runJsonErrors(['foo.bar'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
`dotted unknown command reason must be "sdk_unknown_command", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 3. Missing --pick value ───────────────────────────────────────────────
test('--pick without value emits { ok: false, reason: "usage" }', () => {
const parsed = runJsonErrors(['generate-slug', 'test-text', '--pick'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'usage',
`missing --pick value reason must be "usage", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 4. Config key not found ───────────────────────────────────────────────
test('config-get for absent key emits { ok: false, reason: "config_key_not_found" }', () => {
// Initialise config.json first so we reach the "key not found" branch
// rather than the "no config.json" branch.
runGsdTools(['config-ensure-section'], tmpDir);
const parsed = runJsonErrors(['config-get', 'nonexistent_config_key_xyzzy'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'config_key_not_found',
`reason must be "config_key_not_found", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 5. Unknown subcommand within a domain ────────────────────────────────
test('unknown intel subcommand emits { ok: false, reason: "sdk_unknown_command" }', () => {
const parsed = runJsonErrors(['intel', 'bogus-subcommand-xyzzy'], tmpDir);
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
`unknown subcommand reason must be "sdk_unknown_command", got: ${parsed.reason}`);
assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0,
'message must be a non-empty string');
});
// ── 6. GSD_JSON_ERRORS=1 env var activates structured mode ───────────────
test('GSD_JSON_ERRORS=1 env var produces same structured error as --json-errors flag', () => {
// Run with env var instead of --json-errors flag
const result = runGsdTools(
['totally-unknown-command-xyzzy'],
tmpDir,
{ GSD_JSON_ERRORS: '1' }
);
assert.strictEqual(result.success, false,
'command must fail');
let parsed;
try {
parsed = JSON.parse(result.error);
} catch (e) {
throw new Error(
`GSD_JSON_ERRORS=1 must emit valid JSON on stderr.\n` +
`stderr: ${result.error}\n` +
`parse error: ${e.message}`
);
}
assert.strictEqual(parsed.ok, false,
'error object must have ok: false');
assert.strictEqual(parsed.reason, 'sdk_unknown_command',
`reason must be "sdk_unknown_command", got: ${parsed.reason}`);
});
// ── 7. Successful commands are unaffected by --json-errors ───────────────
test('successful command with --json-errors flag still succeeds normally', () => {
const result = runGsdTools(
['--json-errors', 'generate-slug', 'hello-world'],
tmpDir
);
assert.strictEqual(result.success, true,
`Successful command must not be broken by --json-errors flag.\nstderr: ${result.error}`);
assert.ok(result.output.length > 0,
'stdout must be non-empty for successful generate-slug');
});
// ── 8. Error object shape is stable (no extra top-level keys) ────────────
test('error object contains exactly {ok, reason, message} — no extra keys', () => {
const parsed = runJsonErrors(['totally-unknown-command-xyzzy'], tmpDir);
const keys = Object.keys(parsed).sort();
assert.deepStrictEqual(keys, ['message', 'ok', 'reason'],
`error object must have exactly {ok, reason, message}. Got keys: ${keys.join(', ')}`);
});
// ── 9. Multiple errors in one session: only the first error is emitted ───
test('only one error JSON line is emitted per invocation (process exits on first error)', () => {
const result = runGsdTools(
['--json-errors', 'totally-unknown-command-xyzzy'],
tmpDir
);
assert.strictEqual(result.success, false, 'must fail');
const lines = result.error.trim().split('\n').filter(l => l.length > 0);
assert.strictEqual(lines.length, 1,
`stderr must contain exactly one JSON line, got ${lines.length}:\n${result.error}`);
// Also verify the single line is valid JSON
const parsed = JSON.parse(lines[0]);
assert.strictEqual(parsed.ok, false);
});
// ── 10. Unknown flag emits { ok: false, reason: "usage" } ────────────────
test('unknown version flag emits { ok: false, reason: "usage" }', () => {
const parsed = runJsonErrors(['--version', 'generate-slug', 'x'], tmpDir);
assert.strictEqual(parsed.ok, false, 'error object must have ok: false');
assert.strictEqual(parsed.reason, 'usage',
`--version flag reason must be "usage", got: ${parsed.reason}`);
});
});