diff --git a/.changeset/gentle-tigers-roar.md b/.changeset/gentle-tigers-roar.md new file mode 100644 index 000000000..c4a326bfd --- /dev/null +++ b/.changeset/gentle-tigers-roar.md @@ -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 diff --git a/docs/json-errors.md b/docs/json-errors.md new file mode 100644 index 000000000..8dcdfb697 --- /dev/null +++ b/docs/json-errors.md @@ -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 [args] + +# Env var (preferred for shell wrappers and CI): +GSD_JSON_ERRORS=1 node gsd-tools.cjs [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": "", "message": "" } +``` + +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`. diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 36172a843..2d61834d7 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -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 : 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 [args] [--raw] [--pick ] [--cwd ] [--ws ]\n' + + const TOP_LEVEL_USAGE = 'Usage: gsd-tools [args] [--raw] [--pick ] [--cwd ] [--ws ] [--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 Extract a single field from JSON output (dot/bracket notation)\n' + + ' --cwd Override working directory for project-root resolution\n' + + ' --ws 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); } } } diff --git a/tests/feat-3255-json-errors-mode.test.cjs b/tests/feat-3255-json-errors-mode.test.cjs new file mode 100644 index 000000000..9be76e791 --- /dev/null +++ b/tests/feat-3255-json-errors-mode.test.cjs @@ -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: "", message: "" } + * + * 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}`); + }); +});