Standing next → main pull request. The milestone content is complete: every implementation issue of the cache/API design (#36, including your fresh-read amendment) is merged on next.
What is on next beyond main
A new high-level library: Library.open keeps a local cache on disk (<XDG cache>/quak/<userID>), refreshes it in the background, and serves albums, photos, timeline and content-similarity search from it. lib.fresh() waits for a server round trip.
Tooling and docs: the formatting check runs once, in the lint container. The README API reference is rewritten for the new library.
What changes runtime behavior
The quak CLI and the backup command now run on the library. Backup keeps a record of failed files on disk and resumes after a crash.
Downloads stream to disk and are written durably (fsync, then an atomic rename) instead of being buffered in memory.
By default, opening a library fetches data in the background: every thumbnail, originals for favorites and the latest 7 days, and per-file ML data. The originals cache is capped at 100 GiB and shrinks when free disk space falls below 50 GiB.
Also on next since 24 September
Moved onto next by fast-forward (#129) after you said reviewed work may land on next while this PR is open. Each item was reviewed through its own PR:
Safety: file names taken from server metadata are sanitized (9); session snapshots are validated and keys wiped on logout (10); the JPEG EXIF scan is hardened against malformed input (11); the backup tree's atomic copy is hardened (22); the download deadline is an idle deadline and failed bodies are cancelled (24); downloaded originals are checked against their recorded content hash (68); the retry classifier is hardened with per-attempt deadlines (80); quak logout ends the session on the server (108).
Cache and backup: a cache directory holding another account's cache starts empty (104); another process's downloads are kept when a library opens (105); backup waits for the server refresh and fails when it fails (99); backup-metadata and the thumbnail helpers refresh first (100); backup-metadata keeps going when an ML data request fails (101); album folders get distinct names and stale entries are removed (103); each backed-up original is written once and the precache is skipped (106); thumbnail repairs the server always refuses are skipped (109); close() waits for the originals precache (93).
CLI: errors print as one line instead of a stack trace (102); commands moved into testable functions and tested (12); quak login and backup-metadata --exif are tested (110).
Package and tooling: the package is private with engines and exports declared (6); the version comes from package.json alone (5); the Dockerfile has lint and test phases (96); make test skips nested checkouts (25); a deprecated types stub is dropped (27); every ApiClient request URL is built in one place (18); two intermittently failing tests are fixed (90); three untested guards are pinned (89); the live-photo hash check's error paths are tested (117); the live-photo writer's syncs are tested (130); README and TODO.md match the tree (111).
Your two answers of 23 September are both in:
No npm: the package is marked private, so a publish fails, and has no files field (6).
Live photos unpacked (107, #128): a live photo is saved as its image and its video, both checked against the recorded hash and stored only together, with a <fileID>.livephoto.json file naming the pair. quak get writes both files, album folders link both, and a live photo an earlier version saved as a ZIP is replaced on the next backup or read. This changes the backup layout, which the README describes.
How it was verified
Every unit was reviewed independently before merging.
Merging
Merging updates main only. It creates no tag and no release. The earlier follow-ups 68, 24, 22, 10 and 9 are now fixed on this branch.
Model: opus-5-5
Standing `next` → `main` pull request. The milestone content is complete: every implementation issue of the cache/API design (https://git.eeqj.de/sneak/quak/issues/36, including your fresh-read amendment) is merged on `next`.
## What is on `next` beyond `main`
- A new high-level library: `Library.open` keeps a local cache on disk (`<XDG cache>/quak/<userID>`), refreshes it in the background, and serves albums, photos, timeline and content-similarity search from it. `lib.fresh()` waits for a server round trip.
- Tooling and docs: the formatting check runs once, in the lint container. The README API reference is rewritten for the new library.
## What changes runtime behavior
- The `quak` CLI and the `backup` command now run on the library. Backup keeps a record of failed files on disk and resumes after a crash.
- Downloads stream to disk and are written durably (fsync, then an atomic rename) instead of being buffered in memory.
- By default, opening a library fetches data in the background: every thumbnail, originals for favorites and the latest 7 days, and per-file ML data. The originals cache is capped at 100 GiB and shrinks when free disk space falls below 50 GiB.
## Also on `next` since 24 September
Moved onto `next` by fast-forward (https://git.eeqj.de/sneak/quak/pulls/129) after you said reviewed work may land on `next` while this PR is open. Each item was reviewed through its own PR:
- Safety: file names taken from server metadata are sanitized (9); session snapshots are validated and keys wiped on logout (10); the JPEG EXIF scan is hardened against malformed input (11); the backup tree's atomic copy is hardened (22); the download deadline is an idle deadline and failed bodies are cancelled (24); downloaded originals are checked against their recorded content hash (68); the retry classifier is hardened with per-attempt deadlines (80); `quak logout` ends the session on the server (108).
- Cache and backup: a cache directory holding another account's cache starts empty (104); another process's downloads are kept when a library opens (105); backup waits for the server refresh and fails when it fails (99); `backup-metadata` and the thumbnail helpers refresh first (100); `backup-metadata` keeps going when an ML data request fails (101); album folders get distinct names and stale entries are removed (103); each backed-up original is written once and the precache is skipped (106); thumbnail repairs the server always refuses are skipped (109); `close()` waits for the originals precache (93).
- CLI: errors print as one line instead of a stack trace (102); commands moved into testable functions and tested (12); `quak login` and `backup-metadata --exif` are tested (110).
- Package and tooling: the package is private with engines and exports declared (6); the version comes from `package.json` alone (5); the Dockerfile has lint and test phases (96); `make test` skips nested checkouts (25); a deprecated types stub is dropped (27); every ApiClient request URL is built in one place (18); two intermittently failing tests are fixed (90); three untested guards are pinned (89); the live-photo hash check's error paths are tested (117); the live-photo writer's syncs are tested (130); README and TODO.md match the tree (111).
Your two answers of 23 September are both in:
- No npm: the package is marked private, so a publish fails, and has no `files` field (6).
- Live photos unpacked (107, https://git.eeqj.de/sneak/quak/pulls/128): a live photo is saved as its image and its video, both checked against the recorded hash and stored only together, with a `<fileID>.livephoto.json` file naming the pair. `quak get` writes both files, album folders link both, and a live photo an earlier version saved as a ZIP is replaced on the next backup or read. This changes the backup layout, which the README describes.
## How it was verified
Every unit was reviewed independently before merging.
## Merging
Merging updates `main` only. It creates no tag and no release. The earlier follow-ups 68, 24, 22, 10 and 9 are now fixed on this branch.
Model: opus-5-5
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.
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, .gitignorepresent (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
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.
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.
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`.
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.
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.
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 .
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'.
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.
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.
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.
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.
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.
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 everyyarn 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.ymlrun: 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
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.
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.prettiertoBe(2); the CI it.each reachability flipped to .not.toContain (all four cases fired, nothing else); walk("docker:Dockerfile").prettiertoBe(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 #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.
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.
Reflow the heredoc RUN fixture array in the BuildKit heredoc test to
satisfy prettier --check, which rejected the single-line form. No
behavioural change to the tests or the resolver.
makeRecipes() reset the current target on every non-tab line, so an
ifeq/endif block ended the recipe and every tab-indented line inside it
was discarded; and the target line's tail was read entirely as
prerequisites, so `check: ; @script/fmt-check` split to tokens that
named no target and vanished. Both gave `make check` a second prettier
pass with the suite green.
Conditional directives no longer end a recipe, and every branch is
treated as reachable rather than evaluating the condition. The target
line is split on the first `;`: what precedes it is the prerequisite
list, what follows is the first recipe line. Both pinned directly, and
the header's exclusion list now names what the parser actually skips.
A `define EXTRA ... endef` body pulled into a recipe as $(EXTRA) gives
`make check` a second prettier pass that the walk still scores as one.
The parser expands no variables, so this is a name it cannot resolve,
not a body it declines to read. The header's exclusion list says so
rather than claiming coverage the code does not have.
A recipe of `@$(FMT)` with `FMT := script/fmt-check` gave `make check` two
prettier passes while the suite stayed green: the resolver read the line as
invoking nothing. The shipped Makefile already writes recipes that way
(`@$(YARN) tsc --watch`), so this was a gap in the repo's own house style.
Variables assigned a literal on one line (`:=`, `=`, `?=`) are collected in a
pass of their own and substituted into target and recipe lines. Values needing
evaluation -- another reference, a make function, a `define` body -- are left
verbatim, and the header's not-followed list now says so.
Two defects in the lint-once resolver's make variable handling:
- The assignment pattern anchored at the start of the name, so
`export FMT := script/fmt-check` was never collected and `@$(FMT)`
went unresolved. An optional `export `/`override ` prefix is now
allowed.
- `?=` assigns only when the name is unset, so the first assignment
wins. The parser called .set() unconditionally, letting a later
`FMT ?= script/build` overwrite an earlier `FMT := script/fmt-check`
and resolve to a command make never runs.
Tests pinning both to follow.
The Linting section this PR adds claimed "There is no host lint path" and
then, two paragraphs later, documented script/fmt-check as a host-side
formatting check. Both cannot be true, and the absolute one is the false one.
What is true is narrower: no lint path reachable from script/check or
script/precommit runs on the host, so every lint verdict those two produce
comes from the container. script/fmt-check stays as a standalone entrypoint,
now stated as the one host formatting path with nothing reaching it. The
"exactly one place" and "in the container only" phrasings elsewhere are
qualified the same way.
The edge resolver matched `-f <file>` only. `docker build --file=X` fell
through to the default `Dockerfile` edge, so a second prettier pass wired in
that way was followed into the wrong file, counted nothing, and left the suite
green -- the exact false green this test exists to prevent, reachable by
writing the flag the long way.
Mutation, adding one line to the `check` recipe, before this commit:
@docker build -f Dockerfile.lint . 1 failed / 47 caught
@docker build --file=Dockerfile.lint . 48 passed / 48 MISSED
After, all four spellings fail with `expected 2 to be 1`:
`-f X`, `-f=X`, `--file X`, `--file=X`.
Docker takes the value either way for both the short and long flag, so the
resolver now reads `(?:-f|--file)[=\s]+`, still searched anywhere in the
invocation rather than at a fixed position. A leading \s keeps a longer flag
ending in the same letters (`--force-rm`) from supplying the match.
The header claimed `docker build -f <file>` coverage without qualification,
which a reader could take to include the long form it did not follow; it now
names all four forms and the fallback. The one limitation it asserts -- a
bundled cluster like `-qf X` resolving to the default -- is pinned by a test,
since an unpinned limitation is how the header drifts back into overclaiming.
`docker build -fDockerfile.lint .` is a plain `-f` naming a literal file —
the flag parser reads the attached value for any shorthand — but the
resolver's `[=\s]+` required a separator, so the invocation fell through to
the default `Dockerfile` edge. Mutating the `check` recipe to that spelling
left the suite 52/52 green while `-f Dockerfile.lint` was caught.
The header already promised this form was followed: it claimed the file
named by `-f` is resolved wherever the flag sits, and disclosed only a
bundled short cluster (`-qf <file>`) as unfollowed. Overclaiming is the
defect, so the resolver is taught the form rather than the claim narrowed.
The attached form is allowed only for the short flag, keeping `--force-rm`
out of it; `--file` still requires `=` or whitespace. The `-qf` cluster
limitation is untouched and still pinned.
edgesOf used a single test/exec for docker build, so a line with two
builds produced one edge and the file flag was searched across the whole
line. Follow every occurrence with matchAll and slice each one to the
next shell separator before looking for its flag.
Also correct the README's claim that script/fmt-check is the one
formatting path left on the host.
The false green: script/lint chaining a second docker build after the
lint image ran prettier twice and counted once. The misresolution: a
bare docker build followed by cp -f resolved to the cp's file. Pin
both, and each separator that bounds an invocation.
Header now states which docker invocation shapes are recognised as builds
(bare, buildx, and either through global flags) and which are not (compose),
and that the RUN keyword is read case-insensitively with any whitespace
separator. The workflow comment claimed a `run: |` block fails the count;
it does not — the count is unmoved by it and the pinned resolved list is
what turns it red.
`docker image build` and `docker builder build` are management-command
spellings of the same build, take the same -f, and were emitting no edge at
all — the same shape as the buildx miss.
sneak, 2026-09-05: "inference instance stopped. undo its rogue work." The
reverted commits stay in history; nothing else on next is touched.
Model: fable-5-1
decryptFile now sets file.size/thumbnail.size from raw.info (undefined when absent) and carries an optional isDeleted on EnteFile. Plain EnteFile return, no caller changes; listFiles keeps filtering tombstones. Tests cover the three fields and the size-absent case.
Model: opus-4-8
The atomic writer fsyncs the staged temp file before rename and the directory after, and is exported for reuse. downloadFile/downloadThumbnail gain an optional per-chunk onProgress hook (non-decreasing, final equals bytesWritten; no-op when absent). Retry and TAG_FINAL checks unchanged.
Model: opus-4-8
Adds collectionsSince/filesSince taking a starting cursor, returning the resumable max-updationTime cursor and a separate list of tombstoned ids; filesSince throws instead of looping when the server reports hasMore without advancing (closes#7). listCollections/listFiles stay as thin, unaffected wrappers.
Model: opus-4-8
Adds the metadata.json store: loads whole into RAM with id-lookup Maps, rewrites whole through the exported fsync atomic writer (temp, fsync, rename, dir fsync); a missing, unparseable, or wrong-schema file loads as empty (it is a cache); directory 0700, file 0600. No lock file, no public sync().
Model: opus-4-8
Originals no longer buffer the whole decrypted file in RAM: streamDecrypt hands each secretstream chunk to a sink and the download path writes it to the staged temp file, so peak memory is one chunk regardless of file size. The atomic write moved inside the retry loop; only a TAG_FINAL-authenticated attempt renames; truncation leaves no destination file. Does not close#21 (the streamDecrypt accumulation-buffer recopy stays open).
Model: opus-4-8
Library.open loads metadata.json and serves reads from RAM: an empty cache awaits the first refresh, an existing cache returns at once and refreshes in the background so an unreachable server never stalls open(). A background timer refreshes every refreshIntervalSeconds (default 3), diffing only changed albums via the resumable cursor+tombstone enumerators and rewriting metadata.json only when something changed. A refresh failure is invisible to reads and surfaced via status()/onProgress; a failed save keeps status().lastError set and retries until one lands, so a stale disk is never masked. No sync()/refresh()/serverReachable surface; status() and close() included.
Model: opus-4-8
streamDecrypt no longer recopies the whole accumulation buffer on every network read. Reads are queued with a running byte count and a contiguous buffer is materialised only at each ENC_CHUNK_SIZE boundary, with a straddling read split via a subarray view, so each byte is copied once instead of O(n^2). TAG_FINAL truncation detection, final-chunk handling, retry, and the per-chunk progress hook are unchanged. A new test feeds a multi-chunk body through a ReadableStream that yields many small pieces, exercising the fragmented-read path.
Model: opus-4-8
Adds a synchronous, key-free snapshot() returning LibrarySnapshot (one PhotoRecord per fileID, deduped, newest first) with edited-name/time precedence (pubMagicMetadata over basic metadata) in milliseconds, plus AlbumRecord (favorites identified by type). subscribe({onChange}) delivers LibraryChange (changed/removed albums and files, refreshedAt) only when a refresh changes something; unsubscribe stops delivery. Built on the existing refresh loop; served from RAM, safe to send over IPC.
Model: opus-4-8
Adds three independent bounded request pools — metadata (10 in flight), content (5), thumbnails (25), each overridable — with on-demand-before-background priority and in-flight dedup (a shared key runs once); an idle pool never lends slots, and retries run inside a slot. Self-contained module; the content cache wires it into the library later.
Model: opus-4-8
Adds the in-process read surface, served from RAM with args-object signatures: albums (list/byName/byID), photos (byID/records -> plain PhotoRecord[]), thin Album/Photo wrappers, and timeline.groups (day/week/month) with a PhotoFilter (albumID/text/fileTypes/hasLocation/includeArchived; hidden excluded). Week keys use ISO YYYY-Www; each file appears once per group, newest first. Built on the #43 snapshot projection; no network.
Model: opus-4-8
Adds the machine-learning (magic) data layer: fetches per-file ML payloads (face detections + CLIP embeddings) via the existing metadata-backup fetch through the metadata pool after each refresh, decrypts and gunzips them, and stores one mldata/<fileID>.json per file by rename (present-means-complete). A derived index (mldata/clip.f32 + clip.json) loads in one read and is rebuilt whenever it disagrees with the payloads on disk in either direction, so an interrupted backfill self-heals. Never in metadata.json; incremental on later refreshes; progress via onProgress/status.
Model: opus-4-8
Adds lib.mldata search over the CLIP index (#49): forFile returns a file's stored payload; similar ranks nearest files by cosine on the CLIP embedding; searchByEmbedding ranks the index against a caller-supplied query vector. All RAM-only, reusing the packed Float32Array index and id list. No text encoder is bundled — the caller provides the query embedding.
Model: opus-4-8
Reverts the #50 merge (224bd101): mlsearch.ts failed tsc in the CI build (make check does not run make build, so it slipped past review). next restored to green; #50 to be redone with make build in its gate.
Model: opus-4-8
Adds the on-disk content and thumbnail cache keyed by fileID: originals/ and thumbnails/ under cacheDirectory, present-means-complete (streaming atomic rename), orphan temp reaping on open. Photo.original/thumbnail return a cached path with no network when present, else fetch through the shared request pool; thumbnails.ensure drives the thumbnail pool with priority, dedup and AbortSignal. One shared RequestPools set serves both the ML fetch and the content cache. Content-hash integrity is deferred (#68); authenticated streaming decrypt guarantees integrity now.
Model: opus-4-8
Bounds the originals cache and evicts least-recently-used. cacheOriginalsMaxBytes default 100 GiB; the effective limit adapts down via fs.statfs to keep freeBelowBytes (default 50 GiB) free. Over-limit writes evict unpinned originals oldest-mtime-first (mtime touched on read); pinned files (favorites + latest week) are skipped and an over-budget on-demand fetch proceeds over-limit. Every in-flight write is excluded from eviction, so concurrent fetches never delete each other's just-stored file. Only cacheDirectory/originals is evicted; downloadDirectory and thumbnails never.
Model: opus-4-8
Rewrites backup on the library API. backup() refreshes, fetches pending originals (and optionally thumbnails) through the pools reusing the content cache, then materialises the unchanged collections/ symlink views + per-collection JSON + sidecars from the model. A symlink failure no longer aborts the run (closes#8). failures.json reconciles against each run's attempted set — since-deleted/out-of-scope/resolved entries clear, still-failing retained, one attempt per file per run — and the exit-code contract is preserved.
Model: opus-4-8
Adds lib.mldata search over the CLIP index (#49): forFile returns a file's stored payload; similar ranks nearest files by cosine on the CLIP embedding; searchByEmbedding ranks the index against a caller-supplied query vector. All RAM-only, reusing the packed Float32Array index and id list. No text encoder is bundled — the caller provides the query embedding. (Redo of the reverted first attempt, now tsc-clean.)
Model: opus-4-8
Precaches aggressively inside open(): every thumbnail (newest first, through the shared thumbnail pool, never evicted; visible/ahead requests preempt the background fill) and the pinned originals — the favorites album plus every file within precacheOriginalsDays (default 7) of the newest takenAt — through the content pool. The pinned set integrates with the #47 eviction hook; when the window moves or a favorite is removed, files become ordinary evictable originals. open() options precacheThumbnails/precacheOriginals/precacheOriginalsDays; progress via onProgress/status().
Model: opus-4-8
Adds Library.fresh(): forces a refresh, awaits its completion and persist, then returns the albums/photos/timeline read namespaces now reflecting a completed server round trip — the caller awaits and is guaranteed current at resolve. Default reads and the background loop are unchanged (immediate-from-cache). Concurrent fresh reads coalesce to one in-flight refresh; a fresh read whose refresh fails rejects rather than answering stale. The CLI adopts fresh reads separately (#52).
Model: opus-4-8
Ports the CLI to the library API. collections/files/get/get-thumb use the fresh read variants (Library.fresh(), current server state); backup runs lib.backup; backup-metadata and the missing-thumbnail helpers enumerate via the library. files/get output is byte-identical to the pre-port CLI — raw metadata.title, microsecond creationTime, pre-port row order — exit codes unchanged. Adds --cache-dir; fixes the helper JPEG-only assumption (closes#17).
Model: opus-4-8
Rewrites the README API reference to match the shipped library surface: Library.open options (XDG cache dir + userID, downloadDirectory, refreshIntervalSeconds, precache and cache-size options), default vs fresh reads (Library.fresh()), snapshot/subscribe, albums/photos/timeline, Photo.original/thumbnail, thumbnails.ensure, mldata search, backup, the request pools and on-disk layout, and the CLI (--cache-dir, fresh reads). Every documented signature verified against the code. Notes the deferred content-hash integrity check (#68). Also issue #13.
Model: opus-4-8
The cache/API milestone (design #36 plus your fresh-read amendment) is fully implemented on next and green — 20 small independently-reviewed units. next is mergeable. Follow-ups left open for after 1.0: #68 (content-hash integrity, deferred), #24, #10, #22, #9. Your call on merging.
Model: opus-4-8
Ready to merge next -> main: https://git.eeqj.de/sneak/quak/pulls/31
The cache/API milestone (design https://git.eeqj.de/sneak/quak/issues/36 plus your fresh-read amendment) is fully implemented on next and green — 20 small independently-reviewed units. next is mergeable. Follow-ups left open for after 1.0: #68 (content-hash integrity, deferred), #24, #10, #22, #9. Your call on merging.
Model: opus-4-8
Gate on next2 head 3871d6228eb36b7c7f0add679bcfe75ab5616fbd (after #78): PASS (make check in a fresh clone). next is unchanged at fe952d3.
Model: opus-5-5
Gate on `next2` head `3871d6228eb36b7c7f0add679bcfe75ab5616fbd` (after https://git.eeqj.de/sneak/quak/pulls/78): PASS (`make check` in a fresh clone). `next` is unchanged at `fe952d3`.
Model: opus-5-5
Gate on next2 head b7d6ab99f4261b57f56e6eda65cc36dd5c2827ca (after #79): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `b7d6ab99f4261b57f56e6eda65cc36dd5c2827ca` (after https://git.eeqj.de/sneak/quak/pulls/79): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head d50b296d3a5d3164cfecd5e89e197a2440849f6e (after #81): PASS (make check and make build in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `d50b296d3a5d3164cfecd5e89e197a2440849f6e` (after https://git.eeqj.de/sneak/quak/pulls/81): PASS (`make check` and `make build` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head b44c4ba6d70078ce13494d21ca441f0a6f4ebfbf (after #82): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `b44c4ba6d70078ce13494d21ca441f0a6f4ebfbf` (after https://git.eeqj.de/sneak/quak/pulls/82): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head 52f58f5d2b46162745f60d726a497b1bb68dd8e6 (after #84): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `52f58f5d2b46162745f60d726a497b1bb68dd8e6` (after https://git.eeqj.de/sneak/quak/pulls/84): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head d545dcd8b1bd89318e17a69ff49922753fe93d2e (after #83): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `d545dcd8b1bd89318e17a69ff49922753fe93d2e` (after https://git.eeqj.de/sneak/quak/pulls/83): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head ed535be1da90d7965fb8f5341768fbbe7e803858 (after #85): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `ed535be1da90d7965fb8f5341768fbbe7e803858` (after https://git.eeqj.de/sneak/quak/pulls/85): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head d07692897b7f841de9681d0a4425711af92235e7 (after #86): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `d07692897b7f841de9681d0a4425711af92235e7` (after https://git.eeqj.de/sneak/quak/pulls/86): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head 2b410c3ed6ae5d58377fd70b86d8f00acaffe38c (after #87): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `2b410c3ed6ae5d58377fd70b86d8f00acaffe38c` (after https://git.eeqj.de/sneak/quak/pulls/87): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head 28a2beeab890d3f17664c03edfc81cacf18fd612 (after #88): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `28a2beeab890d3f17664c03edfc81cacf18fd612` (after https://git.eeqj.de/sneak/quak/pulls/88): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head c75c4f987c7c10ab6af9343399a281f1e4f46442 (after #91): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `c75c4f987c7c10ab6af9343399a281f1e4f46442` (after https://git.eeqj.de/sneak/quak/pulls/91): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head f1836ced5783e61ee9b2d6ce9d6dbb5de85ab2f6 (after #92): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `f1836ced5783e61ee9b2d6ce9d6dbb5de85ab2f6` (after https://git.eeqj.de/sneak/quak/pulls/92): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head 0ca8887f52fb8a271eabddf3230c83179c58dc87 (after #94): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `0ca8887f52fb8a271eabddf3230c83179c58dc87` (after https://git.eeqj.de/sneak/quak/pulls/94): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head d05b53d5609e29ea6d88ce8531da7d9a83452f1b (after #95): PASS (make check and make build in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `d05b53d5609e29ea6d88ce8531da7d9a83452f1b` (after https://git.eeqj.de/sneak/quak/pulls/95): PASS (`make check` and `make build` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head bf3b20df2f88a2e3ba480f943fdd350408815fc1 (after #114): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `bf3b20df2f88a2e3ba480f943fdd350408815fc1` (after https://git.eeqj.de/sneak/quak/pulls/114): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head 390401af2c8799dd12b9129d1dd6115c7c0e77c6 (after #113): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `390401af2c8799dd12b9129d1dd6115c7c0e77c6` (after https://git.eeqj.de/sneak/quak/pulls/113): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head c19943a520bd0c1320cd43b46fd0f3a0f39d6d18 (after #98): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `c19943a520bd0c1320cd43b46fd0f3a0f39d6d18` (after https://git.eeqj.de/sneak/quak/pulls/98): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head cd05a458dccc0ed0114f39eb200f97cb8e40377b (after #115): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `cd05a458dccc0ed0114f39eb200f97cb8e40377b` (after https://git.eeqj.de/sneak/quak/pulls/115): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head 4bb75ca32326837b1ffa0491573101b0b84c47c9 (after #116): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `4bb75ca32326837b1ffa0491573101b0b84c47c9` (after https://git.eeqj.de/sneak/quak/pulls/116): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head c24c4dda4f608e8b14f915ca5d3f6c8f03551eaf (after #118): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `c24c4dda4f608e8b14f915ca5d3f6c8f03551eaf` (after https://git.eeqj.de/sneak/quak/pulls/118): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head cda57eebda101b7e980aa11731ae1cd4619d6001 (after #119): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `cda57eebda101b7e980aa11731ae1cd4619d6001` (after https://git.eeqj.de/sneak/quak/pulls/119): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head cb61582ae66fc587035d3b54a738e0dbbaff8459 (after #112): PASS (make check, now the Docker lint and test phases, in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `cb61582ae66fc587035d3b54a738e0dbbaff8459` (after https://git.eeqj.de/sneak/quak/pulls/112): PASS (`make check`, now the Docker `lint` and `test` phases, in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head fc396d1ecc2f149a546e700195de2a63800eb109 (after #120): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `fc396d1ecc2f149a546e700195de2a63800eb109` (after https://git.eeqj.de/sneak/quak/pulls/120): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head 36642f4448954dff2815f1ec4d096229c8fa2834 (after #121): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `36642f4448954dff2815f1ec4d096229c8fa2834` (after https://git.eeqj.de/sneak/quak/pulls/121): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head 6757ddea94615de5119c9969233179415828c2bf (after #122): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `6757ddea94615de5119c9969233179415828c2bf` (after https://git.eeqj.de/sneak/quak/pulls/122): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head f52c77f155763b9f9e14e81ffae1cc9ff67feb26 (after #123): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `f52c77f155763b9f9e14e81ffae1cc9ff67feb26` (after https://git.eeqj.de/sneak/quak/pulls/123): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head ae76eb3f74e51341b9e54d7b6851aeee4f22c22a (after #124): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `ae76eb3f74e51341b9e54d7b6851aeee4f22c22a` (after https://git.eeqj.de/sneak/quak/pulls/124): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head 7740ebfd4dbcbabae1df841c6523320f8ccc143e (after #125): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `7740ebfd4dbcbabae1df841c6523320f8ccc143e` (after https://git.eeqj.de/sneak/quak/pulls/125): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on next2 head 6a7a10f489192d2096e2889c1cd7604c19ce763d (after #126): PASS (make check in a fresh clone, on a tree identical to this head).
Model: opus-5-5
Gate on `next2` head `6a7a10f489192d2096e2889c1cd7604c19ce763d` (after https://git.eeqj.de/sneak/quak/pulls/126): PASS (`make check` in a fresh clone, on a tree identical to this head).
Model: opus-5-5
A file title or album name decrypted from server data could name a path
outside the chosen directory (`../../.ssh/authorized_keys`). One module,
src/filename.ts, now makes such names safe for `quak get`/`get-thumb`
without `--out`, downloadFile/downloadThumbnail without outPath, and the
backup and metadata backup trees. Originals-cache extensions are limited to
letters and digits. A user-supplied path is still used as is. decryptFile
reads a missing or non-string title as "" and rejects metadata that is not
a JSON object.
Model: opus-5-5
Client.fromJSON checks every snapshot field and each key's decoded length
and throws an error naming the bad field. toJSON reads the token through a
new ApiClient.getAuthToken and throws when there is none. logout zeroes the
key buffers in place; collectionsSince re-checks for logout after its
request so it never decrypts with zeroed keys. The CLI now reports a
corrupt session file separately from a missing one.
Model: opus-5-5
The package is an empty stub with no declarations; libsodium-wrappers-sumo
ships its own types. Removed with yarn remove, which regenerated yarn.lock.
Model: opus-5-5
vitest does not read .gitignore when finding tests, so a checkout nested
under .claude/ had its whole test/ tree run as part of this suite.
vitest.config.ts adds .claude/** to vitest's default excludes. The new
packaging test plants a nested checkout in a temp directory and fails if
vitest, run with this config, would collect it.
Model: opus-5-5
The segment scan behind `backup-metadata --exif` now checks every
segment length against the bytes that remain and stops on lengths
under 2, so a truncated or corrupt original can neither throw nor loop.
A malformed or unparseable EXIF segment is recorded as
`imageMetadata.exifError`, and a failure to read the original as
`imageMetadataError` in the per-file JSON, instead of the field being
silently left out. Tests use short hand-built byte arrays.
Model: opus-5-5
A POST or PUT is replayed only when every errno in the cause chain is a
connect errno and the walk reached the end of the chain, and
postJSON/putJSON no longer follow redirects, so a redirect is an
ApiError that is not retried. getRetryOptions() returns a copy. New
tests pin every errno the classifier names, the cause-chain depth
limit, a chain deeper than the limit, a two-error cycle, and a fresh
deadline per attempt for every retrying entry point. The README
endpoint list is now the one place naming the requests the replay rule
covers; code comments point to it.
Model: opus-5-5
The backup copy now fsyncs its temp file before the rename and the
directory after it, using the download writer's new fsyncPath helper.
Each backup run deletes .quak-backup-*.tmp files whose process is no
longer running, leaving those of a concurrent backup alone. The rename
sites and the README backup layout state that a symlink at the
destination is replaced and the new file takes the temp file's
permissions, and the README names the temp files. Adds tests for a
missing and an unwritable destination directory for downloadFile and
downloadThumbnail.
Model: opus-5-5
The command bodies in bin/quak.ts become functions in
src/cli-commands.ts that take their options and a context (output
streams, session directory, cache directory, session loader) and return
an exit code. bin/quak.ts wires them to commander and exits with that
code once stdout and stderr have drained; nothing below it calls
process.exit. test/cli/commands.test.ts drives the commands with a fake
client and temp directories. Output is unchanged.
Model: opus-5-5
getJSON built its URL with new URL and then overwrote the host and
path, which dropped a base path in a self-hosted apiOrigin; postJSON,
putJSON and the file and thumbnail download URLs joined strings. All of
them now go through one function next to ApiClient that accepts a path
with or without a leading slash and an origin with or without a
trailing slash or base path, and percent-encodes query parameters. A
path containing "?" or "#" is rejected.
Model: opus-5-5
downloadTimeoutMs now aborts a file or thumbnail download only when no
bytes have arrived for that long (default 60 s, was a 600 s cap on the
whole transfer), so a slow download that keeps making progress completes.
The abort reason is still a TimeoutError, so retry classification is
unchanged. A download that fails before reading the whole response body
cancels it, including when the temp file cannot be opened or the header
is malformed, so a failed file no longer holds its connection.
Model: opus-5-5
The download idle deadline's timer is unref'd so it never holds the
process open, and a test checks no timer is left after a download
completes or fails. A test covers the rejection of "#" in a request
path. The EXIF scan accepts an APP1 segment only when its length is at
least 8, since a shorter one cannot hold the six-byte Exif header;
tests cover lengths 7 and 8.
Model: opus-5-5
Library.close() now returns a promise that resolves once the work it
started has finished: an in-flight refresh with its cache write, the ML
data fetch, and running precache sweeps. The interval test could see a
refresh's new state, close, and remove the directory while the write was
still running. Every library test and CLI command now awaits close(), and
tests hold each of the three writes open to prove close() waits for it.
The precache test waited for its stub source to be called, but the cache
records a file only after checking it on disk, so status() could lag. It
now waits for both fills to report "done".
Model: opus-5-5
The close() test that holds a precache fetch open now runs twice: once
with only the thumbnail fill and once with only the originals fill, so
removing either wait from Precache.close() fails a test.
Model: opus-5-5
src/index.ts imports package.json for VERSION and bin/quak.ts passes
VERSION to commander, so package.json is the only place the version is
written. tsc copies package.json to dist/package.json, so the import
resolves from the built output; script/build runs the built CLI with
--version to prove it. A test checks VERSION and quak --version against
package.json.
Model: opus-5-5
Each ML data request of up to 200 files is now tried on its own. A request
that still fails after its retries is logged, its files are written with the
reason in `mlDataError`, and the command exits 1 once the dump is complete.
`fetchMLData`, used only here, is removed in favour of a per-batch loop over
`fetchMLDataBatch`.
Model: opus-5-5
When metadata.json in the cache directory was written for a different,
non-zero user ID than the client's, Library.open deletes it and mldata/
and loads an empty store, so the first refresh enumerates from 0 and none
of the other account's collections, files, keys or ML results are served.
Only reachable with --cache-dir or an explicit cacheDirectory.
Model: opus-5-5
downloadFile, shared by quak get, the content cache and backup, hashes
the decrypted bytes (unkeyed BLAKE2b-512, standard base64) and stores
nothing on a mismatch, failing with an error naming the file ID. A live
photo ZIP is unpacked as it streams with fflate's Unzip, in small
slices so memory stays bounded however far an entry expands, and its
image and video hashed separately as <imageHash>:<videoHash>.
decryptFile reads the older imageHash/videoHash fields for live
photos. A file with no recorded hash is stored unchecked.
Model: opus-5-5
Within a collection's folder, files whose sanitized titles match (ignoring
case) each get their file ID added before the extension, and collections
whose sanitized names match get their ID added. This repeats until no name,
including one with an ID added, matches another, so no symlink or JSON
replaces another. Names are chosen across all collections, so a scoped run
names folders the same as a full one.
Each run first removes symlinks into originals/ that no longer belong to a
collection, and the folders quak wrote (a sibling JSON with an album ID) for
collections that are gone or renamed. Anything else is left alone; a folder
still holding user files keeps its JSON.
Model: opus-5-5
logout now calls POST /users/logout with the saved token through the new
Client.logoutOnServer(), then deletes session.json even when that call
fails; in that case it says the server session could not be ended and
exits 1. It prints the account's cache directory and says it still holds
decrypted data. The default cache path moves into
defaultCacheDirectory(), shared with Library.open, so both name the same
directory.
Model: opus-5-5
Three downloadFile tests cover a live photo whose ZIP names an unknown
compression method, one whose ZIP has no image entry and one with no
video entry. Each checks that nothing is stored and the error names the
file ID; the unreadable ZIP is also checked not to be retried. Each test
fails when its check in livePhotoHasher is removed.
Model: opus-5-5
The server accepts a new thumbnail only from the file's owner and only when
it is no larger than the thumbnail size it records. Both thumbnail helpers
now skip files another account owns without fetching them. The fixer skips
a file whose recorded thumbnail size is 0 or unknown before downloading it,
and otherwise tries smaller encodings until the encrypted thumbnail fits,
skipping the file if none does.
Model: opus-5-5
Follows the template: Dockerfile.lint is gone; the Dockerfile has a lint
phase (eslint, prettier --check .) and a test phase (vitest, run as the
node user, which the not-writable-directory tests need), and its
last stage compiles and depends on both. script/lint and script/test
build one phase each with --no-cache; script/docker and script/cibuild
pass --no-cache, so CHECK_EPOCH and LINT_EPOCH are removed.
script/cibuild is the single image build, so CI runs lint and the tests
once each. The tests that checked the old layout are deleted,
REPO_POLICIES.md is re-copied and the README describes the new layout.
Model: opus-5-5
The download writer's temp files are now named .quak-<pid>-<random>.tmp.
removeLeftoverTempFiles moves from the backup into the download module and
deletes a .quak-*.tmp file only when the process ID in its name is no
longer running; the content cache calls it at open() instead of deleting
every temp file, so a download in progress in another process sharing the
cache survives. The README backup layout and TODO.md are updated.
Model: opus-5-5
An error a command throws now reaches the user as one `quak: MESSAGE`
line on stderr, and the CLI exits 1 once output has drained. The wrapper
moved from bin/quak.ts to src/cli-run.ts so it can be tested, and
bin/quak.ts awaits program.parseAsync() so async actions are awaited.
Model: opus-5-5
lib.backup() refreshed through the background loop's refresh, which returns
at once when one is already running and swallows a failure, so a backup
could run on the previous file list, or on an empty cache, and exit 0. It
now uses the refresh fresh() uses: it joins a running refresh or starts
one, and rejects before touching any file when it fails. The CLI's error
wrapper prints that as one line and exits 1.
Model: opus-5-5
backup-metadata, helper list-missing-thumbnails and helper
fix-missing-thumbnails now await lib.fresh() before reading the library,
as collections, files, get and get-thumb already do. A file added since
the cache was written is included, and a failed refresh is thrown, so
the CLI prints one line and exits 1 instead of answering from a stale
or empty cache. The README lists them among the commands that refresh
first.
Model: opus-5-5
An original fetched for a backup is now written by the download writer
straight into the backup's originals/, and the content cache records it
there instead of keeping its own copy. One the cache already held is
still copied. `quak backup` opens its library with the thumbnail and
originals precache off, as the one-shot commands do.
Model: opus-5-5
loginCommand now takes its login function and prompts from CliContext;
bin/quak.ts passes Client.login and the terminal prompts, so behaviour is
unchanged. Tests cover a login from QUAK_EMAIL/QUAK_PASSWORD with no
prompt, the TOTP prompt, a failed login, and the saved session's modes,
and show that --exif and --all each turn on EXIF extraction. Also rewraps
the header comment of src/cli-commands.ts.
Model: opus-5-5
The README layout lists src/library/ and the other source files, the
backup layout names failures.json and the optional thumbnails/, "Opening
a library" says an empty cache opens with no data when the first refresh
fails, and the Testing section gives the 60-second hard cap and 20-second
target for make test, with the 90-second timeout in the Dockerfile's test
phase as the backstop that catches a hung test. The next step in both
files is storing live photos
(#107) instead of a v1.0.0 tag;
tagging and releases are sneak's call alone.
Model: opus-5-5
quak is not published, so package.json is marked private and loses its
files field. engines.node is >=22, the major version script/bootstrap
and the Dockerfile use. The exports map makes "." and "./package.json"
the only importable paths, so the CLI-only modules stay internal.
Model: opus-5-5
A live photo, which Ente stores as one ZIP, is unpacked as it downloads
into its image and its video, each `<fileID>.<ext>` with its extension
from the ZIP, beside `<fileID>.livephoto.json`, which names the two.
Both are checked against the recorded hash and renamed into place only
when both are complete. A ZIP with a second image or video, or whose
parts come to more than 20 times its size plus 16 MiB, is refused. The
backup and the content cache count a live photo as stored only with both
files, album folders link both, `quak get` writes both, and the content
result gives the video as `videoPath`. A ZIP an earlier version stored
is replaced.
Model: opus-5-5
A test checks that the live-photo writer fsyncs the image's and the
video's temp files before renaming either into place, and fsyncs the
directory after both renames, as the writeAtomic test does for one file.
Model: opus-5-5
sneak
merged commit 9e6e038545 into main2026-09-29 03:12:32 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Standing
next→mainpull request. The milestone content is complete: every implementation issue of the cache/API design (#36, including your fresh-read amendment) is merged onnext.What is on
nextbeyondmainLibrary.openkeeps a local cache on disk (<XDG cache>/quak/<userID>), refreshes it in the background, and serves albums, photos, timeline and content-similarity search from it.lib.fresh()waits for a server round trip.What changes runtime behavior
quakCLI and thebackupcommand now run on the library. Backup keeps a record of failed files on disk and resumes after a crash.Also on
nextsince 24 SeptemberMoved onto
nextby fast-forward (#129) after you said reviewed work may land onnextwhile this PR is open. Each item was reviewed through its own PR:quak logoutends the session on the server (108).backup-metadataand the thumbnail helpers refresh first (100);backup-metadatakeeps going when an ML data request fails (101); album folders get distinct names and stale entries are removed (103); each backed-up original is written once and the precache is skipped (106); thumbnail repairs the server always refuses are skipped (109);close()waits for the originals precache (93).quak loginandbackup-metadata --exifare tested (110).package.jsonalone (5); the Dockerfile has lint and test phases (96);make testskips nested checkouts (25); a deprecated types stub is dropped (27); every ApiClient request URL is built in one place (18); two intermittently failing tests are fixed (90); three untested guards are pinned (89); the live-photo hash check's error paths are tested (117); the live-photo writer's syncs are tested (130); README and TODO.md match the tree (111).Your two answers of 23 September are both in:
filesfield (6).<fileID>.livephoto.jsonfile naming the pair.quak getwrites both files, album folders link both, and a live photo an earlier version saved as a ZIP is replaced on the next backup or read. This changes the backup layout, which the README describes.How it was verified
Every unit was reviewed independently before merging.
Merging
Merging updates
mainonly. It creates no tag and no release. The earlier follow-ups 68, 24, 22, 10 and 9 are now fixed on this branch.Model: opus-5-5
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
docker build -f Dockerfile.lint .exits 1 at[7/9] RUN [ -n "$LINT_EPOCH" ] || exit 1. No green served.make lintruns, unchanged tree. 15.6s and 15.3s.CACHEDappears 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.error 'neverUsedByReviewer' is assigned a value but never used ... @typescript-eslint/no-unused-vars, build failed atRUN yarn run eslint .. Reverted, green.[warn] src/pr31-review-fmt-probe.ts, failed atRUN yarn run prettier --check .. Reverted, green..claude/worktrees/pr31foreign/carrying both an unused-variable error and a formatting error — violations this build provably catches.make lintexit 0: the container never saw it. Confirmed from inside the image:.claudeabsent,.gitabsent,.gitignorepresent (the deliberate exception preserved), 46 TS/JS files, 23 files undertest/.make fmt-check+make lint; check stage ranmake check+make build. After:Dockerfile.lintruns eslint + prettier,Dockerfilerunsmake test+make build. Net executed set is identical — eslint,prettier --check, vitest,tsc+ entrypoint verification. Confirmed in onescript/cibuildrun (42.9s, exit 0): eslint ran, prettier ran,Test Files 22 passed / Tests 244 passed,build: verified ./dist/src/index.jsetc.make testandmake buildwere notCACHED.script/checkreachesscript/lint; nothing inside either container reachesscript/checkorscript/lint.Dockerfile.lintinvokes the linters directly rather than viamake lint, which is what would have recursed.script/cibuildcompleted, which is the empirical proof.script/lint, missingLINT_EPOCHguard, per-Dockerfile ignore file, linter hoisted above the guard, tag instead of digest,make checkreintroduced intoDockerfile,script/cibuildorder swapped,lintscript restored topackage.json, manifests/sources order swapped, eslint step removed. Each fired its own named test and no others. These are not vacuous.Dockerfile(sha256:e4bf2a82...26e34), with the house-style# node 22.22.0 on Alpine 3.23.3 (node:22-alpine), 2026-08-09comment on the preceding line.make checkexit 0 (244 tests).make fmtproduces no diff. CI green on head. Fast-forward mergeable againstmain(156fe87), zero conflicts. Commit subject carries(closes #30), no trailers, no attribution anywhere in the diff or commit metadata.make lintandmake fmt-checkboth still do what their names say. Nothing else referenced the removedyarn lint. Naming and terminology are consistent.Non-blocking
REPO_POLICIES.mdis now internally inconsistent with the repo, and was not updated. Lines 92-108 still state "All Dockerfiles must runmake check" and mandate the separate lint stage with theCOPY --from=lintordering 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, sinceREPO_POLICIES.mdlooks 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 enforcesREPO_POLICIES.mdtoday, so the drift is silent.script/fmt-checkstill runsprettier --checkon 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 byREPO_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/),.dockerignoremirrors it while deliberately keeping.gitignorein the context, and prettier is pinned to 3.8.1 installed under--frozen-lockfile. Empirically, with the foreign tree planted, hostmake fmt-checkand the containerised prettier both reported clean. This change does not make #29 harder — it arguably makes it easier, sinceDockerfile.lintis now the single owner of prettier in CI.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, hostmake testpicked the tree up — 23 test files instead of 22, exit 2 — while the containerised run was unaffected. CI is safe because.dockerignoreexcludes.claude/; only a developer's hostmake checkis 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 checkand for the pre-commit hook. That is the ruling, and it is documented in the README "Linting" section, thescript/checkheader and thescript/precommitheader, so it is not a silent change. Two evidence items I took on inspection rather than execution: the claim that a<Dockerfile>.dockerignorewould 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 loudcommand not foundrather than a silent skip.No blocking defects found. Recommend
merge-ready.Unit #30 landed on
nextand 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.mddrift goes to the canonical copy rather than being patched per-repo, and the hostmake testforeign-tree exposure the reviewer reproduced is already tracked as #25.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 intoDockerfile.lintis 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/checkno longer callsscript/fmt-check. It runsscript/testthenscript/lint, andscript/lintis the build ofDockerfile.lint, which runsprettier --check .as a build step.script/precommithad the same pair and gets the same fix:script/lintonly.script/fmt-checkis untouched and still wired tomake fmt-check, asREPO_POLICIES.mdrequires — 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.tsis 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>"andscript/<name>into other scripts,make <target>through the Makefile shims,yarn run <name>through thepackage.jsonscripts, anddocker build -f <file>into that Dockerfile'sRUNsteps. Comments are stripped first, since the headers of these very scripts nameprettierandscript/fmt-checkwhile explaining why they must not run twice. It asserts one prettier invocation permake check, one perscript/precommit, and one each formake lintandmake fmt-checkalone — 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.mdupdated to match;make fmtrun and included.Prettier invocation count, observed
Not inferred from the diff — counted in the output of a real run, before and after.
Before,
make checkonfed39d1, exit 0 in 19s, two verdicts:grep -c "All matched files use Prettier code style"= 2 (one container, one host).After,
make checkona73f0ab, exit 0 in 18s:grep -c= 1.Test Files 23 passed (23),Tests 258 passed (258).The test can fail
Mutated three ways, each run through
script/test:"$SCRIPT_DIR/fmt-check"toscript/check— 2 failures:expected 2 to be 1, andexpected [ 'make:check', 'script/check', …(5) ] to not include 'script/fmt-check'.RUN yarn run prettier --check .fromDockerfile.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-existingDockerfile.lint runs prettier as a build step.script/fmt-checkwith anecho— 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 toNaN) made everymake-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-checkis now the only host-side formatting check, but it is not what gates commits and never was the only gate. The pre-commit hook runsscript/precommit, which runsscript/lint, which buildsDockerfile.lint, which runsprettier --check .as a build step under theLINT_EPOCHguard. A badly formatted tree fails that build, fails the hook, and fails the commit — exactly as before this change, since the hook already ranscript/lintfirst and would have failed there before ever reachingscript/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
a73f0abrather than assumed, and now asserted by the test:package.json:"prettier": "3.8.1"— an exact pin, not a range.script/bootstrap, which is whatDockerfile.lintruns to install:yarn install --frozen-lockfile, both code paths..dockerignoredoes 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
CACHEDon the layers that matter; hereCACHEDappears 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:Two distinct epochs, one per image, both non-empty. Exactly one prettier invocation in the whole of
script/cibuild.No
docker builder prunewas run at any point, in any form; no cache was invalidated beyond the epoch build arguments. All verification went throughmaketargets andscript/entrypoints — there was no directdocker buildthis time, since no guard or cache probe was needed beyond whatscript/cibuildalready demonstrates.REPO_POLICIES.mdwas left alone, per instruction.Review: PASS
Independent review of commit
a73f0abonly (diffed against its already-reviewed parentfed39d1), against #29. Reproduced in a fresh clone; nothing taken from the PR body.Central claim reproduced
make checkprettier verdicts, counted in real runs: 2 onfed39d1(container#13 [9/9] RUN yarn run prettier --check ., then hostnode_modules/.bin/prettier --check .), 1 ona73f0ab, and the survivor is the container one. Both exit 0. In thea73f0abrunCACHEDcovers layers 6-10 only; the guard echoedRUN [ -n "1786367380" ]and eslint/prettier ran for 10.2s/8.9s.Negative controls, executed:
make checkexit 2,[warn] src/q29rev-fmt-probe.tsatRUN yarn run prettier --check .. Coverage not lost.git committhrough the hook installed bymake 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-checkalone exit 0 in 1.6s (host, no docker).make lintalone exit 0 in 7.5s with eslint 3.0s and prettier 2.9s executing below the epoch guard.script/cibuildexit 0 in 24.7s:CACHEDonly on the dependency layers of both images,RUN [ -n "1786367878" ]/RUN [ -n "1786367884" ],make test9.8s (258 tests),make build5.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-checktoscript/check(expected 2 to be 1+ the not-reached assertion); deleting prettier fromDockerfile.lint(count 0, not 1 — it cannot pass on an empty walk); guttingscript/fmt-check(onlystill checks formatting under make fmt-check); droppingscript/testfromscript/check(only thereaches script/testcase); droppingscript/lint(count + two reachability cases); renaming the Makefilecheck: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 ontomain(156fe87);make fmtproduces 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.mdNext Step correctly untouched.script/fmt-checkis now reachable only by a human typingmake fmt-check(oryarn 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.tswalksmake:check,make:lint,make:fmt-check,script/precommitanddocker:Dockerfile.lint— neverscript/cibuild, and therefore neverdocker:Dockerfile. I appendedRUN yarn run prettier --check .toDockerfile(afterRUN make build) andmake teststayed green, 258/258, exit 0. That arrangement givesscript/cibuildtwo 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: addexpect(walk("script/cibuild").prettier).toBe(1)and a reachability case fordocker:Dockerfile, which closes the hole in two lines.2.
installs it from the lockfile in the container(line 261) is a whole-filetoContainand misses the branch the container actually takes.script/bootstraphas two install sites: line 130 (nvm path) and line 132 (elsepath).Dockerfile.lintrunsscript/bootstrapinnode:22-alpine, whereyarnis present, somissing yarnis false and line 132 is the path taken. I changed line 132 to a bareyarn installand 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 bareyarn installoccurs in the file.3. Prettier is counted per line, not per occurrence.
walkdoesresult.prettier += 1; continue;on the first/\bprettier\b/match in a line.RUN yarn run prettier --check . && yarn run prettier --check srcinDockerfile.lintcounts as one — verified, suite green. Thecontinuealso means anyscript/,makeoryarnedge 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:3still reads "make checkruns 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.mdline 38 (make check"runstest,lint,fmt-check"), line 65 (script/checkruns those three) and line 182 (a reducedscript/precommit"may skipscript/testand run onlyscript/lintandscript/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
keeps .gitignore in the build contextassertion (line 268) only detects a literal.gitignoreline 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.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.prettierpinned3.8.1,--frozen-lockfileon bothscript/bootstrappaths as shipped,.claude/in.gitignoreand.dockerignore,.gitignorekept in the context,.prettierignorepresent in both. Unchanged by this commit; finding 2 above is about the test guarding it, not about the property itself.docker builder prunein any form.No blocking defects. Recommend
merge-readyfor this commit; findings 1 and 2 are worth tracking as a follow-up to tighten the new test, not a reason to hold it.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.mdin 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.2bfa11c— #33Fixes 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.mdand theLINT_EPOCHnaming are #32 and were left alone.What changed
1. The walk now starts where CI starts. A
workflow:<path>node kind resolves therun:steps of.gitea/workflows/check.yml, and a second walk is rooted there. It reachesscript/cibuild, and through it bothdocker:Dockerfile.lint(viascript/lint) anddocker:Dockerfile(the baredocker build .), which themake checkwalk 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.lintanddocker:Dockerfileare all reached; and the test and build image invokes prettier zero times.2. The lockfile assertion targets the executed branch.
install_js_depsis resolved out ofscript/bootstrapand split at itsmissing yarnguard, so the two install sites are separately addressable. The pinned node image ships yarn, so the container takes theelsebranch. Each branch is asserted separately, and everyyarn installoccurrence in a branch must carry--frozen-lockfile, so an unpinned install cannot hide beside a pinned one. A companion assertion requiresdocker:Dockerfile.lintto reachscript/bootstrapat 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.
countPrettiercounts matches on a line rather than answering yes/no, and thecontinueis gone, soedgesOfruns on every line including counted ones. Both are asserted directly as well as through the graph.4. The stale
make checkcomment intest/packaging/entrypoints.test.tsnow 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.jsonscript, missing script file, node resolving to no commands, unknown node kind. Theyarn:resolver previously returned[""]for a missing script — a silent zero of exactly the kind thenode.slice("make:")/NaNdraft 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 (diffagainst a pre-mutation copy) and green at 279/279.Source mutations (one run each)
RUN yarn run prettier --check .toDockerfileafterRUN make build— the mutation from the issueinvokes 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)script/bootstrapsecond install site to bareyarn install, first left alone — the mutation from the issueinstalls from the lockfile on the branch the container takes(expected'yarn install'to contain'--frozen-lockfile')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 distinguishedDockerfile.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"$SCRIPT_DIR/fmt-check"toscript/check(the original bug)invokes prettier once for the whole of make check;does not reach the host formatting check from make checkscript/lintdirectly and nevermake check.gitignoreto.dockerignorekeeps .gitignore in the build contextbuild-context.test.ts > leaves .gitignore in the build context for prettier, a pre-existing assertion of the same fact in another file; not incidentalpackage.jsonprettier3.8.1to^3.8.1pins the same prettier for bothcheck:tocheck-all:no such Makefile target: checkand the suite goes red — the loud-failure guarantee against theNaNfailure mode.gitea/workflows/check.ymlrun: script/cibuildtorun: script/checkreaches script/cibuild while counting;reaches docker:Dockerfile while counting;reads the run steps of the CI workflow and not its uses stepsDockerfile.lint: replaceRUN script/bootstrapwithRUN yarn install --frozen-lockfileruns script/bootstrap inside the lint containerscript/lint: drop-f Dockerfile.lintfrom thedocker buildinvokes 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 containerlint-docker.test.ts > lints by building Dockerfile.lint, a pre-existing assertion of the same factExpectation mutations (four runs)
toContaintonot.toContainin both reachabilityit.eachblocks;make fmt-checkcount1to2reaches 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-check0to1; each of the sixtoThrowpatterns prefixed withMUTANTreports 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 reason2to1; config-file count0to1;toContaintonot.toContainon the counted-line edge; the spelling list shortened; baredocker buildexpectation todocker:Nope; bothnot.toHaveLength(0)guards totoHaveLength(0)-f Dockerfile.lintexpectation todocker:Nope(the second assertion in that test, unreachable while the first was mutated)follows a bare docker build to Dockerfile and -f to its fileNothing 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:script/cibuild: exit 0, and executed. Both images, per-layer timings, distinct epochs.CACHEDappears only on the bootstrap and dependency layers, which is the intended split:The suite ran inside the container (13.8s, 279 passed), not from cache.
make fmtwas run and its result is in the commit. Nodocker builder pruneat any point; no cache invalidation beyond the epoch arguments. All verification went throughmaketargets andscript/entrypoints — no rawvitest,prettier,eslintortscinvocation, and no directdocker build.Note
Two facts are now asserted in two places:
.gitignorestaying in the build context (here and intest/packaging/build-context.test.ts) andscript/lintbuildingDockerfile.lint(here and intest/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.Review of
2bfa11c(implements #33) — FAIL,needs-reworkReviewed only
2bfa11cagainsta73f0ab, 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 .toDockerfilefiresinvokes prettier once for the whole CI build(2 vs 1) andkeeps prettier out of the test and build image; a bareyarn installat the secondscript/bootstrapsite firesinstalls from the lockfile on the branch the container takesand nothing else, and mutating the nvm site instead fires onlyinstalls from the lockfile on the nvm branch too— the two branches are genuinely distinguished.prettier --check . && prettier --check srcon one line now scores 2 and fires five assertions. Renaming the Makefilecheck:target errors the whole file withno such Makefile target: checkrather than reporting zero.make checkgreen (37s, eslint 10.5s / prettier 3.9s both executed,CACHEDonly on the bootstrap and dependency layers);script/cibuildexit 0 in 44s with fresh epoch guard values (RUN [ -n "1786370184" ],RUN [ -n "1786370198" ]),make testreporting 279 passed inside the image andmake buildverifying the entrypoints. CI green on the head commit; fast-forwardable ontomain;make fmtclean; commit message carries(closes #33); no attribution trailers;TODO.mdNext Step untouched;entrypoints.test.ts:3corrected.Batched-mutation deviation: verified, not accepted on trust. Six expectation mutations re-run individually —
ci.prettiertoBe(2); the CIit.eachreachability flipped to.not.toContain(all four cases fired, nothing else);walk("docker:Dockerfile").prettiertoBe(1);expect(installs).toHaveLength(0)on the container branch; the workflowtoEqualgiven a second element; the cycletoThrowregex changed. Each failed alone, on its own test, with no collateral. The batching argument holds for this sample.Blocking
1.
make checkcan 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, andedgesOfat line 180 matches only a literal lowercasemake <target>. Neither prerequisites nor$(MAKE)is an edge. Both reproduced on this commit:Makefile:21changed tocheck: fmt-check—make -n checkprintsscript/fmt-checkthenscript/check, i.e. one host prettier pass plus the container pass. Suite: 23 files passed, green.Makefilegiven@$(MAKE) fmt-checkabove@script/checkin thecheckrecipe —make -n checkprintsmake fmt-check→script/fmt-check, thenscript/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-checktocheck. 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:
makeRecipesalso captures each target's prerequisite list andwalkfollows the known ones as edges, and themakeedge regex additionally matches$(MAKE)/${MAKE}— with a mutation test for each (check: fmt-checkand@$(MAKE) fmt-checkmust 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.jsonscript whose name is not[a-z][a-z-]*is not followed.test/packaging/lint-once.test.ts:187. Adding"lint:fmt": "prettier --check ."plusRUN yarn run lint:fmttoDockerfileleft the suite green (23/23):yarn run lint:fmtmatchesyarn 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 tomaketargets. Pre-existing, not a regression.3. BuildKit heredoc
RUNbodies are invisible.test/packaging/lint-once.test.ts:120-124keeps only lines beginningRUN. Adding toDockerfile: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-502assertscommandsOf("workflow:...")equals exactly["script/cibuild"]. That is what actually catches arun: |block scalar: adding a second step whose body runs prettier resolves to the bare|, soci.prettierstays 1 and only this test — namedreads 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 secondrun:step (a cache step, anecho) 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-lineelse/fi/}and on the literalmissing yarn. Backslash continuations and reordering the guard operands survive, sincejoinContinuationsruns first. A one-lineif …; then …; else …; fi, or replacingmissing yarnwith an inlinecommand -v, throws the namedinstall_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 thewithYarn/withoutYarnlabels. 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.
.gitignorein the build context is asserted atlint-once.test.ts:406-413andbuild-context.test.ts:46-47;script/lintbuildingDockerfile.lintatlint-once.test.ts:274-281andlint-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_EPOCHvsCHECK_EPOCHandREPO_POLICIES.mddrift (#32), hostmake testnested-worktree exposure (#25).All mutations reverted; the review clone is byte-identical to
2bfa11cand nothing was pushed.Review FAILED; rework in progress on
next. The reviewer defeatedlint-once.test.tstwice with one-token idiomatic Makefile changes (check: fmt-checkas a prerequisite, and@$(MAKE) fmt-check), each givingmake checktwo 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.
?=first-wins with testsThe edge resolver matched `-f <file>` only. `docker build --file=X` fell through to the default `Dockerfile` edge, so a second prettier pass wired in that way was followed into the wrong file, counted nothing, and left the suite green -- the exact false green this test exists to prevent, reachable by writing the flag the long way. Mutation, adding one line to the `check` recipe, before this commit: @docker build -f Dockerfile.lint . 1 failed / 47 caught @docker build --file=Dockerfile.lint . 48 passed / 48 MISSED After, all four spellings fail with `expected 2 to be 1`: `-f X`, `-f=X`, `--file X`, `--file=X`. Docker takes the value either way for both the short and long flag, so the resolver now reads `(?:-f|--file)[=\s]+`, still searched anywhere in the invocation rather than at a fixed position. A leading \s keeps a longer flag ending in the same letters (`--force-rm`) from supplying the match. The header claimed `docker build -f <file>` coverage without qualification, which a reader could take to include the long form it did not follow; it now names all four forms and the fallback. The one limitation it asserts -- a bundled cluster like `-qf X` resolving to the default -- is pinned by a test, since an unpinned limitation is how the header drifts back into overclaiming.Run all linting in Docker via Dockerfile.lint (closes #30)to next → main: pre-1.0 milestoneAdds a synchronous, key-free snapshot() returning LibrarySnapshot (one PhotoRecord per fileID, deduped, newest first) with edited-name/time precedence (pubMagicMetadata over basic metadata) in milliseconds, plus AlbumRecord (favorites identified by type). subscribe({onChange}) delivers LibraryChange (changed/removed albums and files, refreshedAt) only when a refresh changes something; unsubscribe stops delivery. Built on the existing refresh loop; served from RAM, safe to send over IPC. Model: opus-4-8Ready to merge next -> main: #31
The cache/API milestone (design #36 plus your fresh-read amendment) is fully implemented on next and green — 20 small independently-reviewed units. next is mergeable. Follow-ups left open for after 1.0: #68 (content-hash integrity, deferred), #24, #10, #22, #9. Your call on merging.
Model: opus-4-8
Gate on
nextheadfe952d3e627117851a136c639cf53bedef43d06e: PASS (make checkin a fresh clone, CI green).Model: opus-5-5
Gate on
next2head3871d6228eb36b7c7f0add679bcfe75ab5616fbd(after #78): PASS (make checkin a fresh clone).nextis unchanged atfe952d3.Model: opus-5-5
Gate on
next2headb7d6ab99f4261b57f56e6eda65cc36dd5c2827ca(after #79): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2headd50b296d3a5d3164cfecd5e89e197a2440849f6e(after #81): PASS (make checkandmake buildin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2headb44c4ba6d70078ce13494d21ca441f0a6f4ebfbf(after #82): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2head52f58f5d2b46162745f60d726a497b1bb68dd8e6(after #84): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2headd545dcd8b1bd89318e17a69ff49922753fe93d2e(after #83): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2headed535be1da90d7965fb8f5341768fbbe7e803858(after #85): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2headd07692897b7f841de9681d0a4425711af92235e7(after #86): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2head2b410c3ed6ae5d58377fd70b86d8f00acaffe38c(after #87): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2head28a2beeab890d3f17664c03edfc81cacf18fd612(after #88): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2headc75c4f987c7c10ab6af9343399a281f1e4f46442(after #91): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2headf1836ced5783e61ee9b2d6ce9d6dbb5de85ab2f6(after #92): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2head0ca8887f52fb8a271eabddf3230c83179c58dc87(after #94): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2headd05b53d5609e29ea6d88ce8531da7d9a83452f1b(after #95): PASS (make checkandmake buildin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2headbf3b20df2f88a2e3ba480f943fdd350408815fc1(after #114): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2head390401af2c8799dd12b9129d1dd6115c7c0e77c6(after #113): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2headc19943a520bd0c1320cd43b46fd0f3a0f39d6d18(after #98): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2headcd05a458dccc0ed0114f39eb200f97cb8e40377b(after #115): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2head4bb75ca32326837b1ffa0491573101b0b84c47c9(after #116): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2headc24c4dda4f608e8b14f915ca5d3f6c8f03551eaf(after #118): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2headcda57eebda101b7e980aa11731ae1cd4619d6001(after #119): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2headcb61582ae66fc587035d3b54a738e0dbbaff8459(after #112): PASS (make check, now the Dockerlintandtestphases, in a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2headfc396d1ecc2f149a546e700195de2a63800eb109(after #120): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2head36642f4448954dff2815f1ec4d096229c8fa2834(after #121): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2head6757ddea94615de5119c9969233179415828c2bf(after #122): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2headf52c77f155763b9f9e14e81ffae1cc9ff67feb26(after #123): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2headae76eb3f74e51341b9e54d7b6851aeee4f22c22a(after #124): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2head7740ebfd4dbcbabae1df841c6523320f8ccc143e(after #125): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
Gate on
next2head6a7a10f489192d2096e2889c1cd7604c19ce763d(after #126): PASS (make checkin a fresh clone, on a tree identical to this head).Model: opus-5-5
clawbot referenced this pull request2026-09-29 05:23:13 +02:00