5 Commits

Author SHA1 Message Date
0bef53bd11 Merge next: make build and make cover targets (closes #19, closes #17)
Adds build/cover/cover-html targets writing to a gitignored build/, and removes the last raw go invocation from README.
2026-08-10 15:58:35 +02:00
e3ab4aba8b build: add a make cover target for per-function coverage (closes #17)
make cover writes build/coverage.out and prints the per-function report;
make cover-html renders the same profile to build/coverage.html. The
per-package percentage make test prints cannot say which function is
untested.

Both write files, so neither is in check, and neither may be added to it:
check must not modify the working tree. Their output lands under the
already-ignored build/.

Verified: make cover printed per-function lines and a 62.6% total,
make cover-html wrote build/coverage.html, and git status stayed clean.
GOFLAGS=-count=1 make check green in 24s with the lint layer executing
(12.0s, "0 issues.", not CACHED) and the suite running for real (cmd/rogue
1.037s, game 3.410s); check is still fmt-check lint test and git status is
clean afterwards.
2026-08-10 13:48:58 +00:00
60442ce103 build: add a make build target, drop README's raw go build (closes #19)
The executable builds to build/rogue. All generated artifacts go under
build/, which .gitignore covers as a whole; a target writing outside it
can commit its output.

build is in neither check nor test: check stays fmt-check lint test and
still writes nothing into the working tree.

README's "Building and running" block and the run examples use
./build/rogue, and no raw go invocation is left in the file.

Verified: make build writes build/rogue and git status stays clean;
GOFLAGS=-count=1 make check green in 34s with the lint layer executing
(21.9s, "0 issues.", not CACHED) and the suite running for real (cmd/rogue
1.027s, game 3.450s), git status clean afterwards.
2026-08-10 13:48:58 +00:00
8413c0c7ba Merge next: trim the lint-gate comments to the traps (closes #44)
Comment-only: executable content of script/lint, Dockerfile.lint and .dockerignore is byte-identical.
2026-08-10 15:41:16 +02:00
6f997b8d5c docs: trim the lint-gate comments to the traps (closes #44)
Comments and documentation only; the docker build invocation and its three
flags are byte-identical and .dockerignore's effective rules are unchanged.

script/lint and Dockerfile.lint stated how the shape was derived — why two
stages, why `golangci-lint config verify` was omitted, what earlier drafts
of the comments claimed. That is in the history. What survives is the three
traps, each of which yields a green run over an unlinted or partly linted
tree: --target and --no-cache-filter must both stay with $stage matching the
stage name in Dockerfile.lint; --target checks that the stage exists, not
that it runs golangci-lint, and halts the build there; and .dockerignore
decides what reaches the container, so excluding a self-contained Go file
drops it from the lint silently.

The TODO.md entry loses its "Hardened" and "Corrected" paragraphs, which
argued with earlier versions of themselves, and keeps the flags, the durable
property, the three unguarded seams, and the evidence that the gate was
verified rather than assumed.
2026-08-10 13:38:15 +00:00
7 changed files with 105 additions and 135 deletions

View File

@@ -1,9 +1,8 @@
# 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.
# Part of the lint gate: only what reaches the container is linted, so
# excluding a self-contained Go source here drops it from the lint silently.
# Never exclude Go sources, go.mod/go.sum or .golangci.yml.
.git
# Generated artifacts only; `make build` puts a multi-megabyte binary here
# and it would otherwise be shipped into the build context.
/build/

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
*.log
*.out
*.test
/build/
/rogue

View File

@@ -1,39 +1,20 @@
# 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.
# Lint image, built by script/lint: golangci-lint runs as a build step, so
# a successful build is a clean lint.
# 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.
# match $stage in script/lint. --target halts the build at this stage, so
# moving the lint step to another stage, or adding a stage after this one,
# is not caught.
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

@@ -6,15 +6,42 @@
# 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')
PRETTIER := prettier --tab-width 4 --prose-wrap always
GO_PKGS := ./...
MD_FILES := $(shell git ls-files '*.md')
PRETTIER := prettier --tab-width 4 --prose-wrap always
.PHONY: check fmt fmt-check lint test
# Every generated artifact goes here, and the whole directory is
# git-ignored. Targets that write outside it can commit their output.
BUILD_DIR := build
BIN := $(BUILD_DIR)/rogue
COVERPROF := $(BUILD_DIR)/coverage.out
COVERHTML := $(BUILD_DIR)/coverage.html
# Format, lint, and test — the full local pre-commit gate.
.PHONY: build check cover cover-html fmt fmt-check lint test
# Format, lint, and test — the full local pre-commit gate. Keep this list
# to targets that write nothing into the working tree.
check: fmt-check lint test
# Build the executable into $(BUILD_DIR). `go build -o` does not create the
# parent directory.
build:
@mkdir -p $(BUILD_DIR)
go build -o $(BIN) ./cmd/rogue
# Per-function coverage, for finding which functions are untested. The
# percentage `make test` prints is a per-package total and cannot answer
# that. Writes files, so it stays out of `check`.
cover:
@mkdir -p $(BUILD_DIR)
go test -timeout 30s -coverprofile=$(COVERPROF) $(GO_PKGS)
go tool cover -func=$(COVERPROF)
# Render the same profile as annotated source.
cover-html: cover
go tool cover -html=$(COVERPROF) -o $(COVERHTML)
@echo "wrote $(COVERHTML)"
# Format Go and Markdown in place.
fmt:
gofmt -w .

View File

@@ -21,19 +21,19 @@ original program structure and the design of this port.
Requires Go 1.25 or later and a terminal at least 80x24.
```bash
go build ./cmd/rogue
./rogue
make build
./build/rogue
```
```bash
# Restore a saved game
./rogue ~/rogue.save
./build/rogue ~/rogue.save
# View high scores
./rogue -s
./build/rogue -s
# Test the death screen (demo mode)
./rogue -d
./build/rogue -d
```
## In-game commands
@@ -57,7 +57,7 @@ Press `?` in game for the full list.
export ROGUEOPTS="name=YourName,terse,jump,fruit=mango"
# Wizard (debug) mode, with a reproducible dungeon
ROGUE_WIZARD=1 SEED=12345 ./rogue
ROGUE_WIZARD=1 SEED=12345 ./build/rogue
```
The scoreboard is kept in `~/.rogue.scores`. Save files are Go gob snapshots
@@ -80,9 +80,11 @@ For development, the `Makefile` wraps the toolchain: `make fmt` (gofmt +
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.
with coverage and a timeout), `make check` (all three), `make build` (the
executable), and `make cover` / `make cover-html` (per-function coverage, and
the same profile as annotated source at `build/coverage.html`). Everything they
generate lands in the git-ignored `build/`. Use the targets rather than the
toolchain directly — they carry the flags the project relies on.
## License

82
TODO.md
View File

@@ -35,6 +35,26 @@ is finished.
# Completed Steps
- 2026-08-10 `make cover` added (https://git.eeqj.de/sneak/rgoue/issues/17).
`make cover` writes `build/coverage.out` and prints the per-function report;
`make cover-html` renders the same profile to `build/coverage.html`. The
per-package percentage `make test` prints cannot say _which_ function is
untested, which is how the coverage gaps closed so far had to be found — by
grepping test files for identifiers.
Neither target is in `check`, and neither may be added to it: both write
files, and `make check` must not modify the working tree.
- 2026-08-10 `make build` added (https://git.eeqj.de/sneak/rgoue/issues/19). The
executable is built to `build/rogue`; `README.md` no longer contains a raw
`go` invocation anywhere. `build` is in neither `check` nor `test`
`make check` stays `fmt-check lint test` and still writes nothing into the
working tree.
Generated artifacts now all live under `build/`, which `.gitignore` covers
as a whole. Anything written outside it is committable, so a target that
puts its output elsewhere reintroduces the stray-artifact problem.
- 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
@@ -44,57 +64,25 @@ is finished.
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.
`script/lint` builds with `--target "$stage"`, `--no-cache-filter="$stage"`
and `--output=type=cacheonly`. The durable property to check when touching
any of this: the lint stage executes on every run and is never served from
cache. Three things no tooling checks, left to whoever edits the gate —
`$stage` must match the stage name in `Dockerfile.lint`; that stage must
stay the one running `golangci-lint`, since `--target` halts the build
there; and `.dockerignore` governs what reaches the container, so excluding
a self-contained Go source drops it from the lint silently.
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.
`deps` layers reported `CACHED`; the same build with `--no-cache-filter`
removed reported that layer `CACHED`, so the re-execution is attributable to
the flag rather than to a changed context; deliberate violations failed the
build naming the specific finding and reverted clean; a stage-name typo
failed loudly at exit 1; and a Go file excluded via `.dockerignore` reported
`0 issues.` at exit 0 with the violation still in the tree. Wall-clock
durations vary per host and per run, so they are not recorded here.
- 2026-08-09 `TestAutoSaveOnSignalRacesTurnLoop` de-flaked at the cause
(`fix/autosave-turn-budget-36`, closes #36). The failure text was captured

View File

@@ -1,51 +1,23 @@
#!/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.
# script/lint: lint in docker. golangci-lint is never installed on the host.
#
# --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.
# Traps, each of which yields a green run over an unlinted or partly linted
# tree:
#
# 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.
# 1. --target and --no-cache-filter must both stay, and $stage must match
# the stage name in Dockerfile.lint. BuildKit ignores --no-cache-filter
# when no stage matches its argument, serving the lint layer from cache
# without a word; --target rejects a name that is not in the file, which
# is what makes the single $stage safe.
#
# What the tooling does NOT check, and is left to whoever edits this:
# 2. --target checks that the stage exists, not that it is the stage
# running golangci-lint, and it halts the build there. Moving the lint
# step to another stage, or adding a stage after it, is not caught.
#
# 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
# 3. .dockerignore decides what reaches the container, and only what
# reaches it is linted. Excluding a self-contained Go file drops it from
# the lint silently. 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)"