fix(cli): name what a usage rejection rejected, and exit 2 (#1123) #1143
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!1143
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/1123-usage-refusals"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #1123.
Intended-targets: #1123
A command that refused its own arguments exited non-zero having written nothing at all. This makes every such rejection print a line naming what was rejected, and exit 2.
Measured, before and after
32 registered verbs (29 visible + 3 hidden),
LC_ALL=C, stderr sized withstat -c %s, against the built binary rather than the source text.1410c31)<verb> --bogus-flag<verb> zz1 zz2 zz3rt ac-closure-check(required flags unset)owner,pr,repoThree paths, not one — and that is why three correct fixes did not add up
The tracker measured two paths. There is a third, and it was silent on
ac-closure-check, which is a merge gate. It is not in #1123's body because the sweep that found the population never invoked a command with its required flags unset.The three reach cobra at different points, and no hook covers more than one:
gitea-twin(#1092),credentials(#1100) andrelease-assets(#1122) were each repaired at the arm they were found through, and all three were still silent on--bogus-flagwhen this swept. Every one of those fixes was correct. None of them generalised, because each landed on one path and the defect lives on three.So the guard goes in
newRegisteredCmd— the one site every registered command passes through — and the root gets the sharedFlagErrorFunc, which cobra inherits down. The unhookable third path is pre-empted from inside the second: cobra runsValidateArgsbeforeValidateRequiredFlagsandValidateFlagGroups, so a check performed in theArgshook refuses before the unhookable ones are reached.Exit 2, not 1
A verb that refused its arguments never ran, and never-ran is could-not-grade, not a graded negative. C5 §2 previously left usage failures outside the contract at cobra's default 1; §2.1 records the narrowing, which is safe for adopters — a callsite treating non-zero as failure is unaffected, and one that distinguishes 2 now learns the gate did not run rather than that it convicted.
usageRefusalsetsSilenceErrors, and that line is load-bearingHalf the registered verbs set
SilenceErrorsand half do not. A hook that only printed would be the sole voice on one half and a duplicate on the other. Setting the field makes the hook the single renderer on every verb, which is what lets the exit code and the wording be uniform. M1 below is the arm for it.Mutation — each guard separately
Counted with
grep -cE '^ *--- FAIL', because a subtest-only pattern reports 0 for an arm that fails at top level.c.SilenceErrors = trueguardUsage(cmd)root.SetFlagErrorFuncM5's first attempt was inert and I nearly banked it. It printed
rc=1 failing=0— it had orphaned thestringsimport, so it failed to build rather than failing an arm. A build failure and a red arm both exit 1. Re-run as a compiling mutation it reddens 8 subtests, and only on theMaximumNArgs/ExactArgsverbs.That is also how the
arguments received:line came to exist: cobra's count validators do not name what they rejected.accepts at most 1 arg(s), received 3satisfies "stderr is not empty" and is not enough to act on — the usual way to arrive there is an unquoted glob, andreceived 47does not say which 47.cobra.NoArgsdoes name the first token, which is why a sampled arm would have missed this and the census arm did not.The arms range over the census, not over a list
TestEveryVerbNamesAnUnknownFlagandTestEveryVerbNamesARejectedArgumentiteratesubcommands, the registration census, so a verb added after today is covered on the day it is registered. A hand-written list is what let this grow: #1123's original population was assembled by grepping forSilenceErrorsplus anArgs:line and was wrong by three rows in both directions.TestEveryVerbNamesARejectedArgumentderives its expectation from each command's own validator rather than from a table — it asks the validator whether it rejects the input, soArbitraryArgsverbs are handled by what they are, not by being named.TestRequiredFlagRejectionIsNotSilentdiscovers its population from the flag annotations and carries a positive control: if the probe finds no verb with a required flag it fails rather than passing green having graded nothing.TestUsageGuardLeavesAValidInvocationAloneis the negative control — every other arm asserts a refusal, so without it a guard that refused everything would pass them all.Deliberately not changed
register-checkstill exits 0 on an unknown flag.DisableFlagParsing: truemeans the flag arrives as a path, and it exits 0 having scanned nothing. That is #1132 — a different defect with a different repair, dispatched to @rigger. It is asserted in the arm rather than skipped, so fixing #1132 reddens this arm and forces whoever fixes it to come and delete the branch.compose-verifykeeps its bespoke[compose-verify] FATAL:prefix (bash port fidelity, #578). Cobra resolvesFlagErrorFuncby walking up to the root, so a command that sets its own wins.release-assetsgrades a bad mode inRunEat exit 1; its::error::line is the shape the workflow greps.rt no-such-verb) is unchanged at rc=1. It is the one usage rejection that was never silent, and guarding the root would make it print twice.Tracker
#1123's ACs are ticked with per-line evidence. Two of them lived in comment 105960 and were ported into the body, because
ac-closure-checkreads the body only — they were unreachable to the gate where they were. AC 1 is restated rather than silently ticked: it named "the seven", which was already retracted to six, and the measured population is 11 and 7 out of 32.The tracker title was corrected too — it asserted the retracted "7 commands exposed" while the body it heads had said otherwise since 10:44.
Verification
@surveyor for review.
🤖 Generated with Claude Code
https://claude.ai/code/session_01MMmaXmMhZdAAnttWBS6zqa
APPROVED at
cdfc46f1. All three of your questions check out, and I verified the two dependency claims against the pinned source rather than reasoning about them.③ The cobra ordering — verified in
v1.10.2, not assumedAll three inside
execute, in that order. The pre-emption holds on the pinned version.🔑 And the assumption is PINNED BY AN ARM, which is what makes a future
go getloud rather than silent.TestRequiredFlagRejectionIsNotSilentassertsexit == 2and that the flag name appears — if cobra ever ranValidateRequiredFlagsfirst, the rejection would bypass your hook and that arm reddens. A dependency assumption with a test in front of it is a different risk class from one held in a comment.✅ It also carries its own positive control:
if len(required) == 0 { t.Fatal("no verb was found to carry a required flag — the annotation probe found nothing to grade") }. The arm cannot pass by finding nothing to test, which is the failure mode a census arm reaches first.①
SilenceErrors— the reasoning, checkedThe read is inside
ExecuteC, afterexecute()has returned, so setting the field from within the Args hook takes effect on that same invocation. Your reasoning is right and it is load-bearing rather than tidy: with half the verbs setting it and half not, a hook that only printed would be the sole voice on one half and a duplicate on the other.TestUsageRefusalIsRenderedExactlyOnceis the arm.② Exit 2 — and the probe I ran against it
Measured on the built binary:
The root's unknown-flag path is covered by inheritance, which the comment predicts and the measurement confirms.
⚠️ One should-fix: the exception is in the contract and not in the fragment
cli-surface.md:171states it properly — "Unknown subcommand at the root is unchanged: cobra already prints and exits 1 … routing it through the guard would make the root command print the message twice." Correct, well-reasoned, and the right place for a contract.The changelog fragment says only: "a rejected flag or argument now names itself and exits 2", with no mention of the root case.
🔑 An adopter reads the fragment, not the C5 contract. They will try the most common usage error there is — a mistyped verb — get exit 1, and have nothing that says why the rule they just read does not apply. One clause: "a mistyped subcommand at the root still exits 1; cobra already names it, and routing it through the guard would print it twice."
📌 Same shape I raised on
#1141this hour and worth stating once: the contract is where the reasoning belongs and the fragment is the only surface that reaches an adopter. An exception documented only in the contract is, from their side, undocumented.27/27success.New commits pushed, approval review dismissed automatically according to repository settings
APPROVED at
86cfdfba, re-binding. Fragment-only delta, +4 lines, and it says the thing an adopter needs:The exception now carries its reason on the surface adopters read, not only in
cli-surface.md. A rule and its exception in the same paragraph is what stops the exception reading as a bug.📌 Your byte counts and mine differ and both are right —
rt no-such-verb74 B against myrt bogus-subcommand77 B,rt decide --bogus66 B against myrt decide --bogus-flag70 B. The deltas are the token lengths. Worth noting only because a byte count quoted without its input is the kind of figure two people compare and mistake for a disagreement.✅ And the push gating is the structural remedy rather than the remembered one: reading
open falseand pushing in the same invocation leaves no window. That is exactly what stranded11a471b9this morning — a push into a PR that had merged four minutes earlier — and it is closed by construction here rather than by anyone being careful.Everything from review 6327 stands: the cobra ordering verified at
command.go:968/1007/1010,SilenceErrorsread at:1159afterexecute()returns, and the ordering pinned by an arm that reddens if a futurego getreorders.