docker/Dockerfile's web-build stage ran the SPA vitest suite with two hand-written --excluded
spec paths. That stage is gitless twice over — it copies only web/ + design-system/, so it holds
no .git, and node:22-bookworm-slim ships no git binary — while the suite has members needing one
or the other. So the exclusions were a population nothing derives: a hand-maintained list of "the
specs that cannot run here", beside a suite that grows.
#883 added a third such spec without updating the list. Because Build & push image (amd64) carries if: github.event_name != 'pull_request', the resulting red is structurally unreachable on a PR — it
appears only on pushes to main and on the v* tag path. Every image build has failed since, :latest has stopped being republished, and a release cut would fail at the image build.
What this does
Removes the list rather than extending it. The stage now runs npm run lint && npm run typecheck && npm run build — it builds the SPA and does not test it. The
suite runs once, unfiltered, in docker-build.yml's test job on a real checkout, and build
carries needs: [test, migrations, scan].
Adding a third --exclude would have re-armed the same trap for the next guard, which is what the
issue asked not to do.
The guard, and why it looks the way it does
scripts/tests/test_image_build_delegates_the_spa_suite.py holds three parts — the third exists
because the first two together still certify a publish on which the suite never ran:
NEGATIVE — every Dockerfile stage carrying the SPA source runs exactly its pinned commands.
POSITIVE — every job publishing an image from such a Dockerfile transitively needs: the job
holding the pinned gating step, in that workflow.
EFFECTIVE — that step's run: body, if: and working-directory are pinned; it and its job
carry no continue-on-error in any spelling and no job-level if:; the publish step keeps its own docs_only gate; and web/package.json's script map and web/vite.config.ts's test: block are
pinned, because a pinned RUN npm … executes whatever those say.
The mechanism was replaced once, and that is the part worth reading. Three earlier versions
PARSED shell text to decide "does this run the suite, and can its failure be swallowed?". That
predicate was wrong nine times across three review rounds — executed heredocs treated as data, #
truncating a command mid-word, compound punctuation welding commands, npm t / ./node_modules/.bin/vitest
/ timeout / su -c unrecognised, true || npm test counted as a run it never performs — and twice
a clause added to remove a false red opened a false green. It was withdrawn. Nothing decides what
a command means any more; the risky command lines are compared as TEXT against a pin, and no spelling
has to be recognised in order to be rejected.
The taxonomy that came out of it is in docs/guard-inventory.md: a population decides what is
CHECKED, so a hand-written one goes silently short; a pin decides what is EXPECTED, so a stale one
goes loudly red; a selector decides which members to pin, and going short there is silent — the
worst of the three, and the one that produced this PR's last blocker.
Verification
64-mutant development battery, 0 missed, each caught by the assertion intended for it. Called a
development battery deliberately: it is not in the repo and nothing re-derives it.
One declared clause mutation in scripts/tests/mutation_manifest.py, harness-executed every
suite — that is the standing proof; the other 63 were witnessed during development and are not.
Six independent cold-review rounds, five BLOCKED. Findings that were real holes are closed and
mutant-verified; findings that were overclaiming prose are corrected in place. Every reviewer
independently confirmed the CI path itself: no route publishes an image on which the suite has not
run.
The release path, measured rather than reasoned: on a v* tag, ci-detect-docs-only.sh emits docs_only=false and ci-detect-already-validated.sh emits skip=false, so test runs the full
suite before build. The release path is strictly better off: it no longer fails at the image
build, and the suite still gates it.
Full scripts/tests (1411 passed / 2 skipped), npm run lint, npm run typecheck, npm test -- --run (121 files / 1319 tests), ruff check + format, catalog --check.
Pre-merge image proof: a workflow_dispatch run of docker-build.yml on this branch, where build
runs (gated only on != 'pull_request') but publishes nothing (push: is gated on main/v*).
Docs
New decision record ci.image-build-delegates-the-spa-suite (catalog regenerated), plus the
stale-passage sweep: docs/testing.md's web/ row, docs/guard-inventory.md's note on the realgit
split, and the module comment in web/vite-plugins/trackedSourceFiles.realgit.test.ts all described
the two --excludes as current.
The record enumerates what removing the in-image run gives up rather than waving it through — the one
real loss is running the suite under node:22-bookworm-slim specifically, which is small because the
artifact the image ships is vite build's output and that still runs there.
One correction worth flagging, since it appeared in five places including a mutation expect
string: the original claim "the build context is web/ + design-system/, so there is no .git" is
false. The context is the repository root (context: .) and .dockerignore does not exclude .git.
The true statement is about the STAGE. The conclusion survives — bookworm-slim has no git binary
either — but a reader who checked would have found .git in the context and concluded the note was
stale.
fixes #887
## What was broken
`docker/Dockerfile`'s `web-build` stage ran the SPA vitest suite with two hand-written `--exclude`d
spec paths. That stage is gitless twice over — it copies only `web/` + `design-system/`, so it holds
no `.git`, and `node:22-bookworm-slim` ships no git binary — while the suite has members needing one
or the other. So the exclusions were **a population nothing derives**: a hand-maintained list of "the
specs that cannot run here", beside a suite that grows.
#883 added a third such spec without updating the list. Because `Build & push image (amd64)` carries
`if: github.event_name != 'pull_request'`, the resulting red is structurally unreachable on a PR — it
appears only on pushes to `main` and on the `v*` tag path. **Every image build has failed since,
`:latest` has stopped being republished, and a release cut would fail at the image build.**
## What this does
**Removes the list rather than extending it.** The stage now runs
`npm run lint && npm run typecheck && npm run build` — it builds the SPA and does not test it. The
suite runs once, unfiltered, in `docker-build.yml`'s `test` job on a real checkout, and `build`
carries `needs: [test, migrations, scan]`.
Adding a third `--exclude` would have re-armed the same trap for the next guard, which is what the
issue asked not to do.
## The guard, and why it looks the way it does
`scripts/tests/test_image_build_delegates_the_spa_suite.py` holds three parts — the third exists
because the first two together still certify a publish on which the suite never ran:
- **NEGATIVE** — every Dockerfile stage carrying the SPA source runs exactly its pinned commands.
- **POSITIVE** — every job publishing an image from such a Dockerfile transitively `needs:` the job
holding the pinned gating step, *in that workflow*.
- **EFFECTIVE** — that step's `run:` body, `if:` and `working-directory` are pinned; it and its job
carry no `continue-on-error` in any spelling and no job-level `if:`; the publish step keeps its own
`docs_only` gate; and `web/package.json`'s script map and `web/vite.config.ts`'s `test:` block are
pinned, because a pinned `RUN npm …` executes whatever those say.
**The mechanism was replaced once, and that is the part worth reading.** Three earlier versions
PARSED shell text to decide "does this run the suite, and can its failure be swallowed?". That
predicate was wrong **nine times** across three review rounds — executed heredocs treated as data, `#`
truncating a command mid-word, compound punctuation welding commands, `npm t` / `./node_modules/.bin/vitest`
/ `timeout` / `su -c` unrecognised, `true || npm test` counted as a run it never performs — and twice
a clause added to remove a *false red* opened a *false green*. It was withdrawn. Nothing decides what
a command means any more; the risky command lines are compared as TEXT against a pin, and no spelling
has to be recognised in order to be rejected.
The taxonomy that came out of it is in `docs/guard-inventory.md`: a **population** decides what is
CHECKED, so a hand-written one goes silently short; a **pin** decides what is EXPECTED, so a stale one
goes loudly red; a **selector** decides which members to pin, and going short there is silent — the
worst of the three, and the one that produced this PR's last blocker.
## Verification
- **64-mutant development battery, 0 missed**, each caught by the assertion intended for it. Called a
development battery deliberately: it is not in the repo and nothing re-derives it.
- **One declared clause mutation** in `scripts/tests/mutation_manifest.py`, harness-executed every
suite — that is the standing proof; the other 63 were witnessed during development and are not.
- **Six independent cold-review rounds**, five BLOCKED. Findings that were real holes are closed and
mutant-verified; findings that were overclaiming prose are corrected in place. Every reviewer
independently confirmed the CI path itself: no route publishes an image on which the suite has not
run.
- **The release path, measured rather than reasoned:** on a `v*` tag, `ci-detect-docs-only.sh` emits
`docs_only=false` and `ci-detect-already-validated.sh` emits `skip=false`, so `test` runs the full
suite before `build`. The release path is strictly better off: it no longer fails at the image
build, and the suite still gates it.
- Full `scripts/tests` (1411 passed / 2 skipped), `npm run lint`, `npm run typecheck`,
`npm test -- --run` (121 files / 1319 tests), ruff check + format, catalog `--check`.
- Pre-merge image proof: a `workflow_dispatch` run of `docker-build.yml` on this branch, where `build`
runs (gated only on `!= 'pull_request'`) but publishes nothing (`push:` is gated on `main`/`v*`).
## Docs
New decision record `ci.image-build-delegates-the-spa-suite` (catalog regenerated), plus the
stale-passage sweep: `docs/testing.md`'s `web/` row, `docs/guard-inventory.md`'s note on the `realgit`
split, and the module comment in `web/vite-plugins/trackedSourceFiles.realgit.test.ts` all described
the two `--exclude`s as current.
The record enumerates what removing the in-image run gives up rather than waving it through — the one
real loss is running the suite under `node:22-bookworm-slim` specifically, which is small because the
artifact the image ships is `vite build`'s output and that still runs there.
**One correction worth flagging**, since it appeared in five places including a mutation `expect`
string: the original claim "the build context is `web/` + `design-system/`, so there is no `.git`" is
false. The context is the repository root (`context: .`) and `.dockerignore` does not exclude `.git`.
The true statement is about the STAGE. The conclusion survives — bookworm-slim has no git binary
either — but a reader who checked would have found `.git` in the context and concluded the note was
stale.
`docker/Dockerfile`'s web-build stage is gitless twice over — the build context is
`web/` + `design-system/` so there is no `.git`, and `node:22-bookworm-slim` ships no
git binary. Members of the SPA suite need one or the other, so running the suite there
required naming the ones that cannot run. That list was a population nothing derived:
#883 added a third member without updating the hand-written pair of `--exclude`s, and
because `Build & push image (amd64)` is `if: github.event_name != 'pull_request'` the
resulting red was unreachable on a PR. It landed on `main` and on the `v*` tag path
instead — every image build failed, `:latest` stopped being republished, and a release
cut would have failed at the image build.
Adding a third `--exclude` re-arms the trap, so the list is removed rather than
extended: the stage now lints, typechecks and BUILDS the SPA, and the suite runs once,
unfiltered, in `docker-build.yml`'s `test` job on a real checkout. `build` carries
`needs: [test, migrations, scan]`, so no image is published past a red suite.
`scripts/tests/test_image_build_delegates_the_spa_suite.py` holds both halves — the
negative one alone would be satisfied by deleting the `needs:` edge. Three populations,
all derived: tracked Dockerfiles and workflows from the git index, and which npm scripts
ARE the suite from `web/package.json` (so `test` is in and the Playwright `test:ui-e2e`
is out, with no exemption list). Publishing jobs come from the `docker/build-push-action`
step and the Dockerfile each builds from that step's own `file:` input, which is why
`ci-image.yml` is out of scope by derivation rather than by an entry that would outlive
its reason.
Four mutants witnessed red, each by the intended test: a filtered suite run put back
into the Dockerfile, the `needs:` edge deleted, and the gating run narrowed in both the
block and the single-line `run:` step forms.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
Both independent reviews (Codex GPT-5.6 cross-family, and a cold Opus agent in an
isolated worktree) returned BLOCKED. Both independently confirmed the CI path itself is
sound — neither found a route that publishes an image on which the suite never ran — so
every finding is about the guard's reach, plus one factual error in the prose.
THE STRUCTURAL ONE. The guard asserted a `needs:` edge EXISTS, never that it is load
bearing. Since this change deletes the in-image run, that edge is the only remaining
layer, so `continue-on-error: true`, `if: false`, a job-level `if:`, `npm test … || true`,
a pipe into `tee`, and `set +e` each certified a publish over a red suite with every
assertion green. `test_the_gating_suite_run_is_NOT_ADVISORY` closes all six.
A filter written into `web/package.json`'s script body was invisible at the call site:
`"test": "vitest --exclude x"` with a workflow saying `npm test -- --run` is a filtered
gating run reading as clean — the removed defect, one level down. `vitest_scripts()` now
derives each script's own narrowing arguments and `suite_args` prepends them.
PARSER REACH, every case measured rather than argued. `shlex.split` yields `lint&&npm` as
one token, so unspaced `&&` and `;` re-adds were invisible; `shlex` in punctuation_chars
mode splits them. Added: `sh -c` payload expansion, `npm --prefix`/`npx -p` flag skipping,
`xargs`, heredoc bodies as DATA (a `cat > f <<'EOF' … npm test … EOF` block counted as a
real run), `ADD`/JSON-form/no-trailing-slash `COPY` in `carries_spa_source`, and
redirections no longer read as spec filters. `--root` and `--config` moved to the
narrowing set: both change which specs vitest collects.
A FACTUAL ERROR, in five places including the mutation `expect`: "the build context is
`web/` + `design-system/`, so there is no `.git`". The context is the repository root
(`context: .`) and `.dockerignore` does not exclude `.git`. The true statement is about
the STAGE, which copies only those two directories. The conclusion survives — bookworm
slim has no git binary either — but a reader who checked would have found `.git` in the
context and concluded the note was stale.
ONE FINDING WAS MINE, from the mutant battery rather than from either review, and it is
the reason the battery exists: `failure_suppressions` tokenised the whole multi-line
`run:` body at once. A newline is not a shell separator, so a realistic two-line step —
the `ci-step-ran.sh` marker line, then the suite — merged into ONE segment whose head was
the marker script, and three suppression mutants passed while my single-line unit test
was green. It now works per logical line, and the regression test uses the two-line shape.
17 mutants, 0 missed, each caught by the intended assertion; baseline green. The
`docs/guard-inventory.md` residual list is rewritten as MEASURED reach — the previous one
was wrong rather than merely short, which cold review rightly called worse than silence.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
Both were measured, not reasoned, and both are the shape this repo keeps recording — the
fix round introducing an adjacent defect, and a unit test using a simpler input shape
than the real file has.
`npm test -- --run && echo ok || true` reported NO suppression. `&&`/`||` chain across a
whole list, so when the suite fails the `&&` right-hand side is skipped and the `||`
right-hand side runs: the list exits 0 and the suite's failure is swallowed even though
the `||` is not adjacent to it. The detector looked only at the separator IMMEDIATELY
after the suite segment. It is now scoped to the `;`-delimited list, which also catches a
backgrounded `npm test &` (status never awaited) and `( npm test ) || true`. A `;` ends
the list and resets, so `npm test; other || true` stays clean — that `||` is about the
other command.
`--exclude 2 > log` reported `['--exclude']`, losing the filter's own value: stripping
redirections as a PRE-PASS let the file-descriptor rule claim the `2` before the flag
could. Redirections are now consumed inside the walk, after flag values are taken.
The mutant battery grew from 17 to 24 and is 0-missed. The `docs/guard-inventory.md` row
now states the count and, explicitly, the grading: exactly ONE of the 24 is declared in
`mutation_manifest.py` and re-executed every suite; the other 23 were witnessed by hand
and are NOT standing. That is the same footing `pageSizeCallSites.guard.test.ts` states
for its nine, and saying so is the difference between evidence for the reach and a claim
of a per-run proof.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
Round 4, and the third cold review found the same mechanism failing again, so it is
removed rather than patched a tenth time.
WHAT KEPT BREAKING. Three versions of this guard asked "does this command RUN the suite,
and can its failure be swallowed?" of arbitrary shell text. That predicate was wrong NINE
times across three review rounds, and twice a clause added to remove a FALSE RED opened a
FALSE GREEN on the guard's headline assertion:
* heredoc bodies were skipped as data, but BuildKit EXECUTES `RUN <<EOF` — and the
opener regex also fired inside quotes (`echo "tags<<__EOT__"`), which blinded the
whole-file scan over the last 303 lines of docker-build.yml. Wrong in both directions
at once, and measurably live on this tree.
* `shlex.shlex` does not clear `commenters` the way `shlex.split` does, so `#`
truncated a command mid-word — including the live `${#reports[@]}` idiom — and made
this file's own stated residual false.
* compound punctuation (`);`) welded two commands into one segment.
* `npm t`, `./node_modules/.bin/vitest`, `pnpm vitest`, `yarn vitest`,
`node …/vitest.mjs`, `timeout …`, `su -c …`, `if npm test; then` — all invisible.
* `true || npm test` counted as the gating run while never executing it.
* `continue-on-error: ${{ … }}` passed a check written against two literals — a
presence test that cannot see polarity, fail-OPEN in the one direction that matters.
WHAT REPLACES IT. Nothing in the file decides what a command means any more. The commands
that may run in the two risky places are PINNED as text: the `RUN` lines of every
SPA-carrying Dockerfile stage, and the gating step's `run:` body and `if:`. A suite run
re-added in ANY spelling is simply not equal to its pin — the pin does not have to
recognise a spelling in order to reject it. A pin cannot produce a false green, only a
false red, and a false red is a human reading a diff they should have read anyway.
The population/pin split is the load-bearing distinction, and it is now stated in the
inventory: a POPULATION decides what is CHECKED, so a hand-written one goes silently
short; a PIN decides what is EXPECTED, so a stale one goes loudly red. Only the second is
safe to write by hand. Populations stay derived from the git index.
Two premises that were prose are now assertions: the publish step keeps its own
`docs_only` gate (without it, a docs-only push skips the suite and publishes anyway), and
no step other than the pinned one mentions the suite — a SUBSTRING sweep, deliberately
not a predicate, whose failure mode is a false red asking someone to look.
41 mutants, 0 missed, including all nine spellings above and the three from the previous
round. Exactly ONE is declared in `mutation_manifest.py` and re-executed every suite; the
other 40 were witnessed during development and are NOT standing — stated in the row
rather than left to be assumed.
Also fixed: the truncated sentence the round-2 rewrite left in the Dockerfile comment,
and the `web/src/api/*.guard.test.ts` glob, which over-claimed — it matches three files
and only two of them need git.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
A pin rejects any command it does not equal, so the remaining attack is to change what
the pinned text MEANS. Four such vectors, found by attacking the new mechanism rather
than reading it; the first was MEASURED escaping and the rest are the same class:
* A NEW stage taking the SPA source across with `COPY --from=web-build /source/web`
and running the suite there. `copies_spa_source` excluded every `--from=` copy on the
grounds that a stage copy is not a context copy — true, but it can still carry the
SOURCE TREE from a stage that has it. The receiving stage was therefore unpinned and
unchecked, which is exactly the false-NEGATIVE direction this file's own residual
warns about. A stage copy now counts when its SOURCE has a whole `web` path segment,
which keeps the built-artifact copy this repo actually makes
(`/source/ErsatzTV/wwwroot/app/.`) correctly out.
* `working-directory:` moved off `web` — `npm test` somewhere else runs a different
package, or none, with the pinned command text unchanged. Now pinned.
* `defaults.run.shell` changed from `bash` to `sh`. `bash` here is `bash -e`, which is
what makes a failing command fail the step; changing it changes whether a red suite
blocks the image without touching the step at all. Now pinned.
* A `SHELL` instruction in a pinned stage, which redefines what every later `RUN`
executes. Refused outright rather than modelled — there is none in this repo, so the
honest move is to reject the construct, not to reason about a replacement
interpreter.
Battery 41 -> 45, 0 missed; the stage-copy mutant reddens three assertions. The residual
list gains the two cases that remain in this class and are NOT covered: a stage copy that
RENAMES the tree on the way in (no `web` segment in its source), and an `ENV` altering
`PATH` so a pinned `RUN` resolves a different `npm`. Naming them is the point — the
previous rounds' residual lists read as exhaustive while omitting the largest holes.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
A fourth cold review attacked the pin itself. BLOCKER empty, the mechanism upheld, and
every finding was prose claiming more than I had measured — plus one one-token gap that
was live.
THE ABSOLUTES, refuted by execution and now corrected in all three places they appeared
(guard docstring, `docs/guard-inventory.md`, the decision record's `rule:`):
* "A pin cannot produce a false green." True only in the trivial reading. A pin is
immune to a different SPELLING of the command — the entire class that defeated the
parser nine times — and is NOT immune to the same text MEANING something else. Two
mutants re-armed ersatztv#887 through `web/package.json` alone: `RUN npm run build`
executes whatever that file says, so `"build": "vitest run && …"` puts the suite back
into the gitless stage with every pin still matching, and `"prepare"` does it via
`npm ci`. Now pinned: exactly one script may mention vitest, and its body is fixed.
* Residual (1), "a stage that does not carry the SPA source is unpinned — correct,
since without `web/` there is no suite there". False. The boundary is what
`copies_spa_source` RECOGNISES, which is narrower than "has the suite available".
Restated, with the case still outside it named: a stage copy that RENAMES the tree.
* The substring sweep's "never a false green". Its reported failures are false reds;
what it fails to REPORT is not. `SUITE_MENTIONS` is a hand-written SELECTOR — a third
category beside population and pin, and the worst-behaved, because a population going
short is caught by an equality and a stale pin reddens loudly, while a selector going
short is silent. It was short by exactly one entry: `npm t`, npm's own alias, which
this guard already names among the spellings that defeated the parser. A stage
running `npm t -- --run` escaped it. Fixed, and the category is now named.
ALSO CLOSED: `run: |` -> `run: >` folded the two-line body into one command whose
whitespace-normalised text was byte-identical to the pin, so the marker script swallowed
the suite as its arguments — the body is now compared LINE BY LINE, since a newline
separates two commands. A SECOND step named `Test SPA` inherited the exemption both the
pin lookup and the sweep key on; exactly one is now required. And `COPY web*/` — a glob
that matches `web/` — was read as not carrying the source, leaving the receiving stage
unpinned.
Battery 45 -> 51, 0 missed. The remaining meaning-change route, an `ENV` rewriting `PATH`
so a pinned `RUN` resolves a different `npm`, is not modelled and is recorded as a
residual rather than implied away.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
The previous commit said "five such routes" in three places. Checking rather than
restating found six, and the sixth is one this guard must NOT close itself: the gating
job runs in a `container:`, whose image decides which `npm` exists at all. That is
already pinned by `test_ci_image_pin_population.py`, so it is CITED — two guards on one
condition mask each other (ersatztv#685), and the way to find that out is to delete one
and look for a red, which nobody does.
Also measured rather than assumed: an INDIRECT script chain (`"test": "npm run inner"`
with `inner` running vitest) needs no clause of its own. The set-equality against
`PINNED_VITEST_SCRIPTS` reddens on it, because `inner` mentions vitest and `test` no
longer does — verified across four scenarios, three red and one green.
An enumeration is a claim like any other. This one was written from memory of what had
been fixed rather than from the code, and it was short by one.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
Round 5 found a BLOCKER, and it is the sharpest kind: I made the exact mistake I had
described one screen earlier. `test_only_the_PINNED_npm_SCRIPTS_run_vitest` SELECTED the
scripts to pin by asking whether their body contained the literal `vitest` — a selector,
the category this file calls the worst-behaved because going short is silent — and then
its docstring claimed "going short is caught by the equality below", which is false: a
script the substring misses is absent from the compared map, so the equality still holds.
Four one-line `web/package.json` edits, none of which spells `vitest`, each put the suite
back into the gitless stage with the whole guard green: `"build": "npm run test -- --run
&& …"`, the same via `npm t`, and the `prebuild` / `preinstall` LIFECYCLE HOOKS, which
npm runs for `npm run build` and `npm ci` without anything naming them. That is #883
verbatim, through the route round 4 identified and the previous commit reported closed.
The fix is the one the file's own vocabulary prescribes: pin the WHOLE script map. A
script that does not exist cannot be a lifecycle hook, and one that changes is not equal.
The category disappears rather than being widened by two entries.
ALSO CLOSED, all measured:
* `web/vite.config.ts`'s `test:` block is now pinned. `npm test -- --run` collects what
that file says, so `test.exclude` is where a filter would now naturally be written —
it is the only place left after this change removed the Dockerfile's. Three mutants
narrowed the gating suite through it with the step's own command unchanged.
* A step-level `shell:` and a job-level `defaults:` each override the pinned workflow
default. Both forbidden.
* `test_no_run_BODY_builds_or_pushes_an_image` is RESTORED — I dropped it in the parser
withdrawal, and a job publishing via `run: docker build … && docker push …` was then
outside the action-derived population with anti-vacuity none the wiser.
* A leading-slash context copy (`COPY /web/. ./web/`) was not recognised.
* The sweep gains `yarn test`, `pnpm test`, `bun test`.
The residual naming the uncovered COPY shapes was wrong for the SECOND consecutive round —
it named `COPY --from=X /source/web /elsewhere`, which is covered (only the destination is
renamed). The real gaps are an ANCESTOR source (`/source` brings `/source/web` along) and
`/source/.`. Both measured.
Route count: five, then six, now seven. It has been wrong at every count, so it is now
stated as a running total with that history attached rather than as an enumeration.
Battery 51 -> 60, 0 missed.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
Pinning `web/vite.config.ts`'s `test:` block is worthless while a file that takes
precedence over it can simply be added. Vitest resolves `vitest.config.*` (and
`vitest.workspace.*` / `vitest.projects.*`) BEFORE `vite.config.*`.
MEASURED, not read: dropping a `web/vitest.config.ts` carrying
`include: ['nope/**'], passWithNoTests: true` beside the pinned file made `npx vitest run`
report "No test files found, exiting with code 0". The gating step would be green having
run NOTHING — worse than the filtered run ersatztv#887 removed, because a filtered suite
at least reports on what it ran.
The construct is refused rather than modelled: no such file exists, so the guard asserts
none appears. Its population is the git INDEX, which is right and worth stating — an
untracked config does not exist in a CI checkout either, so the mutant proving this has
to STAGE the file. It failed to redden until it did, which is the correct behaviour
demonstrating itself.
Route count five -> six -> seven -> eight, wrong at every previous count, so it stays a
running total with its history attached. Battery 60 -> 61, 0 missed.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
Round 6 found three more false greens and named the class they share, which is worth
more than any of the three fixes:
* `web/vitest.config.ts` OUTRANKS the pinned `vite.config.ts` — closed in the previous
commit, found by probing vitest rather than reading about it.
* A DECOY first `test: {` block. The comparison took `text.index("test: {")`, so a copy
of the pin placed above `defineConfig` satisfied it while the real block was narrowed.
Exactly one is now required — the same assertion this file already made about the
gating step's NAME, for the same reason, not carried across.
* A `needs:` edge matched by bare job id. `needs:` resolves within its own workflow, so
a SECOND workflow publishing this Dockerfile while needing its own unrelated job
called `test` satisfied it. Now bound to `GATING_WORKFLOW`. (The reviewer downgraded
this to MEDIUM on measuring that `test_remote_state_inventory.py` forces a human to
classify any new workflow — so the hole is "the guard is blind", not "silent". The
forced review asks about remote state, not about whether the image is gated, so the
one-line fix stands.)
* A vite PLUGIN can shell out to the suite from `buildStart()`. The plugin ARRAY is
pinned; the plugin BODIES are a stated residual, mitigated because
`trackedSourceFilesPlugin` is deliberately lazy — a fact its own comment now marks as
LOAD-BEARING for the image build rather than leaving as an optimisation note.
THE CLASS: **a pin assumes it is pinning the artifact that still decides.** Every route
found so far is authority moving where the pin is not looking — to another FILE, another
OCCURRENCE in the same file, another WORKFLOW, or a HOOK the pinned command invokes. That
question is now written down for the next person adding a pin, because a list of four
instances is not what generalises.
Prose, all refuted by execution: the residual naming the uncovered COPY shapes was wrong a
THIRD time at the same site (`/source/web /elsewhere` IS recognised — only the destination
is renamed — and the file's own test 700 lines below said so); "only an `ENV` is
unmodelled" was an absolute and is now a list; "Reach: N mutants, 0 missed" is restated as
a DEVELOPMENT BATTERY, since it is not in the repo, nothing re-derives it, and an
independent battery found misses against an earlier head; and `PUBLISH_ACTION` was claimed
covered by anti-vacuity, which proves the selector is non-empty and cannot prove it
complete.
Battery 61 -> 64, 0 missed.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
Hunting a fifth instance of "the pin assumes it is pinning the thing that still decides"
turned up two candidates that look like routes and are not, both measured rather than
argued:
* `setupFiles` is pinned by NAME while its CONTENT is not, which reads like the
package.json hole one level down. It is fail-NOISY: `process.exit(0)` at the top of
`src/setupTests.ts` makes vitest report `121 failed (121)`, not a green.
* `tsconfig*.json` shapes what `tsc -b` compiles, not what vitest collects.
Recorded because a reader who spots either will otherwise spend the same probe to reach
the same answer — and because the honest residual beside them is the one that IS open: a
dependency's own install script, reached through `npm ci` and `web/package-lock.json`.
That is a supply-chain concern wider than this guard, and it is named rather than claimed
covered.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
Round 7. BLOCKER empty; three real holes, two of them introduced by round six's own
fixes, which is this repo's recorded `each-fix-round-introduces-an-adjacent-defect`
happening inside a single commit.
**`web/vite.config.js` outranks the pinned `.ts` for `vite build`.** Read from the pinned
tarball rather than remembered: vite 8.1.3's `DEFAULT_CONFIG_FILES` is
`["vite.config.js", "vite.config.mjs", "vite.config.ts", …]`. So a `vite.config.js` whose
plugin shells out to the suite runs it in the gitless stage — measured with all 1411
tests green. The governing rule was already written down one family over ("pinning one
config is worthless while a second can outrank it") and had been applied to
`vitest.config.*` and not to this one. The refusal now covers both families.
**The decoy fix counted one SPELLING of the marker.** `text.count("test: {")` is defeated
by writing the real block `test: {` with two spaces beside a decoy that matches exactly:
count is 1, the comparison takes the decoy, and the live block filters out precisely the
specs #883 broke on. Now `re.finditer(r"\btest:\s*\{")`.
**The plugin pin added in that same commit shipped the identical decoy hole** it was
written next door to fix — a raw `text.count(PINNED_VITE_PLUGINS) == 1` with a decoy
above `defineConfig`. Both `vite.config.ts` pins now share ONE bracket walk and ONE
whitespace-tolerant uniqueness rule, so they cannot drift apart again.
PROSE, and this one is a false completion claim in my own previous commit message: I said
the `PUBLISH_ACTION`/anti-vacuity sentence and the singular "only an `ENV`" residual were
corrected. They were — in the record and the inventory row, and NOT in the guard
docstring, which is the artifact a code reader hits first. Both are now fixed there too,
the route COUNT is removed from the docstring and the record and kept in ONE place, and
the residual that stated its own false version before retracting it now states the
boundary once.
Also: the `--from=` branch never reached the JSON exec-form parser, so
`COPY --from=web-build ["/source/web", "/dest"]` left the receiving stage unpinned; the
revalidate arm of the gating `if:` is now described as a DEPENDENCY on
`ci-detect-already-validated.sh` (graded `MUTATION: NONE`) rather than as something
asserted here, since only the `docs_only` arm is; and the plugin-bodies residual now says
there are TWO plugins, `react()`'s being third-party and unmitigated.
Battery 64 -> 68, 0 missed. One of those four exists because the battery itself briefly
reported NOTHING and exited 0 after a bad splice deleted its `main()` — it now carries an
anti-vacuity assert on its own mutant count.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
It counts brackets without understanding string literals, so a `]` inside one ends the
span early. The direction is what matters and it was measured: the truncated span does
not equal the pin, so the outcome is a false RED, never a false green. Stated rather than
fixed — parsing TypeScript to do better is exactly the predicate this file withdrew.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
Round 8. Three consecutive rounds had each closed one SPELLING of the same marker match,
which is `every-blocker-was-one-mechanism-so-delete-it` at the count where it says
withdraw. Round 6 pinned a block; round 7 fixed `test: {` (two spaces); round 8 defeated
the repaired matcher four more ways — `test : {`, `"test": {`, and the same two for
`plugins:` — plus two that never touched the marker at all:
plugins: [react(), trackedSourceFilesPlugin()].concat([evil])
test: { …pinned… }, ...moreTest
`defineConfig` is the identity function in BOTH vite and vitest (read from the installed
tree), so a spread AFTER the pinned span simply replaces what the pin matched. No
respelling of the marker could ever have caught those: the defect was partial matching,
not the pattern.
So the file is pinned whole. 48 lines, nothing generates it, no marker to respell and
nothing after the span. One assertion replaces a bracket walk, a block extractor and two
uniqueness assertions — and catches all seven measured routes. Stated cost, which is the
same one every other pin here carries: any edit to that file reddens, a comment included.
This also retires a claim I made in a commit message AND in the inventory row: that the
two pins "share one bracket walk and cannot drift apart again". It was false when
written — the block extractor had its own inline copy and never called the shared helper.
Verified by spying on the call: the `test:` pin made zero calls to it.
Also closed: the second exec-form branch in `copies_spa_source` was DEAD code (measured by
poisoning it with a `raise` — every test stayed green), since the round-7 reorder rewrites
`remainder` before it; the exec-form `--from` case that reorder was written for had no
STANDING test, only the development battery, and now has three; and
`OUTRANKING_CONFIG_PREFIXES` matched nested paths, so a `web/e2e/vite.config.ts` — and
`web/e2e/` is a real directory — would have reddened with a message asserting it outranks
the pinned config, which is false. Direct children of `web/` only.
Battery 68 -> 73, 0 missed.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
`_normalise_lines` drops blank lines and collapses whitespace WITHIN a line, so a reflow,
an indentation change, and a change to the spacing inside a STRING LITERAL are invisible.
The first two carry no meaning; the third could, and does not here. Line order and any
token change are caught. All four measured.
Stated because the phrase 'pinned whole' invites a reader to assume byte equality, and a
reader who assumes that will not check the one case where it matters.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
Round 9 returned MERGEABLE with BLOCKER and HIGH empty. Every remaining item was a
sentence, and every one erred by UNDERSTATING the guard — which is the safe direction and
still worth fixing, because the decision record is what CLAUDE.md routes convention
lookups to.
The record's `rule:` still listed "`web/vite.config.ts`'s `test:` block" among the pinned
things — the very mechanism the previous commit withdrew — and named only `vitest.config.*`
as the outranking family, omitting `vite.config.js`/`.mjs`, which is the MEASURED attack
from round 7 (a `web/vite.config.js` ran the suite in the gitless stage with 1411 tests
green). That family went short in round 7 and again in round 8. This is
`enumerate-CLAUSES-to-close-a-sweep`: the survivors were phrased in a different category
(WHAT is pinned) from the retracted claim (HOW it is extracted), so sweeping for the
retracted words missed them.
Also: "any edit to this file reddens, including a comment" was an absolute and is
refutable — a reindent, added blank lines, tabs, and a form feed all stay green, because
`_normalise_lines` collapses whitespace. Restated as what is actually true (a line's TOKEN
sequence, a comment's words included) plus the reason the tolerance is currently inert:
this file has no template literal and no ASI-sensitive token outside a comment. And a YAML
single-quote escape had leaked from the frontmatter into the markdown BODY, where `''`
renders literally.
No code change; the guard is unchanged and still 73/0.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
Nine independent cold-review rounds (eight BLOCKED, round 9 MERGEABLE with BLOCKER and HIGH empty). Every measured false green closed and re-verified; 73-mutant development battery, 0 missed; one declared clause mutation harness-executed per suite. Local gate green: scripts/tests 1410 passed/2 skipped, web lint+typecheck+1319 tests, ruff, catalog --check.
Review-verdict: MERGEABLE @ 9da0020
Nine independent cold-review rounds (eight BLOCKED, round 9 MERGEABLE with BLOCKER and HIGH empty). Every measured false green closed and re-verified; 73-mutant development battery, 0 missed; one declared clause mutation harness-executed per suite. Local gate green: scripts/tests 1410 passed/2 skipped, web lint+typecheck+1319 tests, ruff, catalog --check.
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.
fixes #887
What was broken
docker/Dockerfile'sweb-buildstage ran the SPA vitest suite with two hand-written--excludedspec paths. That stage is gitless twice over — it copies only
web/+design-system/, so it holdsno
.git, andnode:22-bookworm-slimships no git binary — while the suite has members needing oneor the other. So the exclusions were a population nothing derives: a hand-maintained list of "the
specs that cannot run here", beside a suite that grows.
#883 added a third such spec without updating the list. Because
Build & push image (amd64)carriesif: github.event_name != 'pull_request', the resulting red is structurally unreachable on a PR — itappears only on pushes to
mainand on thev*tag path. Every image build has failed since,:latesthas stopped being republished, and a release cut would fail at the image build.What this does
Removes the list rather than extending it. The stage now runs
npm run lint && npm run typecheck && npm run build— it builds the SPA and does not test it. Thesuite runs once, unfiltered, in
docker-build.yml'stestjob on a real checkout, andbuildcarries
needs: [test, migrations, scan].Adding a third
--excludewould have re-armed the same trap for the next guard, which is what theissue asked not to do.
The guard, and why it looks the way it does
scripts/tests/test_image_build_delegates_the_spa_suite.pyholds three parts — the third existsbecause the first two together still certify a publish on which the suite never ran:
needs:the jobholding the pinned gating step, in that workflow.
run:body,if:andworking-directoryare pinned; it and its jobcarry no
continue-on-errorin any spelling and no job-levelif:; the publish step keeps its owndocs_onlygate; andweb/package.json's script map andweb/vite.config.ts'stest:block arepinned, because a pinned
RUN npm …executes whatever those say.The mechanism was replaced once, and that is the part worth reading. Three earlier versions
PARSED shell text to decide "does this run the suite, and can its failure be swallowed?". That
predicate was wrong nine times across three review rounds — executed heredocs treated as data,
#truncating a command mid-word, compound punctuation welding commands,
npm t/./node_modules/.bin/vitest/
timeout/su -cunrecognised,true || npm testcounted as a run it never performs — and twicea clause added to remove a false red opened a false green. It was withdrawn. Nothing decides what
a command means any more; the risky command lines are compared as TEXT against a pin, and no spelling
has to be recognised in order to be rejected.
The taxonomy that came out of it is in
docs/guard-inventory.md: a population decides what isCHECKED, so a hand-written one goes silently short; a pin decides what is EXPECTED, so a stale one
goes loudly red; a selector decides which members to pin, and going short there is silent — the
worst of the three, and the one that produced this PR's last blocker.
Verification
development battery deliberately: it is not in the repo and nothing re-derives it.
scripts/tests/mutation_manifest.py, harness-executed everysuite — that is the standing proof; the other 63 were witnessed during development and are not.
mutant-verified; findings that were overclaiming prose are corrected in place. Every reviewer
independently confirmed the CI path itself: no route publishes an image on which the suite has not
run.
v*tag,ci-detect-docs-only.shemitsdocs_only=falseandci-detect-already-validated.shemitsskip=false, sotestruns the fullsuite before
build. The release path is strictly better off: it no longer fails at the imagebuild, and the suite still gates it.
scripts/tests(1411 passed / 2 skipped),npm run lint,npm run typecheck,npm test -- --run(121 files / 1319 tests), ruff check + format, catalog--check.workflow_dispatchrun ofdocker-build.ymlon this branch, wherebuildruns (gated only on
!= 'pull_request') but publishes nothing (push:is gated onmain/v*).Docs
New decision record
ci.image-build-delegates-the-spa-suite(catalog regenerated), plus thestale-passage sweep:
docs/testing.md'sweb/row,docs/guard-inventory.md's note on therealgitsplit, and the module comment in
web/vite-plugins/trackedSourceFiles.realgit.test.tsall describedthe two
--excludes as current.The record enumerates what removing the in-image run gives up rather than waving it through — the one
real loss is running the suite under
node:22-bookworm-slimspecifically, which is small because theartifact the image ships is
vite build's output and that still runs there.One correction worth flagging, since it appeared in five places including a mutation
expectstring: the original claim "the build context is
web/+design-system/, so there is no.git" isfalse. The context is the repository root (
context: .) and.dockerignoredoes not exclude.git.The true statement is about the STAGE. The conclusion survives — bookworm-slim has no git binary
either — but a reader who checked would have found
.gitin the context and concluded the note wasstale.
Round 4, and the third cold review found the same mechanism failing again, so it is removed rather than patched a tenth time. WHAT KEPT BREAKING. Three versions of this guard asked "does this command RUN the suite, and can its failure be swallowed?" of arbitrary shell text. That predicate was wrong NINE times across three review rounds, and twice a clause added to remove a FALSE RED opened a FALSE GREEN on the guard's headline assertion: * heredoc bodies were skipped as data, but BuildKit EXECUTES `RUN <<EOF` — and the opener regex also fired inside quotes (`echo "tags<<__EOT__"`), which blinded the whole-file scan over the last 303 lines of docker-build.yml. Wrong in both directions at once, and measurably live on this tree. * `shlex.shlex` does not clear `commenters` the way `shlex.split` does, so `#` truncated a command mid-word — including the live `${#reports[@]}` idiom — and made this file's own stated residual false. * compound punctuation (`);`) welded two commands into one segment. * `npm t`, `./node_modules/.bin/vitest`, `pnpm vitest`, `yarn vitest`, `node …/vitest.mjs`, `timeout …`, `su -c …`, `if npm test; then` — all invisible. * `true || npm test` counted as the gating run while never executing it. * `continue-on-error: ${{ … }}` passed a check written against two literals — a presence test that cannot see polarity, fail-OPEN in the one direction that matters. WHAT REPLACES IT. Nothing in the file decides what a command means any more. The commands that may run in the two risky places are PINNED as text: the `RUN` lines of every SPA-carrying Dockerfile stage, and the gating step's `run:` body and `if:`. A suite run re-added in ANY spelling is simply not equal to its pin — the pin does not have to recognise a spelling in order to reject it. A pin cannot produce a false green, only a false red, and a false red is a human reading a diff they should have read anyway. The population/pin split is the load-bearing distinction, and it is now stated in the inventory: a POPULATION decides what is CHECKED, so a hand-written one goes silently short; a PIN decides what is EXPECTED, so a stale one goes loudly red. Only the second is safe to write by hand. Populations stay derived from the git index. Two premises that were prose are now assertions: the publish step keeps its own `docs_only` gate (without it, a docs-only push skips the suite and publishes anyway), and no step other than the pinned one mentions the suite — a SUBSTRING sweep, deliberately not a predicate, whose failure mode is a false red asking someone to look. 41 mutants, 0 missed, including all nine spellings above and the three from the previous round. Exactly ONE is declared in `mutation_manifest.py` and re-executed every suite; the other 40 were witnessed during development and are NOT standing — stated in the row rather than left to be assumed. Also fixed: the truncated sentence the round-2 rewrite left in the Dockerfile comment, and the `web/src/api/*.guard.test.ts` glob, which over-claimed — it matches three files and only two of them need git. Refs: #887 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkFA pin rejects any command it does not equal, so the remaining attack is to change what the pinned text MEANS. Four such vectors, found by attacking the new mechanism rather than reading it; the first was MEASURED escaping and the rest are the same class: * A NEW stage taking the SPA source across with `COPY --from=web-build /source/web` and running the suite there. `copies_spa_source` excluded every `--from=` copy on the grounds that a stage copy is not a context copy — true, but it can still carry the SOURCE TREE from a stage that has it. The receiving stage was therefore unpinned and unchecked, which is exactly the false-NEGATIVE direction this file's own residual warns about. A stage copy now counts when its SOURCE has a whole `web` path segment, which keeps the built-artifact copy this repo actually makes (`/source/ErsatzTV/wwwroot/app/.`) correctly out. * `working-directory:` moved off `web` — `npm test` somewhere else runs a different package, or none, with the pinned command text unchanged. Now pinned. * `defaults.run.shell` changed from `bash` to `sh`. `bash` here is `bash -e`, which is what makes a failing command fail the step; changing it changes whether a red suite blocks the image without touching the step at all. Now pinned. * A `SHELL` instruction in a pinned stage, which redefines what every later `RUN` executes. Refused outright rather than modelled — there is none in this repo, so the honest move is to reject the construct, not to reason about a replacement interpreter. Battery 41 -> 45, 0 missed; the stage-copy mutant reddens three assertions. The residual list gains the two cases that remain in this class and are NOT covered: a stage copy that RENAMES the tree on the way in (no `web` segment in its source), and an `ENV` altering `PATH` so a pinned `RUN` resolves a different `npm`. Naming them is the point — the previous rounds' residual lists read as exhaustive while omitting the largest holes. Refs: #887 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkFA fourth cold review attacked the pin itself. BLOCKER empty, the mechanism upheld, and every finding was prose claiming more than I had measured — plus one one-token gap that was live. THE ABSOLUTES, refuted by execution and now corrected in all three places they appeared (guard docstring, `docs/guard-inventory.md`, the decision record's `rule:`): * "A pin cannot produce a false green." True only in the trivial reading. A pin is immune to a different SPELLING of the command — the entire class that defeated the parser nine times — and is NOT immune to the same text MEANING something else. Two mutants re-armed ersatztv#887 through `web/package.json` alone: `RUN npm run build` executes whatever that file says, so `"build": "vitest run && …"` puts the suite back into the gitless stage with every pin still matching, and `"prepare"` does it via `npm ci`. Now pinned: exactly one script may mention vitest, and its body is fixed. * Residual (1), "a stage that does not carry the SPA source is unpinned — correct, since without `web/` there is no suite there". False. The boundary is what `copies_spa_source` RECOGNISES, which is narrower than "has the suite available". Restated, with the case still outside it named: a stage copy that RENAMES the tree. * The substring sweep's "never a false green". Its reported failures are false reds; what it fails to REPORT is not. `SUITE_MENTIONS` is a hand-written SELECTOR — a third category beside population and pin, and the worst-behaved, because a population going short is caught by an equality and a stale pin reddens loudly, while a selector going short is silent. It was short by exactly one entry: `npm t`, npm's own alias, which this guard already names among the spellings that defeated the parser. A stage running `npm t -- --run` escaped it. Fixed, and the category is now named. ALSO CLOSED: `run: |` -> `run: >` folded the two-line body into one command whose whitespace-normalised text was byte-identical to the pin, so the marker script swallowed the suite as its arguments — the body is now compared LINE BY LINE, since a newline separates two commands. A SECOND step named `Test SPA` inherited the exemption both the pin lookup and the sweep key on; exactly one is now required. And `COPY web*/` — a glob that matches `web/` — was read as not carrying the source, leaving the receiving stage unpinned. Battery 45 -> 51, 0 missed. The remaining meaning-change route, an `ENV` rewriting `PATH` so a pinned `RUN` resolves a different `npm`, is not modelled and is recorded as a residual rather than implied away. Refs: #887 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkFRound 6 found three more false greens and named the class they share, which is worth more than any of the three fixes: * `web/vitest.config.ts` OUTRANKS the pinned `vite.config.ts` — closed in the previous commit, found by probing vitest rather than reading about it. * A DECOY first `test: {` block. The comparison took `text.index("test: {")`, so a copy of the pin placed above `defineConfig` satisfied it while the real block was narrowed. Exactly one is now required — the same assertion this file already made about the gating step's NAME, for the same reason, not carried across. * A `needs:` edge matched by bare job id. `needs:` resolves within its own workflow, so a SECOND workflow publishing this Dockerfile while needing its own unrelated job called `test` satisfied it. Now bound to `GATING_WORKFLOW`. (The reviewer downgraded this to MEDIUM on measuring that `test_remote_state_inventory.py` forces a human to classify any new workflow — so the hole is "the guard is blind", not "silent". The forced review asks about remote state, not about whether the image is gated, so the one-line fix stands.) * A vite PLUGIN can shell out to the suite from `buildStart()`. The plugin ARRAY is pinned; the plugin BODIES are a stated residual, mitigated because `trackedSourceFilesPlugin` is deliberately lazy — a fact its own comment now marks as LOAD-BEARING for the image build rather than leaving as an optimisation note. THE CLASS: **a pin assumes it is pinning the artifact that still decides.** Every route found so far is authority moving where the pin is not looking — to another FILE, another OCCURRENCE in the same file, another WORKFLOW, or a HOOK the pinned command invokes. That question is now written down for the next person adding a pin, because a list of four instances is not what generalises. Prose, all refuted by execution: the residual naming the uncovered COPY shapes was wrong a THIRD time at the same site (`/source/web /elsewhere` IS recognised — only the destination is renamed — and the file's own test 700 lines below said so); "only an `ENV` is unmodelled" was an absolute and is now a list; "Reach: N mutants, 0 missed" is restated as a DEVELOPMENT BATTERY, since it is not in the repo, nothing re-derives it, and an independent battery found misses against an earlier head; and `PUBLISH_ACTION` was claimed covered by anti-vacuity, which proves the selector is non-empty and cannot prove it complete. Battery 61 -> 64, 0 missed. Refs: #887 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkFHunting a fifth instance of "the pin assumes it is pinning the thing that still decides" turned up two candidates that look like routes and are not, both measured rather than argued: * `setupFiles` is pinned by NAME while its CONTENT is not, which reads like the package.json hole one level down. It is fail-NOISY: `process.exit(0)` at the top of `src/setupTests.ts` makes vitest report `121 failed (121)`, not a green. * `tsconfig*.json` shapes what `tsc -b` compiles, not what vitest collects. Recorded because a reader who spots either will otherwise spend the same probe to reach the same answer — and because the honest residual beside them is the one that IS open: a dependency's own install script, reached through `npm ci` and `web/package-lock.json`. That is a supply-chain concern wider than this guard, and it is named rather than claimed covered. Refs: #887 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkFRound 7. BLOCKER empty; three real holes, two of them introduced by round six's own fixes, which is this repo's recorded `each-fix-round-introduces-an-adjacent-defect` happening inside a single commit. **`web/vite.config.js` outranks the pinned `.ts` for `vite build`.** Read from the pinned tarball rather than remembered: vite 8.1.3's `DEFAULT_CONFIG_FILES` is `["vite.config.js", "vite.config.mjs", "vite.config.ts", …]`. So a `vite.config.js` whose plugin shells out to the suite runs it in the gitless stage — measured with all 1411 tests green. The governing rule was already written down one family over ("pinning one config is worthless while a second can outrank it") and had been applied to `vitest.config.*` and not to this one. The refusal now covers both families. **The decoy fix counted one SPELLING of the marker.** `text.count("test: {")` is defeated by writing the real block `test: {` with two spaces beside a decoy that matches exactly: count is 1, the comparison takes the decoy, and the live block filters out precisely the specs #883 broke on. Now `re.finditer(r"\btest:\s*\{")`. **The plugin pin added in that same commit shipped the identical decoy hole** it was written next door to fix — a raw `text.count(PINNED_VITE_PLUGINS) == 1` with a decoy above `defineConfig`. Both `vite.config.ts` pins now share ONE bracket walk and ONE whitespace-tolerant uniqueness rule, so they cannot drift apart again. PROSE, and this one is a false completion claim in my own previous commit message: I said the `PUBLISH_ACTION`/anti-vacuity sentence and the singular "only an `ENV`" residual were corrected. They were — in the record and the inventory row, and NOT in the guard docstring, which is the artifact a code reader hits first. Both are now fixed there too, the route COUNT is removed from the docstring and the record and kept in ONE place, and the residual that stated its own false version before retracting it now states the boundary once. Also: the `--from=` branch never reached the JSON exec-form parser, so `COPY --from=web-build ["/source/web", "/dest"]` left the receiving stage unpinned; the revalidate arm of the gating `if:` is now described as a DEPENDENCY on `ci-detect-already-validated.sh` (graded `MUTATION: NONE`) rather than as something asserted here, since only the `docs_only` arm is; and the plugin-bodies residual now says there are TWO plugins, `react()`'s being third-party and unmitigated. Battery 64 -> 68, 0 missed. One of those four exists because the battery itself briefly reported NOTHING and exited 0 after a bad splice deleted its `main()` — it now carries an anti-vacuity assert on its own mutant count. Refs: #887 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkFweb/vite.config.tsis pinned WHOLE 58def5faa1Round 8. Three consecutive rounds had each closed one SPELLING of the same marker match, which is `every-blocker-was-one-mechanism-so-delete-it` at the count where it says withdraw. Round 6 pinned a block; round 7 fixed `test: {` (two spaces); round 8 defeated the repaired matcher four more ways — `test : {`, `"test": {`, and the same two for `plugins:` — plus two that never touched the marker at all: plugins: [react(), trackedSourceFilesPlugin()].concat([evil]) test: { …pinned… }, ...moreTest `defineConfig` is the identity function in BOTH vite and vitest (read from the installed tree), so a spread AFTER the pinned span simply replaces what the pin matched. No respelling of the marker could ever have caught those: the defect was partial matching, not the pattern. So the file is pinned whole. 48 lines, nothing generates it, no marker to respell and nothing after the span. One assertion replaces a bracket walk, a block extractor and two uniqueness assertions — and catches all seven measured routes. Stated cost, which is the same one every other pin here carries: any edit to that file reddens, a comment included. This also retires a claim I made in a commit message AND in the inventory row: that the two pins "share one bracket walk and cannot drift apart again". It was false when written — the block extractor had its own inline copy and never called the shared helper. Verified by spying on the call: the `test:` pin made zero calls to it. Also closed: the second exec-form branch in `copies_spa_source` was DEAD code (measured by poisoning it with a `raise` — every test stayed green), since the round-7 reorder rewrites `remainder` before it; the exec-form `--from` case that reorder was written for had no STANDING test, only the development battery, and now has three; and `OUTRANKING_CONFIG_PREFIXES` matched nested paths, so a `web/e2e/vite.config.ts` — and `web/e2e/` is a real directory — would have reddened with a message asserting it outranks the pinned config, which is false. Direct children of `web/` only. Battery 68 -> 73, 0 missed. Refs: #887 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkFReview-verdict: MERGEABLE @
9da0020Nine independent cold-review rounds (eight BLOCKED, round 9 MERGEABLE with BLOCKER and HIGH empty). Every measured false green closed and re-verified; 73-mutant development battery, 0 missed; one declared clause mutation harness-executed per suite. Local gate green: scripts/tests 1410 passed/2 skipped, web lint+typecheck+1319 tests, ruff, catalog --check.