ADR 002 Exit codes and diagnostics - nekrozis/qtogo GitHub Wiki
Status: Accepted (2026-10-06).
The exit code and the --json failure document are the parts of the CLI that
automation reads. Both are therefore an interface: a script that branches on
"version not found" must keep meaning that, and a CI job must be able to tell a
mistyped version from an unreachable server. The reference implementation has no
such contract; this project needs one.
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | internal error — unclassified |
| 2 | command-line usage error |
| 3 | version or package not found |
| 4 | network or mirror error |
| 5 | integrity or security error |
| 6 | extraction or filesystem error |
| 7 | relocation error |
| 8 | configuration error |
| 9 | authentication error |
| 130 | interrupted by the user |
- 1 is "internal", not "bug". The code is read by users and scripts, so it cannot promise "a developer made a mistake": resource exhaustion, an unexpected state from a dependency, or an unforeseen failure all land here. What it does promise is that the failure did not fit any other category.
- 3 and 4 stay separate. Otherwise a typo and an outage look the same, and a caller cannot choose between "suggest a version" and "retry". A request that the server answers with "no such package" is 3; anything where we could not learn whether the package exists is 4.
- 5 is integrity or security: a checksum that does not match, a signature that fails, or an archive whose paths would escape the destination. In each case the bytes arrived but cannot be trusted.
-
6 is extraction or filesystem, 7 is relocation. 7 means the archives
were extracted correctly and the installation still is not usable — a prefix
rewrite or a
qt.confthat could not be written. Keeping them apart says whether the download is suspect or the post-processing is. - 8 and 9 are reserved now, used later for invalid configuration and for credential failures. Config and credentials are not edge cases in this project's roadmap, and defining them later would be a contract change.
- Codes 10 and above are not pre-defined. A new one is added when the domain actually needs it.
internal/exitcode.Classify is the only code that maps a failure to a number,
and it only trusts errors that implement CodedError:
func Classify(err error) int // nil -> OK; CodedError -> its code; else InternalEverything the program raises is an *errs.Error carrying a category-derived
exit code, a stable machine code and a phase. An error that did not come from
that type is reported as internal, which is a signal to fix the taxonomy rather
than to guess.
*errs.Error is the canonical implementation of CodedError; other
implementations are not part of the public error taxonomy. A second type that
reports its own exit code would put the mapping in two places, which is exactly
what this decision exists to prevent.
Written to stderr, always the same shape:
{"ok":false,"error":{"code":"usage.unknown_command","phase":"parse","message":"unknown command \"list-qt\"","suggestion":"run \"qtogo help\" to see the available commands"}}- Field names are frozen;
suggestionis omitted when empty. -
--jsonis honoured even when parsing is what failed, because the automation that asked for JSON still has to read the failure. This is why the mode is determined by scanning the raw command line. - Text mode is for people:
Error: <message>plus ahint:line when there is a suggestion.
This document is the JSON representation of a failure only. Successful
command output has its own contract, defined by the command that produces it —
this envelope is not a wrapper every --json document has to fit inside.
A panic is reserved for a broken internal invariant — a rule table that is empty when it cannot be, a node the dispatcher cannot reach. Anything a user or the environment can cause is returned as an error, because a stack trace is not a diagnostic.
- Adding a category means adding a code and a row here; the exit-code test pins the numbers so an accidental change fails the build.
- Callers of the library never see a raw error from the top level:
Mainrenders it and returns the code. - Because
--jsonis decided before parsing, a malformed command line still produces a document rather than a plain-text surprise.
- Reuse a general-purpose scheme (0/1/2, or sysexits). Rejected: it cannot express "not found" versus "network", which is the distinction this tool's callers most need.
- Make 1 mean "programming bug". Rejected: it makes an unclassified runtime condition indistinguishable from a defect in a script's eyes.
-
Put the code in the JSON document only. Rejected:
$?is what shell and CI read first, and it must carry the same meaning.