* docs(#2980): ratify the payload-carried error idiom as a degraded result Records ADR-2980: an `error` key in a gsd-tools result payload on stdout with exit 0 is a ratified contract meaning the command ran to completion and is reporting a condition, not a process failure. Faults keep stderr + exit 1 + --json-errors. Normalizing the 42 output({error}) sites to exit 1 was declined on measured blast radius (get_impact rates cmdStateSnapshot CRITICAL; output has 170 direct callers) — a Hyrum's Law break with no versioning escape hatch for an exit code. Adds the "Degraded results vs faults" section to docs/json-errors.md with a correct-caller recipe, indexes that page from docs/README.md, and records the two-channel contract in the CONTEXT.md I/O Module entry. No code change. Closes #2980 * docs(#2980): correct the site count and cross-refs after review The isolated review found the population figure was the answer to a regex, not to the question. `output\(\{\s*error:` only matches literals whose FIRST key is `error`; re-deriving it by brace-matching output()'s first argument gives 60 sites across 9 modules (42 error-first + 18 error-not-first), adding workstream.cts, phase.cts and gsd2-import.cts. roadmap.cts:260 is the case in point — it carries `error` alongside `found:false` and is the site that actually produces the documented `roadmap get-phase` output. Also from review: correct the --raw claim (11 sites pass a rawValue, not 2), reconcile the missing-required-argument count to the 7 verified sites, link the bare ADR-2966 references per the ADR lifecycle rule, and fix 'licence' to American spelling. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -9,6 +9,12 @@ 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.
|
||||
|
||||
> **This page describes one of two failure channels.** A second, equally
|
||||
> intentional one reports conditions in the **result payload on stdout with
|
||||
> exit 0**. A caller that branches on exit status alone will not see it. Read
|
||||
> [Degraded results vs faults](#degraded-results-vs-faults--read-this-before-writing-a-caller)
|
||||
> before writing anything that consumes `gsd-tools` output.
|
||||
|
||||
## Activating
|
||||
|
||||
Either flag or env var activates the mode:
|
||||
@@ -52,6 +58,108 @@ 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).
|
||||
|
||||
## Degraded results vs faults — read this before writing a caller
|
||||
|
||||
`gsd-tools` has **two** ways of telling you something went wrong, and they use **different exit
|
||||
codes**. The wire format above describes only one of them. If you write a caller that branches on
|
||||
exit status alone, you will silently miss the other.
|
||||
|
||||
| | **Fault** | **Degraded result** |
|
||||
|---|---|---|
|
||||
| Produced by | `error(message, reason)` | `output({ error: … })` |
|
||||
| Stream | **stderr** | **stdout** |
|
||||
| Exit code | **1** | **0** |
|
||||
| Shape | `{ "ok": false, "reason": …, "message": … }` | the command's ordinary result object, with an added `error` key |
|
||||
| Honors `--json-errors` | **yes** | **no** — it is a payload, not an error envelope |
|
||||
| How a caller detects it | exit code | **inspect the payload** |
|
||||
|
||||
A **degraded result** means: *the command ran to completion and is reporting a condition through its
|
||||
result.* It is not a process failure. The command succeeded at the job of determining that, for
|
||||
example, the artifact you asked about is absent.
|
||||
|
||||
```console
|
||||
$ gsd-tools state-snapshot # in a project with no STATE.md
|
||||
{
|
||||
"error": "STATE.md not found"
|
||||
}
|
||||
$ echo $?
|
||||
0
|
||||
```
|
||||
|
||||
Some verbs return a companion result alongside the key, which is the shape that makes the intent
|
||||
clearest:
|
||||
|
||||
```console
|
||||
$ gsd-tools roadmap get-phase --phase 1 # no ROADMAP.md
|
||||
{
|
||||
"found": false,
|
||||
"error": "ROADMAP.md not found"
|
||||
}
|
||||
$ echo $?
|
||||
0
|
||||
```
|
||||
|
||||
This is a **ratified contract**, not an accident — see
|
||||
[ADR-2980](adr/2980-payload-carried-error-is-a-degraded-result.md) for the decision and the blast
|
||||
radius that drove it. It applies to **60 call sites across nine modules** — `state`, `verify`,
|
||||
`workstream`, `frontmatter`, `commands`, `template`, `phase`, `roadmap`, and `gsd2-import`.
|
||||
(Issues #2966 and #2980 record this as "42 sites"; that figure counts only the sites where `error`
|
||||
happens to be the object's first key. See ADR-2980 for why the real number is 60.)
|
||||
|
||||
### Writing a correct caller
|
||||
|
||||
The obvious shell form is **wrong** for a degraded result:
|
||||
|
||||
```sh
|
||||
# WRONG — the process exits 0, so this branch never runs
|
||||
if ! gsd-tools state-snapshot > snap.json; then
|
||||
echo "failed"
|
||||
fi
|
||||
```
|
||||
|
||||
Check both channels — the exit code for faults, the payload for degraded results:
|
||||
|
||||
```sh
|
||||
if ! out=$(gsd-tools state-snapshot); then
|
||||
echo "fault (exit non-zero)" >&2 # error() path
|
||||
exit 1
|
||||
fi
|
||||
if err=$(printf '%s' "$out" | jq -er '.error // empty'); then
|
||||
echo "degraded: $err" >&2 # output({error}) path
|
||||
fi
|
||||
```
|
||||
|
||||
### Four things that will surprise you
|
||||
|
||||
1. **`--json-errors` does nothing here.** It governs `error()` only. A degraded result is
|
||||
byte-identical with and without the flag, and still exits 0.
|
||||
2. **`--raw` is not uniform on this path.** Most sites pass no raw value, so `--raw` still yields
|
||||
the JSON object rather than bare text — but eleven sites do pass one and behave differently.
|
||||
Do not infer either behavior from `--raw` alone; check the verb.
|
||||
3. **Not every degraded result is an absent artifact.** A missing required argument is reported the
|
||||
same way — `gsd-tools state add-blocker` with no `--text` returns `{"error":"text required"}` and
|
||||
exits 0. So is unusable input: `gsd-tools state advance-plan` against a STATE.md it cannot parse
|
||||
returns `{"error":"Cannot parse Current Plan or Total Plans in Phase from STATE.md"}`, also exit
|
||||
0. **The exit code does not distinguish absent from malformed from misinvoked** — see ADR-2980's
|
||||
Consequences, where this is recorded as a known cost.
|
||||
4. **`message`/`error` text is not stable.** Assert on structure and on typed `reason` codes, never
|
||||
on prose. The rule in "Writing tests" below applies to both paths.
|
||||
|
||||
### Which one should new code use?
|
||||
|
||||
Prefer the **fault** path, or a result with a named field. ADR-2980 ratifies an existing population;
|
||||
it is not a license to add a 61st `output({ error: … })` site. Where a verb needs to report a
|
||||
non-fatal condition in its payload, prefer the shape `state update-progress` already uses — a named
|
||||
field plus a reason, with no overloaded `error` key:
|
||||
|
||||
```console
|
||||
$ gsd-tools state update-progress # STATE.md present, no Progress field
|
||||
{
|
||||
"updated": false,
|
||||
"reason": "Progress field not found in STATE.md"
|
||||
}
|
||||
```
|
||||
|
||||
## Error code taxonomy
|
||||
|
||||
Codes are frozen constants in `gsd-core/bin/lib/core.cjs` under
|
||||
|
||||
Reference in New Issue
Block a user