Isolate the lint cache per worktree and context-gate the native path (closes #99)
All checks were successful
check / check (pull_request) Successful in 3m11s

Closes #80.

script/lint decided whether it could skip the pinned image by asking what
version was on PATH rather than where it was running, and cache isolation
is part of that same question. Both issues are that one defect.

The cache was one directory per repo, shared by every worktree on the
host. Two checkouts of this repo have identical Go file contents, so
their cache keys collide and golangci-lint replays the stored analysis,
including the paths recorded when it was produced. The loud direction of
that failure - a clean tree failed by a dirty sibling - is the harmless
one. The silent direction, a dirty tree passed by a clean sibling, is
another way for a gate here to report a green it did not earn.

The cache is now keyed on a digest of the worktree path, so a collision
is not possible, and it stays persistent per worktree: a warm run is
still seconds. Each cache records the worktree it belongs to and is
collected when that worktree is gone, so throwaway worktrees do not
accumulate caches; the tree lives under XDG_CACHE_HOME and is disposable
by definition.

script/lint-audit is the backstop, and runs on every lint: it rejects
output citing any file that is not in the tree being linted, so a result
built out of another checkout's analysis is a hard error instead of a
silent pass. It runs on clean output too, because that is the case
nobody investigates. It never certifies that a run passed - it does not
look at whether there were findings - so it cannot itself become a gate
that reports a green.

golangci-lint's "parallel golangci-lint is running" refusal is now a
bounded retry rather than a verdict. It is not a lint result, and
exiting non-zero on it is indistinguishable to a caller from real
findings; issue #88 measured that a private cache does not remove the
contention. Exhausting the retries fails with a message that says the
tree was never analysed.

The native path now requires VAULTIK_LINT_IN_CONTAINER=1, which only the
Dockerfile's lint stage sets, in addition to matching the pin. A
developer's locally installed 2.12.2 is a different build reached by a
different code path and no longer bypasses the digest pin. /.dockerenv
was considered and rejected as the signal: dockerd creates it for
`docker run`, but it is not reliably present during a BuildKit
`docker build`, which is exactly the case the exception exists for.
Inside the container a version mismatch is now a hard error rather than
a fall-through, since there is no daemon there to fall through to.
Version detection uses `golangci-lint version --short`, the interface
meant for it, keeping the banner scrape only as a fallback.

script/bootstrap no longer prints "bootstrap complete" on a machine that
cannot run the gate. Docker missing, or present with an unreachable
daemon, is a hard failure naming exactly what breaks. Installing docker
from bootstrap was rejected: it needs root, a daemon, and on macOS a GUI
cask, so the attempt would itself fail in the common case and trade one
false success for a second failure mode.

TODO.md's claim that `make check` became "as trustworthy as
script/cibuild" is corrected to what README.md already said: only the
lint leg is equivalent, while tests and gofmt still run against the host
toolchain. README.md's requirements section gains docker and sqlite3.

Verified by reproduction, not inspection: two concurrent lints from two
worktrees of differing cleanliness each reported only their own findings
with no lock error; a real run made to report paths outside its tree
exits 1; a matching linter shimmed onto PATH is never invoked while the
pinned image runs; a PATH without docker makes bootstrap fail. script/
cibuild exits 0 with the lint layer executing in the pinned image, which
is what proves the in-container path still works.
This commit is contained in:
2026-08-09 14:47:51 +00:00
parent c51f693527
commit 426a8d7645
6 changed files with 445 additions and 36 deletions

44
TODO.md
View File

@@ -18,6 +18,43 @@ Define remaining scope for a first tagged release and cut v0.1.0.
# Completed Steps
- 2026-08-09: Isolated the lint cache per worktree and context-gated the
native lint path (issues #99, #80). One defect seen twice:
`script/lint` decided whether it could skip the pinned image by asking
what version was on `PATH` rather than where it was running, and cache
isolation is part of that same question. The cache was one directory
per repo, shared by every worktree on the host, so two checkouts with
identical Go file contents collided and golangci-lint replayed the
stored analysis — paths and all. The loud direction of that failure
(a clean tree failed by a dirty sibling) is the harmless one; the
silent direction, a dirty tree **passed** by a clean sibling, is a
sixth way for a gate here to report a green it did not earn. The cache
is now keyed on a digest of the worktree path, and every run is
audited by the new `script/lint-audit`, which rejects output citing any
file that is not in the tree being linted — a backstop that runs on
clean output too, because that is the case nobody investigates. Caches
record the worktree they belong to and are collected when it
disappears, so throwaway worktrees do not accumulate them; the whole
tree lives under `XDG_CACHE_HOME` and is disposable. The
`parallel golangci-lint is running` refusal is now a bounded retry
rather than a verdict: it is not a lint result, and exiting non-zero
on it is indistinguishable to a caller from real findings (#88 showed
a private cache does not remove that contention). The native path now
requires `VAULTIK_LINT_IN_CONTAINER=1`, set only by the `Dockerfile`
lint stage, in addition to matching the pin, so a developer's locally
installed 2.12.2 no longer bypasses the digest pin; `/.dockerenv` was
rejected as the signal because `dockerd` creates it for `docker run`
and it is not reliably present during a BuildKit `docker build`, which
is the case the exception exists for. Version detection uses
`golangci-lint version --short` with the old banner scrape kept only
as a fallback. `script/bootstrap` no longer prints `bootstrap
complete` on a machine that cannot run the gate: a missing docker, or
one whose daemon is unreachable, is a hard failure naming exactly what
breaks. Verification was by reproduction rather than inspection — two
concurrent lints from two worktrees of differing cleanliness, a real
run made to report an outside path, a matching linter shimmed onto
`PATH`, and a `PATH` with docker removed — and is recorded on the pull
request.
- 2026-08-09: Closed the fifth false-green mechanism (issues #93, #69).
`script/test` omitted `-count=1`, so Go's test result cache could
satisfy the gate outright: a second back-to-back `make test` printed
@@ -198,8 +235,11 @@ Define remaining scope for a first tagged release and cut v0.1.0.
exactly the pinned one (which is how the lint stage runs it inside the
container); anything else goes through Docker, and a missing or
unreachable Docker daemon is a hard error rather than a silent
fallback. `make check` is therefore now as trustworthy as
`script/cibuild`.
fallback. Only the **lint** leg of `make check` became equivalent to
`script/cibuild`; its tests and `gofmt` still run on the host against
the host toolchain, as `README.md` states. An earlier version of this
entry claimed `make check` was "as trustworthy as `script/cibuild`"
outright, which overstated it; corrected under issue #80.
- 2026-08-09: Finished the lint remediation under the canonical
`.golangci.yml` (issue #61, which also unblocks issue #59). The
remaining findings were fixed behavior-preservingly: `wsl_v5`