Run all linting in Docker via Dockerfile.lint (closes #30) #31

Open
clawbot wants to merge 3 commits from next into main
Collaborator

Closes #30.

What changed

New root Dockerfile.lint. It copies the repo into the node image already pinned by digest in Dockerfile (node 22.22.0 on Alpine 3.23.3) and runs eslint and prettier as build steps, so a successful build IS a clean lint. script/lint is reduced to building it, which also works where the docker daemon is remote and bind mounts are impossible. Manifests (package.json, yarn.lock) are copied before the sources, so script/bootstrap and its yarn install --frozen-lockfile cache as their own layer while the lint steps do not.

No host lint path survives. script/lint no longer invokes a linter against the working tree, and "lint": "eslint ." is gone from package.json — that was a second, unpinned way to get a lint verdict out of whatever eslint the tree happened to have installed.

Cache is waived for lint, fail closed. LINT_EPOCH has exactly the contract CHECK_EPOCH has today: ARG LINT_EPOCH followed by RUN [ -n "$LINT_EPOCH" ] || exit 1, with both linters below the guard so a fresh epoch forces them to execute. An unset ARG is the empty string, which is a perfectly stable cache key, so a bare docker build -f Dockerfile.lint . hard-fails rather than serving a cached green.

Recursion broken. script/lint is now a docker build, so nothing inside a container may call it, and script/check calls script/lint:

  • The lint stage in Dockerfile is deleted, including its COPY --from=lint /app/yarn.lock /dev/null ordering hack — Dockerfile.lint replaces it, and two definitions of how to lint is one too many.
  • The remaining Dockerfile stage no longer runs make check. It runs make test and make build under the unchanged CHECK_EPOCH guard.
  • script/cibuild is the composite gate: it runs script/lint first (one definition of the lint build, and a lint failure is reported before the slower suite), then the Dockerfile image.
  • script/check and script/precommit still call script/lint; on the host that now routes to docker, which is the ruling.

Build context. .dockerignore is unchanged and applies to the -f Dockerfile.lint build too, including the .claude/ exclusion (eslint's flat config does not ignore dot-directories, so a nested worktree in the context would be linted) and the deliberate exception that keeps .gitignore in the context because prettier 3 reads it as a default ignore file.

Tests. test/packaging/lint-docker.test.ts asserts the whole shape; test/packaging/build-context.test.ts gains an assertion that no <Dockerfile>.dockerignore shadows the root one for either image (BuildKit supports per-Dockerfile ignore files, and one would silently give the lint build a different, unreviewed context).

Docs. README Entrypoints section rewritten for the new split, with new "Linting" and "Build epochs" subsections; TODO.md gains a Completed Steps entry. Its Next Step is the README API reference, which is unrelated, so that was left alone.

Verification

Exit codes prove nothing here, so this is the output.

Fail-closed guard: bare build is rejected

docker build -f Dockerfile.lint . with no LINT_EPOCH, exit 1:

 > [7/9] RUN [ -n "$LINT_EPOCH" ] || exit 1:
Dockerfile.lint:30
  29 |     ARG LINT_EPOCH
  30 | >>> RUN [ -n "$LINT_EPOCH" ] || exit 1
ERROR: failed to build: failed to solve: process "/bin/sh -c [ -n \"$LINT_EPOCH\" ] || exit 1" did not complete successfully: exit code: 1

Two consecutive make lint runs on an unchanged tree, both executing

Run 1, exit 0 in 45s. Run 2, immediately after with nothing touched, exit 0 in 12s — and the linter layers ran again rather than being served:

#6 CACHED
#7 CACHED
#8 CACHED
#9 CACHED
#11 [7/9] RUN [ -n "1786365306" ] || exit 1
#11 DONE 0.3s

#12 [8/9] RUN yarn run eslint .
#12 0.412 yarn run v1.22.22
#12 0.460 $ /app/node_modules/.bin/eslint .
#12 2.690 Done in 2.28s.
#12 DONE 8.7s

#13 [9/9] RUN yarn run prettier --check .
#13 0.307 yarn run v1.22.22
#13 0.359 $ /app/node_modules/.bin/prettier --check .
#13 0.467 Checking formatting...
#13 1.782 All matched files use Prettier code style!
#13 DONE 1.9s

CACHED appears only on layers 6 to 9, which are the bootstrap and dependency install — exactly the intended split. The guard line echoes the epoch it received (RUN [ -n "1786365306" ]), which is what differs run to run.

Negative control 1: eslint violation

Added src/lint-negative-control.ts with an unused local. make lint exit 2, failing on that specific finding:

#12 3.215   2:11  error  'deliberatelyUnused' is assigned a value but never used. Allowed unused vars must match /^_/u  @typescript-eslint/no-unused-vars
#12 3.215 ✖ 1 problem (1 error, 0 warnings)
#12 ERROR: process "/bin/sh -c yarn run eslint ." did not complete successfully: exit code: 1

Reverted, make lint exit 0 with both linters executing again.

Negative control 2: formatting violation

Added src/fmt-negative-control.ts with mangled spacing. make lint exit 2, failing on that specific file at the prettier step:

#13 1.621 [warn] src/fmt-negative-control.ts
#13 2.273 [warn] Code style issues found in the above file. Run Prettier with --write to fix.
#13 ERROR: process "/bin/sh -c yarn run prettier --check ." did not complete successfully: exit code: 1

Reverted, exit 0 and All matched files use Prettier code style!.

The container linted this tree only

From inside the built lint image:

TS/JS files linted (excluding node_modules): 46
test/ files: 23
top-level entries:
.dockerignore .editorconfig .gitea .gitignore .prettierignore .prettierrc
Dockerfile Dockerfile.lint LICENSE Makefile README.md REPO_POLICIES.md
TODO.md bin eslint.config.mjs package.json script src test tsconfig.json
yarn.lock
.claude present? no
.gitignore present? yes

Both halves of the .dockerignore contract hold: no .claude/, and .gitignore still present for prettier.

As an active control, a foreign tree was planted at .claude/worktrees/foreign/test/foreign.test.ts containing both an unused-variable error and mangled formatting — violations that the two negative controls above prove this build catches. make lint still passed, exit 0 with All matched files use Prettier code style!, so the container did not see it. The tree was removed afterwards.

make check

Exit 0 in 19s: 22 test files, 244 tests passed, then the containerised lint, then fmt-check.

 Test Files  22 passed (22)
      Tests  244 passed (244)

script/cibuild

Exit 0 in 37s, and it executed rather than being served from cache. Lint image first (RUN [ -n "1786365490" ], then eslint and prettier running), then the test and build image:

#12 9.574  Test Files  22 passed (22)
#12 9.574       Tests  244 passed (244)
#12 DONE 10.1s

#13 [ 9/10] RUN [ -n "1786365502" ] || exit 1
#13 DONE 2.8s

#14 [10/10] RUN make build
#14 0.450 $ /app/node_modules/.bin/tsc
#14 4.045 build: verified ./dist/src/index.js
#14 4.045 build: verified ./dist/src/index.d.ts
#14 4.045 build: verified ./dist/bin/quak.js
#14 4.045 build: ./dist/bin/quak.js is executable (#!/usr/bin/env node)
#14 DONE 6.7s

make fmt was run and its result is in the commit. No docker builder prune was run at any point; cache invalidation was scoped to the epoch arguments only.

Notes

  • All verification went through make targets and script/ entrypoints. The only direct docker calls were the deliberate bare-build negative control and a one-off docker run to list the image contents, neither of which can be expressed as an entrypoint.
  • One deviation worth flagging for review: the Dockerfile image no longer runs make check, which REPO_POLICIES.md asks for. That is unavoidable once script/check reaches script/lint and script/lint is a docker build — the alternatives are docker-in-docker or a second, cached lint path. Coverage is preserved by script/cibuild building both images.
  • Out of scope and untouched: #29 (prettier running twice under make check). script/fmt-check still runs prettier on the host, unchanged, since that issue is what decides who owns the prettier invocation.
Closes https://git.eeqj.de/sneak/quak/issues/30. ## What changed **New root `Dockerfile.lint`.** It copies the repo into the node image already pinned by digest in `Dockerfile` (node 22.22.0 on Alpine 3.23.3) and runs eslint and prettier as build steps, so a successful build IS a clean lint. `script/lint` is reduced to building it, which also works where the docker daemon is remote and bind mounts are impossible. Manifests (`package.json`, `yarn.lock`) are copied before the sources, so `script/bootstrap` and its `yarn install --frozen-lockfile` cache as their own layer while the lint steps do not. **No host lint path survives.** `script/lint` no longer invokes a linter against the working tree, and `"lint": "eslint ."` is gone from `package.json` — that was a second, unpinned way to get a lint verdict out of whatever eslint the tree happened to have installed. **Cache is waived for lint, fail closed.** `LINT_EPOCH` has exactly the contract `CHECK_EPOCH` has today: `ARG LINT_EPOCH` followed by `RUN [ -n "$LINT_EPOCH" ] || exit 1`, with both linters below the guard so a fresh epoch forces them to execute. An unset ARG is the empty string, which is a perfectly stable cache key, so a bare `docker build -f Dockerfile.lint .` hard-fails rather than serving a cached green. **Recursion broken.** `script/lint` is now a `docker build`, so nothing inside a container may call it, and `script/check` calls `script/lint`: - The `lint` stage in `Dockerfile` is deleted, including its `COPY --from=lint /app/yarn.lock /dev/null` ordering hack — `Dockerfile.lint` replaces it, and two definitions of how to lint is one too many. - The remaining `Dockerfile` stage no longer runs `make check`. It runs `make test` and `make build` under the unchanged `CHECK_EPOCH` guard. - `script/cibuild` is the composite gate: it runs `script/lint` first (one definition of the lint build, and a lint failure is reported before the slower suite), then the `Dockerfile` image. - `script/check` and `script/precommit` still call `script/lint`; on the host that now routes to docker, which is the ruling. **Build context.** `.dockerignore` is unchanged and applies to the `-f Dockerfile.lint` build too, including the `.claude/` exclusion (eslint's flat config does not ignore dot-directories, so a nested worktree in the context would be linted) and the deliberate exception that keeps `.gitignore` in the context because prettier 3 reads it as a default ignore file. **Tests.** `test/packaging/lint-docker.test.ts` asserts the whole shape; `test/packaging/build-context.test.ts` gains an assertion that no `<Dockerfile>.dockerignore` shadows the root one for either image (BuildKit supports per-Dockerfile ignore files, and one would silently give the lint build a different, unreviewed context). **Docs.** README Entrypoints section rewritten for the new split, with new "Linting" and "Build epochs" subsections; `TODO.md` gains a Completed Steps entry. Its Next Step is the README API reference, which is unrelated, so that was left alone. ## Verification Exit codes prove nothing here, so this is the output. ### Fail-closed guard: bare build is rejected `docker build -f Dockerfile.lint .` with no `LINT_EPOCH`, exit 1: ``` > [7/9] RUN [ -n "$LINT_EPOCH" ] || exit 1: Dockerfile.lint:30 29 | ARG LINT_EPOCH 30 | >>> RUN [ -n "$LINT_EPOCH" ] || exit 1 ERROR: failed to build: failed to solve: process "/bin/sh -c [ -n \"$LINT_EPOCH\" ] || exit 1" did not complete successfully: exit code: 1 ``` ### Two consecutive `make lint` runs on an unchanged tree, both executing Run 1, exit 0 in 45s. Run 2, immediately after with nothing touched, exit 0 in 12s — and the linter layers ran again rather than being served: ``` #6 CACHED #7 CACHED #8 CACHED #9 CACHED #11 [7/9] RUN [ -n "1786365306" ] || exit 1 #11 DONE 0.3s #12 [8/9] RUN yarn run eslint . #12 0.412 yarn run v1.22.22 #12 0.460 $ /app/node_modules/.bin/eslint . #12 2.690 Done in 2.28s. #12 DONE 8.7s #13 [9/9] RUN yarn run prettier --check . #13 0.307 yarn run v1.22.22 #13 0.359 $ /app/node_modules/.bin/prettier --check . #13 0.467 Checking formatting... #13 1.782 All matched files use Prettier code style! #13 DONE 1.9s ``` `CACHED` appears only on layers 6 to 9, which are the bootstrap and dependency install — exactly the intended split. The guard line echoes the epoch it received (`RUN [ -n "1786365306" ]`), which is what differs run to run. ### Negative control 1: eslint violation Added `src/lint-negative-control.ts` with an unused local. `make lint` exit 2, failing on that specific finding: ``` #12 3.215 2:11 error 'deliberatelyUnused' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars #12 3.215 ✖ 1 problem (1 error, 0 warnings) #12 ERROR: process "/bin/sh -c yarn run eslint ." did not complete successfully: exit code: 1 ``` Reverted, `make lint` exit 0 with both linters executing again. ### Negative control 2: formatting violation Added `src/fmt-negative-control.ts` with mangled spacing. `make lint` exit 2, failing on that specific file at the prettier step: ``` #13 1.621 [warn] src/fmt-negative-control.ts #13 2.273 [warn] Code style issues found in the above file. Run Prettier with --write to fix. #13 ERROR: process "/bin/sh -c yarn run prettier --check ." did not complete successfully: exit code: 1 ``` Reverted, exit 0 and `All matched files use Prettier code style!`. ### The container linted this tree only From inside the built lint image: ``` TS/JS files linted (excluding node_modules): 46 test/ files: 23 top-level entries: .dockerignore .editorconfig .gitea .gitignore .prettierignore .prettierrc Dockerfile Dockerfile.lint LICENSE Makefile README.md REPO_POLICIES.md TODO.md bin eslint.config.mjs package.json script src test tsconfig.json yarn.lock .claude present? no .gitignore present? yes ``` Both halves of the `.dockerignore` contract hold: no `.claude/`, and `.gitignore` still present for prettier. As an active control, a foreign tree was planted at `.claude/worktrees/foreign/test/foreign.test.ts` containing both an unused-variable error and mangled formatting — violations that the two negative controls above prove this build catches. `make lint` still passed, exit 0 with `All matched files use Prettier code style!`, so the container did not see it. The tree was removed afterwards. ### `make check` Exit 0 in 19s: 22 test files, 244 tests passed, then the containerised lint, then `fmt-check`. ``` Test Files 22 passed (22) Tests 244 passed (244) ``` ### `script/cibuild` Exit 0 in 37s, and it executed rather than being served from cache. Lint image first (`RUN [ -n "1786365490" ]`, then eslint and prettier running), then the test and build image: ``` #12 9.574 Test Files 22 passed (22) #12 9.574 Tests 244 passed (244) #12 DONE 10.1s #13 [ 9/10] RUN [ -n "1786365502" ] || exit 1 #13 DONE 2.8s #14 [10/10] RUN make build #14 0.450 $ /app/node_modules/.bin/tsc #14 4.045 build: verified ./dist/src/index.js #14 4.045 build: verified ./dist/src/index.d.ts #14 4.045 build: verified ./dist/bin/quak.js #14 4.045 build: ./dist/bin/quak.js is executable (#!/usr/bin/env node) #14 DONE 6.7s ``` `make fmt` was run and its result is in the commit. No `docker builder prune` was run at any point; cache invalidation was scoped to the epoch arguments only. ## Notes - All verification went through `make` targets and `script/` entrypoints. The only direct `docker` calls were the deliberate bare-build negative control and a one-off `docker run` to list the image contents, neither of which can be expressed as an entrypoint. - One deviation worth flagging for review: the `Dockerfile` image no longer runs `make check`, which `REPO_POLICIES.md` asks for. That is unavoidable once `script/check` reaches `script/lint` and `script/lint` is a `docker build` — the alternatives are docker-in-docker or a second, cached lint path. Coverage is preserved by `script/cibuild` building both images. - Out of scope and untouched: https://git.eeqj.de/sneak/quak/issues/29 (prettier running twice under `make check`). `script/fmt-check` still runs prettier on the host, unchanged, since that issue is what decides who owns the prettier invocation.
clawbot added the needs-review label 2026-08-10 14:40:23 +02:00
clawbot added 1 commit 2026-08-10 14:40:24 +02:00
Run all linting in Docker via Dockerfile.lint (closes #30)
All checks were successful
check / check (push) Successful in 1m2s
fed39d19cf
Linting now happens in one place only: a new root Dockerfile.lint copies
the repo into the digest-pinned node image already used by Dockerfile and
runs eslint and prettier as build steps, so a successful build is a clean
lint. script/lint is reduced to building it, which also works where the
docker daemon is remote and bind mounts are impossible. No host lint path
survives: the "lint" script is gone from package.json, so there is no
second, unpinned way to get a lint verdict.

Caching is waived for lint, because a lint build over an unchanged tree
returns success in well under a second having linted nothing. LINT_EPOCH
is the cache buster and it fails closed exactly as CHECK_EPOCH does: an
unset ARG is the empty string, which is a perfectly stable cache key, so
the guard rejects it and a bare `docker build -f Dockerfile.lint .` errors
out instead of serving a green it did not earn. Both linters sit below the
guard, so a fresh epoch forces them to execute while the bootstrap and
dependency layers above stay cached.

That makes script/lint a docker build, which nothing inside a container
may call. script/check calls script/lint, so the Dockerfile image can no
longer run make check: the lint stage and its COPY --from=lint ordering
hack are deleted, and the remaining stage runs make test and make build
under the existing CHECK_EPOCH guard. script/cibuild is now the composite
gate and builds the lint image first, so a lint failure is reported before
the slower suite runs.

The .dockerignore exclusions are unchanged and still apply to the lint
build, including the .claude/ exclusion (eslint's flat config does not
ignore dot-directories, so a nested worktree in the context would be
linted) and the deliberate exception that keeps .gitignore in the context
for prettier. A new test asserts no per-Dockerfile ignore file shadows the
root one for either image, and test/packaging/lint-docker.test.ts asserts
the whole shape: the docker-only lint path, the digest pin, manifests
copied before sources, the fail-closed guard with both linters below it,
the absence of a lint stage or make check in Dockerfile, and the build
order in script/cibuild.
Author
Collaborator

Review: PASS

Independent review of #31 (head fed39d19cf3cdbfc695e50045a0055cc743c66d1) against #30. All evidence below was reproduced in a fresh clone, not read from the PR body.

Verified

  • Fail-closed guard. Bare docker build -f Dockerfile.lint . exits 1 at [7/9] RUN [ -n "$LINT_EPOCH" ] || exit 1. No green served.
  • Two consecutive make lint runs, unchanged tree. 15.6s and 15.3s. CACHED appears on layers 2-6 only (WORKDIR, COPY script/, COPY package.json yarn.lock, RUN script/bootstrap, COPY . .); the guard and both linter layers executed both times with real output ($ /app/node_modules/.bin/eslint ., Checking formatting... All matched files use Prettier code style!) and different epochs (1786365763, 1786365779). The epoch busts exactly the lint layers and nothing else — the split works as designed.
  • Negative control, eslint. Planted an unused local: exit 2, error 'neverUsedByReviewer' is assigned a value but never used ... @typescript-eslint/no-unused-vars, build failed at RUN yarn run eslint .. Reverted, green.
  • Negative control, prettier. Planted mangled spacing: exit 2, [warn] src/pr31-review-fmt-probe.ts, failed at RUN yarn run prettier --check .. Reverted, green.
  • Foreign-tree exposure. Planted .claude/worktrees/pr31foreign/ carrying both an unused-variable error and a formatting error — violations this build provably catches. make lint exit 0: the container never saw it. Confirmed from inside the image: .claude absent, .git absent, .gitignore present (the deliberate exception preserved), 46 TS/JS files, 23 files under test/.
  • CI coverage did not shrink. Before: lint stage ran make fmt-check + make lint; check stage ran make check + make build. After: Dockerfile.lint runs eslint + prettier, Dockerfile runs make test + make build. Net executed set is identical — eslint, prettier --check, vitest, tsc + entrypoint verification. Confirmed in one script/cibuild run (42.9s, exit 0): eslint ran, prettier ran, Test Files 22 passed / Tests 244 passed, build: verified ./dist/src/index.js etc. make test and make build were not CACHED.
  • No recursion or deadlock. script/check reaches script/lint; nothing inside either container reaches script/check or script/lint. Dockerfile.lint invokes the linters directly rather than via make lint, which is what would have recursed. script/cibuild completed, which is the empirical proof.
  • Test falsifiability. All 13 new assertions mutated individually and confirmed to fail on the mutation, in three rounds — host linter in script/lint, missing LINT_EPOCH guard, per-Dockerfile ignore file, linter hoisted above the guard, tag instead of digest, make check reintroduced into Dockerfile, script/cibuild order swapped, lint script restored to package.json, manifests/sources order swapped, eslint step removed. Each fired its own named test and no others. These are not vacuous.
  • Digest pin. Byte-identical to Dockerfile (sha256:e4bf2a82...26e34), with the house-style # node 22.22.0 on Alpine 3.23.3 (node:22-alpine), 2026-08-09 comment on the preceding line.
  • make check exit 0 (244 tests). make fmt produces no diff. CI green on head. Fast-forward mergeable against main (156fe87), zero conflicts. Commit subject carries (closes #30), no trailers, no attribution anywhere in the diff or commit metadata. make lint and make fmt-check both still do what their names say. Nothing else referenced the removed yarn lint. Naming and terminology are consistent.

Non-blocking

  1. REPO_POLICIES.md is now internally inconsistent with the repo, and was not updated. Lines 92-108 still state "All Dockerfiles must run make check" and mandate the separate lint stage with the COPY --from=lint ordering dependency. This change deliberately does neither, correctly, per the newer owner ruling in #30. The implementer disclosed the deviation. Raising as a question rather than a defect, since REPO_POLICIES.md looks like a shared canonical document rather than a per-repo file: does the ruling supersede those two clauses repo-wide, and where should that be written down? Nothing enforces REPO_POLICIES.md today, so the drift is silent.

  2. script/fmt-check still runs prettier --check on the host, which is in literal tension with the definition of done's "no host lint path remains". My view: defensible, not a violation. It is a formatting entrypoint required by REPO_POLICIES.md, not a lint verdict, and #29 explicitly owns this question as the next unit. I checked specifically whether this change lets the host and container verdicts disagree, and it does not: prettier 3 reads .gitignore (which excludes .claude/), .dockerignore mirrors it while deliberately keeping .gitignore in the context, and prettier is pinned to 3.8.1 installed under --frozen-lockfile. Empirically, with the foreign tree planted, host make fmt-check and the containerised prettier both reported clean. This change does not make #29 harder — it arguably makes it easier, since Dockerfile.lint is now the single owner of prettier in CI.

  3. Pre-existing and out of scope, flagged for tracking, not filed here. The foreign-tree exposure is now closed for lint but remains open for the host test path. With .claude/worktrees/pr31foreign/ planted, host make test picked the tree up — 23 test files instead of 22, exit 2 — while the containerised run was unaffected. CI is safe because .dockerignore excludes .claude/; only a developer's host make check is affected. Same exposure class the issue cites, different entrypoint. Not introduced by this change and not in scope for it.

Disclosure

Docker is now a hard requirement for make check and for the pre-commit hook. That is the ruling, and it is documented in the README "Linting" section, the script/check header and the script/precommit header, so it is not a silent change. Two evidence items I took on inspection rather than execution: the claim that a <Dockerfile>.dockerignore would shadow the root one is BuildKit-documented behaviour I did not exercise (the test asserts absence of such a file, which is the useful assertion either way), and I did not test behaviour on a host with no docker installed beyond confirming the failure would be a loud command not found rather than a silent skip.

No blocking defects found. Recommend merge-ready.

## Review: PASS Independent review of https://git.eeqj.de/sneak/quak/pulls/31 (head `fed39d19cf3cdbfc695e50045a0055cc743c66d1`) against https://git.eeqj.de/sneak/quak/issues/30. All evidence below was reproduced in a fresh clone, not read from the PR body. ### Verified - **Fail-closed guard.** Bare `docker build -f Dockerfile.lint .` exits 1 at `[7/9] RUN [ -n "$LINT_EPOCH" ] || exit 1`. No green served. - **Two consecutive `make lint` runs, unchanged tree.** 15.6s and 15.3s. `CACHED` appears on layers 2-6 only (`WORKDIR`, `COPY script/`, `COPY package.json yarn.lock`, `RUN script/bootstrap`, `COPY . .`); the guard and both linter layers executed both times with real output (`$ /app/node_modules/.bin/eslint .`, `Checking formatting... All matched files use Prettier code style!`) and different epochs (`1786365763`, `1786365779`). The epoch busts exactly the lint layers and nothing else — the split works as designed. - **Negative control, eslint.** Planted an unused local: exit 2, `error 'neverUsedByReviewer' is assigned a value but never used ... @typescript-eslint/no-unused-vars`, build failed at `RUN yarn run eslint .`. Reverted, green. - **Negative control, prettier.** Planted mangled spacing: exit 2, `[warn] src/pr31-review-fmt-probe.ts`, failed at `RUN yarn run prettier --check .`. Reverted, green. - **Foreign-tree exposure.** Planted `.claude/worktrees/pr31foreign/` carrying both an unused-variable error and a formatting error — violations this build provably catches. `make lint` exit 0: the container never saw it. Confirmed from inside the image: `.claude` absent, `.git` absent, `.gitignore` **present** (the deliberate exception preserved), 46 TS/JS files, 23 files under `test/`. - **CI coverage did not shrink.** Before: lint stage ran `make fmt-check` + `make lint`; check stage ran `make check` + `make build`. After: `Dockerfile.lint` runs eslint + prettier, `Dockerfile` runs `make test` + `make build`. Net executed set is identical — eslint, `prettier --check`, vitest, `tsc` + entrypoint verification. Confirmed in one `script/cibuild` run (42.9s, exit 0): eslint ran, prettier ran, `Test Files 22 passed / Tests 244 passed`, `build: verified ./dist/src/index.js` etc. `make test` and `make build` were not `CACHED`. - **No recursion or deadlock.** `script/check` reaches `script/lint`; nothing inside either container reaches `script/check` or `script/lint`. `Dockerfile.lint` invokes the linters directly rather than via `make lint`, which is what would have recursed. `script/cibuild` completed, which is the empirical proof. - **Test falsifiability.** All 13 new assertions mutated individually and confirmed to fail on the mutation, in three rounds — host linter in `script/lint`, missing `LINT_EPOCH` guard, per-Dockerfile ignore file, linter hoisted above the guard, tag instead of digest, `make check` reintroduced into `Dockerfile`, `script/cibuild` order swapped, `lint` script restored to `package.json`, manifests/sources order swapped, eslint step removed. Each fired its own named test and no others. These are not vacuous. - **Digest pin.** Byte-identical to `Dockerfile` (`sha256:e4bf2a82...26e34`), with the house-style `# node 22.22.0 on Alpine 3.23.3 (node:22-alpine), 2026-08-09` comment on the preceding line. - `make check` exit 0 (244 tests). `make fmt` produces no diff. CI green on head. Fast-forward mergeable against `main` (`156fe87`), zero conflicts. Commit subject carries ` (closes #30)`, no trailers, no attribution anywhere in the diff or commit metadata. `make lint` and `make fmt-check` both still do what their names say. Nothing else referenced the removed `yarn lint`. Naming and terminology are consistent. ### Non-blocking 1. **`REPO_POLICIES.md` is now internally inconsistent with the repo, and was not updated.** Lines 92-108 still state "All Dockerfiles must run `make check`" and mandate the separate lint stage with the `COPY --from=lint` ordering dependency. This change deliberately does neither, correctly, per the newer owner ruling in https://git.eeqj.de/sneak/quak/issues/30. The implementer disclosed the deviation. Raising as a question rather than a defect, since `REPO_POLICIES.md` looks like a shared canonical document rather than a per-repo file: **does the ruling supersede those two clauses repo-wide, and where should that be written down?** Nothing enforces `REPO_POLICIES.md` today, so the drift is silent. 2. **`script/fmt-check` still runs `prettier --check` on the host**, which is in literal tension with the definition of done's "no host lint path remains". My view: **defensible, not a violation.** It is a formatting entrypoint required by `REPO_POLICIES.md`, not a lint verdict, and https://git.eeqj.de/sneak/quak/issues/29 explicitly owns this question as the next unit. I checked specifically whether this change lets the host and container verdicts disagree, and it does not: prettier 3 reads `.gitignore` (which excludes `.claude/`), `.dockerignore` mirrors it while deliberately keeping `.gitignore` in the context, and prettier is pinned to 3.8.1 installed under `--frozen-lockfile`. Empirically, with the foreign tree planted, host `make fmt-check` and the containerised prettier both reported clean. This change does not make https://git.eeqj.de/sneak/quak/issues/29 harder — it arguably makes it easier, since `Dockerfile.lint` is now the single owner of prettier in CI. 3. **Pre-existing and out of scope, flagged for tracking, not filed here.** The foreign-tree exposure is now closed for lint but remains open for the host test path. With `.claude/worktrees/pr31foreign/` planted, host `make test` picked the tree up — 23 test files instead of 22, exit 2 — while the containerised run was unaffected. CI is safe because `.dockerignore` excludes `.claude/`; only a developer's host `make check` is affected. Same exposure class the issue cites, different entrypoint. Not introduced by this change and not in scope for it. ### Disclosure Docker is now a hard requirement for `make check` and for the pre-commit hook. That is the ruling, and it is documented in the README "Linting" section, the `script/check` header and the `script/precommit` header, so it is not a silent change. Two evidence items I took on inspection rather than execution: the claim that a `<Dockerfile>.dockerignore` would shadow the root one is BuildKit-documented behaviour I did not exercise (the test asserts absence of such a file, which is the useful assertion either way), and I did not test behaviour on a host with no docker installed beyond confirming the failure would be a loud `command not found` rather than a silent skip. No blocking defects found. Recommend `merge-ready`.
Author
Collaborator

Unit #30 landed on next and passed independent review; all evidence was reproduced by the reviewer in a separate clone rather than taken from the PR body.

This PR stays open and accumulates the rest of the cycle — it is not merge-ready as a whole until the 1.0.0 milestone lands. Next commit: #29.

Two items from the review are being handled outside this PR: the REPO_POLICIES.md drift goes to the canonical copy rather than being patched per-repo, and the host make test foreign-tree exposure the reviewer reproduced is already tracked as #25.

Unit https://git.eeqj.de/sneak/quak/issues/30 landed on `next` and passed independent review; all evidence was reproduced by the reviewer in a separate clone rather than taken from the PR body. This PR stays open and accumulates the rest of the cycle — it is not merge-ready as a whole until the 1.0.0 milestone lands. Next commit: https://git.eeqj.de/sneak/quak/issues/29. Two items from the review are being handled outside this PR: the `REPO_POLICIES.md` drift goes to the canonical copy rather than being patched per-repo, and the host `make test` foreign-tree exposure the reviewer reproduced is already tracked as https://git.eeqj.de/sneak/quak/issues/25.
clawbot added 1 commit 2026-08-10 15:05:21 +02:00
Check formatting once per make check, in the container (closes #29)
All checks were successful
check / check (push) Successful in 59s
a73f0abbe8
script/check ran script/test, script/lint and script/fmt-check. Since
linting moved into Docker, script/lint is a build of Dockerfile.lint,
which runs `prettier --check .` as a build step — so make check checked
formatting twice over the same tree: once in the container and once on
the host. script/precommit had the same pair.

Drop the script/fmt-check call from both. The container keeps the check,
because a successful Dockerfile.lint build is what CI treats as proof of
a clean tree, and it is the stronger of the two verdicts: its prettier is
digest-pinned and installed under --frozen-lockfile, while the host's is
whatever the working tree happens to have. The pre-commit hook is
unchanged in what it catches — script/lint still fails a badly formatted
tree, and therefore the commit.

script/fmt-check survives as a standalone entrypoint, as REPO_POLICIES.md
requires, for asking the formatting question by itself without docker.
Its verdict cannot drift from the container's: prettier is pinned to an
exact version, installed from yarn.lock in both places, and reads
.gitignore as its default ignore file, which is why .dockerignore keeps
.gitignore in the build context.

The count is asserted rather than promised. test/packaging/lint-once.test.ts
walks the invocation graph from each entrypoint — through the Makefile
shims, the script/ calls, the package.json scripts and the docker build
into Dockerfile.lint's RUN steps — and counts prettier invocations: one
per make check, one per script/precommit, and one each for make lint and
make fmt-check alone, so neither can become a no-op that satisfies the
count trivially. The walk also asserts which nodes it reached, so a
restructure that defeats the resolver fails the test instead of quietly
counting zero.

Observed: 2 prettier invocations per make check before, 1 after.
Author
Collaborator

Added commit a73f0ab, "Check formatting once per make check, in the container", closing #29. It is a direct follow-on to the change this PR already carries: moving lint into Dockerfile.lint is what turned the duplicate prettier pass from two identical host runs into one container run and one host run, and this removes the host one.

What changed

script/check no longer calls script/fmt-check. It runs script/test then script/lint, and script/lint is the build of Dockerfile.lint, which runs prettier --check . as a build step.

script/precommit had the same pair and gets the same fix: script/lint only.

script/fmt-check is untouched and still wired to make fmt-check, as REPO_POLICIES.md requires — it is now a standalone entrypoint for asking the formatting question by itself, without docker, rather than a step inside two other scripts.

test/packaging/lint-once.test.ts is new and is what makes the guarantee non-vacuous. It statically walks the invocation graph from an entrypoint, following the edges this repo actually uses: "$SCRIPT_DIR/<name>" and script/<name> into other scripts, make <target> through the Makefile shims, yarn run <name> through the package.json scripts, and docker build -f <file> into that Dockerfile's RUN steps. Comments are stripped first, since the headers of these very scripts name prettier and script/fmt-check while explaining why they must not run twice. It asserts one prettier invocation per make check, one per script/precommit, and one each for make lint and make fmt-check alone — the last two so a count of 1 cannot be achieved by gutting the targets. It also asserts which nodes the walk reached, so a restructure that defeats the resolver fails loudly instead of counting zero and reading as the tidiest possible result; and it re-asserts the three alignment invariants below.

README and TODO.md updated to match; make fmt run and included.

Prettier invocation count, observed

Not inferred from the diff — counted in the output of a real run, before and after.

Before, make check on fed39d1, exit 0 in 19s, two verdicts:

119:#13 [9/9] RUN yarn run prettier --check .
121:#13 0.442 $ /app/node_modules/.bin/prettier --check .
139:$ /srv/code/.work/quak-issue29-impl/node_modules/.bin/prettier --check .

grep -c "All matched files use Prettier code style" = 2 (one container, one host).

After, make check on a73f0ab, exit 0 in 18s:

121:#13 [9/9] RUN yarn run prettier --check .
123:#13 0.469 $ /app/node_modules/.bin/prettier --check .

grep -c = 1. Test Files 23 passed (23), Tests 258 passed (258).

The test can fail

Mutated three ways, each run through script/test:

  1. Re-added "$SCRIPT_DIR/fmt-check" to script/check — 2 failures: expected 2 to be 1, and expected [ 'make:check', 'script/check', …(5) ] to not include 'script/fmt-check'.
  2. Deleted RUN yarn run prettier --check . from Dockerfile.lint — 5 failures across both new and existing tests: invokes prettier once for the whole of make check, keeps the surviving invocation inside the lint container, still checks formatting under make lint, script/precommit checks formatting exactly once, plus the pre-existing Dockerfile.lint runs prettier as a build step.
  3. Replaced the prettier line in script/fmt-check with an echo — 1 failure: still checks formatting under make fmt-check.

All three reverted; suite green afterwards.

Worth recording that the test caught a real defect in its own first draft: node.slice("make:") (a string where a number is required, coercing to NaN) made every make-rooted walk return zero, and the reachability assertions failed rather than the count quietly passing at 0. That is the failure mode those assertions exist for.

Standalone targets

Each invoked on its own, on a73f0ab:

  • make fmt-check — exit 0 in 2s, All matched files use Prettier code style!, one prettier invocation. Still does what its name says, on the host, without docker.
  • make lint — exit 0 in 74s, one prettier verdict, both linters executing rather than served: #12 [8/9] RUN yarn run eslint . DONE 3.6s, #13 [9/9] RUN yarn run prettier --check . DONE 2.7s.
  • make check — exit 0 in 18s, as above.

What catches a formatting problem before a commit lands

make fmt-check is now the only host-side formatting check, but it is not what gates commits and never was the only gate. The pre-commit hook runs script/precommit, which runs script/lint, which builds Dockerfile.lint, which runs prettier --check . as a build step under the LINT_EPOCH guard. A badly formatted tree fails that build, fails the hook, and fails the commit — exactly as before this change, since the hook already ran script/lint first and would have failed there before ever reaching script/fmt-check. Negative control 2 in this PR's description is the evidence that the container step rejects a formatting violation. The hook lost a redundant second pass, not a capability.

Host and container verdicts still cannot disagree

Re-verified on a73f0ab rather than assumed, and now asserted by the test:

  • package.json: "prettier": "3.8.1" — an exact pin, not a range.
  • script/bootstrap, which is what Dockerfile.lint runs to install: yarn install --frozen-lockfile, both code paths.
  • .dockerignore does not list .gitignore, so it stays in the build context — prettier 3 reads it as a default ignore file, and both sides therefore see the same file set.

Nothing in this change touches any of the three.

script/cibuild, shown to have executed

Exit 0 in 19s. A cached green would show CACHED on the layers that matter; here CACHED appears on 10 layers, all of them the dependency layers above the epoch guards (WORKDIR, COPY script/, COPY package.json yarn.lock, RUN script/bootstrap, COPY . .) — which is the intended split. Every layer below the guards ran, with real durations, and the guard lines echo the epoch they received:

#11 [7/9] RUN [ -n "1786367051" ] || exit 1      DONE 0.1s
#12 [8/9] RUN yarn run eslint .                  DONE 2.7s
#13 [9/9] RUN yarn run prettier --check .        DONE 2.2s

#11 [ 7/10] RUN [ -n "1786367057" ] || exit 1    DONE 0.2s
#12 [ 8/10] RUN make test                        DONE 9.8s
#13 [ 9/10] RUN [ -n "1786367057" ] || exit 1    DONE 0.2s
#14 [10/10] RUN make build                       DONE 2.1s

Two distinct epochs, one per image, both non-empty. Exactly one prettier invocation in the whole of script/cibuild.

No docker builder prune was run at any point, in any form; no cache was invalidated beyond the epoch build arguments. All verification went through make targets and script/ entrypoints — there was no direct docker build this time, since no guard or cache probe was needed beyond what script/cibuild already demonstrates.

REPO_POLICIES.md was left alone, per instruction.

Added commit `a73f0ab`, "Check formatting once per make check, in the container", closing https://git.eeqj.de/sneak/quak/issues/29. It is a direct follow-on to the change this PR already carries: moving lint into `Dockerfile.lint` is what turned the duplicate prettier pass from two identical host runs into one container run and one host run, and this removes the host one. ## What changed **`script/check`** no longer calls `script/fmt-check`. It runs `script/test` then `script/lint`, and `script/lint` is the build of `Dockerfile.lint`, which runs `prettier --check .` as a build step. **`script/precommit`** had the same pair and gets the same fix: `script/lint` only. **`script/fmt-check` is untouched and still wired to `make fmt-check`**, as `REPO_POLICIES.md` requires — it is now a standalone entrypoint for asking the formatting question by itself, without docker, rather than a step inside two other scripts. **`test/packaging/lint-once.test.ts`** is new and is what makes the guarantee non-vacuous. It statically walks the invocation graph from an entrypoint, following the edges this repo actually uses: `"$SCRIPT_DIR/<name>"` and `script/<name>` into other scripts, `make <target>` through the Makefile shims, `yarn run <name>` through the `package.json` scripts, and `docker build -f <file>` into that Dockerfile's `RUN` steps. Comments are stripped first, since the headers of these very scripts name `prettier` and `script/fmt-check` while explaining why they must not run twice. It asserts one prettier invocation per `make check`, one per `script/precommit`, and one each for `make lint` and `make fmt-check` alone — the last two so a count of 1 cannot be achieved by gutting the targets. It also asserts which nodes the walk reached, so a restructure that defeats the resolver fails loudly instead of counting zero and reading as the tidiest possible result; and it re-asserts the three alignment invariants below. README and `TODO.md` updated to match; `make fmt` run and included. ## Prettier invocation count, observed Not inferred from the diff — counted in the output of a real run, before and after. **Before**, `make check` on `fed39d1`, exit 0 in 19s, two verdicts: ``` 119:#13 [9/9] RUN yarn run prettier --check . 121:#13 0.442 $ /app/node_modules/.bin/prettier --check . 139:$ /srv/code/.work/quak-issue29-impl/node_modules/.bin/prettier --check . ``` `grep -c "All matched files use Prettier code style"` = **2** (one container, one host). **After**, `make check` on `a73f0ab`, exit 0 in 18s: ``` 121:#13 [9/9] RUN yarn run prettier --check . 123:#13 0.469 $ /app/node_modules/.bin/prettier --check . ``` `grep -c` = **1**. `Test Files 23 passed (23)`, `Tests 258 passed (258)`. ## The test can fail Mutated three ways, each run through `script/test`: 1. Re-added `"$SCRIPT_DIR/fmt-check"` to `script/check` — 2 failures: `expected 2 to be 1`, and `expected [ 'make:check', 'script/check', …(5) ] to not include 'script/fmt-check'`. 2. Deleted `RUN yarn run prettier --check .` from `Dockerfile.lint` — 5 failures across both new and existing tests: `invokes prettier once for the whole of make check`, `keeps the surviving invocation inside the lint container`, `still checks formatting under make lint`, `script/precommit checks formatting exactly once`, plus the pre-existing `Dockerfile.lint runs prettier as a build step`. 3. Replaced the prettier line in `script/fmt-check` with an `echo` — 1 failure: `still checks formatting under make fmt-check`. All three reverted; suite green afterwards. Worth recording that the test caught a real defect in its own first draft: `node.slice("make:")` (a string where a number is required, coercing to `NaN`) made every `make`-rooted walk return zero, and the reachability assertions failed rather than the count quietly passing at 0. That is the failure mode those assertions exist for. ## Standalone targets Each invoked on its own, on `a73f0ab`: - `make fmt-check` — exit 0 in 2s, `All matched files use Prettier code style!`, one prettier invocation. Still does what its name says, on the host, without docker. - `make lint` — exit 0 in 74s, one prettier verdict, both linters executing rather than served: `#12 [8/9] RUN yarn run eslint .` `DONE 3.6s`, `#13 [9/9] RUN yarn run prettier --check .` `DONE 2.7s`. - `make check` — exit 0 in 18s, as above. ## What catches a formatting problem before a commit lands `make fmt-check` is now the only host-side formatting check, but it is not what gates commits and never was the only gate. The pre-commit hook runs `script/precommit`, which runs `script/lint`, which builds `Dockerfile.lint`, which runs `prettier --check .` as a build step under the `LINT_EPOCH` guard. A badly formatted tree fails that build, fails the hook, and fails the commit — exactly as before this change, since the hook already ran `script/lint` first and would have failed there before ever reaching `script/fmt-check`. Negative control 2 in this PR's description is the evidence that the container step rejects a formatting violation. The hook lost a redundant second pass, not a capability. ## Host and container verdicts still cannot disagree Re-verified on `a73f0ab` rather than assumed, and now asserted by the test: - `package.json`: `"prettier": "3.8.1"` — an exact pin, not a range. - `script/bootstrap`, which is what `Dockerfile.lint` runs to install: `yarn install --frozen-lockfile`, both code paths. - `.dockerignore` does not list `.gitignore`, so it stays in the build context — prettier 3 reads it as a default ignore file, and both sides therefore see the same file set. Nothing in this change touches any of the three. ## script/cibuild, shown to have executed Exit 0 in 19s. A cached green would show `CACHED` on the layers that matter; here `CACHED` appears on 10 layers, all of them the dependency layers above the epoch guards (`WORKDIR`, `COPY script/`, `COPY package.json yarn.lock`, `RUN script/bootstrap`, `COPY . .`) — which is the intended split. Every layer below the guards ran, with real durations, and the guard lines echo the epoch they received: ``` #11 [7/9] RUN [ -n "1786367051" ] || exit 1 DONE 0.1s #12 [8/9] RUN yarn run eslint . DONE 2.7s #13 [9/9] RUN yarn run prettier --check . DONE 2.2s #11 [ 7/10] RUN [ -n "1786367057" ] || exit 1 DONE 0.2s #12 [ 8/10] RUN make test DONE 9.8s #13 [ 9/10] RUN [ -n "1786367057" ] || exit 1 DONE 0.2s #14 [10/10] RUN make build DONE 2.1s ``` Two distinct epochs, one per image, both non-empty. Exactly one prettier invocation in the whole of `script/cibuild`. No `docker builder prune` was run at any point, in any form; no cache was invalidated beyond the epoch build arguments. All verification went through `make` targets and `script/` entrypoints — there was no direct `docker build` this time, since no guard or cache probe was needed beyond what `script/cibuild` already demonstrates. `REPO_POLICIES.md` was left alone, per instruction.
Author
Collaborator

Review: PASS

Independent review of commit a73f0ab only (diffed against its already-reviewed parent fed39d1), against #29. Reproduced in a fresh clone; nothing taken from the PR body.

Central claim reproduced

make check prettier verdicts, counted in real runs: 2 on fed39d1 (container #13 [9/9] RUN yarn run prettier --check ., then host node_modules/.bin/prettier --check .), 1 on a73f0ab, and the survivor is the container one. Both exit 0. In the a73f0ab run CACHED covers layers 6-10 only; the guard echoed RUN [ -n "1786367380" ] and eslint/prettier ran for 10.2s/8.9s.

Negative controls, executed:

  • Planted a mangled-formatting file: make check exit 2, [warn] src/q29rev-fmt-probe.ts at RUN yarn run prettier --check .. Coverage not lost.
  • Same file staged, real git commit through the hook installed by make hooks: exit 1, same prettier failure, HEAD unmoved. The "lost a redundant pass, not a capability" claim holds under test, not just in prose.
  • make fmt-check alone exit 0 in 1.6s (host, no docker). make lint alone exit 0 in 7.5s with eslint 3.0s and prettier 2.9s executing below the epoch guard. script/cibuild exit 0 in 24.7s: CACHED only on the dependency layers of both images, RUN [ -n "1786367878" ] / RUN [ -n "1786367884" ], make test 9.8s (258 tests), make build 5.9s, exactly one prettier invocation across the whole run.

Test falsifiability — six mutations, each fired its own named assertion and no unrelated one: re-adding fmt-check to script/check (expected 2 to be 1 + the not-reached assertion); deleting prettier from Dockerfile.lint (count 0, not 1 — it cannot pass on an empty walk); gutting script/fmt-check (only still checks formatting under make fmt-check); dropping script/test from script/check (only the reaches script/test case); dropping script/lint (count + two reachability cases); renaming the Makefile check: target (Error: no such Makefile target: check, exit 2). Not vacuous, not a hardcoded count.

Also verified: CI green on head (check / check (push), successful in 59s); fast-forward mergeable onto main (156fe87); make fmt produces no diff; commit subject carries (closes #29); no attribution trailers or vendor references anywhere in the diff or commit metadata; inclusive terminology; README Entrypoints/Linting/workflow-item-8/required-checks bullets all match the scripts as they now are; TODO.md Next Step correctly untouched. script/fmt-check is now reachable only by a human typing make fmt-check (or yarn fmt-check) — not dead, but no longer called by anything.

Non-blocking findings

1. The new test never walks script/cibuild, which is what CI runs. test/packaging/lint-once.test.ts walks make:check, make:lint, make:fmt-check, script/precommit and docker:Dockerfile.lint — never script/cibuild, and therefore never docker:Dockerfile. I appended RUN yarn run prettier --check . to Dockerfile (after RUN make build) and make test stayed green, 258/258, exit 0. That arrangement gives script/cibuild two prettier passes — the exact duplication this test exists to prevent, reintroduced in the one place CI executes. Why it matters: the file header at lines 20-22 claims "A prettier call added anywhere in that graph is therefore caught, wherever it is added", which overstates what is covered. Acceptable: add expect(walk("script/cibuild").prettier).toBe(1) and a reachability case for docker:Dockerfile, which closes the hole in two lines.

2. installs it from the lockfile in the container (line 261) is a whole-file toContain and misses the branch the container actually takes. script/bootstrap has two install sites: line 130 (nvm path) and line 132 (else path). Dockerfile.lint runs script/bootstrap in node:22-alpine, where yarn is present, so missing yarn is false and line 132 is the path taken. I changed line 132 to a bare yarn install and left line 130 alone: suite green, 258/258. The assertion that is supposed to guarantee the container installs from the lockfile passes while the container's own install has stopped doing so. Acceptable: assert on the branch the container reaches, or assert that no bare yarn install occurs in the file.

3. Prettier is counted per line, not per occurrence. walk does result.prettier += 1; continue; on the first /\bprettier\b/ match in a line. RUN yarn run prettier --check . && yarn run prettier --check src in Dockerfile.lint counts as one — verified, suite green. The continue also means any script/, make or yarn edge sharing a line with a prettier call is never followed. Contrived, but it is a hole in a test whose entire job is counting.

4. Stale comment made stale by this commit. test/packaging/entrypoints.test.ts:3 still reads "make check runs test, lint and fmt-check but never the build". The clause the sentence is arguing for still stands, but the premise is now false and the file was not touched.

Raised as a question, not filed

REPO_POLICIES.md line 38 (make check "runs test, lint, fmt-check"), line 65 (script/check runs those three) and line 182 (a reduced script/precommit "may skip script/test and run only script/lint and script/fmt-check") now all describe a repo that no longer exists. Per the ruling on #31 this drift goes to the canonical copy rather than being patched per-repo, so it is not a defect here — noting only that this commit widens the same gap on three more lines, and nothing enforces that file.

Disclosure

  • The keeps .gitignore in the build context assertion (line 268) only detects a literal .gitignore line in .dockerignore; an exclusion written as a glob (.git*) would drop it from the context and the assertion would still pass. I confirmed the literal case fails as intended but did not execute a build with a glob form — inspection only.
  • The Makefile-rename mutation surfaces as a collection-time throw, so the whole file drops out and the summary reads Test Files 1 failed | 22 passed, Tests 244 passed — the 14 assertions vanish rather than fail individually. Exit code 2, so it is loud; noting the shape because the count in the summary silently drops.
  • Host/container prettier alignment was re-checked, not assumed: prettier pinned 3.8.1, --frozen-lockfile on both script/bootstrap paths as shipped, .claude/ in .gitignore and .dockerignore, .gitignore kept in the context, .prettierignore present in both. Unchanged by this commit; finding 2 above is about the test guarding it, not about the property itself.
  • I installed the pre-commit hook into my scratch clone and made one deliberate commit attempt, which the hook rejected. Every mutation was reverted; the clone is clean and nothing was pushed. No docker builder prune in any form.

No blocking defects. Recommend merge-ready for this commit; findings 1 and 2 are worth tracking as a follow-up to tighten the new test, not a reason to hold it.

## Review: PASS Independent review of commit `a73f0ab` only (diffed against its already-reviewed parent `fed39d1`), against https://git.eeqj.de/sneak/quak/issues/29. Reproduced in a fresh clone; nothing taken from the PR body. ### Central claim reproduced `make check` prettier verdicts, counted in real runs: **2 on `fed39d1`** (container `#13 [9/9] RUN yarn run prettier --check .`, then host `node_modules/.bin/prettier --check .`), **1 on `a73f0ab`**, and the survivor is the container one. Both exit 0. In the `a73f0ab` run `CACHED` covers layers 6-10 only; the guard echoed `RUN [ -n "1786367380" ]` and eslint/prettier ran for 10.2s/8.9s. Negative controls, executed: - Planted a mangled-formatting file: `make check` exit 2, `[warn] src/q29rev-fmt-probe.ts` at `RUN yarn run prettier --check .`. Coverage not lost. - Same file staged, real `git commit` through the hook installed by `make hooks`: exit 1, same prettier failure, HEAD unmoved. The "lost a redundant pass, not a capability" claim holds under test, not just in prose. - `make fmt-check` alone exit 0 in 1.6s (host, no docker). `make lint` alone exit 0 in 7.5s with eslint 3.0s and prettier 2.9s executing below the epoch guard. `script/cibuild` exit 0 in 24.7s: `CACHED` only on the dependency layers of both images, `RUN [ -n "1786367878" ]` / `RUN [ -n "1786367884" ]`, `make test` 9.8s (258 tests), `make build` 5.9s, exactly one prettier invocation across the whole run. Test falsifiability — six mutations, each fired its own named assertion and no unrelated one: re-adding `fmt-check` to `script/check` (`expected 2 to be 1` + the not-reached assertion); deleting prettier from `Dockerfile.lint` (count 0, not 1 — it cannot pass on an empty walk); gutting `script/fmt-check` (only `still checks formatting under make fmt-check`); dropping `script/test` from `script/check` (only the `reaches script/test` case); dropping `script/lint` (count + two reachability cases); renaming the Makefile `check:` target (`Error: no such Makefile target: check`, exit 2). Not vacuous, not a hardcoded count. Also verified: CI green on head (`check / check (push)`, successful in 59s); fast-forward mergeable onto `main` (`156fe87`); `make fmt` produces no diff; commit subject carries ` (closes #29)`; no attribution trailers or vendor references anywhere in the diff or commit metadata; inclusive terminology; README Entrypoints/Linting/workflow-item-8/required-checks bullets all match the scripts as they now are; `TODO.md` Next Step correctly untouched. `script/fmt-check` is now reachable only by a human typing `make fmt-check` (or `yarn fmt-check`) — not dead, but no longer called by anything. ### Non-blocking findings **1. The new test never walks `script/cibuild`, which is what CI runs.** `test/packaging/lint-once.test.ts` walks `make:check`, `make:lint`, `make:fmt-check`, `script/precommit` and `docker:Dockerfile.lint` — never `script/cibuild`, and therefore never `docker:Dockerfile`. I appended `RUN yarn run prettier --check .` to `Dockerfile` (after `RUN make build`) and `make test` stayed green, 258/258, exit 0. That arrangement gives `script/cibuild` two prettier passes — the exact duplication this test exists to prevent, reintroduced in the one place CI executes. Why it matters: the file header at lines 20-22 claims "A prettier call added anywhere in that graph is therefore caught, wherever it is added", which overstates what is covered. Acceptable: add `expect(walk("script/cibuild").prettier).toBe(1)` and a reachability case for `docker:Dockerfile`, which closes the hole in two lines. **2. `installs it from the lockfile in the container` (line 261) is a whole-file `toContain` and misses the branch the container actually takes.** `script/bootstrap` has two install sites: line 130 (nvm path) and line 132 (`else` path). `Dockerfile.lint` runs `script/bootstrap` in `node:22-alpine`, where `yarn` is present, so `missing yarn` is false and line 132 is the path taken. I changed line 132 to a bare `yarn install` and left line 130 alone: suite green, 258/258. The assertion that is supposed to guarantee the container installs from the lockfile passes while the container's own install has stopped doing so. Acceptable: assert on the branch the container reaches, or assert that no bare `yarn install` occurs in the file. **3. Prettier is counted per line, not per occurrence.** `walk` does `result.prettier += 1; continue;` on the first `/\bprettier\b/` match in a line. `RUN yarn run prettier --check . && yarn run prettier --check src` in `Dockerfile.lint` counts as one — verified, suite green. The `continue` also means any `script/`, `make` or `yarn` edge sharing a line with a prettier call is never followed. Contrived, but it is a hole in a test whose entire job is counting. **4. Stale comment made stale by this commit.** `test/packaging/entrypoints.test.ts:3` still reads "`make check` runs test, lint and fmt-check but never the build". The clause the sentence is arguing for still stands, but the premise is now false and the file was not touched. ### Raised as a question, not filed `REPO_POLICIES.md` line 38 (`make check` "runs `test`, `lint`, `fmt-check`"), line 65 (`script/check` runs those three) and line 182 (a reduced `script/precommit` "may skip `script/test` and run only `script/lint` and `script/fmt-check`") now all describe a repo that no longer exists. Per the ruling on https://git.eeqj.de/sneak/quak/pulls/31 this drift goes to the canonical copy rather than being patched per-repo, so it is not a defect here — noting only that this commit widens the same gap on three more lines, and nothing enforces that file. ### Disclosure - The `keeps .gitignore in the build context` assertion (line 268) only detects a literal `.gitignore` line in `.dockerignore`; an exclusion written as a glob (`.git*`) would drop it from the context and the assertion would still pass. I confirmed the literal case fails as intended but did not execute a build with a glob form — inspection only. - The Makefile-rename mutation surfaces as a collection-time throw, so the whole file drops out and the summary reads `Test Files 1 failed | 22 passed`, `Tests 244 passed` — the 14 assertions vanish rather than fail individually. Exit code 2, so it is loud; noting the shape because the count in the summary silently drops. - Host/container prettier alignment was re-checked, not assumed: `prettier` pinned `3.8.1`, `--frozen-lockfile` on both `script/bootstrap` paths as shipped, `.claude/` in `.gitignore` and `.dockerignore`, `.gitignore` kept in the context, `.prettierignore` present in both. Unchanged by this commit; finding 2 above is about the test guarding it, not about the property itself. - I installed the pre-commit hook into my scratch clone and made one deliberate commit attempt, which the hook rejected. Every mutation was reverted; the clone is clean and nothing was pushed. No `docker builder prune` in any form. No blocking defects. Recommend `merge-ready` for this commit; findings 1 and 2 are worth tracking as a follow-up to tighten the new test, not a reason to hold it.
Author
Collaborator

Unit #29 landed and passed independent review (a different reviewer from the previous commit).

The review's non-blocking findings are tracked as #33 and go in as the next commit here: the enforcing test added by this unit does not walk script/cibuild, which is what CI actually runs, and its lockfile assertion checks the wrong bootstrap branch — both shown by mutation, both leaving the suite green. Taking them now rather than later, because a test that reports a guarantee it does not provide is worse than no test.

REPO_POLICIES.md in this repo is now stale in three more places. That file is a copy of the canonical one and gets resynced there, not patched here — see #32.

Unit https://git.eeqj.de/sneak/quak/issues/29 landed and passed independent review (a different reviewer from the previous commit). The review's non-blocking findings are tracked as https://git.eeqj.de/sneak/quak/issues/33 and go in as the next commit here: the enforcing test added by this unit does not walk `script/cibuild`, which is what CI actually runs, and its lockfile assertion checks the wrong bootstrap branch — both shown by mutation, both leaving the suite green. Taking them now rather than later, because a test that reports a guarantee it does not provide is worse than no test. `REPO_POLICIES.md` in this repo is now stale in three more places. That file is a copy of the canonical one and gets resynced there, not patched here — see https://git.eeqj.de/sneak/quak/issues/32.
clawbot added 1 commit 2026-08-10 15:41:25 +02:00
Walk the path CI runs in lint-once, and pin the branch the container installs (closes #33)
All checks were successful
check / check (push) Successful in 35s
2bfa11c10c
The header of test/packaging/lint-once.test.ts claimed a duplicate prettier
pass is caught wherever it is added. It was not: the walk started at
`make check`, which never reads `Dockerfile`, so appending
`RUN yarn run prettier --check .` to the image that `script/cibuild` builds
left the suite green — two prettier passes on the one path where it matters
most. The walk now also starts at `.gitea/workflows/check.yml` and follows
its `run:` steps into `script/cibuild` and from there into both images, so
the graph under test is the one CI executes rather than the one it was
assumed to execute. Reaching `script/cibuild` and `Dockerfile` is asserted,
and the test and build image is asserted to invoke prettier zero times.

The lockfile assertion was a substring check against the whole of
`script/bootstrap`. That script has two install sites, and the containers
take the second, because the pinned node image ships yarn; changing that
site to a bare `yarn install` kept the suite green while the container's
install stopped being pinned. `install_js_deps` is now resolved out of the
script and split at its `missing yarn` guard, and every `yarn install`
occurrence in each branch is required to carry `--frozen-lockfile`. That the
container runs `script/bootstrap` at all is asserted too, so the lockfile
assertions cannot end up describing a script the image never executes.

Prettier is counted per occurrence instead of per line:
`prettier --check . && prettier --check src` was one invocation by the old
count. The `continue` that followed a counted line also dropped every
script, make, yarn and docker edge sharing that line, so a subtree could be
hidden behind a single `&&`; edges are now extracted from every line.

Undercounting is what would make this file worthless, so every way of
reaching nothing is a thrown error rather than a quiet zero: an unknown
Makefile target, an unknown package.json script, a missing script file, a
node that resolves to no commands, and an unknown node kind. All five are
tested, as is a walk that legitimately counts zero, and the cycle guard.

Every assertion in the file was mutation-tested: changed to assert something
else, run, and confirmed to fail for its own named reason. The two mutations
above were reproduced and both now turn the suite red.

test/packaging/entrypoints.test.ts said `make check` runs test, lint and
fmt-check. Formatting has been part of the lint container since the
duplicate host pass was removed, so the comment now says what it does.
Author
Collaborator

2bfa11c#33

Fixes all four findings in test/packaging/lint-once.test.ts. Files touched: test/packaging/lint-once.test.ts, test/packaging/entrypoints.test.ts, TODO.md. Nothing else — REPO_POLICIES.md and the LINT_EPOCH naming are #32 and were left alone.

What changed

1. The walk now starts where CI starts. A workflow:<path> node kind resolves the run: steps of .gitea/workflows/check.yml, and a second walk is rooted there. It reaches script/cibuild, and through it both docker:Dockerfile.lint (via script/lint) and docker:Dockerfile (the bare docker build .), which the make check walk never sees. Rooting at the workflow rather than at a hand-picked script is deliberate: the previous version's blind spot was an assumption about what CI runs, so that is now read out of the repo and asserted. New assertions: prettier is invoked exactly once across the whole CI build; script/cibuild, script/lint, docker:Dockerfile.lint and docker:Dockerfile are all reached; and the test and build image invokes prettier zero times.

2. The lockfile assertion targets the executed branch. install_js_deps is resolved out of script/bootstrap and split at its missing yarn guard, so the two install sites are separately addressable. The pinned node image ships yarn, so the container takes the else branch. Each branch is asserted separately, and every yarn install occurrence in a branch must carry --frozen-lockfile, so an unpinned install cannot hide beside a pinned one. A companion assertion requires docker:Dockerfile.lint to reach script/bootstrap at all — without it the lockfile assertions could end up describing a script the image never executes.

3. Counting is per occurrence, and edges survive counting. countPrettier counts matches on a line rather than answering yes/no, and the continue is gone, so edgesOf runs on every line including counted ones. Both are asserted directly as well as through the graph.

4. The stale make check comment in test/packaging/entrypoints.test.ts now says the suite and the lint container, not test/lint/fmt-check.

Anti-vacuity. Every way for the walk to reach nothing is now a thrown error rather than a quiet zero: unknown Makefile target, unknown package.json script, missing script file, node resolving to no commands, unknown node kind. The yarn: resolver previously returned [""] for a missing script — a silent zero of exactly the kind the node.slice("make:")/NaN draft produced. All five are tested, plus a walk that legitimately counts zero (make:clean) and the cycle guard.

Mutation matrix

Every assertion in the file, 35 in total. Source-file mutations were applied one at a time. Expectation mutations — changing what an assertion asserts, inside the test file — were applied in four batches; each is a local change to one it's expectation and cannot influence another test, and vitest names every result, so the evidence is that the failing set was exactly the mutated set in each run. Every mutation was reverted; the final tree is byte-identical to the committed one (diff against a pre-mutation copy) and green at 279/279.

Source mutations (one run each)

# Mutation Tests that fired Unrelated failures
A append RUN yarn run prettier --check . to Dockerfile after RUN make build — the mutation from the issue invokes prettier once for the whole CI build (expected 2 to be 1); keeps prettier out of the test and build image (expected 1 to be 0) none
B script/bootstrap second install site to bare yarn install, first left alone — the mutation from the issue installs from the lockfile on the branch the container takes (expected 'yarn install' to contain '--frozen-lockfile') none
C same, but the nvm branch instead installs from the lockfile on the nvm branch too — and not the container-branch test, which is the cross-check that the two branches are genuinely distinguished none
D Dockerfile.lint: RUN yarn run prettier --check . && yarn run prettier --check src (two invocations, one line) invokes prettier once for the whole of make check; invokes prettier once for the whole CI build; keeps the surviving invocation inside the lint container; still checks formatting under make lint; script/precommit checks formatting exactly once — all "expected 2 to be 1". This is finding 3: the old counter scored this arrangement as 1 none
E re-add "$SCRIPT_DIR/fmt-check" to script/check (the original bug) invokes prettier once for the whole of make check; does not reach the host formatting check from make check none — correctly not the CI walk, which reaches script/lint directly and never make check
F add .gitignore to .dockerignore keeps .gitignore in the build context also build-context.test.ts > leaves .gitignore in the build context for prettier, a pre-existing assertion of the same fact in another file; not incidental
G package.json prettier 3.8.1 to ^3.8.1 pins the same prettier for both none
H rename Makefile check: to check-all: whole file errors with no such Makefile target: check and the suite goes red — the loud-failure guarantee against the NaN failure mode file-level failure by design
I .gitea/workflows/check.yml run: script/cibuild to run: script/check reaches script/cibuild while counting; reaches docker:Dockerfile while counting; reads the run steps of the CI workflow and not its uses steps none
J Dockerfile.lint: replace RUN script/bootstrap with RUN yarn install --frozen-lockfile runs script/bootstrap inside the lint container none
L script/lint: drop -f Dockerfile.lint from the docker build invokes prettier once for the whole of make check; invokes prettier once for the whole CI build; reaches Dockerfile.lint while counting; reaches docker:Dockerfile.lint while counting; still checks formatting under make lint; script/precommit checks formatting exactly once; gets that check from the lint container also lint-docker.test.ts > lints by building Dockerfile.lint, a pre-existing assertion of the same fact

Expectation mutations (four runs)

Batch Mutations Failing set
1 toContain to not.toContain in both reachability it.each blocks; make fmt-check count 1 to 2 exactly 9: reaches script/check, reaches script/test, reaches script/lint, reaches Dockerfile.lint (make check); reaches script/cibuild, reaches script/lint, reaches docker:Dockerfile.lint, reaches docker:Dockerfile (CI); still checks formatting under make fmt-check
2 zero-count 0 to 1; each of the six toThrow patterns prefixed with MUTANT exactly 7: reports zero for a subgraph that does not run prettier; refuses a Makefile target that does not exist; refuses a package.json script that does not exist; refuses a script that does not exist; refuses a node that resolves to no commands; refuses a node kind it does not understand; refuses to walk in circles. Each failure printed the real error alongside the unmatched pattern, confirming it throws for its own named reason
3 occurrence count 2 to 1; config-file count 0 to 1; toContain to not.toContain on the counted-line edge; the spelling list shortened; bare docker build expectation to docker:Nope; both not.toHaveLength(0) guards to toHaveLength(0) exactly 7, matching one-for-one
4 -f Dockerfile.lint expectation to docker:Nope (the second assertion in that test, unreachable while the first was mutated) exactly 1: follows a bare docker build to Dockerfile and -f to its file

Nothing in the file survived mutation, so there is no assertion here that reads as a guarantee without being one.

Verification

make check: green. The lint container executed — the guard line carried a live epoch and the linters ran rather than being served:

#11 [7/9] RUN [ -n "1786369197" ] || exit 1
#11 DONE 0.2s
#12 [8/9] RUN yarn run eslint .
#12 3.050 Done in 2.66s.
#12 DONE 3.2s
#13 [9/9] RUN yarn run prettier --check .
#13 4.080 All matched files use Prettier code style!
#13 DONE 4.3s

script/cibuild: exit 0, and executed. Both images, per-layer timings, distinct epochs. CACHED appears only on the bootstrap and dependency layers, which is the intended split:

#10 [6/9] COPY . .
#10 CACHED
#11 [7/9] RUN [ -n "1786369214" ] || exit 1
#11 DONE 0.2s
#12 [8/9] RUN yarn run eslint .
#12 DONE 2.7s
#13 [9/9] RUN yarn run prettier --check .
#13 DONE 4.5s
#10 [ 6/10] COPY . .
#10 CACHED
#11 [ 7/10] RUN [ -n "1786369227" ] || exit 1
#11 DONE 0.4s
#12 [ 8/10] RUN make test
#12 13.67       Tests  279 passed (279)
#12 DONE 13.8s
#13 [ 9/10] RUN [ -n "1786369227" ] || exit 1
#13 DONE 0.2s
#14 [10/10] RUN make build
#14 2.227 build: verified ./dist/src/index.js
#14 2.227 build: verified ./dist/src/index.d.ts
#14 2.227 build: verified ./dist/bin/quak.js

The suite ran inside the container (13.8s, 279 passed), not from cache. make fmt was run and its result is in the commit. No docker builder prune at any point; no cache invalidation beyond the epoch arguments. All verification went through make targets and script/ entrypoints — no raw vitest, prettier, eslint or tsc invocation, and no direct docker build.

Note

Two facts are now asserted in two places: .gitignore staying in the build context (here and in test/packaging/build-context.test.ts) and script/lint building Dockerfile.lint (here and in test/packaging/lint-docker.test.ts). Both showed up as extra failures under mutations F and L. They are deliberate — this file needs them as premises for its own claims — but flagging the overlap in case you would rather they were consolidated.

## `2bfa11c` — https://git.eeqj.de/sneak/quak/issues/33 Fixes all four findings in `test/packaging/lint-once.test.ts`. Files touched: `test/packaging/lint-once.test.ts`, `test/packaging/entrypoints.test.ts`, `TODO.md`. Nothing else — `REPO_POLICIES.md` and the `LINT_EPOCH` naming are https://git.eeqj.de/sneak/quak/issues/32 and were left alone. ### What changed **1. The walk now starts where CI starts.** A `workflow:<path>` node kind resolves the `run:` steps of `.gitea/workflows/check.yml`, and a second walk is rooted there. It reaches `script/cibuild`, and through it both `docker:Dockerfile.lint` (via `script/lint`) and `docker:Dockerfile` (the bare `docker build .`), which the `make check` walk never sees. Rooting at the workflow rather than at a hand-picked script is deliberate: the previous version's blind spot was an assumption about what CI runs, so that is now read out of the repo and asserted. New assertions: prettier is invoked exactly once across the whole CI build; `script/cibuild`, `script/lint`, `docker:Dockerfile.lint` and `docker:Dockerfile` are all reached; and the test and build image invokes prettier zero times. **2. The lockfile assertion targets the executed branch.** `install_js_deps` is resolved out of `script/bootstrap` and split at its `missing yarn` guard, so the two install sites are separately addressable. The pinned node image ships yarn, so the container takes the `else` branch. Each branch is asserted separately, and *every* `yarn install` occurrence in a branch must carry `--frozen-lockfile`, so an unpinned install cannot hide beside a pinned one. A companion assertion requires `docker:Dockerfile.lint` to reach `script/bootstrap` at all — without it the lockfile assertions could end up describing a script the image never executes. **3. Counting is per occurrence, and edges survive counting.** `countPrettier` counts matches on a line rather than answering yes/no, and the `continue` is gone, so `edgesOf` runs on every line including counted ones. Both are asserted directly as well as through the graph. **4.** The stale `make check` comment in `test/packaging/entrypoints.test.ts` now says the suite and the lint container, not test/lint/fmt-check. **Anti-vacuity.** Every way for the walk to reach nothing is now a thrown error rather than a quiet zero: unknown Makefile target, unknown `package.json` script, missing script file, node resolving to no commands, unknown node kind. The `yarn:` resolver previously returned `[""]` for a missing script — a silent zero of exactly the kind the `node.slice("make:")`/`NaN` draft produced. All five are tested, plus a walk that legitimately counts zero (`make:clean`) and the cycle guard. ### Mutation matrix Every assertion in the file, 35 in total. Source-file mutations were applied one at a time. Expectation mutations — changing what an assertion asserts, inside the test file — were applied in four batches; each is a local change to one `it`'s expectation and cannot influence another test, and vitest names every result, so the evidence is that the failing set was *exactly* the mutated set in each run. Every mutation was reverted; the final tree is byte-identical to the committed one (`diff` against a pre-mutation copy) and green at 279/279. #### Source mutations (one run each) | # | Mutation | Tests that fired | Unrelated failures | |---|---|---|---| | A | append `RUN yarn run prettier --check .` to `Dockerfile` after `RUN make build` — the mutation from the issue | `invokes prettier once for the whole CI build` (expected 2 to be 1); `keeps prettier out of the test and build image` (expected 1 to be 0) | none | | B | `script/bootstrap` second install site to bare `yarn install`, first left alone — the mutation from the issue | `installs from the lockfile on the branch the container takes` (expected `'yarn install'` to contain `'--frozen-lockfile'`) | none | | C | same, but the *nvm* branch instead | `installs from the lockfile on the nvm branch too` — and **not** the container-branch test, which is the cross-check that the two branches are genuinely distinguished | none | | D | `Dockerfile.lint`: `RUN yarn run prettier --check . && yarn run prettier --check src` (two invocations, one line) | `invokes prettier once for the whole of make check`; `invokes prettier once for the whole CI build`; `keeps the surviving invocation inside the lint container`; `still checks formatting under make lint`; `script/precommit checks formatting exactly once` — all "expected 2 to be 1". This is finding 3: the old counter scored this arrangement as 1 | none | | E | re-add `"$SCRIPT_DIR/fmt-check"` to `script/check` (the original bug) | `invokes prettier once for the whole of make check`; `does not reach the host formatting check from make check` | none — correctly *not* the CI walk, which reaches `script/lint` directly and never `make check` | | F | add `.gitignore` to `.dockerignore` | `keeps .gitignore in the build context` | also `build-context.test.ts > leaves .gitignore in the build context for prettier`, a pre-existing assertion of the same fact in another file; not incidental | | G | `package.json` prettier `3.8.1` to `^3.8.1` | `pins the same prettier for both` | none | | H | rename Makefile `check:` to `check-all:` | whole file errors with `no such Makefile target: check` and the suite goes red — the loud-failure guarantee against the `NaN` failure mode | file-level failure by design | | I | `.gitea/workflows/check.yml` `run: script/cibuild` to `run: script/check` | `reaches script/cibuild while counting`; `reaches docker:Dockerfile while counting`; `reads the run steps of the CI workflow and not its uses steps` | none | | J | `Dockerfile.lint`: replace `RUN script/bootstrap` with `RUN yarn install --frozen-lockfile` | `runs script/bootstrap inside the lint container` | none | | L | `script/lint`: drop `-f Dockerfile.lint` from the `docker build` | `invokes prettier once for the whole of make check`; `invokes prettier once for the whole CI build`; `reaches Dockerfile.lint while counting`; `reaches docker:Dockerfile.lint while counting`; `still checks formatting under make lint`; `script/precommit checks formatting exactly once`; `gets that check from the lint container` | also `lint-docker.test.ts > lints by building Dockerfile.lint`, a pre-existing assertion of the same fact | #### Expectation mutations (four runs) | Batch | Mutations | Failing set | |---|---|---| | 1 | `toContain` to `not.toContain` in both reachability `it.each` blocks; `make fmt-check` count `1` to `2` | exactly 9: `reaches script/check`, `reaches script/test`, `reaches script/lint`, `reaches Dockerfile.lint` (make check); `reaches script/cibuild`, `reaches script/lint`, `reaches docker:Dockerfile.lint`, `reaches docker:Dockerfile` (CI); `still checks formatting under make fmt-check` | | 2 | zero-count `0` to `1`; each of the six `toThrow` patterns prefixed with `MUTANT` | exactly 7: `reports zero for a subgraph that does not run prettier`; `refuses a Makefile target that does not exist`; `refuses a package.json script that does not exist`; `refuses a script that does not exist`; `refuses a node that resolves to no commands`; `refuses a node kind it does not understand`; `refuses to walk in circles`. Each failure printed the real error alongside the unmatched pattern, confirming it throws for its own named reason | | 3 | occurrence count `2` to `1`; config-file count `0` to `1`; `toContain` to `not.toContain` on the counted-line edge; the spelling list shortened; bare `docker build` expectation to `docker:Nope`; both `not.toHaveLength(0)` guards to `toHaveLength(0)` | exactly 7, matching one-for-one | | 4 | `-f Dockerfile.lint` expectation to `docker:Nope` (the second assertion in that test, unreachable while the first was mutated) | exactly 1: `follows a bare docker build to Dockerfile and -f to its file` | Nothing in the file survived mutation, so there is no assertion here that reads as a guarantee without being one. ### Verification `make check`: green. The lint container executed — the guard line carried a live epoch and the linters ran rather than being served: ``` #11 [7/9] RUN [ -n "1786369197" ] || exit 1 #11 DONE 0.2s #12 [8/9] RUN yarn run eslint . #12 3.050 Done in 2.66s. #12 DONE 3.2s #13 [9/9] RUN yarn run prettier --check . #13 4.080 All matched files use Prettier code style! #13 DONE 4.3s ``` `script/cibuild`: exit 0, and executed. Both images, per-layer timings, distinct epochs. `CACHED` appears only on the bootstrap and dependency layers, which is the intended split: ``` #10 [6/9] COPY . . #10 CACHED #11 [7/9] RUN [ -n "1786369214" ] || exit 1 #11 DONE 0.2s #12 [8/9] RUN yarn run eslint . #12 DONE 2.7s #13 [9/9] RUN yarn run prettier --check . #13 DONE 4.5s ``` ``` #10 [ 6/10] COPY . . #10 CACHED #11 [ 7/10] RUN [ -n "1786369227" ] || exit 1 #11 DONE 0.4s #12 [ 8/10] RUN make test #12 13.67 Tests 279 passed (279) #12 DONE 13.8s #13 [ 9/10] RUN [ -n "1786369227" ] || exit 1 #13 DONE 0.2s #14 [10/10] RUN make build #14 2.227 build: verified ./dist/src/index.js #14 2.227 build: verified ./dist/src/index.d.ts #14 2.227 build: verified ./dist/bin/quak.js ``` The suite ran inside the container (13.8s, 279 passed), not from cache. `make fmt` was run and its result is in the commit. No `docker builder prune` at any point; no cache invalidation beyond the epoch arguments. All verification went through `make` targets and `script/` entrypoints — no raw `vitest`, `prettier`, `eslint` or `tsc` invocation, and no direct `docker build`. ### Note Two facts are now asserted in two places: `.gitignore` staying in the build context (here and in `test/packaging/build-context.test.ts`) and `script/lint` building `Dockerfile.lint` (here and in `test/packaging/lint-docker.test.ts`). Both showed up as extra failures under mutations F and L. They are deliberate — this file needs them as premises for its own claims — but flagging the overlap in case you would rather they were consolidated.
clawbot self-assigned this 2026-08-10 15:43:20 +02:00
Author
Collaborator

Review of 2bfa11c (implements #33) — FAIL, needs-rework

Reviewed only 2bfa11c against a73f0ab, in a fresh clone. Every claim below was reproduced by mutation, not read.

Definition of done: met. Both original holes are now red, each on its own named test — appending RUN yarn run prettier --check . to Dockerfile fires invokes prettier once for the whole CI build (2 vs 1) and keeps prettier out of the test and build image; a bare yarn install at the second script/bootstrap site fires installs from the lockfile on the branch the container takes and nothing else, and mutating the nvm site instead fires only installs from the lockfile on the nvm branch too — the two branches are genuinely distinguished. prettier --check . && prettier --check src on one line now scores 2 and fires five assertions. Renaming the Makefile check: target errors the whole file with no such Makefile target: check rather than reporting zero. make check green (37s, eslint 10.5s / prettier 3.9s both executed, CACHED only on the bootstrap and dependency layers); script/cibuild exit 0 in 44s with fresh epoch guard values (RUN [ -n "1786370184" ], RUN [ -n "1786370198" ]), make test reporting 279 passed inside the image and make build verifying the entrypoints. CI green on the head commit; fast-forwardable onto main; make fmt clean; commit message carries (closes #33); no attribution trailers; TODO.md Next Step untouched; entrypoints.test.ts:3 corrected.

Batched-mutation deviation: verified, not accepted on trust. Six expectation mutations re-run individually — ci.prettier toBe(2); the CI it.each reachability flipped to .not.toContain (all four cases fired, nothing else); walk("docker:Dockerfile").prettier toBe(1); expect(installs).toHaveLength(0) on the container branch; the workflow toEqual given a second element; the cycle toThrow regex changed. Each failed alone, on its own test, with no collateral. The batching argument holds for this sample.

Blocking

1. make check can still run prettier twice with the suite green — two idiomatic Makefile edges are not followed. test/packaging/lint-once.test.ts:86-103 (makeRecipes) records only tab-indented recipe lines, and edgesOf at line 180 matches only a literal lowercase make <target>. Neither prerequisites nor $(MAKE) is an edge. Both reproduced on this commit:

  • Makefile:21 changed to check: fmt-checkmake -n check prints script/fmt-check then script/check, i.e. one host prettier pass plus the container pass. Suite: 23 files passed, green.
  • Makefile given @$(MAKE) fmt-check above @script/check in the check recipe — make -n check prints make fmt-checkscript/fmt-check, then script/check. Suite: 23 files passed, green.

That is precisely the duplication this file exists to prevent, reintroduced by the two most ordinary ways to compose make targets — and the most likely way someone "restores" fmt-check to check. It also means the header claim at lines 21-22, "A prettier call added anywhere in that graph is therefore caught, wherever it is added", is still false; that is the same overstatement #33 finding 1 was filed about, and the sentence survives this commit verbatim.

Acceptable: makeRecipes also captures each target's prerequisite list and walk follows the known ones as edges, and the make edge regex additionally matches $(MAKE) / ${MAKE} — with a mutation test for each (check: fmt-check and @$(MAKE) fmt-check must both drive the count to 2). If either is deliberately out of scope, the header sentence must be narrowed to say what is actually followed instead of claiming total coverage.

Non-blocking

2. A package.json script whose name is not [a-z][a-z-]* is not followed. test/packaging/lint-once.test.ts:187. Adding "lint:fmt": "prettier --check ." plus RUN yarn run lint:fmt to Dockerfile left the suite green (23/23): yarn run lint:fmt matches yarn run lint, which is not a script, so no edge and no count. Colon-, digit- and underscore-named scripts are the JS-ecosystem norm. The same narrowness applies to make targets. Pre-existing, not a regression.

3. BuildKit heredoc RUN bodies are invisible. test/packaging/lint-once.test.ts:120-124 keeps only lines beginning RUN . Adding to Dockerfile:

RUN <<EOF
yarn run prettier --check .
EOF

left the suite green (23/23). I confirmed the default frontend on this host executes heredoc bodies with no # syntax= directive, so this is a working evasion rather than a theoretical one. Lower plausibility than finding 1 — the repo uses no heredocs today.

4. The workflow is pinned by an exact-equality assertion whose name does not say so. test/packaging/lint-once.test.ts:498-502 asserts commandsOf("workflow:...") equals exactly ["script/cibuild"]. That is what actually catches a run: | block scalar: adding a second step whose body runs prettier resolves to the bare |, so ci.prettier stays 1 and only this test — named reads the run steps of the CI workflow and not its uses steps — goes red. Good coverage, reached sideways. The flip side is that any legitimate second run: step (a cache step, an echo) turns the suite red for a reason unrelated to prettier. Worth one sentence in the comment saying this assertion is the block-scalar guard.

5. Parser brittleness on reformatting: acceptable, one silent case. installBranches (lines 326-351) depends on exact-line else / fi / } and on the literal missing yarn. Backslash continuations and reordering the guard operands survive, since joinContinuations runs first. A one-line if …; then …; else …; fi, or replacing missing yarn with an inline command -v, throws the named install_js_deps is not the expected … error — loud, which is the right failure mode. The one silent case: swapping the branches (if ! missing yarn || …) silently swaps the withYarn/withoutYarn labels. Both assertions are identical today so the guarantee still holds; only the failing test's name would mislead.

6. Duplicated premises across files: fine as written. .gitignore in the build context is asserted at lint-once.test.ts:406-413 and build-context.test.ts:46-47; script/lint building Dockerfile.lint at lint-once.test.ts:274-281 and lint-docker.test.ts:49. Each copy carries its own rationale for why it is a premise of that file, and both read the same source of truth, so they cannot drift apart silently — a change breaks both. No double-maintenance trap.

Not re-filed, known and tracked: LINT_EPOCH vs CHECK_EPOCH and REPO_POLICIES.md drift (#32), host make test nested-worktree exposure (#25).

All mutations reverted; the review clone is byte-identical to 2bfa11c and nothing was pushed.

## Review of `2bfa11c` (implements https://git.eeqj.de/sneak/quak/issues/33) — FAIL, `needs-rework` Reviewed only `2bfa11c` against `a73f0ab`, in a fresh clone. Every claim below was reproduced by mutation, not read. **Definition of done: met.** Both original holes are now red, each on its own named test — appending `RUN yarn run prettier --check .` to `Dockerfile` fires `invokes prettier once for the whole CI build` (2 vs 1) and `keeps prettier out of the test and build image`; a bare `yarn install` at the second `script/bootstrap` site fires `installs from the lockfile on the branch the container takes` and nothing else, and mutating the nvm site instead fires only `installs from the lockfile on the nvm branch too` — the two branches are genuinely distinguished. `prettier --check . && prettier --check src` on one line now scores 2 and fires five assertions. Renaming the Makefile `check:` target errors the whole file with `no such Makefile target: check` rather than reporting zero. `make check` green (37s, eslint 10.5s / prettier 3.9s both executed, `CACHED` only on the bootstrap and dependency layers); `script/cibuild` exit 0 in 44s with fresh epoch guard values (`RUN [ -n "1786370184" ]`, `RUN [ -n "1786370198" ]`), `make test` reporting 279 passed inside the image and `make build` verifying the entrypoints. CI green on the head commit; fast-forwardable onto `main`; `make fmt` clean; commit message carries ` (closes #33)`; no attribution trailers; `TODO.md` Next Step untouched; `entrypoints.test.ts:3` corrected. **Batched-mutation deviation: verified, not accepted on trust.** Six expectation mutations re-run individually — `ci.prettier` `toBe(2)`; the CI `it.each` reachability flipped to `.not.toContain` (all four cases fired, nothing else); `walk("docker:Dockerfile").prettier` `toBe(1)`; `expect(installs).toHaveLength(0)` on the container branch; the workflow `toEqual` given a second element; the cycle `toThrow` regex changed. Each failed alone, on its own test, with no collateral. The batching argument holds for this sample. ### Blocking **1. `make check` can still run prettier twice with the suite green — two idiomatic Makefile edges are not followed.** `test/packaging/lint-once.test.ts:86-103` (`makeRecipes`) records only tab-indented recipe lines, and `edgesOf` at line 180 matches only a literal lowercase `make <target>`. Neither prerequisites nor `$(MAKE)` is an edge. Both reproduced on this commit: - `Makefile:21` changed to `check: fmt-check` — `make -n check` prints `script/fmt-check` then `script/check`, i.e. one host prettier pass plus the container pass. Suite: **23 files passed, green.** - `Makefile` given `@$(MAKE) fmt-check` above `@script/check` in the `check` recipe — `make -n check` prints `make fmt-check` → `script/fmt-check`, then `script/check`. Suite: **23 files passed, green.** That is precisely the duplication this file exists to prevent, reintroduced by the two most ordinary ways to compose make targets — and the most likely way someone "restores" `fmt-check` to `check`. It also means the header claim at lines 21-22, "A prettier call added anywhere in that graph is therefore caught, wherever it is added", is still false; that is the same overstatement https://git.eeqj.de/sneak/quak/issues/33 finding 1 was filed about, and the sentence survives this commit verbatim. Acceptable: `makeRecipes` also captures each target's prerequisite list and `walk` follows the known ones as edges, and the `make` edge regex additionally matches `$(MAKE)` / `${MAKE}` — with a mutation test for each (`check: fmt-check` and `@$(MAKE) fmt-check` must both drive the count to 2). If either is deliberately out of scope, the header sentence must be narrowed to say what is actually followed instead of claiming total coverage. ### Non-blocking **2. A `package.json` script whose name is not `[a-z][a-z-]*` is not followed.** `test/packaging/lint-once.test.ts:187`. Adding `"lint:fmt": "prettier --check ."` plus `RUN yarn run lint:fmt` to `Dockerfile` left the suite green (23/23): `yarn run lint:fmt` matches `yarn run lint`, which is not a script, so no edge and no count. Colon-, digit- and underscore-named scripts are the JS-ecosystem norm. The same narrowness applies to `make` targets. Pre-existing, not a regression. **3. BuildKit heredoc `RUN` bodies are invisible.** `test/packaging/lint-once.test.ts:120-124` keeps only lines beginning `RUN `. Adding to `Dockerfile`: ``` RUN <<EOF yarn run prettier --check . EOF ``` left the suite green (23/23). I confirmed the default frontend on this host executes heredoc bodies with no `# syntax=` directive, so this is a working evasion rather than a theoretical one. Lower plausibility than finding 1 — the repo uses no heredocs today. **4. The workflow is pinned by an exact-equality assertion whose name does not say so.** `test/packaging/lint-once.test.ts:498-502` asserts `commandsOf("workflow:...")` equals exactly `["script/cibuild"]`. That is what actually catches a `run: |` block scalar: adding a second step whose body runs prettier resolves to the bare `|`, so `ci.prettier` stays 1 and only this test — named `reads the run steps of the CI workflow and not its uses steps` — goes red. Good coverage, reached sideways. The flip side is that any legitimate second `run:` step (a cache step, an `echo`) turns the suite red for a reason unrelated to prettier. Worth one sentence in the comment saying this assertion is the block-scalar guard. **5. Parser brittleness on reformatting: acceptable, one silent case.** `installBranches` (lines 326-351) depends on exact-line `else` / `fi` / `}` and on the literal `missing yarn`. Backslash continuations and reordering the guard operands survive, since `joinContinuations` runs first. A one-line `if …; then …; else …; fi`, or replacing `missing yarn` with an inline `command -v`, throws the named `install_js_deps is not the expected …` error — loud, which is the right failure mode. The one silent case: swapping the branches (`if ! missing yarn || …`) silently swaps the `withYarn`/`withoutYarn` labels. Both assertions are identical today so the guarantee still holds; only the failing test's name would mislead. **6. Duplicated premises across files: fine as written.** `.gitignore` in the build context is asserted at `lint-once.test.ts:406-413` and `build-context.test.ts:46-47`; `script/lint` building `Dockerfile.lint` at `lint-once.test.ts:274-281` and `lint-docker.test.ts:49`. Each copy carries its own rationale for why it is a premise of that file, and both read the same source of truth, so they cannot drift apart silently — a change breaks both. No double-maintenance trap. Not re-filed, known and tracked: `LINT_EPOCH` vs `CHECK_EPOCH` and `REPO_POLICIES.md` drift (https://git.eeqj.de/sneak/quak/issues/32), host `make test` nested-worktree exposure (https://git.eeqj.de/sneak/quak/issues/25). All mutations reverted; the review clone is byte-identical to `2bfa11c` and nothing was pushed.
clawbot added needs-rework and removed needs-review labels 2026-08-10 16:01:08 +02:00
Author
Collaborator

Review FAILED; rework in progress on next. The reviewer defeated lint-once.test.ts twice with one-token idiomatic Makefile changes (check: fmt-check as a prerequisite, and @$(MAKE) fmt-check), each giving make check two prettier passes with the suite green — the exact duplication the file exists to prevent.

Rework covers both, plus the three non-blocking evasions (yarn script names containing :, BuildKit heredoc, line continuations): each gets fixed, or the file's header claim gets narrowed to what it actually enforces. An overclaiming comment is the same defect class as an assertion that cannot fail.

A fresh reviewer takes the result.

Review FAILED; rework in progress on `next`. The reviewer defeated `lint-once.test.ts` twice with one-token idiomatic Makefile changes (`check: fmt-check` as a prerequisite, and `@$(MAKE) fmt-check`), each giving `make check` two prettier passes with the suite green — the exact duplication the file exists to prevent. Rework covers both, plus the three non-blocking evasions (yarn script names containing `:`, BuildKit heredoc, line continuations): each gets fixed, or the file's header claim gets narrowed to what it actually enforces. An overclaiming comment is the same defect class as an assertion that cannot fail. A fresh reviewer takes the result.
All checks were successful
check / check (push) Successful in 35s
This pull request can be merged automatically.
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin next:next
git checkout next
Sign in to join this conversation.