From 3ec73f3769371bb1a437fcb953a6d883d003dbed Mon Sep 17 00:00:00 2001 From: Timothy Date: Sun, 30 Aug 2026 14:07:47 +0200 Subject: [PATCH] fix(887): a pin assumes it is pinning the thing that still DECIDES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF --- docs/decisions/README.md | 2 +- .../ci/image-build-delegates-the-spa-suite.md | 4 +- docs/guard-inventory.md | 2 +- ...est_image_build_delegates_the_spa_suite.py | 67 ++++++++++++++++--- web/vite-plugins/trackedSourceFiles.ts | 4 +- 5 files changed, 66 insertions(+), 13 deletions(-) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 392971edd..379c6915d 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -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=` 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. Only an `ENV` rewriting `PATH` is unmodelled. EIGHT routes is a running total, not a proof — it was written as five, then six, then seven, and each was wrong when checked. 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. 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.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) | diff --git a/docs/decisions/records/ci/image-build-delegates-the-spa-suite.md b/docs/decisions/records/ci/image-build-delegates-the-spa-suite.md index ca4fa38eb..842af8549 100644 --- a/docs/decisions/records/ci/image-build-delegates-the-spa-suite.md +++ b/docs/decisions/records/ci/image-build-delegates-the-spa-suite.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. Only an `ENV` rewriting `PATH` is unmodelled. EIGHT routes is a running total, not a proof — it was written as five, then six, then seven, and each was wrong when checked. 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. 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`.' 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. 61 mutants, 0 missed.' +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).' --- The web-build stage is gitless twice over. It copies only `web/` and `design-system/`, so it holds diff --git a/docs/guard-inventory.md b/docs/guard-inventory.md index d0441fadf..f25537176 100644 --- a/docs/guard-inventory.md +++ b/docs/guard-inventory.md @@ -173,7 +173,7 @@ The audit #806 asked for, recorded whichever way it came out, because "we looked | `test_workflow_job_guards.py` | `.gitea/workflows/*.y*ml` | **yes** — every tracked workflow's jobs must each declare a `CI_JOB_ROLE`, and the guard-declaring ones must match `## Workflow-job guards` both ways | index-derived from the start (ersatztv#786); registered in `test_guard_populations_derive_from_git.py` so the shared proof covers it | | `test_pr_changed_files.py` | `.gitea/workflows/*.y*ml` | **yes** — "no OTHER workflow writes the review-verdict status", and since ersatztv#748 "every tracked workflow declares a `permissions:` block", with NO exemption list (the one `ci-image.yml` briefly needed was deleted when #744 landed, per #835) | converted. Not on #806's list: an untracked `.yaml` dropped in `.gitea/workflows/` reddened two guards while absent from the index — the issue's list of files to assess was a starting point, not the population | | `test_workflow_persist_credentials.py` | `.gitea/workflows/*.yml` + `*.yaml` | **yes** — every `actions/checkout` in every workflow must drop the persisted credential | derived from the index from the start (#835). Shipped with NO exemption list, which is why it waited for #744: the one non-compliant checkout would otherwise have bought a permanent entry that keeps passing after its reason expires | -| `test_image_build_delegates_the_spa_suite.py` | DERIVED: tracked Dockerfiles and workflows from the git index; the stages within them that carry the SPA source. PINNED (hand-written, and the distinction is the whole design): the commands each such stage runs, and the gating `Test SPA` step's `run:` body and `if:` | **yes on the derived populations, by pin rather than by predicate** — every SPA-carrying stage must have a pin and match it; every image-publishing job whose Dockerfile has such a stage must transitively `needs:` the gating job | ersatztv#887. **THE PARSER WAS WITHDRAWN, and that is the finding worth carrying.** Three versions of this guard asked "does this command RUN the suite, and can it FAIL?" of arbitrary shell text, and that predicate was wrong NINE times across three cold-review rounds, always the same mechanism: heredoc bodies skipped as data when BuildKit EXECUTES `RUN < `run: >` folds two commands into one; (3) Gitea's treatment of a SKIPPED `needs:` job is not modelled — the guard forbids the job-level `if:` that would create one rather than reasoning about it; (4) the pins are hand-written and a legitimate edit reddens this guard, which is the intended review trigger and the stated cost | +| `test_image_build_delegates_the_spa_suite.py` | DERIVED: tracked Dockerfiles and workflows from the git index; the stages within them that carry the SPA source. PINNED (hand-written, and the distinction is the whole design): the commands each such stage runs, and the gating `Test SPA` step's `run:` body and `if:` | **yes on the derived populations, by pin rather than by predicate** — every SPA-carrying stage must have a pin and match it; every image-publishing job whose Dockerfile has such a stage must transitively `needs:` the gating job | ersatztv#887. **THE PARSER WAS WITHDRAWN, and that is the finding worth carrying.** Three versions of this guard asked "does this command RUN the suite, and can it FAIL?" of arbitrary shell text, and that predicate was wrong NINE times across three cold-review rounds, always the same mechanism: heredoc bodies skipped as data when BuildKit EXECUTES `RUN < `run: >` folds two commands into one; (3) Gitea's treatment of a SKIPPED `needs:` job is not modelled — the guard forbids the job-level `if:` that would create one rather than reasoning about it; (4) the pins are hand-written and a legitimate edit reddens this guard, which is the intended review trigger and the stated cost | | `web/src/api/pageSizeCallSites.guard.test.ts` | `import.meta.glob` over `web/src/**/*.{ts,tsx,mts,cts}`, INTERSECTED with the git index | **yes** — an unregistered discovered site fails | converted under #819. The population is the glob INTERSECTED with the git index, and the walk/index divergence is asserted in BOTH directions WITHIN a shared scope: a key the index does not carry is dropped, and a tracked, on-disk, in-scope path that never reached the scanner FAILS rather than shrinking the population silently (that direction catches a dotfile, which the glob cannot match, and a disk/index spelling divergence, which `core.ignorecase` and NFD/NFC normalisation make permanent). The SCOPE itself cancels out of that equality — both sides call one `isInScopeSourcePath` — so narrowing it moves both sets together and no assertion comparing the two sides can see it. That is closed separately, by re-deriving the predicate from its two components over the whole tracked index (`the shared scope predicate is EXACTLY its two documented components`). Two mechanisms carry that. FIRST, the scope predicate is proved by a CLOSED-FORM restatement — it may share no helper, at any depth, with the predicate it checks, since anything shared sits on both sides of the comparison and cancels. FOUR earlier attempts were each measured going green while removing real files: a table of example paths (four of `src/`'s eight directories, missed a 23-file narrowing), a decomposition delegating its filename half to `isScannableSourceFileName` (11 files), one still sharing a `basename` helper (15 files, and blind to a planted call site), and one still sharing the tracked-file array every comparison was derived from (8 files, also blind). SECOND — because that last one showed a restatement cannot police the population it reads — the plugin runs a separate `git ls-files --others` query and the guard requires every in-scope walked path to appear in `tracked ∪ others`; narrowing `tracked` cannot suppress that, since it adds nothing to `others`. SIX residuals, NOT a claim of closure, each with its direction MEASURED by planting a real `pageSize` call site rather than reasoned about. FAIL-NOISY (they redden a checkout; they cannot hide a call site): (2) a scope term matching no tracked path today survives until the day it first matches one; (4) the WIRING in `listSourceFiles`, indistinguishable on a clean checkout where the walk set and the index set agree on every in-scope key (they are not literally equal — the index also carries `.css`, which the glob never yields), so no assertion COMPARING THE TWO POPULATIONS can tell which is passed; (5) `ABSENT_FROM_DISK` emptied. BLIND — each hid a planted `pageSize: 100` with the whole suite green: (1) a COORDINATED edit of the scope predicate and BOTH closed-form restatements, which is three sites and not two, since a second restatement guards `expectedTrackedSources` (measured: the two-site edit reddens, the three-site edit does not) — tolerated because a three-site policy edit is review-visible in a way a one-line slip is not; and (3) a misdescription by the plugin's THREE outputs — not only a mispartition of `tracked`/`others`, which preserves the union any consumer compares, but a FATTENED `absentFromDisk`, which subtracts real files from the hole-detection direction. The fattening is now caught wherever it overlaps a file the walk found (a walked path is on disk by construction, so it can never be legitimately absent); restricted to paths the walk cannot see anyway — a dotfile, a case-divergent name — it stays invisible from the consuming side and is answerable only by testing the derivation directly, and the real-git tests that do so catch an UNCONDITIONAL misdescription, not one keyed on a path pattern. Neither blind residual is tolerated for being harmless. A sixth is specific to WATCH mode and is stale in BOTH directions, so it belongs to neither group: the virtual module has no backing file, so the index is read once per dev-server lifetime while the glob refreshes. A file CREATED mid-session reddens the cross-check misleadingly (noisy); a file already UNTRACKED when the watcher started keeps that classification when staged mid-session, so its call sites are never scanned — MEASURED green across both phases while `npx vitest run` on the same tree reports `UNREGISTERED`, i.e. blind, and a window that did not exist before #819 because the population was then the walk. A watch-mode green is therefore not authoritative for this guard; restarting the watcher clears the noisy case and opens the blind one, so the remedy is `npm test -- --run`. Invalidating the module from `configureServer` was implemented, measured and REJECTED: it fixes the created-mid-session red and additionally blinds the create-then-stage sequence, while the already-untracked-then-staged sequence is blind either way, since `git add` fires no watcher event in either design. The index is read by `web/vite-plugins/trackedSourceFiles.ts` in Vite's own Node context and handed to the app project as the virtual module `virtual:etv-tracked-source-files`. That is what reaches the index without admitting `@types/node` to `tsconfig.app.json` — the obstacle that deferred this, since wiring those types in was tried and reverted (it leaked Node's `setTimeout` into the app project and broke three unrelated tests). The plugin throws rather than falling back to the unfiltered walk when git fails, reports zero files, or cannot learn the Vite root. Residual, stated: a file tracked but DELETED in the working tree is subtracted deliberately, since an unstaged deletion is a normal developer state and a guard red on one gets ignored (#806); it is distinguished from the hole cases above by an on-disk existence check, not conflated with them | | `web/src/api/completeAnnotations.guard.test.ts` | TWO derived populations: the `Complete<…>` annotations across `web/src/**/*.{ts,tsx,mts,cts}` INTERSECTED with the git index, and the droppable SCHEMAS parsed out of the generated `src/api/generated/v1.d.ts` | **partial, and the split is stated** — set equality holds for the SCHEMA population (a new optional member fails until dispositioned) and every tracked in-scope path must be supplied by the glob; there is NO closed-form restatement of the scope predicate, so a coordinated edit to it is caught by five named path pins AND a 0.95 population-ratio floor, not by a completeness proof | ersatztv#820. Turns `test_optional_request_members.py`'s `COVERED` disposition — worded "the builder is annotated `Complete`", a claim about ANOTHER LANGUAGE'S source that nothing checked — into a check, and makes `docs/spa-conventions.md` §4b's prohibitions executable as asserted ABSENCES. It found one live defect: `playouts.ts` declared two request types as hand-written mirrors SHADOWING generated schemas of the same name, so their `Complete<>` was checking a local copy rather than the contract. **NARROWED after four BLOCKED review rounds, and the narrowing is the point.** It originally also derived the write WRAPPERS and required the annotation on the wrapper parameter. Every blocker across those rounds came out of that one mechanism — the obligation attached to the wrong population; reachability mistaken for protection (`Complete` is shallow, so a wrapper annotation never reached a nested schema); body discovery keyed on a parameter NAME, then on parameter-versus-local; and finally an `export function` -> `export const` refactor that removed real protection while both the AST scan and its supposedly independent regex cross-check stayed blind TOGETHER, because both keyed on the same token. Five defects from one mechanism is the `process.enumerate-workaround-behaviors-before-deleting` signal to remove it rather than patch a sixth time, and ~250 lines went with it. WHAT IS NOT COVERED, listed because the earlier version of this row stated residuals in the direction that was actually covered: (1) the obligation is per-SCHEMA, not per-SITE and not per-WRAPPER — deleting the `Complete<>` from an API wrapper stays green as long as some production file still names that schema, so the wrapper half of §4b rests on review; (2) it is a TOKEN-PRESENCE check, so a dead `export type X = Complete` that nothing uses discharges the obligation as well as a live builder does — it catches deletion, which is the failure actually observed in #807, not substitution; (3) the PHANTOM direction needs a fresh literal in a contextually typed position and is not checked at all (sites-in-code, #777); (4) the test-file exclusion covers `*.test.*`, `*.spec.*` and the ONE setup file `vite.config.ts` names, whose path is pinned so a rename reddens — a SECOND `setupFiles` entry is loaded by vitest every run and WOULD discharge the obligation; that needs a three-site coordinated edit and is tolerated on the terms the sibling guard states for its own; (5) a MUST-NOT-ANNOTATE violation inside a `*.guard.test.ts` is not seen, since that file class is excluded to keep `completeRequest.guard.test.ts`'s synthetic `Complete<{…}>` fixtures out of the resolver. The disposition VALUES are cross-checked against the authoritative Python table by `scripts/tests/test_complete_annotation_dispositions.py` — without it, flipping one row from `ANNOTATED` to `CREATE` silently retired the requirement, which cold review demonstrated. Nine mutations were witnessed by hand across development, NOT re-executed per suite, so this guard claims no standing `MUTATION` grade — the same footing as `pageSizeCallSites.guard.test.ts`. (Its Python cross-check `test_complete_annotation_dispositions.py` DOES carry a declared harness-executed mutation and is graded accordingly.) A SIXTH residual, shared with its sibling: every population here derives from `trackedSources.tracked`, so a narrowing inside `web/vite-plugins/trackedSourceFiles.ts` cancels out of every comparison — this guard never reads the plugin's separate `others` query, and relies on `pageSizeCallSites.guard.test.ts` and `trackedSourceFiles.test.ts` policing the plugin. A coordinated scope edit is caught by five named path pins AND a 0.95 population-ratio floor whose denominator is computed by a different expression, not by a completeness proof | | `test_ci_release_path_scan_job.py` | `.gitea/workflows/*.y*ml` + `scripts/**` | **no** — a fixture assembling a tmp harness, asserted about behaviour not membership | takes its file LIST from the index anyway, for hermeticity not completeness: `shutil.copytree` copied whatever was on disk, including untracked files and `scripts/__pycache__`, into a tree whose behaviour the probes then measure. Content still comes from the working tree. The copy is not a git repo, so the two files this step RUNS may not use the helper — see the fixture docstring | diff --git a/scripts/tests/test_image_build_delegates_the_spa_suite.py b/scripts/tests/test_image_build_delegates_the_spa_suite.py index d9aee7173..bcf667292 100644 --- a/scripts/tests/test_image_build_delegates_the_spa_suite.py +++ b/scripts/tests/test_image_build_delegates_the_spa_suite.py @@ -66,6 +66,14 @@ So: the pin removes the SPELLING problem outright and reduces the MEANING proble be worked through — a much better place than a predicate that was wrong nine times. It is not "cannot be wrong", and the list's own history says so. +THE SHAPE THOSE ROUTES SHARE, which is the thing to check when adding a pin rather than a list to +memorise: **a pin assumes it is pinning the artifact that still DECIDES.** Every instance found so +far is authority moving somewhere the pin is not looking — to another FILE (`vitest.config.*` +outranks `vite.config.ts`), to another OCCURRENCE in the same file (a decoy first `test: {`), to +another WORKFLOW (a `needs:` edge naming a job called `test` that is not this one), or to a HOOK the +pinned command invokes (a vite plugin, an npm lifecycle script). Ask of any new pin: what else could +decide this, and would the pin still match? + The cost is stated rather than hidden: a legitimate edit to those command lines reddens this guard and its author must update the pin. That is the same deliberate review trigger `KNOWN_GATE_*` uses, applied to the two places where a silent change publishes an untested image. @@ -93,9 +101,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 — nor is a source that is an ANCESTOR of it (`COPY --from=web-build /source /src` - brings `/source/web` along without naming it). Both were MEASURED; the previous two attempts at - this sentence each named a shape that was in fact covered while omitting these. The + 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 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. @@ -213,6 +224,12 @@ VITE_CONFIG = "web/vite.config.ts" # 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.") +# 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()]," PINNED_VITE_TEST_BLOCK = """test: { environment: 'jsdom', environmentOptions: { @@ -245,6 +262,16 @@ 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." + ) start = text.index("test: {") depth = 0 for index in range(start, len(text)): @@ -560,13 +587,20 @@ def test_every_IMAGE_PUBLISHING_job_is_gated_on_the_job_that_runs_the_suite() -> if not any(stage_rel == dockerfile for stage_rel, _, _ in spa_carrying_stages()): continue gates = _transitive_needs(job_id, jobs) - if GATING_JOB not in gates: + # `needs:` names a job in the SAME workflow, so the edge only reaches the pinned step when + # the publishing job lives in `GATING_WORKFLOW`. Matching a bare job id anywhere was a false + # green: a second workflow publishing this Dockerfile while `needs:`-ing its OWN unrelated + # job called `test` (`run: echo ok`) satisfied it — measured, and it escaped every + # workflow-population guard in the repo, 588 tests green. + if rel != GATING_WORKFLOW or GATING_JOB not in gates: faults.append( f"{rel}: job `{job_id}` publishes an image built from `{dockerfile}`, which carries " - f"the SPA source, but does not transitively `needs:` `{GATING_JOB}` — the job " - f"holding the pinned suite step. It needs: {sorted(gates)}. Since ersatztv#887 the " - f"image build deliberately does not run the suite itself, so this `needs:` edge is " - f"the ONLY thing standing between a red suite and a published image." + f"the SPA source, but does not transitively `needs:` `{GATING_JOB}` IN " + f"`{GATING_WORKFLOW}` — the job holding the pinned suite step. It needs: " + f"{sorted(gates)}. A `needs:` edge resolves within its own workflow, so a job named " + f"`{GATING_JOB}` in a DIFFERENT workflow is a different job and gates nothing. Since " + f"ersatztv#887 the image build does not run the suite itself, so this edge is the " + f"ONLY thing between a red suite and a published image." ) assert not faults, "\n".join(faults) @@ -698,6 +732,23 @@ def test_the_npm_SCRIPT_MAP_is_exactly_what_was_reviewed() -> None: ) +def test_the_VITE_PLUGIN_LIST_is_exactly_what_was_reviewed() -> None: + """A plugin can run the suite from a build hook, so the list of them is pinned. + + Vite plugins execute in the same process as `vite build` and `vitest`, and a `buildStart()` that + shells out to `npm test` puts the suite back into the gitless stage with every command pin + intact — measured, both as an inline plugin and inside an existing one. The list is pinned so a + new plugin cannot arrive unnoticed; what each plugin DOES is a residual, and the mitigation + 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." + ) + + def test_NO_OTHER_vitest_CONFIG_outranks_the_pinned_one() -> None: """A pinned config is worthless if a second file can take precedence over it. diff --git a/web/vite-plugins/trackedSourceFiles.ts b/web/vite-plugins/trackedSourceFiles.ts index 1624307fe..ede5ddbe0 100644 --- a/web/vite-plugins/trackedSourceFiles.ts +++ b/web/vite-plugins/trackedSourceFiles.ts @@ -150,7 +150,9 @@ export function trackedSourceFilesPlugin(runGit?: GitRunner, fileExists?: FileEx return id === TRACKED_SOURCE_FILES_ID ? RESOLVED_ID : undefined; }, // Deliberately LAZY: git runs only when something imports the virtual module. `vite build` - // never resolves this id and so never shells out to git. + // never resolves this id and so never shells out to git. LOAD-BEARING FOR THE IMAGE BUILD since + // ersatztv#887: `docker/Dockerfile`'s web-build stage runs `npm run build` in a stage with no git + // binary, so making this eager would break every image build for #887's original root cause. load(id) { if (id !== RESOLVED_ID) { return undefined;