Returned command errors are not printed, and most documented exit code categories are collapsed into usage exit code 2.
Evidence
cmd/license-tool/commands.go:42 to cmd/license-tool/commands.go:43 sets SilenceUsage and SilenceErrors on the root command.
cmd/license-tool/main.go:18 to cmd/license-tool/main.go:26 exits 2 for any returned error and does not print the error.
README.md:113 to README.md:121 documents distinct exit codes for policy failure, usage error, write refusal, and internal error.
docs/license-tool-design.md:29 documents the same contract.
internal/report/report.go:377 to internal/report/report.go:385 can distinguish check internal errors, but the CLI does not surface that distinction.
Reproduction
Build the binary.
Run an invalid format value for audit.
Observe exit code 2 with empty stdout and stderr.
Run a write-enabled apply in a non-git directory without force.
Observe exit code 2 instead of the documented write-refusal code, with no diagnostic.
Why It Matters
Users and CI cannot diagnose failures from stderr, and automation cannot distinguish usage mistakes from unsafe write refusals or internal failures.
Suggested Fix
Introduce a command error type or command execution result that carries the intended exit code. Print returned errors to stderr when Cobra is silenced, and map write refusals and internal failures to the documented codes.
## Finding
Returned command errors are not printed, and most documented exit code categories are collapsed into usage exit code `2`.
## Evidence
- `cmd/license-tool/commands.go:42` to `cmd/license-tool/commands.go:43` sets `SilenceUsage` and `SilenceErrors` on the root command.
- `cmd/license-tool/main.go:18` to `cmd/license-tool/main.go:26` exits `2` for any returned error and does not print the error.
- `README.md:113` to `README.md:121` documents distinct exit codes for policy failure, usage error, write refusal, and internal error.
- `docs/license-tool-design.md:29` documents the same contract.
- `internal/report/report.go:377` to `internal/report/report.go:385` can distinguish check internal errors, but the CLI does not surface that distinction.
## Reproduction
1. Build the binary.
2. Run an invalid format value for `audit`.
3. Observe exit code `2` with empty stdout and stderr.
4. Run a write-enabled apply in a non-git directory without force.
5. Observe exit code `2` instead of the documented write-refusal code, with no diagnostic.
## Why It Matters
Users and CI cannot diagnose failures from stderr, and automation cannot distinguish usage mistakes from unsafe write refusals or internal failures.
## Suggested Fix
Introduce a command error type or command execution result that carries the intended exit code. Print returned errors to stderr when Cobra is silenced, and map write refusals and internal failures to the documented codes.
Status: confirmed — both halves reproduce. With SilenceErrors: true set on the root command, returned errors are never printed, and main.go collapses every returned error to exit 2, swallowing the documented 1/3/4 codes on the error path.
Severity: high (label is bug/unspecified). It is not critical (no data loss / no incorrect writes), but it defeats the tool's CI purpose: automation cannot distinguish a usage mistake from an unsafe write refusal or an internal failure, and operators get zero diagnostic text. High is justified for a CI-gating tool whose entire value is machine-readable failure signaling.
Verified root cause:
cmd/license-tool/commands.go:42-43 (newRootCmd) — SilenceUsage: trueandSilenceErrors: true. The SilenceErrors flag is the silence root cause; cobra would otherwise print returned errors itself.
cmd/license-tool/main.go:18-26 (main) — if err := root.Execute(); err != nil { os.Exit(2) }. Blanket exit 2, no print. The inline comment here ("Cobra has already printed the error") is factually wrong given SilenceErrors: true.
Exit-code collapse on the error path is structural, not just main:
apply write refusal originates at internal/applier/applier.go:298 (gateWrite: errors.New("applier: refusing to write in a non-git directory without --force")). newApplyCmd RunE (commands.go:198-200) just return err, so it lands on main.go's exit 2 instead of the documented 3.
check internal error: report.Check returns (4, err) (internal/report/report.go:382), but newCheckCmd RunE (commands.go:136-137) does if err != nil { return err }, discarding the 4. The os.Exit(code) at commands.go:140 only runs on the err == nil branch, so the 4 never reaches the process.
write-enabled apply --write in a non-git dir without --force → exit 2 (documented: 3), empty stdout/stderr — error text from applier.go:298 is swallowed.
genuine cobra usage error (audit --nope .) → exit 2, also silent (this is the SilenceErrors over-reach: it suppresses cobra's own usage/flag errors too, not just RunE-returned errors).
Suggested fix: Mirror the v0.2.1 universal-shebang precedent of fixing the behavior at every relevant site rather than one. Define a small typed command error (e.g. type exitError struct { code int; err error }) and have RunE bodies return it:
apply/license: wrap the gateWrite refusal as code 3. Cleanest is for gateWrite/applier.Apply to return a typed refusal that the RunE maps to 3.
check: stop discarding the returned code — return exitError{4, err} when report.Check returns a non-zero code with an error, and keep the existing os.Exit(code) for the 1 case (or fold that into the typed error too).
usage/validation errors (ParseFormat, bad flags, config.Resolve) → code 2.
internal/unexpected → code 4.
Then in main.go: keep SilenceUsage but either drop SilenceErrors or print the error to stderr explicitly before exiting, and read the code off the typed error (errors.As) defaulting to 2. Because main.go is the only covered-excluded CLI file (.testcoverage.yml:45), put the code-mapping logic in commands.go (which is gated at 100%) and keep main.go a thin errors.As + os.Exit shim — otherwise the new branches in main.go are untestable and the gate notes for main.go stay honest. New commands.go branches (refusal→3, internal→4, usage→2) must each get a unit test to hold the 100% total.
Corrections to the issue:
The body cites internal/report/report.go:377-385 as where check "can distinguish internal errors." The return 4, err is at line 382 (the Check func spans ~376-388); the cited range is close but the load-bearing line is 382. Minor.
The body's framing ("most documented exit code categories are collapsed into 2") is accurate but slightly understates one detail worth recording: SilenceErrors: true also silences cobra's own usage/flag errors (repro #3), so the silence is broader than RunE-returned errors. Not an over-claim, an under-claim.
Otherwise the file:line references (commands.go:42-43, main.go:18-26, README 113-121, design :29) are accurate.
Open questions:
For check policy failure today the process exits 1 via os.Exit(code)with no stderr text — should a policy failure also print a one-line summary to stderr, or stay silent and rely on the rendered report on stdout? Affects whether 1 flows through the new typed-error path or keeps its direct os.Exit.
Should --quiet suppress the new stderr error line, or is an error always printed regardless of --quiet? (Recommend: errors always print.)
Confirm the intended code for config.Resolve failures (missing license/holder in CI): usage 2 or internal 4? README lumps "missing required fields … hard-error in CI" without assigning a code.
Overlaps: none found from this issue's body. A maintainer should confirm no separate open issue already tracks the apply write-refusal exit code specifically, since the fix here must touch that path too.
Automated maintainer review.
**Status**: confirmed — both halves reproduce. With `SilenceErrors: true` set on the root command, returned errors are never printed, and `main.go` collapses every returned error to exit `2`, swallowing the documented `1/3/4` codes on the error path.
**Severity**: high (label is `bug`/unspecified). It is not critical (no data loss / no incorrect writes), but it defeats the tool's CI purpose: automation cannot distinguish a usage mistake from an unsafe write refusal or an internal failure, and operators get zero diagnostic text. High is justified for a CI-gating tool whose entire value is machine-readable failure signaling.
**Verified root cause**:
- `cmd/license-tool/commands.go:42-43` (`newRootCmd`) — `SilenceUsage: true` **and** `SilenceErrors: true`. The `SilenceErrors` flag is the silence root cause; cobra would otherwise print returned errors itself.
- `cmd/license-tool/main.go:18-26` (`main`) — `if err := root.Execute(); err != nil { os.Exit(2) }`. Blanket exit `2`, no print. The inline comment here ("Cobra has already printed the error") is **factually wrong** given `SilenceErrors: true`.
- Exit-code collapse on the error path is structural, not just `main`:
- `apply` write refusal originates at `internal/applier/applier.go:298` (`gateWrite`: `errors.New("applier: refusing to write in a non-git directory without --force")`). `newApplyCmd` RunE (`commands.go:198-200`) just `return err`, so it lands on `main.go`'s exit `2` instead of the documented `3`.
- `check` internal error: `report.Check` returns `(4, err)` (`internal/report/report.go:382`), but `newCheckCmd` RunE (`commands.go:136-137`) does `if err != nil { return err }`, discarding the `4`. The `os.Exit(code)` at `commands.go:140` only runs on the `err == nil` branch, so the `4` never reaches the process.
**Reproduction** (built at HEAD, temp dir):
1. `audit --format bogus .` → exit `2`, empty stdout, empty stderr.
2. write-enabled `apply --write` in a non-git dir without `--force` → exit `2` (documented: `3`), empty stdout/stderr — error text from `applier.go:298` is swallowed.
3. genuine cobra usage error (`audit --nope .`) → exit `2`, **also silent** (this is the `SilenceErrors` over-reach: it suppresses cobra's own usage/flag errors too, not just RunE-returned errors).
**Suggested fix**: Mirror the v0.2.1 universal-shebang precedent of fixing the behavior at every relevant site rather than one. Define a small typed command error (e.g. `type exitError struct { code int; err error }`) and have RunE bodies return it:
- `apply`/`license`: wrap the `gateWrite` refusal as code `3`. Cleanest is for `gateWrite`/`applier.Apply` to return a typed refusal that the RunE maps to `3`.
- `check`: stop discarding the returned code — return `exitError{4, err}` when `report.Check` returns a non-zero code with an error, and keep the existing `os.Exit(code)` for the `1` case (or fold that into the typed error too).
- usage/validation errors (`ParseFormat`, bad flags, `config.Resolve`) → code `2`.
- internal/unexpected → code `4`.
Then in `main.go`: keep `SilenceUsage` but **either drop `SilenceErrors` or print the error to stderr explicitly** before exiting, and read the code off the typed error (`errors.As`) defaulting to `2`. Because `main.go` is the *only* covered-excluded CLI file (`.testcoverage.yml:45`), put the code-mapping logic in `commands.go` (which is gated at 100%) and keep `main.go` a thin `errors.As` + `os.Exit` shim — otherwise the new branches in `main.go` are untestable and the gate notes for `main.go` stay honest. New `commands.go` branches (refusal→3, internal→4, usage→2) must each get a unit test to hold the 100% total.
**Corrections to the issue**:
- The body cites `internal/report/report.go:377-385` as where check "can distinguish internal errors." The `return 4, err` is at **line 382** (the `Check` func spans ~376-388); the cited range is close but the load-bearing line is 382. Minor.
- The body's framing ("most documented exit code categories are collapsed into `2`") is accurate but slightly understates one detail worth recording: `SilenceErrors: true` also silences cobra's *own* usage/flag errors (repro #3), so the silence is broader than RunE-returned errors. Not an over-claim, an under-claim.
- Otherwise the file:line references (`commands.go:42-43`, `main.go:18-26`, README `113-121`, design `:29`) are accurate.
**Open questions**:
- For `check` policy failure today the process exits `1` via `os.Exit(code)` *with no stderr text* — should a policy failure also print a one-line summary to stderr, or stay silent and rely on the rendered report on stdout? Affects whether `1` flows through the new typed-error path or keeps its direct `os.Exit`.
- Should `--quiet` suppress the new stderr error line, or is an error always printed regardless of `--quiet`? (Recommend: errors always print.)
- Confirm the intended code for `config.Resolve` failures (missing license/holder in CI): usage `2` or internal `4`? README lumps "missing required fields … hard-error in CI" without assigning a code.
**Overlaps**: none found from this issue's body. A maintainer should confirm no separate open issue already tracks the `apply` write-refusal exit code specifically, since the fix here must touch that path too.
_Automated maintainer review._
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Finding
Returned command errors are not printed, and most documented exit code categories are collapsed into usage exit code
2.Evidence
cmd/license-tool/commands.go:42tocmd/license-tool/commands.go:43setsSilenceUsageandSilenceErrorson the root command.cmd/license-tool/main.go:18tocmd/license-tool/main.go:26exits2for any returned error and does not print the error.README.md:113toREADME.md:121documents distinct exit codes for policy failure, usage error, write refusal, and internal error.docs/license-tool-design.md:29documents the same contract.internal/report/report.go:377tointernal/report/report.go:385can distinguish check internal errors, but the CLI does not surface that distinction.Reproduction
audit.2with empty stdout and stderr.2instead of the documented write-refusal code, with no diagnostic.Why It Matters
Users and CI cannot diagnose failures from stderr, and automation cannot distinguish usage mistakes from unsafe write refusals or internal failures.
Suggested Fix
Introduce a command error type or command execution result that carries the intended exit code. Print returned errors to stderr when Cobra is silenced, and map write refusals and internal failures to the documented codes.
Status: confirmed — both halves reproduce. With
SilenceErrors: trueset on the root command, returned errors are never printed, andmain.gocollapses every returned error to exit2, swallowing the documented1/3/4codes on the error path.Severity: high (label is
bug/unspecified). It is not critical (no data loss / no incorrect writes), but it defeats the tool's CI purpose: automation cannot distinguish a usage mistake from an unsafe write refusal or an internal failure, and operators get zero diagnostic text. High is justified for a CI-gating tool whose entire value is machine-readable failure signaling.Verified root cause:
cmd/license-tool/commands.go:42-43(newRootCmd) —SilenceUsage: trueandSilenceErrors: true. TheSilenceErrorsflag is the silence root cause; cobra would otherwise print returned errors itself.cmd/license-tool/main.go:18-26(main) —if err := root.Execute(); err != nil { os.Exit(2) }. Blanket exit2, no print. The inline comment here ("Cobra has already printed the error") is factually wrong givenSilenceErrors: true.main:applywrite refusal originates atinternal/applier/applier.go:298(gateWrite:errors.New("applier: refusing to write in a non-git directory without --force")).newApplyCmdRunE (commands.go:198-200) justreturn err, so it lands onmain.go's exit2instead of the documented3.checkinternal error:report.Checkreturns(4, err)(internal/report/report.go:382), butnewCheckCmdRunE (commands.go:136-137) doesif err != nil { return err }, discarding the4. Theos.Exit(code)atcommands.go:140only runs on theerr == nilbranch, so the4never reaches the process.Reproduction (built at HEAD, temp dir):
audit --format bogus .→ exit2, empty stdout, empty stderr.apply --writein a non-git dir without--force→ exit2(documented:3), empty stdout/stderr — error text fromapplier.go:298is swallowed.audit --nope .) → exit2, also silent (this is theSilenceErrorsover-reach: it suppresses cobra's own usage/flag errors too, not just RunE-returned errors).Suggested fix: Mirror the v0.2.1 universal-shebang precedent of fixing the behavior at every relevant site rather than one. Define a small typed command error (e.g.
type exitError struct { code int; err error }) and have RunE bodies return it:apply/license: wrap thegateWriterefusal as code3. Cleanest is forgateWrite/applier.Applyto return a typed refusal that the RunE maps to3.check: stop discarding the returned code — returnexitError{4, err}whenreport.Checkreturns a non-zero code with an error, and keep the existingos.Exit(code)for the1case (or fold that into the typed error too).ParseFormat, bad flags,config.Resolve) → code2.4.Then in
main.go: keepSilenceUsagebut either dropSilenceErrorsor print the error to stderr explicitly before exiting, and read the code off the typed error (errors.As) defaulting to2. Becausemain.gois the only covered-excluded CLI file (.testcoverage.yml:45), put the code-mapping logic incommands.go(which is gated at 100%) and keepmain.goa thinerrors.As+os.Exitshim — otherwise the new branches inmain.goare untestable and the gate notes formain.gostay honest. Newcommands.gobranches (refusal→3, internal→4, usage→2) must each get a unit test to hold the 100% total.Corrections to the issue:
internal/report/report.go:377-385as where check "can distinguish internal errors." Thereturn 4, erris at line 382 (theCheckfunc spans ~376-388); the cited range is close but the load-bearing line is 382. Minor.2") is accurate but slightly understates one detail worth recording:SilenceErrors: truealso silences cobra's own usage/flag errors (repro #3), so the silence is broader than RunE-returned errors. Not an over-claim, an under-claim.commands.go:42-43,main.go:18-26, README113-121, design:29) are accurate.Open questions:
checkpolicy failure today the process exits1viaos.Exit(code)with no stderr text — should a policy failure also print a one-line summary to stderr, or stay silent and rely on the rendered report on stdout? Affects whether1flows through the new typed-error path or keeps its directos.Exit.--quietsuppress the new stderr error line, or is an error always printed regardless of--quiet? (Recommend: errors always print.)config.Resolvefailures (missing license/holder in CI): usage2or internal4? README lumps "missing required fields … hard-error in CI" without assigning a code.Overlaps: none found from this issue's body. A maintainer should confirm no separate open issue already tracks the
applywrite-refusal exit code specifically, since the fix here must touch that path too.Automated maintainer review.
Resolved on
developin commit742c124e6ec2afa9312075db3112bc2def3c91ff.Validation:
Closing as fixed.