Skip to content

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
mainfrom
fix/256-validate-path-exit-code
Open

fix(exit-codes): a nonexistent project path is a usage error (exit 2), not a manifest-less project#265
ZacxDev wants to merge 6 commits into
mainfrom
fix/256-validate-path-exit-code

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Closes #256.

The defect

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". Measured on the base binary built from origin/main@8ed4d69:

invocation base this PR
app validate /nope/does/not/exist ✗ 1 validation error(s) … block.manifest.json not found at project root /nope/does/not/existrc 1 Error: /nope/does/not/exist: no such directory — pass the path to an App project root, or scaffold one with \civitai app init `` — rc 2
app validate README.md Error: stat README.md/block.manifest.json: not a directory — a path the CLI assembled and the user never typed — rc 1 Error: README.md is not a directory — pass the App project ROOT (the directory holding block.manifest.json), not a filerc 2
app validate <dir with no manifest> rc 1 rc 1, byte-identical (control)
app submit /nope/does/not/exist --package-only ✗ validation failed (1 error(s)) … not found at project rootrc 1 rc 2, same message as validate

The published contract says a path that does not exist is a mistake about the invocation and exits 2; civitai generate --input /nope/x.json has 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:

does not exist      -> asUsageError  (exit 2)
exists, not a dir   -> asUsageError  (exit 2)
a directory         -> nil; validate.Dir decides, unchanged
other stat failure  -> untagged      (exit 1 — the #241 answer for ENOTDIR/EACCES)

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:

  • The gate is in internal/cmd, not internal/validate. ErrUsage is this package's sentinel (AGENTS item 7) and validate.Dir returns a validation verdict; pushing the tag down would make internal/validate import the usage sentinel and hold a slice of the exit-code contract. app init's validate.ManifestOnly self-check is untouched — it validates a directory it just created and has no user-named path to classify.
  • One helper, both call sites, and the set is asserted. TestEveryValidateDirCallerGatesOnResolveProjectDir AST-walks the package and requires the set of files calling validate.Dir to equal the set calling resolveProjectDir — 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). In app submit the gate runs ahead of --skip-validate, because "does the directory you typed exist" is not a validation opinion to waive.

🔴 Deliberate --json wire break

civitai app validate /nope --json used 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 .ok for a possibly-missing path must branch on $? first.

--json for a real directory is unaffected: app validate <dir with no manifest> --json still emits the full object and exits 1 (asserted as a positive control, so "stdout is empty" cannot be satisfied by a --json mode that never prints).

Contract text

Both surfaces are generated from exitCodeDocs, so this is one edit that moves --help and the README table together (the README table region is regenerated, not hand-edited — TestREADMEExitCodeTableIsGenerated enforces byte-identity).

  • Code 2's note was itself inaccurate: it read "that split is the rule for every local path a flag names", and the two commands that broke it take the path positionally, so the sentence structurally excluded exactly the cases that disagreed with it. Widened to "every local path the CLI is handed — a flag's value and a positional argument alike", plus an explicit clause for app validate <dir> / app submit <dir>.
  • Code 1 gained app validate's own exit code, which was documented nowhere despite being the most common outcome of the most-run command: a validation verdict is 1, a manifest-less directory is the same verdict, and --json's ok field 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 stays 1), so the narrower sentence cannot come back quietly.

Tests

internal/cmd/project_dir_test.go, driving the real commands through NewRootCmd() + SetArgs. Every classification assertion is errors.Is(err, ErrUsage) — never message text (item 7): asUsageError preserves the message byte for byte, so a wording assertion says nothing about echo $?.

  • TestProjectDirExitCodes — 4 rows × validate and submit (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.json is 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 --- FAIL lines, leaf + parent, over the new tests plus the two doc guards. Baseline: RUN=34, FAIL=0, build errors=0.

mutant FAIL lines what reddened
M1 delete the gate from both call sites (= origin/main) 14 all 8 leaf rows: both exit-2 rows × both commands, both message rows, both --json rows, the seam guard
M2 gate runs but returns plain fmt.Errorf (classification gone, messages byte-identical) 13 the 4 exit-2 command rows, both --json rows, both unit rows — nothing message-based could have seen this
M3 the over-fix: tag every validation failure as a usage error 4 only the CONTROL rows (manifest-less directory, and the --json positive control) — this is what stops "tag everything" from passing
M4 gate moved below the --json block (old wire behaviour restored) 8 exactly the 3 --json/regular-file rows; the text-mode path does not exist row stays green, so an exit-code-only table would have missed it
M5 gate dropped from app submit only (one rule, two places) 7 the 2 submit rows, the submit message row, and the seam guard by name
M6 revert the widened code-2 wording 3 TestPublishedExitCodeClaims/…flag_OR_positional + TestREADMEExitCodeTableIsGenerated
M7 hand-edit the README table 1 TestREADMEExitCodeTableIsGenerated

Both 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 ci green. Read from the output, not the exit code: --- FAIL = 0, build failed = 0, 18/18 packages ok, gofmt -s -l . prints nothing over 298 .go files.

AGENTS.md

Item 24 gains a bullet for this fourth instance — the collapsed os.IsNotExist branch, the internal/cmd boundary, the asserted call-site set, the --json break, and the untagged-ENOTDIR residual. No existing item was renumbered.

🤖 Generated with Claude Code

ZacxDev and others added 2 commits August 7, 2026 15:03
… 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>
ZacxDev and others added 2 commits August 7, 2026 17:07
…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>
ZacxDev and others added 2 commits August 7, 2026 18:13
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

app validate on a path that does not exist reports a missing manifest and exits 1 — the contract says 2

1 participant