1 Commits

Author SHA1 Message Date
35858dab66 Run every lint in a container via Dockerfile.lint (closes #40)
All checks were successful
check / check (push) Successful in 13s
The linter is no longer installed on the host and no longer invoked
there. script/lint is now `docker build -f Dockerfile.lint .` and
nothing else, with the linter running as a build step, so a successful
build of that file is a clean lint — and it works unchanged where the
docker daemon is remote and bind mounts are impossible.

That removes three host-only failure mechanisms rather than mitigating
them: the result cache keyed on file content rather than location, which
produced a confirmed false green and a string of findings reported
against other checkouts; the host-global $TMPDIR/golangci-lint.lock,
which fails a run with `parallel golangci-lint is running` in a way no
caller can distinguish from findings; and host/container version skew,
which hid thirteen findings on one repo. A container per run has its own
cache, its own lock and a binary pinned by digest.

Resolving the recursion this creates. script/lint is a docker build, so
a Dockerfile that runs `make check` would nest a build inside a build
step where there is no daemon. Fixed by direction, not detection: the
main Dockerfile runs script/test and script/fmt-check individually, with
a comment saying why `make check` must not come back, and script/cibuild
runs script/lint first for fail-fast feedback. script/check still runs
all three, so developers and the pre-commit hook are unaffected.

Dockerfile.lint carries the same CHECK_EPOCH guard as the main image,
with the ARG placed below the dependency layer so only the lint steps
re-run. Blanket --no-cache was rejected: it re-runs the dependency
install on every lint and makes linting network-dependent.

golangci-lint config verify is kept, on measurement rather than
preference. Under the pinned v2.12.2, a bogus top-level key and a bogus
key nested under linters.settings.lll both pass `golangci-lint run` with
exit 0 and `0 issues` while config verify exits 3 and names them; an
unknown linter name fails run and passes config verify. The two catch
disjoint classes, and `run` alone silently ignores the class where a
threshold reads as configured and is not applied. The concern that
config verify fetches its JSON schema over live HTTPS does not hold for
this version: every case reproduced byte-identically under
`docker run --network none`, in a container where `getent hosts
golangci-lint.run` exits 2. The schema is embedded in the pinned binary.

Two canonical forms are superseded and deleted rather than left standing
beside the new one, because consuming repos read these documents
literally and two contradictory canonical script/lint forms is worse
than either. The script/bootstrap golangci-lint install landed for
#28 is removed: nothing invokes
a host linter now, so it can only reintroduce the skew it was written to
close. Its version-enforcement principle — compare version not presence,
re-resolve through PATH after installing, let a mis-parse fall through
to reinstall, and call it — stays documented for any other pinned host
tool. The per-checkout GOLANGCI_LINT_CACHE/TMPDIR wrapper is removed
with it; its entire subject was making a host run trustworthy. Adopting
repos delete .lint-cache/ from .gitignore and .dockerignore too. The Go
multistage lint stage and its COPY --from=lint ordering trick go the
same way: that stage ran `make lint`, which is now a docker build.

Corrected everywhere the claim that a successful docker build implies
lint passed — REPO_POLICIES.md, both repo checklists, the Go styleguide
and the README. The guarantee now belongs to script/cibuild, which runs
both container builds; a bare `docker build .` never lints at all.

Verified in this repo, not only documented: two consecutive script/lint
runs on a byte-identical tree both executed prettier (4.556s and 3.738s,
lint layers DONE with a fresh epoch printed, dependency layers CACHED as
intended); a planted violation failed the build naming the file, and
reverting it went green; a bare `docker build -f Dockerfile.lint .`
failed on the guard; make check, script/docker and script/cibuild all
green with the check layers demonstrably executing; and the main image
build completed without attempting a nested build.

Rework, from independent review of this commit. The canonical text is
the deliverable here, so a false sentence is a fleet-wide defect: the
Dockerfile rule still said the build "fails if the branch is not green",
which stopped being true when lint left that image, and the earlier
sweep grepped one phrasing rather than the claim. Re-swept on the claim
itself — green/red-branch wording, build-fails wording, entailment verbs
near build/lint/check, and "linted" asserted as covered — across
prompts/, README.md, TODO.md, both Dockerfiles and every script.

Two absolutes are narrowed to what is actually true, because seventeen
repos adopt this literally. The rule is that no lint VERDICT may come
from a host invocation, not that the binary never exists on the host: a
JS repo's `yarn install` puts its linter in node_modules on the host
unavoidably, and in a repo whose formatter is its linter — this one —
`script/fmt-check` runs the same command that Dockerfile.lint runs. That
gap is now stated with its bound (the version is pinned in the repo's
own node_modules, so no shared cache, no host lock, nothing to skew) and
the audit grep keeps its reach, gaining a note on which two hits are
expected rather than being weakened.

script/lint conflates "found issues" with "could not run": docker build
exits 1 for both. The exit-75 VOID machinery is deliberately not
restored, and the reasoning is now recorded where a reader looking for
it lands. The dangerous direction is already closed, since a build that
cannot run fails closed and can never read as clean; BuildKit already
names the failing step, where the old lock error went to stderr while
findings went to stdout and was easy to lose; and the failure is not
transient, so the retry that justified the old machinery would be wrong
here. Rebuilding the distinction would mean per-invocation capture files
and traps again plus matching on BuildKit's message format, which is not
a stable interface, and a mis-match in the "treat as infrastructure"
direction would be the false green this rule exists to prevent. What
survives is binding as a reading rule: a run that did not reach the lint
step is not a verdict.

Also corrected: script/lint was listed above a CHECK_EPOCH snippet that
does a bare `docker build .` with no -f, which would have built the main
image and linted nothing; the canonical Go Dockerfile template used
`make fmt-check` / `make test` where every prose rule in the same
document says script/, one Makefile edit away from re-entering the
recursion; README omitted the mandatory VERSION build arg; script/docker
did not say lint had left its image, a comment that propagates
fleet-wide; the "byte-identical across repos" claim for script/lint is
narrowed to its executable lines; and "--no-cache makes linting
network-dependent" is softened to the measured comparative claim.
2026-08-10 13:14:57 +00:00
8 changed files with 216 additions and 164 deletions

View File

@@ -118,10 +118,15 @@ alpine. We provide:
`script/docker` for the image tag
- `script/test` — run the test suite (no tests defined here)
- `script/lint` — lint the markdown files, by building `Dockerfile.lint`. The
linter runs in a container, always: it is never installed on the host and
never invoked there. Linting happens as a build step, so a successful build is
a clean lint, and the same per-invocation `CHECK_EPOCH` nonce used elsewhere
is what stops Docker serving that lint from cache on an unchanged tree
lint verdict comes only from the container; nothing on the host produces one.
(The prettier in `node_modules` that `script/fmt` and `script/fmt-check` use
is the same binary, which is why this says "verdict" rather than "never on the
host" — see the scope note in `prompts/REPO_POLICIES.md`.) Linting happens as
a build step, so a successful build is a clean lint, and the same
per-invocation `CHECK_EPOCH` nonce used elsewhere is what stops Docker serving
that lint from cache on an unchanged tree. A failure that names no finding —
daemon down, image unpullable — is not a lint result: read which build step
failed, fix that, and re-run
- `script/fmt` — format all markdown files with prettier (writes)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
@@ -130,12 +135,14 @@ alpine. We provide:
(byte-identical across repos); passes the same `CHECK_EPOCH` nonce as
`script/cibuild`
- `script/cibuild` — cd to the repo root, run `script/lint` first, then assign
`epoch="$(date +%s%N)$$"` and
`docker build --build-arg CHECK_EPOCH="$epoch" .` (what CI runs). Two
container builds: the lint image, then the main image, which runs
`script/test` and `script/fmt-check` but deliberately not `make check` — that
would nest a docker build inside a build step. A bare `docker build .` fails
closed on purpose
`epoch="$(date +%s%N)$$"` and `version="$(git describe ...)"` on their own
lines and
`docker build --build-arg CHECK_EPOCH="$epoch" --build-arg VERSION="$version" .`
(what CI runs; both build args are mandatory). Two container builds: the lint
image, then the main image, which runs `script/test` and `script/fmt-check`
but deliberately not `make check` — that would nest a docker build inside a
build step. So `script/cibuild` is what proves the branch green; a bare
`docker build .` never lints, and fails closed on purpose
- `script/precommit` — run by the git pre-commit hook (our own extension); calls
`script/check`
- `script/install-precommit` — installs the git pre-commit hook (our own

29
TODO.md
View File

@@ -21,21 +21,6 @@ fmt-check, and commit.
# Completed Steps
- 2026-08-10: Closed three gaps the containerised-lint rule left between the
canonical text and the first repos to implement it. `.dockerignore` excluding
the agent scratch directory is now stated as a correctness precondition of
that rule rather than a context-size measure: the lint image lints whatever
`COPY . .` copies, and toolchains discover files by walking the tree instead
of reading `.gitignore`, so a nested worktree puts the foreign-tree false reds
back inside the container — `sneak/quak` measured the same discovery mechanism
taking a test count from 210 to 1050. The cache-bust arg is fixed at
`CHECK_EPOCH` in `Dockerfile.lint` as well, because a per-file name is
invisible to the grep that proves every build is busted, making a renamed
guard indistinguishable from a missing one. And the formatting check is now
required to run in exactly one of the two images, with either placement
allowed: splitting lint out of the `Dockerfile` is precisely when `fmt-check`
gets dropped from both, and running the formatter beside the linters is the
better shape where it is the same pinned dependency.
- 2026-08-10: Moved every lint run into a container, on the owner's ruling, and
made this repo do it rather than merely document it. `script/lint` is now
`docker build -f Dockerfile.lint .` and nothing else; the linter is never
@@ -64,8 +49,18 @@ fmt-check, and commit.
measurement, not preference — a bogus config key passes `golangci-lint run`
with `0 issues` and fails `config verify`, and every case reproduced
byte-identically under `docker run --network none`, so the schema is embedded
in the pinned binary and the line costs no network. Verified with two
consecutive runs on an unchanged tree both executing the linter, a planted
in the pinned binary and the line costs no network. Two positions are stated
rather than left as gaps, because canon that omits them gets re-derived
wrongly: `docker build` returns 1 both for findings and for a build that never
reached the lint step, and the exit-75 VOID machinery is deliberately not
restored — the dangerous direction is closed since an unrunnable lint fails
closed, BuildKit already names the failing step, and the failure is not
transient, so what survives is the reading rule that a run which did not lint
is not a verdict. And the rule is about linters: `script/fmt` and
`script/fmt-check` run on the host by necessity, which in a repo whose
formatter is its linter — this one — means that exact command does run there,
recorded as a known and bounded gap rather than papered over. Verified with
two consecutive runs on an unchanged tree both executing the linter, a planted
violation caught and reverted, the bare-build guard firing, and the main image
building without attempting a nested build.
- 2026-08-09: Made a golangci-lint result belong to the tree that asked for it.

View File

@@ -112,10 +112,13 @@ last_modified: 2026-08-10
1. For anything beyond a simple script or tool, or anything that is going to
run in any sort of "production" anywhere, make sure it passes
`golangci-lint`. Run it with `make lint`, which builds `Dockerfile.lint`:
the linter runs in a container, always, and is never installed on the host.
A `golangci-lint` invoked directly on a shared host reads a result cache
keyed on file content rather than location and a host-global lock, so its
answer may belong to another checkout entirely.
the linter runs in a container, always, and `golangci-lint` is not installed
on the host at all — no repo's `script/bootstrap` installs it any more. A
`golangci-lint` invoked directly on a shared host reads a result cache keyed
on file content rather than location and a host-global lock, so its answer
may belong to another checkout entirely, and a `make lint` is the only
verdict worth recording. A failure that names no finding is not a verdict
either: read which build step failed before concluding anything.
1. Write a `Dockerfile` for every repo, even if it only runs the tests. It runs
the non-lint checks; linting lives in `Dockerfile.lint` and is run by

View File

@@ -52,15 +52,7 @@ with your task.
containerised-lint rule in `REPO_POLICIES.md` for the canonical file. Its
base image is pinned by sha256 with a version/date comment, it carries
`ARG CHECK_EPOCH` **after** the dependency layer with the guard below it,
and it invokes the linter directly rather than through `make lint`. The
arg is named `CHECK_EPOCH` in this file too — a repo that calls it
`LINT_EPOCH` here is missed by the grep that checks every build is
cache-busted.
- [ ] The formatting check runs in exactly one of the two images — either
`script/fmt-check` in the `Dockerfile` or the formatter beside the linters
in `Dockerfile.lint`, whichever puts it on the pinned toolchain. Neither
image running it is the failure to look for here, since moving lint out of
the `Dockerfile` is exactly when it gets dropped.
and it invokes the linter directly rather than through `make lint`.
- [ ] `.dockerignore` excludes the repo's own host-built artifacts (compiled
binaries, test binaries, coverage output), written root-anchored —
`/myapp`, never `**/myapp`, which would also match `cmd/myapp/`. An
@@ -69,11 +61,8 @@ with your task.
- [ ] `.dockerignore` excludes `.claude`, root-anchored and with no `**/`
prefix. Agent worktrees are entire checkouts of the repo, so they inflate
the context by a multiple of it and can copy another session's unreviewed
work into an image layer — and `Dockerfile.lint` then lints that checkout
as though it were this one, because toolchains discover files by walking
the tree and never read `.gitignore` (`sneak/quak`: 210 discovered tests
became 1050). Confirm by enumerating the image, not by reading the file —
`.gitignore` hides these from `git status` too.
work into an image layer. Confirm by enumerating the image, not by reading
the file — `.gitignore` hides these from `git status` too.
- [ ] **Do agents in this repo run anywhere other than the repo root?** The
scratch directory is created in the agent's working directory, so the
canonical anchored entry misses `services/api/.claude/` in a monorepo with
@@ -122,16 +111,25 @@ with your task.
`script/install-precommit`, shimmed by `make hooks`) runs it
- [ ] README has an **Entrypoints** section documenting the `script/`
entrypoints and linking the standard
- [ ] `script/lint` is the canonical container build and nothing else. No host
linter invocation survives anywhere in the repo — grep for the linter's
own name in `script/`, the `Makefile` and CI config, not just in
`script/lint`. An existing repo is where a second path to the linter is
likeliest to exist: a `make lint-fast`, a container-versus-host branch, or
a CI step that calls the binary directly.
- [ ] `script/bootstrap` installs no linter. Delete the golangci-lint install
block, its version and ref variables, and its call site: nothing invokes a
host linter any more, so all it can still do is put a differently
versioned binary where somebody runs it by hand and believes the result.
- [ ] `script/lint` is the canonical container build and nothing else, and no
host invocation anywhere in the repo can produce a lint **verdict** — grep
for the linter's own name across `script/`, the `Makefile` and CI config,
not just in `script/lint`. An existing repo is where a second path to the
linter is likeliest to exist: a `make lint-fast`, a container-versus-host
branch, or a CI step that calls the binary directly. **Two hits are
expected and are not the defect**, so triage rather than delete: any
`script/fmt` (a formatter must run on the host — that is its job), and, in
a repo whose formatter is also its linter, `script/fmt-check`, whose
command will be the same string as the one in `Dockerfile.lint`. See the
scope note in the containerised-lint rule. Everything else the grep finds
is a real second path and goes.
- [ ] `script/bootstrap` installs no linter **for the purpose of linting**.
Delete a dedicated install — the golangci-lint block, its version and ref
variables, and its call site: nothing invokes a host linter any more, so
all it can still do is put a differently versioned binary where somebody
runs it by hand and believes the result. A JS repo's `yarn install` stays;
it brings a linter along with every other dependency, which is unavoidable
and harmless as long as no verdict is taken from it.
- [ ] The per-checkout lint state is gone: no `GOLANGCI_LINT_CACHE` or `TMPDIR`
exports, no `--allow-serial-runners`, and `.lint-cache/` removed from
`.gitignore` and `.dockerignore`. A container has its own cache and its

View File

@@ -93,16 +93,7 @@ Template files can be fetched from:
`golangci-lint run`; other repos use the same pattern around their own
linter (eslint, ruff, prettier). Copy the canonical file from
`REPO_POLICIES.md`. The linter is invoked directly there, never via
`make lint`, which would recurse. The arg keeps the name `CHECK_EPOCH` in
this file as well, so one grep covers both builds.
- The formatting check runs in exactly one of the two images: either
`script/fmt-check` in the `Dockerfile`, or the formatter beside the
linters in `Dockerfile.lint` where that is the same pinned dependency.
Never neither, never both.
- `.dockerignore` must exclude the agent scratch directory before this image
is trusted: it lints whatever is in the build context, and toolchains walk
the tree rather than reading `.gitignore`, so an agent worktree that
reaches the context is linted as though it were the repo.
`make lint`, which would recurse.
- [ ] Gitea Actions workflow at `.gitea/workflows/check.yml` that runs
`script/cibuild` on push — reference
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitea/workflows/check.yml`
@@ -131,11 +122,12 @@ are thin shims calling them. Model scripts:
timeout)
- [ ] `script/lint` / `make lint` — builds `Dockerfile.lint` and nothing else:
`epoch="$(date +%s%N)$$"` on its own line, then
`docker build --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .`. The
linter is never installed on the host and never invoked there. Copy the
canonical script from `REPO_POLICIES.md`; it is byte-identical across
repos. Without the nonce this script exits 0 on an unchanged tree having
linted nothing.
`docker build --build-arg CHECK_EPOCH="$epoch" -f Dockerfile.lint .`. No
lint verdict may come from a host invocation. Copy the canonical script
from `REPO_POLICIES.md`; its executable lines are identical across repos.
Without the nonce this script exits 0 on an unchanged tree having linted
nothing, and without `-f Dockerfile.lint` it builds the main image and
lints nothing at all.
- [ ] `script/fmt` / `make fmt` — formats code (writes)
- [ ] `script/fmt-check` / `make fmt-check` — checks formatting (read-only)
- [ ] `script/check` / `make check` — runs `test`, `lint`, `fmt-check`; must not

View File

@@ -94,12 +94,17 @@ style conventions are in separate documents:
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`. It must run the repo's checks as build
steps so the build fails if the branch is not green — which requires
`ARG CHECK_EPOCH` and its guard in every stage containing a check-running
`RUN`, per the `CHECK_EPOCH` rule below. Without them a Dockerfile satisfies
this criterion while its check layers are served from cache, so the build
cannot fail on a branch that is not green.
- Every repo should have a `Dockerfile`. It must run the repo's **non-lint**
checks as build steps, so the build fails on a branch those checks reject —
which requires `ARG CHECK_EPOCH` and its guard in every stage containing a
check-running `RUN`, per the `CHECK_EPOCH` rule below. Without them a
Dockerfile satisfies this criterion while its check layers are served from
cache, so it cannot fail on a branch those checks would have rejected.
**A green `docker build .` does not mean the branch is green**, because this
file does not lint. Only `script/cibuild` carries that meaning: it runs
`script/lint` and then this build. Do not restate the older, stronger claim
anywhere — it was true only while lint ran inside this image.
**It runs the individual non-lint checks — `script/test` and
`script/fmt-check` — and never `make check`.** `script/lint` is itself a
@@ -108,10 +113,7 @@ style conventions are in separate documents:
build step, where there is no daemon. Lint is not skipped by this: it runs
in its own container, and `script/cibuild` runs it first. Put a comment to
that effect directly above those `RUN` lines, because `make check` is what
the next person will reach for. Of the two, only `script/test` is fixed
here: a repo may run its formatter in `Dockerfile.lint` beside the linters
instead, and some should — see the containerised-lint rule below. It must
then run in that file and not in this one, and never in neither.
the next person will reach for.
For non-server repos, the Dockerfile should bring up a development
environment and run those checks. For server repos, they should run as an
@@ -138,7 +140,7 @@ style conventions are in separate documents:
RUN echo "check epoch: ${CHECK_EPOCH}" && <the check command>
```
and in `script/lint`, `script/cibuild` and `script/docker`:
and in both `script/cibuild` and `script/docker`:
```sh
epoch="$(date +%s%N)$$"
@@ -150,12 +152,16 @@ style conventions are in separate documents:
.
```
`script/lint` needs the same nonce but is **not** this command: it builds a
different file and passes no version. Do not copy the block above into it —
a `docker build` with no `-f Dockerfile.lint` builds the main image instead,
which is a lint that silently lints nothing. Its canonical form is in the
containerised-lint rule below; copy that one.
The `VERSION` lines are there for a different reason, covered by the
git-describe rule below; they are shown here so the two rules do not each
document half a command. `script/lint` passes only `CHECK_EPOCH`, since no
version is embedded in a lint image. All four `CHECK_EPOCH` elements are
load-bearing; none is optional, and each guards a failure mode that
otherwise fails green:
document half a command. All four `CHECK_EPOCH` elements are load-bearing;
none is optional, and each guards a failure mode that otherwise fails green:
- `ARG` is stage-scoped, so a single declaration leaves the other check
stages frozen while the fix reviews as complete. Declare it in every stage
that runs checks, immediately above the first such `RUN`.
@@ -186,19 +192,29 @@ style conventions are in separate documents:
cached, so it does not push against the five-minute Docker build ceiling.
Blanket `--no-cache` is **not** an acceptable substitute, on `Dockerfile` or
on `Dockerfile.lint`: it re-runs `go mod download` / `yarn install` on every
invocation, which makes linting network-dependent and pushes a lint that
should take seconds toward the build ceiling. Never reach for
`docker builder prune` to achieve the same end — the build cache is shared
with every other build on the host, including other people's.
invocation, so a lint that should take seconds pays a dependency install
each time and reaches the network on **every** run rather than only on the
first and after a manifest change. It is a difference of degree, not an
absolute — a container build is never fully offline-independent — but it is
the difference between a lint that usually needs nothing and one that always
does. Never reach for `docker builder prune` to achieve the same end: the
build cache is shared with every other build on the host, including other
people's.
- **Every lint run happens in a container, and `script/lint` is that container
build.** The linter is never installed on the host and never invoked there.
Every repo carries a `Dockerfile.lint` next to its `Dockerfile`; the linter
runs as a **build step**, so a successful build _is_ a clean lint. Building
rather than bind-mounting is deliberate: it is what makes the pattern work
unchanged where the docker daemon is remote and bind mounts are impossible.
Docker is assumed available in every environment. Discarding the linter's
cache on every run is the point of this rule, not a cost it pays.
build.** The precise claim, because it is the one that has to survive contact
with a repo whose formatter and linter are the same binary: **no lint verdict
may come from a host invocation.** No `script/`, no `Makefile` target and no
CI step may produce a lint result by running a linter on the host. That is
stronger than it sounds and weaker than "the binary is never on the host" —
see the scope note at the end of this rule, which says exactly which host
invocations remain legitimate and why. Every repo carries a `Dockerfile.lint`
next to its `Dockerfile`; the linter runs as a **build step**, so a successful
build _is_ a clean lint. Building rather than bind-mounting is deliberate: it
is what makes the pattern work unchanged where the docker daemon is remote and
bind mounts are impossible. Docker is assumed available in every environment.
Discarding the linter's cache on every run is the point of this rule, not a
cost it pays.
This closes a family of defects, every one of them an artefact of running
the linter on a shared host, and every one of them observed rather than
@@ -265,14 +281,20 @@ style conventions are in separate documents:
RUN golangci-lint run --config .golangci.yml ./...
```
and the canonical `script/lint`, identical in every repo:
and the canonical `script/lint`, whose executable lines are identical in
every repo (only the comment wording is a repo's own):
```sh
#!/bin/sh
# script/lint: run the linter. The linter is never installed on the host
# and never invoked there — it runs in a container, one way, everywhere,
# so a run cannot inherit another checkout's cache, another process's
# lock, or a host toolchain that differs from the pinned one.
# script/lint: run the linter. This is the ONLY source of a lint verdict
# in this repo — the linter runs in a container, one way, everywhere, so
# a run cannot inherit another checkout's cache, another process's lock,
# or a host toolchain that differs from the pinned one.
#
# A failure here that names no finding is NOT a lint result: docker
# build exits 1 both for findings and for a build that never reached
# the lint step. BuildKit names the failing step; read it, fix the
# environment, and re-run.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -300,46 +322,17 @@ style conventions are in separate documents:
close, arriving through a new file. The `ARG` goes **after** the
dependency layer so `go mod download` / `yarn install` stay cached and
only the lint steps re-run. Blanket `--no-cache` also busts the dependency
layer, which makes every lint network-dependent.
layer, so every lint reaches the network instead of only the first one.
- **Non-Go repos get the same pattern around their own linter** — `eslint`,
`ruff`, `prettier`, `shellcheck` — because the ruling is every lint run,
not every Go lint run. Only the base image and the lint commands change;
the `WORKDIR`, dependency layer, `ARG CHECK_EPOCH`, guard and
expanded-value `RUN` are identical. A JS or docs repo bases on its pinned
node image, runs `script/bootstrap` as the dependency layer, and lints
with the linter from `node_modules`, which is also how it gets the version
pinned in `package.json` rather than whatever is on the host.
- **The lint container lints whatever is in the build context, so
`.dockerignore` is part of this rule and not merely hygiene.** `COPY . .`
copies an agent scratch worktree — an entire second checkout of the repo —
into the lint image unless `.dockerignore` excludes it, and language
toolchains discover files by walking the tree rather than by reading
`.gitignore`, so `./...`, `eslint .` and `prettier --check .` all descend
into it. `sneak/quak` measured this on the same discovery mechanism in its
test runner: a nested `.claude/` worktree took the discovered test count
from 210 to 1050 (https://git.eeqj.de/sneak/quak/issues/30). Left in the
context it re-creates _inside_ the container the foreign-tree false reds
that moving lint into a container was adopted to end, and it does so in
the convincing form — the findings are real, they simply belong to another
checkout. See the `.dockerignore` rules below, and verify by enumerating
the image rather than by reading the patterns.
- **The build arg is named `CHECK_EPOCH` in `Dockerfile.lint` too**, not
`LINT_EPOCH` or any other per-file name, and `script/lint` passes it under
that name. Both files guard the same failure under the same contract, and
the single name is what lets a reviewer grep a repo for `CHECK_EPOCH` and
see every cache-bust it has. Rename it in one file and that grep silently
misses it, so a renamed guard and an absent guard read identically without
opening both Dockerfiles.
- **The formatting check runs in exactly one of the two images, and either
one is allowed.** The canonical `Dockerfile` above runs `script/fmt-check`
because that is where the non-lint checks live. A repo may instead run its
formatter in `Dockerfile.lint` beside the linters, which is the better
shape wherever the formatter is the same pinned dependency as the linter
(`prettier` out of `node_modules`, say), because it takes the last host
toolchain off the checked path for the same reason the linter came off it.
What is not allowed is running it in neither image, or in both. Whichever
image runs it carries the epoch guard, and `script/check` still runs all
three targets on the developer's side either way.
node image, runs `script/bootstrap` as the dependency layer **inside the
image**, and lints with the linter from that image's `node_modules`. Note
what this does not claim: a developer also runs `script/bootstrap` on the
host, so a copy of that linter exists there too. What the rule forbids is
taking a **verdict** from it.
- **Keep `golangci-lint config verify`, and it costs no network.** The two
commands catch **disjoint** classes of defect, measured under the pinned
v2.12.2 against a config carrying one planted defect at a time: a bogus
@@ -356,9 +349,48 @@ style conventions are in separate documents:
`getent hosts golangci-lint.run` exits 2. The schema is embedded in the
pinned binary. Re-run that control when bumping the pin rather than
treating the result as permanent.
- **No repo installs a linter on the host, in `script/bootstrap` or anywhere
else.** A host install is now dead weight whose only remaining effect is
to reintroduce the version skew above.
- **No repo installs a linter on the host _in order to lint_ — remove any
such install from `script/bootstrap`.** A dedicated linter install (the
`go install` of golangci-lint is the case that existed) is now dead weight
whose only remaining effect is to reintroduce the version skew above. This
does not forbid a host dependency install that happens to bring a linter
along with everything else, which is unavoidable in a JS repo and is fine
as long as no verdict is taken from it.
- **A failed `script/lint` that names no finding is not a lint result.** The
exit status alone cannot tell you which happened: `docker build` returns 1
both when the lint step fails on findings and when the build never got
that far — daemon unreachable, base image unpullable, disk full,
dependency layer failing. The per-checkout wrapper this rule replaced drew
that line explicitly, exiting 75 for a run that analysed nothing, and that
machinery is deliberately **not** restored. Three reasons, and they are a
position rather than an omission:
- **The dangerous direction is already closed.** The wrapper's exit 75
existed because a lock collision could be read as a _result_ on a run
that analysed nothing. A build that cannot run fails **closed**: it
can never read as clean. What is lost is diagnosability, not safety.
- **The failure is self-describing, where the lock collision was not.**
BuildKit prints the failing step verbatim —
`ERROR: failed to solve: process "/bin/sh -c <the lint command>"` for
a genuine finding, against a named earlier step or a daemon error for
anything else. The discriminator is already in the output and needs no
code. The lock message, by contrast, went to stderr while findings
went to stdout and was easy to lose.
- **It is not transient, so retrying is wrong.** The lock collision
cleared on a retry, which is what made an automatic retry worth
building. A dead daemon or a full disk does not, and a retry loop over
a failing dependency fetch hides a real reproducibility problem.
Rebuilding the distinction in the script would also mean reintroducing
per-invocation capture files and traps to get the exit status out from
under a pipe, plus matching on BuildKit's message format, which is not
a stable interface — and a mis-match in the "treat it as
infrastructure" direction would be the false green this whole rule
exists to prevent.
The substance of the VOID rule survives as a reading rule, and it is
binding: **do not record a lint verdict from a run that did not reach
the lint step, and do not "fix" anything on the strength of one.** Read
which step failed, fix the environment, and re-run.
- **`script/check` still runs `test`, `lint` and `fmt-check`**, so a
developer and the pre-commit hook get all three. It therefore requires a
docker daemon, and it must never be invoked from inside a build stage —
@@ -371,6 +403,27 @@ style conventions are in separate documents:
- If linting requires CGO or system libraries (e.g. `vips-dev`), install
them in `Dockerfile.lint`.
**Scope: this rule is about linters, and a formatter is not one.**
`script/fmt` writes to your working tree, so it can only run on the host;
`script/fmt-check` is its read-only twin and runs on the host too, as well
as inside the main image. Neither is a lint run and neither is in scope.
**The awkward case, stated rather than left for an adopter to trip over: in
a repo whose formatter _is_ its linter** — prettier over a markdown or docs
repo is the standard shape, and this repo is one — the command in
`Dockerfile.lint` and the command in `script/fmt-check` are the same string,
so that exact command does still run on the host. That is a real gap in the
absolute reading and it is accepted for two reasons: the mechanisms this
rule exists to close do not reach it (prettier's version is pinned in
`package.json` and installed into the repo's own `node_modules`, so there is
no shared content-keyed cache, no host-global lock, and no version to skew
against), and CI's verdict is containerised regardless, because
`script/fmt-check` also runs inside the main image build. What is **not**
acceptable is taking a lint verdict from the host copy: `script/lint` stays
the only source of one. If a repo's linter and formatter ever diverge in
version or configuration, this gap becomes a real defect and the check-mode
invocation has to move into the container too.
**What a consuming repo does to adopt this**, in order: add
`Dockerfile.lint`; replace `script/lint` with the build above; delete the
`lint` stage from its `Dockerfile` along with the
@@ -408,8 +461,8 @@ style conventions are in separate documents:
# docker build (Dockerfile.lint), so `make check` here would nest a
# build inside a build step, where there is no daemon. Lint is not
# skipped — script/cibuild runs it first, in its own container.
RUN echo "check epoch: ${CHECK_EPOCH}" && make fmt-check
RUN make test
RUN echo "check epoch: ${CHECK_EPOCH}" && script/fmt-check
RUN script/test
# VERSION comes from the host via --build-arg; see the git-describe rule
# below. Never run `git describe` here: .dockerignore excludes .git, so
@@ -571,22 +624,14 @@ style conventions are in separate documents:
inflates by a multiple of the repo, and another session's unreviewed,
sometimes uncommitted work can be copied into an image layer. The directory is
also created and destroyed constantly, so it invalidates `COPY . .` for
reasons that have nothing to do with the repo's own content. **And because
`Dockerfile.lint` and `Dockerfile` run their tooling over the copied context,
a worktree that reaches it is linted and tested as though it were the repo.**
Nothing else stops that: language toolchains discover files by walking the
tree and do not read `.gitignore`, which is how `sneak/quak` saw a nested
`.claude/` worktree take its discovered test count from 210 to 1050
(https://git.eeqj.de/sneak/quak/issues/30). This entry is therefore a
correctness precondition of the containerised-lint rule above and not a size
optimisation — without it the foreign-tree false reds that rule exists to end
simply move inside the container. In `.gitignore` the entry is `.claude/`,
unanchored, which already matches at every depth. In `.dockerignore` it is
`.claude`, anchored and with **no** `**/` prefix: the directory occurs exactly
once **where agents run at the repo root**, and the prefixed form would also
match any nested directory of that name and delete it from the build. It is
not case-folded the way the secret patterns are, because tooling creates it in
exactly one spelling, so a folded pattern would add no coverage.
reasons that have nothing to do with the repo's own content. In `.gitignore`
the entry is `.claude/`, unanchored, which already matches at every depth. In
`.dockerignore` it is `.claude`, anchored and with **no** `**/` prefix: the
directory occurs exactly once **where agents run at the repo root**, and the
prefixed form would also match any nested directory of that name and delete it
from the build. It is not case-folded the way the secret patterns are, because
tooling creates it in exactly one spelling, so a folded pattern would add no
coverage.
**Known gap that comes with the anchored form.** The directory is created in
the agent's working directory, so the "exactly once, at the root" premise is

View File

@@ -4,6 +4,12 @@
# Dockerfile's checks only actually run because CHECK_EPOCH is a fresh
# nonce on every invocation; without it a warm cache turns this into a
# green that proves nothing.
#
# Those checks are the NON-LINT ones. Linting left this image: it runs
# in its own container, built by script/lint, because script/lint is a
# docker build and cannot run inside one. So a green here does not mean
# the branch is green — script/cibuild, which runs script/lint first, is
# what means that.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"

View File

@@ -1,11 +1,17 @@
#!/bin/sh
# script/lint: run the linter. The linter is never installed on the host
# and never invoked there — it runs in a container, one way, everywhere,
# so a run cannot inherit another checkout's cache, another process's
# lock, or a host toolchain that differs from the pinned one. Linting
# happens as a build step (see Dockerfile.lint), so a successful build
# is a clean lint, and it works where the docker daemon is remote and
# bind mounts are impossible.
# script/lint: run the linter. This is the ONLY source of a lint verdict
# in this repo — the linter runs in a container, one way, everywhere, so
# a run cannot inherit another checkout's cache, another process's lock,
# or a host toolchain that differs from the pinned one. Linting happens
# as a build step (see Dockerfile.lint), so a successful build is a
# clean lint, and it works where the docker daemon is remote and bind
# mounts are impossible.
#
# A failure here that names no finding is NOT a lint result: docker
# build exits 1 both for findings and for a build that never reached the
# lint step (daemon down, image unpullable, disk full). BuildKit names
# the failing step; read it, fix the environment, and re-run. Do not
# record a verdict from a run that did not lint.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"