5 Commits

Author SHA1 Message Date
9f079ab594 Merge next: run all linting in Docker (closes #41)
Pins golangci-lint by digest in Dockerfile.lint and removes the host lint path entirely, killing the false-green class that started the issue.
2026-08-10 15:33:21 +02:00
3eb9f81fc4 docs: name the two required flags, and record .dockerignore as part of the gate
Comments and documentation only; the docker build invocation and its flags
are untouched, and .dockerignore's effective rules are unchanged.

Two prose gaps from review. First, "both flags below must stay" had lost
its anchor: it ended a paragraph naming only --no-cache-filter, --target
was not introduced until the next one, and three flags follow on the
command, so a reader could pick the wrong pair. It now names --target and
--no-cache-filter explicitly.

Second, the list of things the tooling does not check covered the $stage
seam but not .dockerignore, which sits in the same trust boundary and is
the more likely thing to be edited — the first review on this change
actively suggested extending it for build artifacts. Only what reaches the
container is linted, so excluding a Go source there removes it from the
lint with no warning. Verified rather than asserted: a planted violation
plus that one path in .dockerignore yields `0 issues.` at exit 0 with the
violation still in the working tree, while excluding a file other code
still references fails loudly on `undefined:` typecheck errors instead.
The warning is recorded in script/lint alongside the $stage seam and in
.dockerignore itself, where the edit would actually be made.
2026-08-10 13:29:44 +00:00
20cfb47912 build: define the lint stage name once so the two flags cannot diverge
The previous commit claimed --target and --no-cache-filter "validate each
other's magic string". They do not. --target validates only its own
argument; a typo confined to --no-cache-filter left the build green and
linting nothing:

    docker build --target lint --no-cache-filter=lnit ...
    #10 [lint 2/2] RUN golangci-lint run ... CACHED   exit 0

Three of the four edit paths were caught and one was not, so the original
false green survived in the narrow case.

The duplication was the defect: the stage name appeared twice on one
command line and nothing tied the copies together. Correcting only the
prose would have left the hazard live and merely warned about, so the name
is now written once, as `stage=lint`, and passed to both flags. Divergence
is unrepresentable rather than documented — there is a single name to get
wrong, and --target rejects it loudly when it is not a stage in
Dockerfile.lint, which now covers the filter too because it is the same
string.

The comments in script/lint and Dockerfile.lint and the TODO.md entry drop
the false "validate each other" claim and state the real property, along
with the residual hazard that is genuinely unguarded: --target checks that
the name exists, not that it names the stage which actually runs
golangci-lint, and it stops the build there, so relocating the lint step
or appending a stage after it would go unnoticed.
2026-08-10 13:11:42 +00:00
329c03f06e build: make the lint cache-busting self-validating in script/lint
`--no-cache-filter=lint` is silently ignored by BuildKit when no stage
matches the name, so the entire anti-false-green mechanism hung on one
unvalidated magic string: renaming or mistyping the `lint` stage would
have left the lint layer served from cache and `script/lint` reporting
green having linted nothing. Reproduced here — with the filter pointed at
a nonexistent stage and no `--target`, an unchanged tree built with
`RUN golangci-lint run ... CACHED` and exited 0.

`--target lint` closes it: a stage name that does not exist now fails
loudly (`target stage "nosuchstage" could not be found`, exit 1) instead
of passing. The two flags name the same stage from the same string and
validate each other; both the script and the stage definition in
Dockerfile.lint carry a comment saying they must be kept in sync.

`--output=type=cacheonly` drops the image export. Nothing consumes the
image — the deliverable of this build is an exit code — and the export
cost seconds per run and left one dangling image behind every time, on a
host where pruning is prohibited. The lint stage still executes and a
lint failure still exits non-zero, both verified rather than assumed.

TODO.md: the 2026-08-07 entry's claim that the repo has no linter pin and
lints on the host is marked superseded in place rather than rewritten;
the narrowed scaffold exemption now names `.dockerignore` alongside
`Dockerfile.lint` and `script/lint`; and the specific wall-clock timings
are replaced by the durable property they were evidence for, since they
vary per host and per run.
2026-08-10 12:55:40 +00:00
599286a88e build: run golangci-lint in a pinned container via script/lint (closes #41)
golangci-lint is no longer invoked on the host anywhere in the repo.
Dockerfile.lint pins golangci/golangci-lint:v2.12.2 by digest and runs
the linter as a build step, so a successful build IS a clean lint, and
`make lint` becomes a thin shim over script/lint. This removes the host
linter install that produced a false green here, where a branch that was
genuinely red with a goconst finding reported "0 issues" off the shared
host cache; a container per run has its own cache and lock.

Two deliberate divergences from the sneak/homoicon reference shape:

  - Two stages rather than one. A cached `deps` stage holds
    `go mod download`, then `FROM deps AS lint` carries the source copy
    and the lint run, and script/lint builds with
    `--no-cache-filter=lint`. Caching of the lint result is explicitly
    waived (a cached build lints nothing), and splitting the stages means
    busting the lint layer does not re-fetch the module cache over the
    network on every run.

  - No `golangci-lint config verify` step. It resolves its JSON schema
    over a live, unpinned HTTPS call: an unpinned network input inside
    the one step whose purpose is a pinned, reproducible gate, and a
    schema-host outage would surface as a red build. `golangci-lint run`
    already fails on a malformed config. The reason is recorded in a
    comment in Dockerfile.lint.

.dockerignore excludes .git only; the lint reads the Go sources,
go.mod/go.sum and .golangci.yml, none of which come from there.

The TODO.md scaffold-exemption note is narrowed rather than dropped:
Dockerfile.lint and script/lint are now permitted and required, while CI
config, REPO_POLICIES.md, an application Dockerfile and any other
script/ entrypoint still are not.

Verified, since a green docker build is the classic false green: two
consecutive script/lint runs on an unchanged tree each showed the
`golangci-lint run` layer executing (9.8s and 7.9s, both "0 issues.")
while the deps layers reported CACHED; a deliberate indent-error-flow
violation failed the build naming that finding and the unused one, and a
revert went clean again. `make check` green.
2026-08-10 12:33:41 +00:00
6 changed files with 205 additions and 15 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
# The lint build reads the Go sources, go.mod/go.sum and .golangci.yml;
# none of that comes out of .git, so keep the build context small.
#
# This file is part of the lint gate, not housekeeping: only what reaches
# the container gets linted, so excluding a Go source here silently drops
# it from the lint (a self-contained file yields `0 issues.` at exit 0 with
# the violation still in the tree; it fails loudly only if other code still
# references it). Never exclude Go sources, go.mod/go.sum or .golangci.yml.
.git

39
Dockerfile.lint Normal file
View File

@@ -0,0 +1,39 @@
# Lint-only image: used by script/lint on machines where the docker
# daemon is remote (no bind mounts possible) — the repo is COPYed into
# the build context and golangci-lint runs as a build step, so a
# successful build means a clean lint.
#
# Two stages on purpose. script/lint builds with --no-cache-filter=lint so
# that the lint stage re-executes on every run, including on an unchanged
# tree (caching of the lint result is explicitly waived: a cached build
# lints nothing). Keeping `go mod download` in a separate `deps` stage
# means busting the lint stage does not also re-fetch the module cache
# over the network every time.
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
WORKDIR /src
# Copy go mod files first for better layer caching
COPY go.mod go.sum ./
RUN go mod download
# This stage must stay the one that runs golangci-lint, and its name must
# match $stage in script/lint, which passes that single name to both
# --target and --no-cache-filter. Renaming here without updating script/lint
# fails the build loudly (--target rejects a name that is not in this file),
# so a mismatch cannot pass silently — but moving the lint step to another
# stage, or adding a stage after this one, would not be caught. Change the
# two files together.
FROM deps AS lint
# Copy source code
COPY . .
# No `golangci-lint config verify` step here, deliberately (sneak/homoicon
# has one). It resolves its JSON schema over a live, unpinned HTTPS call:
# an unpinned network input inside the one step whose whole purpose is a
# pinned, reproducible gate, and a schema-host outage would show up as a
# red build. `golangci-lint run` already fails on a malformed config.
RUN golangci-lint run --config .golangci.yml ./...

View File

@@ -1,8 +1,10 @@
# Development convenience targets. This repo is exempt from the standard
# policy scaffold (no Dockerfile, CI, or REPO_POLICIES.md); this Makefile
# is only a thin wrapper around the Go toolchain, golangci-lint, and
# prettier so `make fmt` / `make check` behave the same as in sneak's
# other repos.
# policy scaffold (no CI config, no REPO_POLICIES.md, no application
# Dockerfile) except for the lint container: per sneak's 2026-08-09
# ruling, linting runs in docker only, so Dockerfile.lint and script/lint
# are part of this repo. This Makefile is otherwise only a thin wrapper
# around the Go toolchain and prettier so `make fmt` / `make check` behave
# the same as in sneak's other repos.
GO_PKGS := ./...
MD_FILES := $(shell git ls-files '*.md')
@@ -26,9 +28,11 @@ fmt-check:
fi
$(PRETTIER) --check $(MD_FILES)
# Run the house linter (config in .golangci.yml).
# Run the house linter. golangci-lint is never installed on the host: the
# work happens inside the pinned container built by Dockerfile.lint, and
# this target is a thin shim over the script that builds it.
lint:
golangci-lint run $(GO_PKGS)
./script/lint
# Run the test suite. Quiet on success; on failure, rerun verbosely for the
# full output and still fail the target (the first run already proved the

View File

@@ -77,10 +77,12 @@ sequences, dungeon-generation golden checks, and an RNG compatibility test
against the original C generator.
For development, the `Makefile` wraps the toolchain: `make fmt` (gofmt +
prettier), `make lint` (golangci-lint), `make test` (the suite, under the race
detector with coverage and a timeout), and `make check` (all three). Use the
targets rather than invoking `go test` directly — they carry the flags the
project relies on.
prettier), `make lint` (`script/lint`, which runs golangci-lint inside the
pinned container built from `Dockerfile.lint` — it is never installed on the
host, so docker is required), `make test` (the suite, under the race detector
with coverage and a timeout), and `make check` (all three). Use the targets
rather than invoking `go test` directly — they carry the flags the project
relies on.
## License

81
TODO.md
View File

@@ -35,6 +35,67 @@ is finished.
# Completed Steps
- 2026-08-10 Linting moved into a container
(https://git.eeqj.de/sneak/rgoue/issues/41). `golangci-lint` is no longer
invoked on the host anywhere in the repo: `Dockerfile.lint` pins
`golangci/golangci-lint:v2.12.2` by digest and runs the linter as a build
step, so a successful build is a clean lint, and `make lint` is now a shim
over `script/lint`. This is what killed the false green seen earlier, where a
branch that was genuinely red with a `goconst` finding reported `0 issues` off
the shared host cache; a container per run has its own cache and lock.
Two deliberate divergences from the `sneak/homoicon` reference. The image
has two stages rather than one — a cached `deps` stage holding
`go mod download`, then `FROM deps AS lint` with the copy and the lint run —
and `script/lint` builds with `--no-cache-filter=lint`. Caching of the lint
result is explicitly waived (a cached build lints nothing), and splitting
the stages means busting the lint layer does not also re-fetch the module
cache over the network on every run. And `golangci-lint config verify` is
left out: it resolves its JSON schema over a live, unpinned HTTPS call,
which is an unpinned network input inside the one step whose purpose is a
pinned reproducible gate, and a schema-host outage would surface as a red
build. `golangci-lint run` already fails on a malformed config.
Verified rather than assumed, since a green docker build is the classic
false green: two consecutive runs on an unchanged tree each showed the
`golangci-lint run` layer executing and reporting `0 issues.` while the
`deps` layers reported `CACHED`, and a deliberate `indent-error-flow`
violation failed the build naming that finding plus the `unused` one before
a revert went clean again. The durable property to check when touching any
of this is that the lint stage executes on every run and is never served
from cache; wall-clock durations vary per host and per run, so they are not
recorded here.
Hardened 2026-08-10 after review. `script/lint` now also passes `--target`
and `--output=type=cacheonly`. `--target` is what makes `--no-cache-filter`
trustworthy: BuildKit silently ignores the filter when no stage matches its
argument, so a rename or typo of the `lint` stage would have left the lint
layer cached and `script/lint` green having linted nothing — the same false
green in a new place. `--target` fails loudly on a name that is not in the
file. `--output=type=cacheonly` skips the image export: nothing consumes the
image (the deliverable is an exit code), and exporting it cost seconds per
run and left a dangling image behind each time.
Corrected 2026-08-10 after a second review, which was right to reject the
claim first made here that the two flags "validate each other". They did
not: `--target` validates only its own argument, so a typo confined to
`--no-cache-filter` still built `CACHED` at exit 0 — the original defect,
surviving in the narrow case. The duplicated stage name was the defect, so
it is now written once, as `stage=lint` in `script/lint`, and passed to both
flags. The true property is that there is only one name to get wrong, and
`--target` rejects it loudly if it is not a stage in `Dockerfile.lint`, so a
typo or a stale rename is a hard error rather than a silent skip. What
remains on the editor, and is not checked by anything: `$stage` must name
the stage that actually runs `golangci-lint`. `--target` verifies the name
exists, not that it is the right stage, and it stops the build there — so
moving the lint step to another stage, or adding a stage after it, would go
unnoticed. The same applies to `.dockerignore`, which is part of this gate
rather than housekeeping: only what reaches the container is linted, so
excluding a Go source there drops it from the lint silently — verified, a
planted violation plus that one path in `.dockerignore` gives `0 issues.` at
exit 0 with the violation still in the tree, and it fails loudly only when
other code still references the excluded file.
- 2026-08-09 `TestAutoSaveOnSignalRacesTurnLoop` de-flaked at the cause
(`fix/autosave-turn-budget-36`, closes #36). The failure text was captured
before anything was changed and it is **not** a data race: the assertion was
@@ -705,7 +766,11 @@ is finished.
24 long lines wrapped or their comments tightened, control bytes in
`term/tcell.go` as character literals, and two `wsl_v5` defer cuddles. The
repo has no golangci-lint version pin to bump (no Dockerfile or CI;
`make lint` runs whatever `golangci-lint` is on the host).
`make lint` runs whatever `golangci-lint` is on the host). Superseded
2026-08-10: there is a pin now, and no host lint path — `Dockerfile.lint` pins
the linter image by digest and `script/lint` runs it in a container. See the
2026-08-10 entry at the top of this section
(https://git.eeqj.de/sneak/rgoue/issues/41).
- 2026-07-24 Seed compatibility — item tables (seed-compat): instrumented the C
reference on modern-rogue with a DUMP mode (testdata/c_seedcompat.patch) that
@@ -853,7 +918,13 @@ is finished.
per-game dungeon dimensions instead of the 80x24 constants; open design
questions are resize policy, gameplay tuning at larger sizes, and a --classic
80x24 mode.
2. Note: this repo is exempt from the standard policy scaffold. A minimal dev
Makefile (fmt/fmt-check/lint/test/check targets) exists per sneak's
2026-07-07 request, but do not add a Dockerfile, CI config, or
REPO_POLICIES.md.
2. Note: this repo is exempt from the standard policy scaffold, but the
exemption is narrower than it was. A minimal dev Makefile
(fmt/fmt-check/lint/test/check targets) exists per sneak's 2026-07-07
request. `Dockerfile.lint` and `script/lint` are now also permitted, and
required, along with the `.dockerignore` that scopes their build context:
sneak's 2026-08-09 ruling (https://git.eeqj.de/sneak/rgoue/issues/41) is that
every repo lints in a container invoked through `script/lint`, and being
later and explicit it overrides the 2026-07-07 exemption for those three
files only. Still do not add: CI config, `REPO_POLICIES.md`, an application
`Dockerfile`, or any other `script/` entrypoint.

65
script/lint Executable file
View File

@@ -0,0 +1,65 @@
#!/bin/sh
# script/lint: run the linter. golangci-lint is never installed locally:
# it runs via docker only, one way, everywhere — script/lint builds
# Dockerfile.lint, which COPYs the repo into the pinned golangci-lint
# image and lints as a build step. This works even when the docker daemon
# is remote and bind mounts are impossible.
#
# --no-cache-filter forces the lint stage to re-execute every run, so an
# unchanged tree is still actually linted; the deps stage keeps its cache,
# so the module download is not repeated. It and --target must both stay,
# for the reason in the next paragraph: do not "simplify" either of those
# two away.
#
# The stage name is written ONCE, in $stage, and passed to both --target
# and --no-cache-filter, so the two flags cannot come to name different
# stages. That is the whole point of the variable. BuildKit silently
# ignores --no-cache-filter when no stage matches its argument: a filter
# naming a stage that does not exist is a no-op, the lint layer is served
# from cache, and script/lint reports green having linted nothing — the
# false green this setup exists to prevent. --target, by contrast, fails
# loudly on a name that is not in the file. With a single shared name, a
# typo or a stale rename therefore becomes a hard error instead of a
# silent skip, because the one name reaches both flags.
#
# What the tooling does NOT check, and is left to whoever edits this:
#
# 1. $stage must name the stage in Dockerfile.lint that actually runs
# golangci-lint. --target verifies that the name exists, not that it is
# the right stage, and it stops the build at that stage — so moving the
# lint step into a different stage, or adding a stage after this one,
# would not be caught here. Keep this file and Dockerfile.lint in sync.
#
# 2. .dockerignore decides what reaches the container, and only what
# reaches it gets linted. Excluding a Go file there removes it from the
# lint with no warning: verified by planting a real violation and adding
# just that file's path to .dockerignore, which produced `0 issues.` at
# exit 0 with the violation still sitting in the working tree. It shows
# up only if the rest of the package still references the excluded file,
# in which case the build fails loudly on `undefined:` typecheck errors;
# a self-contained file drops out silently. So .dockerignore is part of
# this gate, not housekeeping — keep it to build inputs the lint does
# not read, and never exclude Go sources, go.mod/go.sum or
# .golangci.yml.
#
# --output=type=cacheonly skips the image export. Nothing consumes the
# image — the deliverable of this build is an exit code — and exporting it
# costs seconds per run and leaves a dangling image behind every time. The
# lint stage still executes and a lint failure still exits non-zero.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the stage name in Dockerfile.lint.
stage=lint
main() {
cd "$ROOT"
docker build \
--target "$stage" \
--no-cache-filter="$stage" \
--output=type=cacheonly \
-f Dockerfile.lint .
}
main "$@"