fix(exit-codes): a nonexistent project path is a usage error (exit 2), not a manifest-less project - #265
Open
ZacxDev wants to merge 6 commits into
Open
fix(exit-codes): a nonexistent project path is a usage error (exit 2), not a manifest-less project#265ZacxDev wants to merge 6 commits into
ZacxDev wants to merge 6 commits into
Conversation
… not a manifest-less project `civitai app validate /nope` reported the missing path as "block.manifest.json not found at project root /nope" and exited 1; `civitai app validate README.md` printed `stat README.md/block.manifest.json: not a directory` — a path the CLI assembled and the user never typed — also on 1. The published contract says a path that does not exist is a mistake about the invocation and exits 2, and `generate --input` has honoured that since #251. `app submit <dir>` had the identical hole. Root cause: validate.Dir stats the JOINED <dir>/block.manifest.json and branches on os.IsNotExist, which collapses "the directory does not exist" into "the directory exists but has no manifest". resolveProjectDir (internal/cmd/project_dir.go) branches three ways on the path the user NAMED — nonexistent -> ErrUsage (2), exists-but-not-a-directory -> ErrUsage (2), a real directory -> unchanged, so a manifest-less directory keeps its finding and exit 1 because the invocation was right and the project is wrong. It lives in internal/cmd because ErrUsage is this package's sentinel (AGENTS item 7) and validate.Dir returns a validation verdict; `app init`'s ManifestOnly self-check is untouched. One helper, both call sites, and the set is asserted by an AST guard that fails when it grows OR shrinks. The published code-2 note said "every local path a FLAG names", which excluded the two commands that broke it — both take the path positionally — so it is widened to "every local path the CLI is handed". `app validate`'s own exit code (a validation verdict is 1, and --json's `ok` is the structured form) is now documented under code 1. README + --help move together from exitCodeDocs. BREAKING (--json): `civitai app validate /nope --json` used to write {"ok":false,"errors":[...]} to stdout and exit 1. It now writes nothing and exits 2 — a path that does not exist produced no validation result — keeping the CLI-wide convention that a usage error emits no JSON object. Announced in the code-2 README cell. Closes #256. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tat arm; scope two published claims that overclaimed Audit fixes on top of the #256 gate. Nothing here changes the exit code of any invocation; it closes the gap between what the contract SAYS and what anything observes, plus one message-stutter regression the gate introduced. F1 — `--skip-validate` ordering had ZERO test. `app_submit.go` states, in its own comment and in the PR body and in AGENTS.md, that `resolveProjectDir` runs ahead of `--skip-validate`. Moving the call inside the `if !skipValidate` block — a one-line move that reads like a tidy-up — left the ENTIRE suite green while reverting `app submit <nonexistent> --package-only --skip-validate` from rc 2 to rc 1. The only test that mentioned the flag used a VALID directory. TestSubmitGateRunsBeforeSkipValidate adds four rows (two exit-2, two controls); re-measured, that mutant now reddens 2 leaf subtests by name. F6 — the widening mutant rested on ONE skippable row. Re-tagging the untagged stat arm as asUsageError reddened exactly one leaf subtest, and that subtest carried a t.Skip when its fixture produced no error. That is AGENTS item 24's own recorded "battery rested on a single row" shape, regenerated. project_dir_gate_test.go now runs two independent stat shapes (ENOTDIR below a regular file, EACCES on an unsearchable parent) across three surfaces (the helper, `app validate`, `app submit`), each row ASSERTING ITS OWN PREMISE — an independent os.Stat must fail with something that is neither ENOENT nor a live non-directory, or the row fails rather than quietly testing another branch — plus a count floor. The pre-existing control row asserts its premise too. Re-measured: 1 -> 9 leaf subtests. F5 — message stutter. os.Stat returns an *fs.PathError whose Error() already begins `stat <path>: `, so the wrapper printed the op and the path twice: `Error: stat …/file.txt/x.json: stat …/file.txt/x.json: not a directory`, where the base binary printed one `stat`. The error is now returned bare; the classification (untagged, exit 1) is unchanged. TestProjectDirStatErrorDoesNotStutter counts occurrences rather than matching a golden string, because the defect is a duplicate. F2 — the new code-1 note published a promise the code does not keep. It read "`app validate --json` prints the full result … so a script never has to read stderr", unqualified. Measured: `app validate <mode-000 project> --json` exits 1 with stdout EMPTY (0 bytes), because validate.Dir returns an error rather than a Result for a non-ENOENT stat failure and for a schema() failure. The note is SCOPED ("for a project directory it could read") rather than the code changed to match it, which would mean reworking every such arm. TestValidateJSONOnlyEmitsAResultItActuallyProduced pins it with a readable-dir positive control. F3 — the widened code-2 sentence still overclaimed. "Every local path the CLI is HANDED" has a live counterexample inside its own scope, measured identical on base and here: `civitai app listing status --dir /does/not/exist` exits 1, and so does `app submit <valid> --package-only --out /nodir/x.zip`. The sentence now publishes the SHAPE — a flag's value and a positional argument alike — over the paths it enumerates, and the README states the residual instead of hiding it. `--dir` is deliberately NOT brought into the gate (that touches app_listing.go and is a behaviour change, not a docs fix). TestUngatedPathFlagsAreNotUsageErrors pins the residual and FAILS if it ever closes, which is the moment the published paragraph goes stale. F4 — the --json wire break was announced in exactly one place, and not the one a --json consumer reads. Added to the `app validate` row of the command table and to the canonical "The `--json` result shape" section, with an exit -> stdout table: 2 never carries an object, 1 carries one for a validation verdict but not for a filesystem failure, 0 always does. F7 — AGENTS.md placement. The new content was appended under item 24, whose index clause describes it as the ONE transport-vs-filesystem predicate. A project-path classification gate is not that predicate, so it moves to a new item 25 (items are append-only; nothing was renumbered) and the index clause is extended. Every `item N` reference was re-grepped; agents_index_test.go / agents_xrefs_test.go stay green. The README exit-code table is regenerated from exitCodeDocs, never hand-edited. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev
added a commit
that referenced
this pull request
Aug 7, 2026
…break
README
- The `app create` command-table row was still missing `--slug` entirely, and
no repo test enforces README<->flag parity, so it was not going to happen by
itself.
- New "The blockId" section: what the id is, that it can never be renamed, and
the BREAKING CHANGE — a non-ASCII name used to mint a silently different
permanent public id ("Café App" -> caf-app) and now exits 2 asking for
--slug. Announced the way the `--json` field-notation break was: inline, in
the section a reader is already in, with "update your scripts".
- A table of the three inputs that still DERIVE rather than refuse, so the
section documents the residuals instead of implying closure.
AGENTS.md
- New item 25. The durable claim is the exemption: derivation is safe to refuse
only because it exempts every rune that LOWERCASES INTO ASCII, which is what
keeps every pre-existing derivation byte-identical — do NOT "improve" that
into a character allowlist. Plus the four residuals stated as residuals
(invalid UTF-8 now closed; the İ/K pair; symbols/emoji per #272; NFD
rendering), and the four process lessons this round produced: classify on the
lowered rune but report the original, a flag that skips a prompt must
enumerate what else the prompt collects, the echoed URL is future tense, and
a mustNotProduce row placed after a t.Fatalf is not coverage.
- Indexed it in the preamble clause paragraph, per the file's own rule that an
item nothing points at is unreachable navigation.
NUMBERING: this takes item 25, not 26. PR #265 is adding an item concurrently
and was expected to take 25 — but parseAgentsItems in agents_xrefs_test.go
ENFORCES CONTIGUITY (the idx-th heading must be numbered idx+1), so skipping to
26 fails that guard unconditionally, today, on a 24-item file. AGENTS.md's own
maintenance rule covers the collision: the PR merging SECOND renumbers its own
new items. Whichever of #265 / #267 lands second renumbers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t two mutation counts Found by mutation-testing the audit fixes themselves — the fix round resets the gate, and this is what the re-measurement turned up. SWAPPED OPERANDS SURVIVED. Exchanging resolveProjectDir's two exit-2 remedies passed the ENTIRE suite: 0 failures. Both arms tag the same ErrUsage sentinel, so no errors.Is assertion can tell them apart (item 7 is about the exit code, and the exit code is identical either way), and the one message test asks only that the path the user typed appears — true of both spellings. The result is advice that is exactly backwards: a path that is simply missing told to "pass the App project ROOT ... not a file" (about a file that does not exist), and someone who pointed at their own manifest told to scaffold with `app init` a project they already have. That is AGENTS item 21(f)/(g)'s operand-order class, reached from a third direction. The two remedies are now named constants (remedyNoSuchDir / remedyNotADir) and TestProjectDirRemediesMatchTheirArm derives each arm's expected text FROM THE CONSTANT and requires the other arm's to be ABSENT — with a non-empty + distinct precondition, because strings.Contains(x, "") is always true and an empty or duplicated remedy would silently disarm every assertion in the guard. Re-measured with a swap that compiles and vets cleanly (swap which constant each arm passes, so the arg counts still match): 1 leaf subtest, by name. A swap of the constant BODIES is additionally caught by go vet's printf check, but that is the compiler's kill, not the guard's, so it is not what the count is measured on. TWO PUBLISHED MUTATION COUNTS CORRECTED, because a number nobody re-ran is a claim. AGENTS.md said the widening mutant "reddened exactly one leaf subtest" at the audited tip. Measured over the WHOLE module at a4807f4 it reddened TWO: the internal/cmd control row plus a pre-existing cmd/civitai end-to-end row (TestFilesystemErrorsExitGenericEndToEnd/app_validate_(ENOTDIR)), which a package-scoped run does not see. The finding itself stands — inside internal/cmd it was one row, and that row can t.Skip itself — but the item now states the measured figure, the re-measured 8 on the fixed tree, and the 7 that remain when the old single row is deleted too, which is what shows the new battery does not rest on a row anyone can remove. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
My README.md edits collided with #268 (which added a TOC and the app list/view/pull docs). Measured by exit code, not by grepping for markers: `git merge-tree --write-tree origin/main <ref>` exits 0 for the audited tip a4807f4 and 1 for my tip, so the conflict is mine and not pre-existing. Merged rather than rebased, deliberately: a rebase would rewrite a4807f4, which the PR must keep, and five sibling PRs are in flight against these same files. The conflict was one 3-row hunk of the command table where each side edited a DIFFERENT row — #268 rewrote `app dev-token` and `app dev-tunnel`, I rewrote `app validate`. Resolved row-by-row against the merge BASE rather than by picking a side: main's row wherever main changed it, mine wherever I changed it, with the resolver refusing outright if both had touched one row. Neither side's block was correct on its own. Verified on the MERGED tree, which is the tree that matters and the one neither side's review saw: make ci green — 18 packages ok, `--- FAIL` 0, `build failed` 0, timeout panics 0, gofmt clean — including the readme-nav, root-help-order and login-help guards main added while this branch was open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t three published mutation numbers
Round-2 audit fixes. All three findings are in guards I added last round — a
fix round resets the gate, and this is what re-auditing the guards themselves
turned up.
🔴 THE RESIDUAL GUARD WAS INERT ON CI. TestUngatedPathFlagsAreNotUsageErrors
drives `app listing status --dir …`, but `newListingClient()` runs BEFORE
`resolveListingSlug`, so with no credential the command dies at
`no token configured` (ErrUnauthorized) and never reaches the --dir path. The
bare `!errors.Is(err, ErrUsage)` assertion is satisfied by that auth failure.
Reproduced hermetically (HOME + XDG_CONFIG_HOME at an empty dir, i.e.
ubuntu-latest): both rows PASSED, and STILL PASSED with the residual-closing
mutant applied — the change the guard exists to catch was invisible in the only
environment that matters. It looked healthy locally only because a real config
made it observe the manifest error instead. Fixed with t.Setenv of a dummy token
(never sent — manifest.Load fails first) plus a premise assertion that t.Fatals
on ErrUnauthorized. Re-measured hermetically: clean run passes, mutant reddens 2
leaf subtests.
🟡 THE REMEDY GUARD WAS HALF-INERT, AND THE DEAD HALF WAS THE ONE ITEM 25
ADVERTISES. Both remedies interpolate the path and the two rows rendered theirs
with DIFFERENT paths, so `Contains(err, deny)` compared against a string
carrying a path the error never mentions — false whatever the code does.
Measured on the one-arm swap: 2 kills, 2 from `want`, 0 from `deny`. The same
defect made the "distinct" precondition compare strings differing only by path,
so two IDENTICAL remedy constants passed it. Fixed by rendering both arms from
tc.dir via noSuchAt/notDirAt with the precondition on one shared probe path.
Re-measured: one-arm swap want 1 / deny 1; both-arms swap want 2 / deny 2.
Worth recording rather than claiming: what actually stops a duplicate reaching
main today is `go vet`, not this guard — the two constants have different
arities, so copy-pasting one over the other breaks a call site (measured: 2
`build failed`, 0 test failures). The precondition is the backstop for the day
someone equalises those arities, and it now carries its own positive control so
"it cannot reject anything" fails loudly instead of reading as a pass.
🟢 THREE PUBLISHED NUMBERS CORRECTED, in the item that says an unreproduced
mutation number is a claim:
* sliding the gate below the --json block reddens 4 leaves, not 3, and TWO of
them are TEXT-mode rows — not "exactly the JSON rows while the text-mode
rows stay green". Unavoidable: any placement below the --json block is also
below validate.Dir. Same 4 at a4807f4 and at HEAD.
* dropping the submit gate reddens 6 in the tree it ships in, not the 4
measured at a4807f4 (this round's two new rows account for it).
* the code-1 scoping was over-broad by one arm: "for a project directory it
could READ" still promises an object for the schema() arm, which is a
directory the CLI reads fine that yields no Result. Both the note and the
README now condition on whether validation PRODUCED a result — the thing the
code branches on. Wording only; that arm is effectively unreachable in a
released binary and the operative instruction was correct throughout.
Gate: make ci green (18 pkgs ok, --- FAIL 0, build failed 0, timeout panics 0),
gofmt clean, and golangci-lint v2.12.2 — the version CI pins, run separately
because `make ci` is tidy+vet+test+build and does NOT lint — reports 0 issues,
with a deliberate ST1005+ineffassign+unused probe confirming it reports 3 when
there is something to find.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… POSITIVE, and retract a convenient conclusion Round-3 audit fixes. No production defect; this is comments and test assertions, plus one zero-behaviour-change constant extraction. 🔴 RETRACTED: "go vet already catches a duplicate remedy". Two mutants were built and both observations were real — the disagreement was that we had built DIFFERENT mutants. Re-measured myself at ab1e685, three real edits to the two remedy constants: shape build vet full suite A copy the body over (arity 2->1) ok rc=1 1 `build failed` line (2 printf diags), 0 --- FAIL B same text + trailing `%.0s` ok rc=0 rc 0, 18 ok, 0 --- FAIL C same advice + ` (looking for %s)` ok rc=0 rc 0, 18 ok, 0 --- FAIL B and C are arity-preserving — no equalisation required — and C is a completely natural way to write the message. Both shipped the backwards advice fully green while the binary answered a real file with "no such directory ... civitai app init" at rc 2. And even A BUILDS: `go build ./cmd/civitai` succeeds, so what stops A is CI running `go test`, not a compile failure. At HEAD, B is killed by the precondition and C by the `deny` assertion. The guard is the live protection. AGENTS.md and the mirroring test comment now say so, and the test file no longer contradicts itself forty lines apart. The conclusion I published was the CONVENIENT one — if vet already catches it, the finding downgrades to a nit and no repair is needed. Worth recording as the direction to be most suspicious of, especially in AGENTS.md, which is the file the next person trusts instead of re-measuring. 🔴 THE BLOCK LABELLED "POSITIVE CONTROL" COULD NOT FAIL — third instance of that shape in this PR. It was `dup != noSuchAt(probe) || dup == notDirAt(probe)` with `dup := noSuchAt(probe)`: clause 1 is `s == s` on a deterministic pure call, false in every state; clause 2 was byte-identical to the precondition above it, which has already t.Fatalf'd. Measured: mutant B fires the precondition, not it; only after deleting the precondition does its one live clause fire — it backstopped a deleted test line. (staticcheck SA4000 misses the `var != f(x)` spelling.) Replaced with a real control: `remediesAreDistinct` is now a named comparator, fed a KNOWN DUPLICATE pair and required to report one, running FIRST so the precondition cannot shadow it. Proven: making the comparator return true unconditionally reddens the control by its own message. 🔴 THE REACH PREMISE WAS A DENYLIST OF ONE SENTINEL. Asserting the error is not ErrUnauthorized closes the gate we knew about and says nothing about reaching resolveListingSlug. Measured hermetically: inserting any new preflight ahead of it that fails with a plain untagged error left both rows PASSING — and with the residual-closing mutant ALSO applied they still both passed, i.e. the whole defect back, invisibly. Now positive: the error must carry resolveListingSlug's own wrapper, derived from the new `listingSlugResolveFailure` constant (extracted in app_listing.go; identical string, no behaviour change) so a reword moves both together. Re-running that same preflight mutant: 2 leaf subtests, 2 PREMISE BROKEN messages, where the denylist form survived it. 🟢 Two more numbers corrected. The "2 kills, 2 from want, 0 from deny" belongs to the BOTH-ARM swap; the one-arm swap gives 1 kill (also entirely from `want`). And "2 build failed" was 2 vet printf DIAGNOSTICS producing 1 `FAIL ... [build failed]` line. Gate: make ci green (18 pkgs ok, --- FAIL 0, build failed 0, timeout panics 0), gofmt clean, golangci-lint v2.12.2 reports 0 issues with a deliberate probe confirming it reports 3 when there is something to find. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #256.
The defect
validate.Dirstats the joined<dir>/block.manifest.jsonand branches onos.IsNotExist, which collapses "the directory does not exist" into "the directory exists but has no manifest". Measured on the base binary built fromorigin/main@8ed4d69:app validate /nope/does/not/exist✗ 1 validation error(s) … block.manifest.json not found at project root /nope/does/not/exist— rc 1Error: /nope/does/not/exist: no such directory — pass the path to an App project root, or scaffold one with \civitai app init `` — rc 2app validate README.mdError: stat README.md/block.manifest.json: not a directory— a path the CLI assembled and the user never typed — rc 1Error: README.md is not a directory — pass the App project ROOT (the directory holding block.manifest.json), not a file— rc 2app validate <dir with no manifest>app submit /nope/does/not/exist --package-only✗ validation failed (1 error(s)) … not found at project root— rc 1The published contract says a path that does not exist is a mistake about the invocation and exits 2;
civitai generate --input /nope/x.jsonhas honoured that since #251.app submit <dir>carried the identical hole — the fourth instance of the "one predicate, N copies, found one at a time" shape AGENTS item 24 exists to prevent.The fix
resolveProjectDir(internal/cmd/project_dir.go) branches three ways on the path the user named, never the joined manifest path:A directory that exists with no manifest is deliberately unchanged: the user pointed at a real place, so the invocation was right and the project is wrong. That is a validation verdict, not a usage error.
Two placement decisions worth reviewing:
internal/cmd, notinternal/validate.ErrUsageis this package's sentinel (AGENTS item 7) andvalidate.Dirreturns a validation verdict; pushing the tag down would makeinternal/validateimport the usage sentinel and hold a slice of the exit-code contract.app init'svalidate.ManifestOnlyself-check is untouched — it validates a directory it just created and has no user-named path to classify.TestEveryValidateDirCallerGatesOnResolveProjectDirAST-walks the package and requires the set of files callingvalidate.Dirto equal the set callingresolveProjectDir— it fails when the set grows (a third command validating a user-named directory without the gate) and when it shrinks (a deleted gate, which would otherwise leave the ledger a false map). Inapp submitthe gate runs ahead of--skip-validate, because "does the directory you typed exist" is not a validation opinion to waive.🔴 Deliberate
--jsonwire breakcivitai app validate /nope --jsonused to print a fabricated validation result and exit 1:{ "dir": "/nope", "errors": [ { "field": "(root)", "message": "block.manifest.json not found at project root /nope" } ], "ok": false, "warnings": [] }It now writes nothing to stdout and exits 2. A path that does not exist produced no validation result to report, and this keeps the CLI-wide convention that a usage error emits no JSON object. This is a break of the same class as item 23's notation change, and it is announced in the code-2 README cell ("A usage error emits no JSON object, in every mode … Scripts that parsed that object must branch on the exit code first"). Scripts that read
.okfor a possibly-missing path must branch on$?first.--jsonfor a real directory is unaffected:app validate <dir with no manifest> --jsonstill emits the full object and exits 1 (asserted as a positive control, so "stdout is empty" cannot be satisfied by a--jsonmode that never prints).Contract text
Both surfaces are generated from
exitCodeDocs, so this is one edit that moves--helpand the README table together (the README table region is regenerated, not hand-edited —TestREADMEExitCodeTableIsGeneratedenforces byte-identity).app validate <dir>/app submit <dir>.app validate's own exit code, which was documented nowhere despite being the most common outcome of the most-run command: a validation verdict is1, a manifest-less directory is the same verdict, and--json'sokfield is the structured form of the answer.exitCodeContractClaims(the decision ledger asserted against both rendered surfaces) gains rows for the widened wording, the new project-path rule, and the counterweight (a validation verdict stays1), so the narrower sentence cannot come back quietly.Tests
internal/cmd/project_dir_test.go, driving the real commands throughNewRootCmd()+SetArgs. Every classification assertion iserrors.Is(err, ErrUsage)— never message text (item 7):asUsageErrorpreserves the message byte for byte, so a wording assertion says nothing aboutecho $?.TestProjectDirExitCodes— 4 rows ×validateandsubmit(the shared gate means both must answer identically): missing path → usage; regular file → usage; CONTROL real directory without a manifest → not usage; CONTROL valid project → no error.TestProjectDirRefusalNamesThePathTheUserTyped— a negative assertion that the assembled<file>/block.manifest.jsonis absent from the message (survives any rewording of the replacement, which a positive assertion would not), plus that the path the user typed is present.TestValidateJSONEmitsNothingForARefusedPath— the wire break, with the positive control above.TestResolveProjectDirClassification— unit rows including two controls: the default.must pass the gate, and ENOTDIR below a regular file must stay untagged (exit 1, the Every filesystem error exits 5 (the retry code): syscall.Errno satisfies net.Error, so isNetworkErr matches through *fs.PathError #241 answer).TestEveryValidateDirCallerGatesOnResolveProjectDir— the seam ledger, with a count floor so a pass cannot be built on two empty sets.Mutation matrix
Every mutant checksum-gated (an edit that failed to apply exits non-zero rather than reading as a survivor). Counts are
--- FAILlines, leaf + parent, over the new tests plus the two doc guards. Baseline: RUN=34, FAIL=0, build errors=0.origin/main)--jsonrows, the seam guardfmt.Errorf(classification gone, messages byte-identical)--jsonrows, both unit rows — nothing message-based could have seen this--jsonpositive control) — this is what stops "tag everything" from passing--jsonblock (old wire behaviour restored)--json/regular-file rows; the text-modepath does not existrow stays green, so an exit-code-only table would have missed itapp submitonly (one rule, two places)TestPublishedExitCodeClaims/…flag_OR_positional+TestREADMEExitCodeTableIsGeneratedTestREADMEExitCodeTableIsGeneratedBoth directions are covered: M1/M2 prove the new tests see the defect, M3 proves the controls stop the over-fix, M5 proves the shared-helper ledger is not decorative.
Gate
make cigreen. Read from the output, not the exit code:--- FAIL= 0,build failed= 0, 18/18 packagesok,gofmt -s -l .prints nothing over 298.gofiles.AGENTS.md
Item 24 gains a bullet for this fourth instance — the collapsed
os.IsNotExistbranch, theinternal/cmdboundary, the asserted call-site set, the--jsonbreak, and the untagged-ENOTDIR residual. No existing item was renumbered.🤖 Generated with Claude Code