fix(887): the rule applied to one config family and not its sibling

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
This commit is contained in:
2026-08-30 15:34:31 +02:00
co-authored by Claude Opus 5
parent 06b8081664
commit 8141ac7807
5 changed files with 121 additions and 51 deletions
+4 -2
View File
@@ -32,8 +32,10 @@ WORKDIR /source/web
# checkout, and `build` — the job that invokes this Dockerfile — carries `needs: [test, migrations,
# scan]`, so an image cannot be published past a red suite. Two skips exist inside `test` and
# neither leaves an image unvalidated: `docs_only` also gates `Build and push`, so that arm ships
# nothing; the #420 revalidate skip fires only on a tree byte-identical to a head that already
# carried a green combined status, i.e. this exact source already passed. Held by
# nothing — and that arm IS asserted, by `test_the_DOCS_ONLY_arm_cannot_publish_an_image`. The #420
# revalidate skip fires only on a tree byte-identical to a head that already carried a green combined
# status; that arm is NOT asserted here — it rests on `scripts/ci-detect-already-validated.sh`, which
# carries no mutation proof of its own, so it is a dependency rather than a check. Held by
# `scripts/tests/test_image_build_delegates_the_spa_suite.py`. That guard PINS the command line
# below as text: any edit to it reddens, whatever the edit says. Update the pin in the same commit,
# and do not use it to re-add a suite run.
+1 -1
View File
@@ -54,7 +54,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
| `ci.gate-trigger-base-resolved` | The workflow that writes the branch-protection-required `review-verdict/h10` status triggers on `pull_request_target` with `branches: [main]`, never on plain `pull_request`. Gitea resolves a `pull_request` workflow DEFINITION from the PR's own head commit, so under that trigger a PR editing `.gitea/workflows/review-verdict.yml` ran its own rewritten copy and could post `h10=success` for itself; `pull_request_target` resolves the definition from the base instead. The `branches: [main]` filter is part of the rule, not a refinement of it: base resolution only relocates the rewrite from the head to the base, so without the filter a PR opened into an attacker-pushed base branch runs that branch's gate. `pull_request_target` is safe HERE only because this job never checks out or executes head-supplied code — it checks out `base.sha` and runs only that tree's scripts (`ci.shared-pr-file-enumeration`); reintroducing a head checkout under this trigger would be worse than the bug it fixed. This closes the rewrite route through THIS workflow and does NOT close the class. Gitea injects `GITEA_TOKEN` into EVERY job and it USED to be write-capable everywhere, so any ref-resolved workflow could forge `review-verdict/h10`; since #748 (2026-08-27) all six workflows here declare `permissions:` — five `code: read`, and `review-verdict.yml`'s gate job `code: write`, deliberately, because it IS the gate (the five rest on the TOP-LEVEL form, which is INFERRED rather than probed — the gate job's JOB-LEVEL form is the one measured; see `ci.actions-credential-scoping`). That narrows the INJECTED token and only that: deliberately NO "only X can post a status" summary is stated here, because any such summary generalizes from the injected token, which `permissions:` scopes, to every credential a job can reach, which it does not touch — `RENOVATE_TOKEN` is in the same secret store and is referenced by `renovate.yml`, one of the five holding `code: read`. `ci-image.yml` was the last holdout — its unfiltered `push:` made it the worst-placed of the six — and #744 closed that trigger route (`ci.toolchain-image-publish-is-a-dispatch`) while #748 scoped its token. So the route is NARROWED, not closed, and the SHAPE is open — a newly added workflow declaring write, or omitting a declaration and inheriting the owner default, is a route again, which is why `test_pr_changed_files.py` asserts every tracked workflow declares a block. And a collaborator's own API token still can, since branch protection binds the context and not its issuer. The credential half is now RESOLVED in `ci.actions-credential-scoping` (#697): CI's registry secret was the ADMIN account's basic auth and is now a PAT that cannot post a status, which removes the ADMIN escalation and that credential's route (an ALLOW-LISTED user credential's forgery carries a matching `creator` and is inherited as a verdict; an Actions job's carries `creator: null` and is re-derived, as since #742 is every account outside `H10_REVIEWERS` — but do NOT read that asymmetry as protection: re-derivation fires only on the trigger's `types`, and posting a status is not one of them, so a POST timed after the last PR event simply stands). It does not remove EVERY route: `RENOVATE_TOKEN` is a `write:repository` bot PAT in the same secret store, reachable by any PR-added workflow. The owner-level Restricted default (server-management#714) remains a separate OPEN lever — its effect on a declared `code: write` is unmeasured, and the version half of that condition has been satisfied since the 1.25.4 -> 1.27.1 upgrade on 2026-08-05, so it is not something to wait on a Gitea release for. A collaborator's own token remains unfixable; the exemption path has its own separate defects in #698. | 2026-07-28 | [link](records/ci/gate-trigger-base-resolved.md) |
| `ci.gitea-milestone-filter-noop` | Never filter issues with the server-side `?milestones=<name>` parameter — fetch all open issues once and filter LOCALLY on each issue's `.milestone.title`. | 2026-07-21 | [link](records/ci/gitea-milestone-filter-noop.md) |
| `ci.grep-q-pipefail-inversion` | In any script running under `set -o pipefail`, a security or classification predicate of the form `producer \| grep -q…` is FORBIDDEN: `grep -q` exits at its first match, the producer then takes SIGPIPE and exits 141 once the data exceeds the pipe buffer (~64K), so `pipefail` reports the pipeline as FAILED even though grep MATCHED — inverting the predicate exactly when the input is large. A here-string (`grep -q… <<< "$data"`) is ALSO forbidden: bash materialises a large here-string via temporary storage, so it fails when temp space is full or unwritable, and inside an `if`/`!` that failure flips the predicate the same way. COUNT instead — `n=$(printf '%s\n' "$data" \| grep -cE "$re")` — because `grep -c` drains stdin (no early exit, no SIGPIPE) over an ordinary pipe (no temp file). Read grep's status honestly: exit 1 means a zero count and is a legitimate answer, anything >1 is a real error. Evaluate the counts ONCE at TOP LEVEL, never inline inside an `if`/`elif` condition: inside `$( )` an `exit` leaves only the subshell and `set -e` does not fire, so an error silently reads as "no match". Validate that each result is numeric and fail closed if not. This applies to both the enforced gate `.gitea/workflows/review-verdict.yml` and the advisory hook `.claude/hooks/pretooluse-merge-consent.sh`. | 2026-07-29 | [link](records/ci/grep-q-pipefail-inversion.md) |
| `ci.image-build-delegates-the-spa-suite` | `docker/Dockerfile` runs no vitest suite. Its web-build stage lints, typechecks and BUILDS the SPA; the suite runs once, unfiltered, in `docker-build.yml`'s `test` job on a real checkout, and `build` carries `needs: [test, migrations, scan]` so no image is published past a red suite. THAT EDGE IS NOW THE ONLY LAYER, so the guard checks it is real and not merely present — and it does so by PINNING TEXT rather than parsing it. The commands each SPA-carrying Dockerfile stage runs, and the gating step's `run:` body and `if:`, are compared as strings against a declared pin; the step and its job must carry no `continue-on-error` in any spelling, the job no job-level `if:`, and the publish step must keep its own `docs_only` gate. A guard that asks what a command MEANS was wrong nine times in three review rounds. A pin is immune to a different SPELLING of the command, which is that whole class; it is not immune to the same text meaning something else, so the routes to that are pinned or refused too: `working-directory` and the step's own `shell:`, the workflow `defaults.run.shell` and the job-level `defaults` overriding it, a stage `SHELL`, `web/package.json`'s script map (pinned WHOLE — selecting on the literal `vitest` missed `npm run test`, `npm t` and the `prebuild`/`preinstall` lifecycle hooks), `web/vite.config.ts`'s `test:` block, which decides what the suite collects, and the ABSENCE of any `vitest.config.*` — vitest resolves its own config in preference to `vite.config.*`, so pinning one file is worthless while a second can outrank it. The gating job's `container:` image is left to `ci.image-pin-population` rather than guarded twice. What is unmodelled is a LIST, not an "only": an `ENV` changing what a pinned `RUN` resolves, the plugin BODIES, and a publish through another action. ELEVEN routes is a running total, not a proof — written as five, six, seven and eight, each wrong when checked. They share one shape, which is the transferable part: A PIN ASSUMES IT IS PINNING THE ARTIFACT THAT STILL DECIDES, and every route found so far is authority moving to another file, another occurrence, another workflow, or a hook the pinned command invokes. Do NOT restore a filtered in-image run by naming the specs that cannot run there: that list is a population nothing derives, and the red it produces when it goes stale is unreachable on a PR — `Build & push image (amd64)` is `if: github.event_name != 'pull_request'` — so it lands on `main` and on the `v*` tag path, where it fails the release cut. Held in both directions by `scripts/tests/test_image_build_delegates_the_spa_suite.py`. | 2026-08-30 | [link](records/ci/image-build-delegates-the-spa-suite.md) |
| `ci.image-build-delegates-the-spa-suite` | `docker/Dockerfile` runs no vitest suite. Its web-build stage lints, typechecks and BUILDS the SPA; the suite runs once, unfiltered, in `docker-build.yml`'s `test` job on a real checkout, and `build` carries `needs: [test, migrations, scan]` so no image is published past a red suite. THAT EDGE IS NOW THE ONLY LAYER, so the guard checks it is real and not merely present — and it does so by PINNING TEXT rather than parsing it. The commands each SPA-carrying Dockerfile stage runs, and the gating step's `run:` body and `if:`, are compared as strings against a declared pin; the step and its job must carry no `continue-on-error` in any spelling, the job no job-level `if:`, and the publish step must keep its own `docs_only` gate. A guard that asks what a command MEANS was wrong nine times in three review rounds. A pin is immune to a different SPELLING of the command, which is that whole class; it is not immune to the same text meaning something else, so the routes to that are pinned or refused too: `working-directory` and the step's own `shell:`, the workflow `defaults.run.shell` and the job-level `defaults` overriding it, a stage `SHELL`, `web/package.json`'s script map (pinned WHOLE — selecting on the literal `vitest` missed `npm run test`, `npm t` and the `prebuild`/`preinstall` lifecycle hooks), `web/vite.config.ts`'s `test:` block, which decides what the suite collects, and the ABSENCE of any `vitest.config.*` — vitest resolves its own config in preference to `vite.config.*`, so pinning one file is worthless while a second can outrank it. The gating job's `container:` image is left to `ci.image-pin-population` rather than guarded twice. What is unmodelled is a LIST, not an "only": an `ENV` changing what a pinned `RUN` resolves, the plugin BODIES, and a publish through another action. The count of such routes lives in the `docs/guard-inventory.md` row and nowhere else — it briefly existed in three places with two different values, which is how a number stays wrong. It is a running total, wrong at every count so far. They share one shape, which is the transferable part: A PIN ASSUMES IT IS PINNING THE ARTIFACT THAT STILL DECIDES, and every route found so far is authority moving to another file, another occurrence, another workflow, or a hook the pinned command invokes. Do NOT restore a filtered in-image run by naming the specs that cannot run there: that list is a population nothing derives, and the red it produces when it goes stale is unreachable on a PR — `Build & push image (amd64)` is `if: github.event_name != 'pull_request'` — so it lands on `main` and on the `v*` tag path, where it fails the release cut. Held in both directions by `scripts/tests/test_image_build_delegates_the_spa_suite.py`. | 2026-08-30 | [link](records/ci/image-build-delegates-the-spa-suite.md) |
| `ci.infra-shaped-red-under-load` | When a job dies inside a setup/cache step before your code compiles, check the runner host's load before diagnosing the diff, and never file a CI bug off one sample under pressure. | 2026-07-21 | [link](records/ci/infra-shaped-red-under-load.md) |
| `ci.jq-version-contract` | Every shell gate that shells out to `jq` is authored to the jq 1.6-compatible subset, because the CI runner ships jq 1.6 while every developer Mac ships 1.8.x. `scripts/jq-preflight.sh` (no args) prints the parsed version and asserts a floor of 1.6 in every gate job's log; `scripts/jq-preflight.sh --expect 1.6` additionally pins and fails loudly, but ONLY in the `script-tests` job. `review-verdict.yml` never pins — it writes the branch-protection-required `review-verdict/h10` status, so a hard pin there would turn any jq bump into a repo-wide merge deadlock. | 2026-07-26 | [link](records/ci/jq-version-contract.md) |
| `ci.killed-job-triage` | Never trust a job's `conclusion` field alone — read the log tail and require an `❌ Failure - Main …` marker before treating a red as a real failure. | 2026-07-21 | [link](records/ci/killed-job-triage.md) |
@@ -5,9 +5,9 @@ status: active
since: '2026-08-30'
supersedes: none
superseded-by: none
rule: '`docker/Dockerfile` runs no vitest suite. Its web-build stage lints, typechecks and BUILDS the SPA; the suite runs once, unfiltered, in `docker-build.yml`''s `test` job on a real checkout, and `build` carries `needs: [test, migrations, scan]` so no image is published past a red suite. THAT EDGE IS NOW THE ONLY LAYER, so the guard checks it is real and not merely present — and it does so by PINNING TEXT rather than parsing it. The commands each SPA-carrying Dockerfile stage runs, and the gating step''s `run:` body and `if:`, are compared as strings against a declared pin; the step and its job must carry no `continue-on-error` in any spelling, the job no job-level `if:`, and the publish step must keep its own `docs_only` gate. A guard that asks what a command MEANS was wrong nine times in three review rounds. A pin is immune to a different SPELLING of the command, which is that whole class; it is not immune to the same text meaning something else, so the routes to that are pinned or refused too: `working-directory` and the step''s own `shell:`, the workflow `defaults.run.shell` and the job-level `defaults` overriding it, a stage `SHELL`, `web/package.json`''s script map (pinned WHOLE — selecting on the literal `vitest` missed `npm run test`, `npm t` and the `prebuild`/`preinstall` lifecycle hooks), `web/vite.config.ts`''s `test:` block, which decides what the suite collects, and the ABSENCE of any `vitest.config.*` — vitest resolves its own config in preference to `vite.config.*`, so pinning one file is worthless while a second can outrank it. The gating job''s `container:` image is left to `ci.image-pin-population` rather than guarded twice. What is unmodelled is a LIST, not an "only": an `ENV` changing what a pinned `RUN` resolves, the plugin BODIES, and a publish through another action. ELEVEN routes is a running total, not a proof — written as five, six, seven and eight, each wrong when checked. They share one shape, which is the transferable part: A PIN ASSUMES IT IS PINNING THE ARTIFACT THAT STILL DECIDES, and every route found so far is authority moving to another file, another occurrence, another workflow, or a hook the pinned command invokes. Do NOT restore a filtered in-image run by naming the specs that cannot run there: that list is a population nothing derives, and the red it produces when it goes stale is unreachable on a PR — `Build & push image (amd64)` is `if: github.event_name != ''pull_request''` — so it lands on `main` and on the `v*` tag path, where it fails the release cut. Held in both directions by `scripts/tests/test_image_build_delegates_the_spa_suite.py`.'
rule: '`docker/Dockerfile` runs no vitest suite. Its web-build stage lints, typechecks and BUILDS the SPA; the suite runs once, unfiltered, in `docker-build.yml`''s `test` job on a real checkout, and `build` carries `needs: [test, migrations, scan]` so no image is published past a red suite. THAT EDGE IS NOW THE ONLY LAYER, so the guard checks it is real and not merely present — and it does so by PINNING TEXT rather than parsing it. The commands each SPA-carrying Dockerfile stage runs, and the gating step''s `run:` body and `if:`, are compared as strings against a declared pin; the step and its job must carry no `continue-on-error` in any spelling, the job no job-level `if:`, and the publish step must keep its own `docs_only` gate. A guard that asks what a command MEANS was wrong nine times in three review rounds. A pin is immune to a different SPELLING of the command, which is that whole class; it is not immune to the same text meaning something else, so the routes to that are pinned or refused too: `working-directory` and the step''s own `shell:`, the workflow `defaults.run.shell` and the job-level `defaults` overriding it, a stage `SHELL`, `web/package.json`''s script map (pinned WHOLE — selecting on the literal `vitest` missed `npm run test`, `npm t` and the `prebuild`/`preinstall` lifecycle hooks), `web/vite.config.ts`''s `test:` block, which decides what the suite collects, and the ABSENCE of any `vitest.config.*` — vitest resolves its own config in preference to `vite.config.*`, so pinning one file is worthless while a second can outrank it. The gating job''s `container:` image is left to `ci.image-pin-population` rather than guarded twice. What is unmodelled is a LIST, not an "only": an `ENV` changing what a pinned `RUN` resolves, the plugin BODIES, and a publish through another action. The count of such routes lives in the `docs/guard-inventory.md` row and nowhere else — it briefly existed in three places with two different values, which is how a number stays wrong. It is a running total, wrong at every count so far. They share one shape, which is the transferable part: A PIN ASSUMES IT IS PINNING THE ARTIFACT THAT STILL DECIDES, and every route found so far is authority moving to another file, another occurrence, another workflow, or a hook the pinned command invokes. Do NOT restore a filtered in-image run by naming the specs that cannot run there: that list is a population nothing derives, and the red it produces when it goes stale is unreachable on a PR — `Build & push image (amd64)` is `if: github.event_name != ''pull_request''` — so it lands on `main` and on the `v*` tag path, where it fails the release cut. Held in both directions by `scripts/tests/test_image_build_delegates_the_spa_suite.py`.'
signals: 'image build fails on main only · every image build fails · latest not republished · release cut fails at the image build · gitless build STAGE not context · node:22-bookworm-slim ships no git · virtual:etv-tracked-source-files could not read the git index · hand-maintained exclude list · exclusion list is the underived population · suite members needing the git binary vs a checkout · COPY .git would not help · needs edge is the only gate · unfiltered gating run · paths: `docker/Dockerfile`, `.gitea/workflows/docker-build.yml`, `scripts/tests/test_image_build_delegates_the_spa_suite.py`, `web/vite-plugins/trackedSourceFiles.ts` · issues: #887, #883, #819, #806, #420'
mechanics: 'Populations are DERIVED from the git index (tracked Dockerfiles and workflows, and the stages within them carrying the SPA source); the command lines in those places are PINNED as text. 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. Publishing jobs come from the `docker/build-push-action` step and the Dockerfile each builds from that step''s `file:` input, so `ci-image.yml` is out of scope by derivation rather than by an exemption. 64 mutants, 0 missed (development battery).'
mechanics: 'Populations are DERIVED from the git index (tracked Dockerfiles and workflows, and the stages within them carrying the SPA source); the command lines in those places are PINNED as text. 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. Publishing jobs come from the `docker/build-push-action` step and the Dockerfile each builds from that step''s `file:` input, so `ci-image.yml` is out of scope by derivation rather than by an exemption. 68 mutants, 0 missed (development battery).'
---
The web-build stage is gitless twice over. It copies only `web/` and `design-system/`, so it holds
@@ -45,9 +45,13 @@ not: green on every PR, red on `main` and on the release tag.
(`process.enumerate-workaround-behaviors-before-deleting`):
1. *Blocking a publish on a red suite* — retained, by the `needs:` edge. Two skips exist inside
`test` and neither leaves an image unvalidated: `docs_only` also gates `Build and push`, so that
arm ships nothing, and the #420 revalidate skip fires only on a tree byte-identical to a head
that already carried a green combined status.
`test`. `docs_only` also gates `Build and push`, so that arm ships nothing — ASSERTED, by
`test_the_DOCS_ONLY_arm_cannot_publish_an_image`, after cold review pointed out it had been
stated as fact with nothing checking it. The #420 revalidate skip fires only on a tree
byte-identical to a head that already carried a green combined status; that arm is a DEPENDENCY
on `scripts/ci-detect-already-validated.sh` rather than something asserted here, and that script
is graded `MUTATION: NONE`. Said plainly because #887 is what made the second arm load-bearing:
before it, the in-image run covered that case.
2. *Running the suite under `node:22-bookworm-slim` specifically***lost**, and this is the real
cost. It is small because the suite is jsdom and pure JS, and because the artifact the image
actually ships is the output of `vite build`, which still runs there: a test that passes in the
File diff suppressed because one or more lines are too long
@@ -45,22 +45,33 @@ pin and reddens.
WHAT A PIN IS AND IS NOT IMMUNE TO, stated precisely because an earlier version of this paragraph
claimed the absolute "a pin cannot have a false green" and cold review refuted it by execution. A
pin is immune to the class that defeated the parser: a DIFFERENT SPELLING of the command. It is not
immune to the same text MEANING something else. EIGHT such routes have been IDENTIFIED, and that
number is a running total, not a proof: it was written as five, then six, then seven, and each was
wrong when checked. Six are handled `working-directory` and the gating step's own `shell:`; the workflow
`defaults.run.shell` and the job-level `defaults` that would override it; a stage `SHELL`, refused
outright; `web/package.json`'s script map, pinned WHOLE after selecting on the literal `vitest`
missed `npm run test`, `npm t`, and the `prebuild`/`preinstall` LIFECYCLE HOOKS npm runs for
`npm run build` and `npm ci`; `web/vite.config.ts`'s `test:` block, which decides what the suite
COLLECTS and is where a filter would now naturally be written; the ABSENCE of any
`vitest.config.*`/`vitest.workspace.*`/`vitest.projects.*`, which vitest resolves in PREFERENCE to
that file pinning one config is worthless while a second can outrank it, and the measured effect
of adding one was a run that collected zero specs and exited 0, which is worse than a filtered one;
and the gating job's `container:` image, pinned by `test_ci_image_pin_population.py`
and CITED rather than re-asserted, because two guards on one condition mask each other (#685).
immune to the same text MEANING something else. The routes found so far, and what each is worth,
are enumerated ONCE in `docs/guard-inventory.md`'s row for this guard, which is where the count
lives. It is not repeated here: it existed in three artifacts with two different values at one
point, and re-syncing three copies is how a number stays wrong (`state-the-invariant-not-the-
measurement`). What this file states is the RULE they are instances of, below.
The eighth, an `ENV` rewriting `PATH` so a pinned `RUN` resolves a different `npm`, is not modelled
and is stated as a residual.
Handled here: `working-directory` and the gating step's own `shell:`; the workflow
`defaults.run.shell` and the job-level `defaults` that would override it; a stage `SHELL`, refused
outright; `web/package.json`'s script map, pinned WHOLE after SELECTING on the literal `vitest`
missed `npm run test`, `npm t`, and the `prebuild`/`preinstall` LIFECYCLE HOOKS npm runs for
`npm run build` and `npm ci`; `web/vite.config.ts`'s `test:` block and `plugins:` array, both
extracted by the same bracket walk and required to be UNIQUE, because a decoy copy above
`defineConfig` satisfies a comparison that takes the first match; and the ABSENCE of any config file
that outranks the pinned one `vitest.config.*` for vitest, and `vite.config.js`/`.mjs` for `vite
build`, whose `DEFAULT_CONFIG_FILES` puts them BEFORE `.ts`. Pinning one config is worthless while a
second can outrank it, and both halves of that were measured: a `vitest.config.ts` made the suite
collect zero specs and exit 0, and a `vite.config.js` ran the suite inside the gitless stage with
all 1411 tests green.
Cited rather than re-asserted: the gating job's `container:` image, pinned by
`test_ci_image_pin_population.py` two guards on one condition mask each other (#685).
NOT modelled, as a list rather than an "only": an `ENV` in a pinned stage that changes what a pinned
`RUN` resolves or executes; the BODIES of the pinned plugins (there are two, and `react()`'s is
third-party and unmitigated only `trackedSourceFilesPlugin`'s laziness is a mitigation); a
dependency's own install script, reached through `npm ci` and `web/package-lock.json`; and a publish
through some action other than `docker/build-push-action`.
So: the pin removes the SPELLING problem outright and reduces the MEANING problem to a list that can
be worked through a much better place than a predicate that was wrong nine times. It is not
@@ -109,12 +120,12 @@ WHAT IS NOT COVERED, stated rather than implied:
copy whose source names `web` and NOT the wider "has the suite available to run", which is
what an earlier version of this sentence claimed. A stage copy that RENAMES the tree on the way
in (`COPY --from=web-build /source/web /elsewhere`) has no `web` segment in its source and is
not recognised. THAT CLAUSE WAS WRONG THREE TIMES and is now stated from a measurement rather
than from memory: `COPY --from=web-build /source/web /elsewhere` IS recognised (only the
DESTINATION is renamed, and only sources are read). What is not: a source that is an ANCESTOR of
the tree (`COPY --from=web-build /source /src` brings `/source/web` along without naming it) or
names it only as `/source/.`. Both measured, and both backstopped by the substring sweep an
ancestor-copy stage running `npm t`, `npx vitest` or a new npm script is caught. The
not recognised. What that boundary EXCLUDES, measured: a source that is an ANCESTOR of the
tree (`COPY --from=web-build /source /src` brings `/source/web` along without naming it), and
one that names it only as `/source/.`. Both are backstopped by the substring sweep an
ancestor-copy stage running `npm t`, `npx vitest` or a new npm script is caught. (This sentence
was wrong three times before it was written from a measurement; a destination rename such as
`/source/web /elsewhere` IS recognised, because only sources are read.) The
false-NEGATIVE direction is what drops a stage out of the check, so the predicate is pinned in
both directions by its own test, and the substring sweep below is the backstop for whatever it
still misses.
@@ -124,7 +135,9 @@ WHAT IS NOT COVERED, stated rather than implied:
equality, a pin going stale goes loudly red, but a SELECTOR going short is silent. It was short
by exactly one entry (`npm t`, npm's own alias, which this file names among the spellings that
defeated the parser) until cold review measured a stage escaping through it. `PUBLISH_ACTION` is
the same category and is covered by the anti-vacuity assertion instead.
the same category and is NOT covered anti-vacuity proves the selector is non-empty, which the
real `build` job guarantees, and cannot prove it complete: a publish through a different action
is invisible to it. `test_no_run_BODY_builds_or_pushes_an_image` covers only the shell-body form.
* The gating body is compared LINE BY LINE with each line whitespace-normalised, not byte-exact.
Indentation and reflow are free; line boundaries are not, because a newline separates two
commands changing `run: |` to `run: >` folds them into one and the whole-text comparison could
@@ -231,13 +244,20 @@ VITE_CONFIG = "web/vite.config.ts"
# "No test files found, exiting with code 0" — the gating step green having run NOTHING, and the
# image published. Pinning one file is worthless while a second can outrank it, so the absence of
# every higher-precedence spelling is asserted rather than assumed.
VITEST_CONFIG_PREFIXES = ("vitest.config.", "vitest.workspace.", "vitest.projects.")
# Every config filename that can outrank the pinned one, for EITHER tool. Read out of the pinned
# tarballs 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` beats the
# pinned `.ts` for `vite build`, which is what the gitless stage runs. Vitest 4.1.9 resolves
# `vitest.config.*` before `vite.config.*`. MEASURED: a `web/vite.config.js` whose plugin shells out
# to the suite ran it in the gitless stage with all 1411 tests green.
OUTRANKING_CONFIG_PREFIXES = ("vitest.config.", "vitest.workspace.", "vitest.projects.", "vite.config.")
# A vite plugin runs inside the same process as the suite and can shell out from a build hook, so a
# `buildStart()` calling `npm test` re-arms ersatztv#887 from the plugin side with every other pin
# matching (measured). The ARRAY is pinned so no plugin joins silently; the plugin BODIES are not,
# and that is a stated residual — `trackedSourceFiles.ts` is deliberately LAZY for this exact
# reason, which its own comment now marks as load-bearing for the image build.
PINNED_VITE_PLUGINS = "plugins: [react(), trackedSourceFilesPlugin()],"
# The ARRAY, as the bracket walk returns it — no `plugins:` prefix, no trailing comma.
PINNED_VITE_PLUGINS = "[react(), trackedSourceFilesPlugin()]"
PINNED_VITE_TEST_BLOCK = """test: {
environment: 'jsdom',
environmentOptions: {
@@ -262,6 +282,25 @@ def _normalise(text: str) -> str:
return " ".join(text.split())
def _bracket_span(text: str, start: int, opener: str, closer: str) -> str:
"""The balanced `opener`…`closer` span beginning at or after `start`.
Shared by both `vite.config.ts` pins so they cannot drift: the plugin array shipped a raw
substring comparison while the `test:` block used a walk, and the substring one was defeated by
the very decoy the walk had just been taught to reject.
"""
begin = text.index(opener, start)
depth = 0
for index in range(begin, len(text)):
if text[index] == opener:
depth += 1
elif text[index] == closer:
depth -= 1
if depth == 0:
return text[begin : index + 1]
raise AssertionError(f"{VITE_CONFIG}: unbalanced {opener}{closer} from offset {begin}")
def _vite_test_block() -> list[str]:
"""The `test: {` … matching `}` block of `web/vite.config.ts`, normalised per line.
@@ -270,17 +309,17 @@ def _vite_test_block() -> list[str]:
explained without the explanation being pinned.
"""
text = _read(VITE_CONFIG)
occurrences = text.count("test: {")
# A decoy `const reviewed = { test: {…} }` copied byte-for-byte from the pin, placed ABOVE
# `defineConfig`, satisfies a comparison that takes the FIRST match while the real block is
# narrowed — measured green. The file already requires exactly ONE step named `Test SPA` for the
# same reason; the reasoning had not been applied here.
assert occurrences == 1, (
f"{VITE_CONFIG} contains {occurrences} `test: {{` blocks, not one. The pin compares the "
f"FIRST, so a second is either a decoy that satisfies it while another block decides, or a "
f"real second config nobody is checking."
# WHITESPACE-TOLERANT, because counting one SPELLING is not counting the marker: a decoy written
# `test: {` beside a real block written `test: {` made `text.count("test: {") == 1` while the
# comparison took the decoy — measured green with the real block filtering out exactly the specs
# ersatztv#883 broke on. One space defeated the previous fix.
matches = list(re.finditer(r"\btest:\s*\{", text))
assert len(matches) == 1, (
f"{VITE_CONFIG} contains {len(matches)} `test:` blocks, not one. The pin compares the FIRST, "
f"so a second is either a decoy that satisfies it while another block decides, or a real "
f"second config nobody is checking."
)
start = text.index("test: {")
start = matches[0].start()
depth = 0
for index in range(start, len(text)):
if text[index] == "{":
@@ -400,6 +439,15 @@ def copies_spa_source(command: str) -> bool:
if not tokens or tokens[0].upper() not in COPY_INSTRUCTIONS:
return False
remainder = " ".join(token for token in tokens[1:] if not token.startswith("--")).strip()
if remainder.startswith("["):
# Parse the exec form BEFORE the `--from=` branch: splitting `["/source/web", "/dest"]` on
# whitespace leaves a trailing comma on the first token, so the segment test never matched
# and the stage went unpinned. Measured False.
try:
parsed = json.loads(remainder)
except ValueError:
return True
remainder = " ".join(str(item) for item in parsed) if isinstance(parsed, list) else ""
if any(token.startswith("--from=") for token in tokens[1:]):
# A stage copy is not a CONTEXT copy, so `--from=build .` brings in that stage's `/`, not the
# repository. But it can still carry the SPA SOURCE across from a stage that has it, and a
@@ -750,10 +798,23 @@ def test_the_VITE_PLUGIN_LIST_is_exactly_what_was_reviewed() -> None:
there is that `trackedSourceFilesPlugin` is deliberately lazy.
"""
text = _read(VITE_CONFIG)
assert text.count(PINNED_VITE_PLUGINS) == 1, (
f"`{VITE_CONFIG}`'s plugin list is not the pinned one ({PINNED_VITE_PLUGINS!r}). A vite "
f"plugin runs in the same process as the suite and can shell out from a build hook, so a "
f"new one is a decision to state. Update the pin deliberately."
# The SAME bracket walk and the SAME uniqueness rule as the `test:` block. The first version of
# this assertion was `text.count(PINNED_VITE_PLUGINS) == 1` — a raw substring — and it shipped
# the decoy hole it was written one function away from fixing: a decoy `const reviewedPlugins =
# { plugins: [react(), trackedSourceFilesPlugin()], }` above `defineConfig` satisfied the count
# while a third plugin sat in the live array. Measured green. That is
# `each-fix-round-introduces-an-adjacent-defect` inside a single commit.
matches = list(re.finditer(r"\bplugins:\s*\[", text))
assert len(matches) == 1, (
f"`{VITE_CONFIG}` contains {len(matches)} `plugins:` arrays, not one. The pin compares the "
f"FIRST, so a second is either a decoy or a real list nobody is checking."
)
array = _bracket_span(text, matches[0].start(), "[", "]")
assert _normalise(array) == _normalise(PINNED_VITE_PLUGINS), (
f"`{VITE_CONFIG}`'s plugin list is not the pinned one.\n pinned: "
f"{_normalise(PINNED_VITE_PLUGINS.rstrip(','))}\n actual: {_normalise(array)}\n"
f" A vite plugin runs in the same process as the suite and can shell out from a build hook, "
f"so a new one is a decision to state. Update the pin deliberately."
)
@@ -770,12 +831,15 @@ def test_NO_OTHER_vitest_CONFIG_outranks_the_pinned_one() -> None:
intruders = sorted(
path
for path in tracked_files._git_ls_files()
if path.startswith("web/") and path.rpartition("/")[2].startswith(VITEST_CONFIG_PREFIXES)
if path.startswith("web/")
and path.rpartition("/")[2].startswith(OUTRANKING_CONFIG_PREFIXES)
and path != VITE_CONFIG
)
assert not intruders, (
f"these files outrank the pinned `{VITE_CONFIG}` in vitest's config resolution: {intruders}. "
f"Whatever they say about `include`/`exclude` decides which specs the gating run collects, "
f"and the pin on `{VITE_CONFIG}` cannot see it — a suite that collects nothing still exits 0. "
f"these files outrank the pinned `{VITE_CONFIG}` in vite's or vitest's config resolution: "
f"{intruders}. Whatever they say decides what the gating run COLLECTS and what `vite build` "
f"loads in the gitless stage, and the pin on `{VITE_CONFIG}` cannot see either — a suite "
f"that collects nothing still exits 0, and a plugin loaded from a `.js` config still runs. "
f"Either fold the settings into `{VITE_CONFIG}` and its pin, or pin this file too."
)