fix(819): derive the SPA page-size guard population from the git index (#875)
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m52s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m23s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m5s

The guard asserted EXACT completeness over a population enumerated by a directory
walk, so an untracked .ts/.tsx under web/src/ entered it and failed as unregistered
on that developer's checkout while CI — which only ever checks out tracked files —
stayed green.

The glob still supplies file CONTENT; the POPULATION is now the git index, read by
web/vite-plugins/trackedSourceFiles.ts in Vite's own Node context and handed to the
app project as a virtual module. That reaches the index without admitting
@types/node to tsconfig.app.json, the obstacle that deferred this in #818.

Three mechanisms carry the proof, each added because the previous was measured
insufficient: a closed-form restatement of the shared scope predicate (sharing no
helper at any depth with what it checks); a second independent `ls-files --others`
query cross-checking the population; and real-git tests that execute the derivation
against a temp repository.

Six residuals are stated with their MEASURED fail-directions, and
testing.guard-derives-population-from-source gains a bounded exception plus the
closed-form criterion.

fixes #819

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
This commit was merged in pull request #875.
This commit is contained in:
2026-08-29 09:14:15 +00:00
committed by timothy
parent b6b3520bdb
commit 8aeacd534a
13 changed files with 1149 additions and 27 deletions
+31 -1
View File
@@ -9,7 +9,37 @@ WORKDIR /source
COPY design-system/. ./design-system/ COPY design-system/. ./design-system/
COPY web/. ./web/ COPY web/. ./web/
WORKDIR /source/web WORKDIR /source/web
RUN npm run lint && npm run typecheck && npm test -- --run && npm run build # The SPA suite runs here except for two files (ersatztv#819), excluded for OVERLAPPING reasons —
# one needs the git binary, the other needs the binary AND a checkout. Reading them as two separate
# reasons is what broke this stage once already:
# * `web/src/api/pageSizeCallSites.guard.test.ts` needs a git CHECKOUT — and, through it, the
# binary. It derives its file population from `git ls-files` rather than a directory walk, and
# refuses rather than falling back. This stage's context is `web/` + `design-system/` only, so
# there is no `.git`.
# * `web/vite-plugins/trackedSourceFiles.realgit.test.ts` needs the BINARY but no checkout: it
# builds its own temp repository to prove the derivation by executing it.
# `node:22-bookworm-slim` ships no git (`command -v git` -> not found), so it dies with
# `spawnSync git ENOENT`.
# So this is NOT checkout-versus-binary, and adding a `COPY .git` would not let either run here —
# the binary would still be missing. Excluding only the first is not enough either, and a replica
# that merely deletes `.git` cannot show that: verify any change here with the git binary off
# `PATH`, not just with the directory absent.
# Everything else — all but those two files — runs fine gitless and is kept, rather than dropping
# the whole suite for one file as an earlier fix here did.
# The excluded pair is not skipped overall: `docker-build.yml`'s `Build & test (.NET)` job runs the
# whole suite on a real checkout, and `build` (the job that invokes this Dockerfile) carries
# `needs: [test, migrations, scan]`. State that chain precisely, because the `needs:` edge is not
# all of it: `Test SPA` is also gated on `docs_only` and on the #420 revalidate skip, and `build` is
# not gated on `revalidate`. On a push whose tree is byte-identical to an already-green head the
# suite is skipped and the image still builds — carried there by #420's byte-identical-tree
# argument. The `docs_only` arm cannot ship an image at all (`Build and push` is gated on it too),
# and `ci-detect-docs-only.sh` classifies by PATH SHAPE (`docs/` or `*.md`), not by directory, so a
# `web/*.md` would count as docs — there are none today, but do not restate it as "any `web/**`".
RUN npm run lint && npm run typecheck && \
npm test -- --run \
--exclude 'src/api/pageSizeCallSites.guard.test.ts' \
--exclude 'vite-plugins/trackedSourceFiles.realgit.test.ts' && \
npm run build
FROM --platform=linux/amd64 192.168.1.95:3000/timothy/ersatztv-ffmpeg:8.1.2 AS runtime-base FROM --platform=linux/amd64 192.168.1.95:3000/timothy/ersatztv-ffmpeg:8.1.2 AS runtime-base
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6
View File
@@ -23,6 +23,12 @@ Vite + React + TypeScript, builds to `ErsatzTV/wwwroot/app` (see `web/vite.confi
- **Screens**: `web/src/screens/*.tsx`, one file per top-level screen, generally with a colocated - **Screens**: `web/src/screens/*.tsx`, one file per top-level screen, generally with a colocated
`*.test.tsx`. `*.test.tsx`.
- **API clients**: `web/src/api/<domain>.ts` (see §4). - **API clients**: `web/src/api/<domain>.ts` (see §4).
- **Build-time plugins**: `web/vite-plugins/*.ts` — Vite plugins that run in NODE, not in the
browser bundle, and are type-checked under `tsconfig.node.json` (never `tsconfig.app.json`,
which must stay free of `@types/node`). This is the seam for anything a test needs that only
Node can answer — `trackedSourceFiles.ts` reads `git ls-files` so `pageSizeCallSites.guard.test.ts`,
the one SPA guard that derives a file population, takes it from the git index rather than a
directory walk (#819).
- **Styling**: `web/src/shell.css` (+ `web/src/components/components.css`) — utility classes with a - **Styling**: `web/src/shell.css` (+ `web/src/components/components.css`) — utility classes with a
`ctv-` prefix (~690 occurrences across those two files). Reuse an existing `ctv-*` class before `ctv-` prefix (~690 occurrences across those two files). Reuse an existing `ctv-*` class before
inventing a new one. `shell.css` carries the only base reset — `html, body { margin: 0 }` plus inventing a new one. `shell.css` carries the only base reset — `html, body { margin: 0 }` plus
+8 -2
View File
@@ -20,7 +20,7 @@ before adding tests, not just `docs/contributing.md` §8 (which now just points
| `ErsatzTV.Scanner.Tests` | Library scanning: scan handlers, folder scanners, NFO readers | Handler tests substitute the folder scanners + `ILibraryRepository` and assert the resulting repository writes (e.g. `ScanLocalLibraryHandlerTests` pins which `LastScan` levels a scan records — ersatztv#264). Fakes/`Testably` back the file-system-facing scanners. ~1,485 tests (approximate on purpose — an exact count goes stale on every PR that adds one). Additionally contains `Core/FFmpeg/TranscodingTests``[Explicit]` + `[Combinatorial]`, so it never runs in CI or a plain `dotnet test` (it needs real ffmpeg/hardware) and contributes 0 to that count; run it by name when touching the transcoding pipeline. | | `ErsatzTV.Scanner.Tests` | Library scanning: scan handlers, folder scanners, NFO readers | Handler tests substitute the folder scanners + `ILibraryRepository` and assert the resulting repository writes (e.g. `ScanLocalLibraryHandlerTests` pins which `LastScan` levels a scan records — ersatztv#264). Fakes/`Testably` back the file-system-facing scanners. ~1,485 tests (approximate on purpose — an exact count goes stale on every PR that adds one). Additionally contains `Core/FFmpeg/TranscodingTests``[Explicit]` + `[Combinatorial]`, so it never runs in CI or a plain `dotnet test` (it needs real ffmpeg/hardware) and contributes 0 to that count; run it by name when touching the transcoding pipeline. |
| `ErsatzTV.Architecture.Tests` | Layering rules via NetArchTest.eNhancedEdition | Core↛Infra/App/EF; FFmpeg↛all; App↛concrete providers. 5 tests. See `docs/contributing.md` §1. | | `ErsatzTV.Architecture.Tests` | Layering rules via NetArchTest.eNhancedEdition | Core↛Infra/App/EF; FFmpeg↛all; App↛concrete providers. 5 tests. See `docs/contributing.md` §1. |
| `ErsatzTV.FFmpeg.Tests` | FFmpeg command construction | Build a pipeline, assert the exact rendered arg string (`PipelineBuilderBaseTests.cs`). | | `ErsatzTV.FFmpeg.Tests` | FFmpeg command construction | Build a pipeline, assert the exact rendered arg string (`PipelineBuilderBaseTests.cs`). |
| `web/` (vitest) | React SPA unit tests | 995 tests across 105 files; run alongside typecheck + build (see below). Collects `src/**` *and* `web/scripts/**`, but deliberately **excludes** `web/e2e/**` (the Playwright specs — vitest's default `**/*.spec.*` glob would otherwise run them under jsdom). | | `web/` (vitest) | React SPA unit tests | Run alongside typecheck + build (see below). Collects `src/**`, `web/scripts/**` *and* `web/vite-plugins/**`, but deliberately **excludes** `web/e2e/**` (the Playwright specs — vitest's default `**/*.spec.*` glob would otherwise run them under jsdom). Two files have git prerequisites since ersatztv#819. `web/src/api/pageSizeCallSites.guard.test.ts` needs a git **checkout** AND, through it, the **binary** — it derives its file population from `git ls-files` via `web/vite-plugins/trackedSourceFiles.ts` rather than a directory walk, and refuses rather than falling back. `web/vite-plugins/trackedSourceFiles.realgit.test.ts` needs the **binary** but no checkout: it builds its own temp repository to prove that derivation by executing it. So it is not checkout-versus-binary — supplying a `.git` alone would not let either run. Every other file runs fine with neither. `docker/Dockerfile`'s web-build stage runs the suite with exactly those two `--exclude`d, because its context carries no `.git` and `node:22-bookworm-slim` ships no git — and the two exclusions overlap rather than divide — both files need the binary. |
| `web/e2e/` (Playwright) | UI-interactive E2E flows against a **live** instance | Not a unit suite and **not** part of `npm test` — needs a running server, so it runs via `scripts/e2e-ui.sh` (boots its own fresh instance) and in CI as a step of the `functional-e2e` job. Headless Chromium, `serial`, `retries: 0`. Scope rule: assert only what the curl harness structurally cannot. See `docs/e2e-local.md` → "UI-E2E harness". | | `web/e2e/` (Playwright) | UI-interactive E2E flows against a **live** instance | Not a unit suite and **not** part of `npm test` — needs a running server, so it runs via `scripts/e2e-ui.sh` (boots its own fresh instance) and in CI as a step of the `functional-e2e` job. Headless Chromium, `serial`, `retries: 0`. Scope rule: assert only what the curl harness structurally cannot. See `docs/e2e-local.md` → "UI-E2E harness". |
## Golden-file nets ## Golden-file nets
@@ -98,7 +98,13 @@ dotnet test ErsatzTV.Core.Tests --filter FullyQualifiedName~ChannelPlaylistGolde
Web (`web/`): Web (`web/`):
```bash ```bash
npm test # vitest (excludes web/e2e — those need a live server) npm test -- --run # vitest, single pass — use this for the SPA guards (see below)
npm test # vitest WATCH mode. `pageSizeCallSites.guard.test.ts` reads the git index ONCE
# per dev-server lifetime while the glob refreshes, so it drifts BOTH ways: a
# file created mid-session reddens it misleadingly, and a file that was already
# untracked when the watcher started stays invisible to it after `git add` — a
# green that is not authoritative. Restarting the watcher swaps the first
# problem for the second; confirm with `npm test -- --run`.
npm run typecheck # tsc -b --pretty false npm run typecheck # tsc -b --pretty false
npm run lint # eslint . npm run lint # eslint .
npm run build # tsc -b && vite build npm run build # tsc -b && vite build
+587 -13
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import trackedSources from 'virtual:etv-tracked-source-files';
import { scanPageSizeSites } from './pageSizeScan'; import { scanPageSizeSites } from './pageSizeScan';
/** /**
@@ -339,7 +340,7 @@ const REGISTRY: RegistryEntry[] = [
} }
]; ];
// Enumerates every source file under `src/` via Vite's `import.meta.glob` — eagerly, as raw text // Reads every source file under `src/` via Vite's `import.meta.glob` — eagerly, as raw text
// (`query: '?raw', import: 'default'`) — INSTEAD OF Node's `fs`/`path`/`url` (#650 follow-up). // (`query: '?raw', import: 'default'`) — INSTEAD OF Node's `fs`/`path`/`url` (#650 follow-up).
// This is the only file under `src` that ever needed real filesystem access, and `@types/node` // This is the only file under `src` that ever needed real filesystem access, and `@types/node`
// isn't wired into `tsconfig.app.json`'s project (deliberately: it covers production browser code // isn't wired into `tsconfig.app.json`'s project (deliberately: it covers production browser code
@@ -347,17 +348,123 @@ const REGISTRY: RegistryEntry[] = [
// single-program compilation it leaked Node's ambient `setTimeout` into the whole app project, // single-program compilation it leaked Node's ambient `setTimeout` into the whole app project,
// breaking three unrelated `window.setTimeout` mocks that expect the DOM signature). `import.meta // breaking three unrelated `window.setTimeout` mocks that expect the DOM signature). `import.meta
// .glob` needs neither `node:fs` nor a tsconfig change: it's resolved by Vite at transform time, // .glob` needs neither `node:fs` nor a tsconfig change: it's resolved by Vite at transform time,
// natively available in the browser/app project, and is the idiomatic Vite/vitest way to enumerate // natively available in the browser/app project. Keys are POSIX paths from the project root, e.g.
// source files. Keys are POSIX paths from the project root, e.g. `/src/api/pageSizeScan.ts`. // `/src/api/pageSizeScan.ts`.
//
// The glob supplies CONTENT, not the POPULATION (#819). A directory walk is not an authoritative
// source: an untracked `.ts`/`.tsx` under `web/src/` — a scratch file, a half-finished component,
// an editor dropping — used to enter the population and fail as unregistered on that checkout,
// while CI, which only ever checks out tracked files, stayed green. Developer-red / CI-green, the
// #778 shape that #806 removed from every other guard here. So the keys are intersected with the
// git index (`virtual:etv-tracked-source-files`, resolved by `vite-plugins/trackedSourceFiles.ts`
// in Vite's Node context, which is how the index is reached without admitting `@types/node` to the
// app project).
const rawSourceModules = import.meta.glob('/src/**/*.{ts,tsx,mts,cts}', { const rawSourceModules = import.meta.glob('/src/**/*.{ts,tsx,mts,cts}', {
query: '?raw', query: '?raw',
import: 'default', import: 'default',
eager: true eager: true
}) as Record<string, string>; }) as Record<string, string>;
function basename(path: string): string { /**
const idx = path.lastIndexOf('/'); * The git index, as the guard compares it: paths relative to the Vite root (`web/`), e.g.
return idx === -1 ? path : path.slice(idx + 1); * `src/api/pageSizeScan.ts`. Built once membership is asked per globbed file.
*/
const TRACKED_SOURCE_PATHS: ReadonlySet<string> = new Set(trackedSources.tracked);
/**
* Tracked paths with no readable file see `expectedTrackedSources` for why they are separated.
*
* Residual, stated: emptying this set is NOT caught by any test here, because on a clean checkout it
* is already empty and nothing observes the difference. That is tolerable only because of which way
* it fails an EMPTY set makes the guard RED on an unstaged deletion, never blind.
*
* FATTENING it is the dangerous direction, and it is not this constant's to police: the fixture
* test INJECTS `absentFromDisk`, so it structurally cannot see what the plugin actually derives.
* MEASURED widening the derivation by `|| path.startsWith('src/components/')` hid a planted
* `pageSize: 100` in a tracked dotfile there. That is caught now, where it overlaps a walked file,
* by the assertion in `every in-scope path the WALK sees is accounted for by git`; restricted to
* glob-invisible paths it remains part of residual (3).
*/
const ABSENT_FROM_DISK: ReadonlySet<string> = new Set(trackedSources.absentFromDisk);
/** `import.meta.glob` keys are root-absolute (`/src/...`); the index is root-relative. */
export function globKeyToRootRelativePath(key: string): string {
return key.startsWith('/') ? key.slice(1) : key;
}
/**
* The #819 fix, extracted so it is pinned BY ITSELF rather than by the repo happening to be clean.
*
* Testing this by planting a real untracked file proves the behaviour exists today and pins
* nothing the same argument `isScannableSourceFileName` records below, and the reason that
* predicate was extracted too. A guard whose proof depends on the checkout's incidental contents
* cannot report the day it stopped working.
*/
export function isTrackedSourcePath(key: string, tracked: ReadonlySet<string>): boolean {
return tracked.has(globKeyToRootRelativePath(key));
}
/**
* Walked paths git accounted for in NEITHER list see the cross-check test for why that is the
* signal. Extracted and exported for the same reason every other clause in this file was: on a
* clean checkout `others` is EMPTY, so the term is a structural no-op on the only tree CI ever
* sees, and dropping it leaves the whole suite green. That is not a small omission without
* `others`, an ordinary untracked scratch file becomes "unaccounted for" and reddens the
* developer's checkout while CI stays green, which is the exact #778/#806 shape #819 exists to
* remove. It needs a fixture, not a real-population run.
*
* A trailing-slash entry in `others` covers its subtree: `git ls-files --others` collapses an
* untracked NESTED GIT REPOSITORY to a single directory entry rather than listing its files. That
* cannot be used to hide a narrowing of `tracked`, because git emits a directory entry only for an
* untracked nested repo, never for a tracked path.
*/
export function unaccountedWalkedPaths(
walked: readonly string[],
tracked: readonly string[],
others: readonly string[]
): string[] {
const accountedFor = new Set([...tracked, ...others]);
const untrackedDirs = others.filter((entry) => entry.endsWith('/'));
return walked.filter(
(path) => !accountedFor.has(path) && !untrackedDirs.some((dir) => path.startsWith(dir))
);
}
/**
* Whether the on-disk population has shrunk so far below the index that it reads as a degenerate
* plugin result rather than as a developer mid-edit.
*
* A named function rather than an inline comparison so the threshold has a clause proof: disarming
* an inline `toBeGreaterThan` is an uncaught mutation whose failure direction is fail-OPEN a
* disarmed floor lets a degenerate population pass every completeness claim below it.
*
* The threshold is deliberately tight, and the cost is stated: a tenth of the in-scope files is
* ~14 today, so deleting a whole feature directory without staging it trips this. That is the #806
* shape (a guard red on an ordinary state), accepted here only because the message names deletions
* first and the remedy is one `git add`.
*/
export function populationIsDegenerate(presentCount: number, inScopeCount: number): boolean {
return presentCount <= inScopeCount * 0.9;
}
/**
* Whether a root-relative path is in scope for the scanner at all the ONE place that decides it.
*
* Both sides of the comparison call this: the walk (`listSourceFilesFrom`) and the index
* (`expectedTrackedSources`). Two copies of this predicate would be the classic one-helper-two-callers
* divergence, and a divergence between them is a bug in EITHER direction the walk quietly scanning
* something the index side does not expect, or vice versa.
*/
export function isInScopeSourcePath(rootRelativePath: string): boolean {
// The basename is computed INLINE rather than through a shared `basename()` helper, and that is
// the #819 round-5 fix rather than a style choice: a helper here is a helper the restatement in
// `the shared scope predicate is EXACTLY its two documented components` would also call, which
// puts it on both sides of that comparison where it cancels. It was shared, and teaching it to
// return '' for deep paths removed 15 real files and blinded the guard to a planted call site
// with the whole suite green. There is now no shared helper to tidy this back into.
const name = rootRelativePath.slice(rootRelativePath.lastIndexOf('/') + 1);
return !rootRelativePath.includes('/generated/') && isScannableSourceFileName(name);
} }
// Extracted from `listSourceFiles`'s inline condition so it's independently testable (#650 // Extracted from `listSourceFiles`'s inline condition so it's independently testable (#650
@@ -370,7 +477,7 @@ function basename(path: string): string {
// repo happening to contain (or not contain) a matching file. This predicate is still what the // repo happening to contain (or not contain) a matching file. This predicate is still what the
// glob's results are filtered THROUGH below (`listSourceFiles`) — the extension SET moved into the // glob's results are filtered THROUGH below (`listSourceFiles`) — the extension SET moved into the
// glob literal, but discovery still runs every matched file through this same named, tested // glob literal, but discovery still runs every matched file through this same named, tested
// function, not a second copy of the logic. // function (via `isInScopeSourcePath`, which `listSourceFilesFrom` calls), not a second copy.
export function isScannableSourceFileName(name: string): boolean { export function isScannableSourceFileName(name: string): boolean {
// `.mts`/`.cts` are legal TS extensions `tsconfig.app.json`'s `include` covers alongside // `.mts`/`.cts` are legal TS extensions `tsconfig.app.json`'s `include` covers alongside
// `.ts`/`.tsx` — none exist in this repo today, but the glob must not silently skip one if it // `.ts`/`.tsx` — none exist in this repo today, but the glob must not silently skip one if it
@@ -386,13 +493,43 @@ interface ScannableSource {
text: string; text: string;
} }
function listSourceFiles(): ScannableSource[] { /**
* The discovery pipeline, over INJECTED inputs so each of its filters can be mutated and witnessed
* (`testing.guard-ships-with-mutation-proof`). `listSourceFiles` below is the real-source caller.
*
* Extracted for the #819 tracked-file clause specifically: with a clean checkout the clause is a
* no-op over real inputs, so deleting it from a `listSourceFiles` that reads only real source
* leaves the whole suite green the guard would ship unproven, which is the exact failure this
* repo has recorded six times.
*/
export function listSourceFilesFrom(
modules: Record<string, string>,
tracked: ReadonlySet<string>
): ScannableSource[] {
const out: ScannableSource[] = []; const out: ScannableSource[] = [];
for (const [key, text] of Object.entries(rawSourceModules)) { for (const [key, text] of Object.entries(modules)) {
if (key.includes('/generated/')) { if (!isInScopeSourcePath(globKeyToRootRelativePath(key))) {
continue; continue;
} }
if (!isScannableSourceFileName(basename(key))) { // #819: the population is the git index, not the directory. This narrows the glob's results;
// it is NOT the "filter before a completeness claim" that
// `testing.guard-derives-population-from-source` forbids — that rule forbids narrowing the
// population so the ABSENT member becomes unrepresentable, and this restores the population to
// the authoritative source the rule names.
//
// Dropping a key here is only safe because the OTHER direction is asserted: every tracked,
// on-disk, in-scope path must come back out of this function, checked by
// `supplies content for every tracked source file on disk`. Without that, a tracked file the
// WALK cannot see — a dotfile, or a name whose disk spelling diverges from the index spelling,
// which `core.ignorecase` (and NFD/NFC normalisation) makes persistent on macOS — would vanish
// from the population silently, the same defect one level down from the one this clause fixes.
//
// What that direction does NOT prove, stated because "both directions are asserted" would
// otherwise read as more than it is: both sides share `isInScopeSourcePath`, so the SCOPE
// itself cancels out of the equality. A change to what counts as in-scope moves both sets
// together and is invisible here — it is caught by `the shared scope predicate is EXACTLY its
// two documented components`, which re-derives that predicate over the whole tracked index.
if (!isTrackedSourcePath(key, tracked)) {
continue; continue;
} }
out.push({ file: key.replace(/^\/src\//, ''), text }); out.push({ file: key.replace(/^\/src\//, ''), text });
@@ -400,6 +537,66 @@ function listSourceFiles(): ScannableSource[] {
return out; return out;
} }
/**
* The real-source caller and the one WIRING nothing here pins, stated as residual (4).
*
* MEASURED: replacing this body with `listSourceFilesFrom(rawSourceModules, new
* Set(Object.keys(rawSourceModules).map(globKeyToRootRelativePath)))` — the walk as its own
* population, i.e. #819 reverted in full leaves the entire suite green, and so does widening
* `TRACKED_SOURCE_PATHS` to `tracked others`.
*
* Why no assertion COMPARING THE TWO POPULATIONS closes it, measured rather than assumed: on a
* clean checkout the walk set and the index set agree on every IN-SCOPE key every tracked file
* is on disk and nothing untracked exists (they are not literally equal: the index also carries
* `.css`, which the glob never yields) so
* a comparison between the two populations is green whichever one is wired in. The difference is
* observable only on a tree that HAS an untracked source file, which is exactly the tree CI never
* checks out. Two assertions of that shape were written and measured green against both mutations
* before being removed rather than left in as decoration. A `listSourceFiles.toString()` assertion
* does redden the first mutation and is rejected rather than impossible: a text snapshot of one's
* own source is the hand-written-mirror shape this file spent nine rounds removing, and it does not
* catch the second mutation anyway.
*
* Direction, which is why this is a residual and not a defect: both mutations are fail-NOISY, never
* blind. A reverted wiring reddens the developer's checkout that has a scratch file annoying, and
* the original #819 complaint but it cannot hide a `pageSize` call site from the registry.
*
* That is NOT true of every residual: the blind directions the cross-check pins all run through the
* population SOURCE, and a mispartition that PRESERVES the union is not among them see residuals
* (1) and (3) at the restatement below, which are the blind ones.
*/
function listSourceFiles(): ScannableSource[] {
return listSourceFilesFrom(rawSourceModules, TRACKED_SOURCE_PATHS);
}
/**
* What the index says the population SHOULD be: every tracked path under `src/` that is in scope
* for the scanner and actually exists on disk.
*
* `absentFromDisk` is subtracted rather than ignored, and the distinction is the point. A tracked
* path with no readable file is a mid-edit deletion transient, and reddening on it would make the
* guard red on an ordinary working state, which teaches readers to ignore it (#806). A tracked path
* that DOES exist but never reached the walk is a hole: the glob matches no dotfile or
* dot-directory, and on a case-insensitive filesystem `git` keeps the index spelling while the disk
* keeps another (likewise NFD/NFC under `core.precomposeunicode`), so the intersection misses it
* permanently. Comparing against this set is what tells those two apart instead of collapsing them.
*
* This is a DELIBERATE DEVIATION from `scripts/tests/tracked_files.py`, not an instance of it. That
* module asserts existence `assert absolute.is_file()`, on the argument that reporting a mid-edit
* tree "is strictly better than silently shrinking the population" and this filters on it. The
* deviation is taken because the two populations differ in kind: that one covers a handful of hooks
* and scripts, where a missing file is remarkable, while this one runs over every tracked path under
* `web/src/` (~260, of which ~141 are in scope) in a tree a developer edits continuously, where an
* unstaged deletion is routine. The cost is recorded rather
* than hidden: see the residual note in `docs/guard-inventory.md`'s row.
*/
export function expectedTrackedSources(
tracked: readonly string[],
absentFromDisk: ReadonlySet<string>
): string[] {
return tracked.filter((path) => isInScopeSourcePath(path) && !absentFromDisk.has(path));
}
interface DiscoveredSite { interface DiscoveredSite {
file: string; file: string;
line: number; line: number;
@@ -493,7 +690,7 @@ describe('pageSize call-site guard (#650)', () => {
['builder/libraryBrowse.test.mts', false], ['builder/libraryBrowse.test.mts', false],
['api/pageSizeScan.test.cts', false], ['api/pageSizeScan.test.cts', false],
['api/pageSizeCallSites.guard.test.ts', false], ['api/pageSizeCallSites.guard.test.ts', false],
['api/generated/v1.ts', true], // the predicate itself is filename-only; the 'generated' DIRECTORY exclusion lives in listSourceFiles, tested separately below. ['api/generated/v1.d.ts', true], // the predicate itself is filename-only; the 'generated' DIRECTORY exclusion lives in isInScopeSourcePath, tested separately below.
['components.js', false], ['components.js', false],
['data.json', false], ['data.json', false],
['README.md', false], ['README.md', false],
@@ -512,7 +709,384 @@ describe('pageSize call-site guard (#650)', () => {
} }
); );
it('scans a healthy number of source files (anti-vacuity: a broken glob must not pass on zero input)', () => { // ---- #819: the population is the git index, not the directory walk -------------------
//
// These four are the CLAUSE proof for the tracked-file filter. Over real source on a clean
// checkout that clause removes nothing, so nothing below may depend on real source: delete
// `if (!isTrackedSourcePath(...))` from `listSourceFilesFrom` and the first of these goes red on
// its own, which is what `testing.guard-ships-with-mutation-proof` requires and what a
// plant-a-real-file check could never give.
it('EXCLUDES an untracked file that matches the scope in every other way (#819)', () => {
const modules = {
'/src/screens/Tracked.tsx': 'const a = { pageNum: 0, pageSize: 25 };',
'/src/screens/Untracked.tsx': 'const b = { pageNum: 0, pageSize: 25 };'
};
const tracked = new Set(['src/screens/Tracked.tsx']);
expect(listSourceFilesFrom(modules, tracked).map((source) => source.file)).toEqual(['screens/Tracked.tsx']);
});
it('INCLUDES both when both are tracked — the filter keys on the index, not on the name', () => {
// Positive control. Without it, a clause mutated to reject everything would still satisfy the
// exclusion test above while emptying the population entirely.
const modules = {
'/src/screens/Tracked.tsx': 'const a = { pageNum: 0, pageSize: 25 };',
'/src/screens/AlsoTracked.tsx': 'const b = { pageNum: 0, pageSize: 25 };'
};
const tracked = new Set(['src/screens/Tracked.tsx', 'src/screens/AlsoTracked.tsx']);
expect(listSourceFilesFrom(modules, tracked).map((source) => source.file).sort()).toEqual([
'screens/AlsoTracked.tsx',
'screens/Tracked.tsx'
]);
});
it('applies the tracked filter to the REAL population, not only to injected fixtures', () => {
// Liveness, which the fixture tests above cannot give. Those drive `listSourceFilesFrom` with
// 1-2 injected entries, so a clause narrowed to `... && tracked.size < 10` stays green over
// them while being dead over the real 260-path index — the fix fully regressed, suite green.
// "The guard being WIRED is not the guard RUNNING." This drives the REAL glob and the REAL
// index, with one synthetic key added, so the clause has to be live on the path that ships.
const imposter = {
...rawSourceModules,
'/src/screens/NotTracked819Imposter.tsx': 'const a = { pageNum: 0, pageSize: 25 };'
};
expect(listSourceFilesFrom(imposter, TRACKED_SOURCE_PATHS).map((source) => source.file)).not.toContain(
'screens/NotTracked819Imposter.tsx'
);
});
it.each([
['src/api/pageSizeScan.ts', true],
['src/screens/TrashScreen.tsx', true],
['src/components/forms.tsx', true],
['src/builder/libraryBrowse.mts', true],
['src/api/generated/v1.d.ts', false],
['src/api/pageSizeScan.test.ts', false],
['src/api/pageSizeCallSites.guard.test.ts', false],
['src/shell.css', false]
])(
'isInScopeSourcePath(%s) === %s — worked examples of the shared scope',
(path, expected) => {
// ILLUSTRATIVE, explicitly NOT the proof. A table of paths is a hand-written mirror of a
// population — "a filter frozen at authoring time, correct on the day it was written and
// unable to report the day it stopped being" — and this one was measured failing exactly
// that way: it names four of `src/`'s eight directories, so narrowing the predicate to skip
// the other four dropped 23 real files with all 35 tests green. What actually reddens an
// added term is `the shared scope predicate is EXACTLY its two documented components` below,
// which re-derives over the whole index instead of over remembered examples.
expect(isInScopeSourcePath(path)).toBe(expected);
}
);
it('the shared scope predicate is EXACTLY its two documented components, over the whole index', () => {
// `isInScopeSourcePath` is called by BOTH directions, so scope cancels out of the equality
// between them and no assertion comparing the two sides can see a term added to it. Nor can a
// table of example paths: it only covers the directories whoever wrote it thought of.
//
// So spell the intended scope out once, deliberately, and require the predicate to equal it
// over the REAL tracked population.
//
// BOTH halves are restated here, and the second one is the whole point. An earlier version
// called `isScannableSourceFileName` on this side too, delegating the filename half to the same
// function `isInScopeSourcePath` calls — which put it on both sides of the comparison, where it
// cancels algebraically. MEASURED: adding `&& !name.endsWith('.d.ts')` to that predicate
// dropped a real tracked file and adding a term that skips lowercase-initial `.tsx` dropped
// eleven, both with the whole suite green. Delegating half a decomposition proves
// half of it.
//
// THE CRITERION, stated as a checkable property because four rounds of "restate the scope" were
// (#819, 2026-08-29) each defeated by something the restatement still SHARED with its subject: the filename half
// (delegated to `isScannableSourceFileName`), and before that a table of example paths. Anything
// shared sits on both sides of the `toEqual` below and cancels algebraically.
//
// The restatement must be CLOSED-FORM over the raw path: it may share NO helper, at ANY
// depth, with `isInScopeSourcePath`. Not `isScannableSourceFileName`, and not `basename`.
//
// `basename` is why this is spelled out rather than left as "restate both halves". It looks like
// plumbing rather than policy, and it was shared. MEASURED under #819 (2026-08-29): teaching it to
// return `''` for paths four segments deep removed 15 real tracked files, and a `pageSize: 100`
// planted in one of them went entirely unseen — 36/36 green. The inlined `lastIndexOf` below is
// that fix, and it is the whole reason the criterion is phrased as "no helper at any depth".
//
// What this form is, and why it terminates where a member list does not: a verbatim restatement
// of a FIXED-SIZE predicate, re-checked against its subject over the whole derived index on
// every run — the shape the record already blesses for `MARKED_JOBS`. A list of members or
// example paths decays because its size tracks a growing population; four regex clauses do not.
//
// Six residuals, and they are NOT a claim of closure — two earlier #819 attempts asserted
// closure here and were each measured wrong:
// (1) a COORDINATED edit of the scope predicate and BOTH closed-form restatements passes.
// Three sites, not two: `isInScopeSourcePath`, this restatement, and the second one in
// `supplies content for every tracked source file on disk` — four when the term also
// moves a worked-example row. MEASURED: the two-site edit REDDENS (the second
// restatement catches it); the three-site edit is green.
// (2) a term matching zero tracked paths today survives until the day it first matches one,
// at which point it reddens — so a term becomes visible the moment it AFFECTS a tracked
// path, not when it is written.
// (3) the plugin's THREE outputs are trusted to describe the repository faithfully — not
// just `tracked`/`others` partitioning the on-disk set, but `absentFromDisk` reporting
// only files that are really gone.
// (4) the WIRING — see `listSourceFiles`, where the index set and the walk set are
// indistinguishable on a clean checkout, so no assertion COMPARING THE TWO POPULATIONS
// can tell which one is passed.
// (5) `ABSENT_FROM_DISK` emptied, documented at its own declaration.
// (6) WATCH MODE, stale in BOTH directions — this one is not sorted into the noisy/blind
// bullets below because it is each in turn. The virtual module has no backing file, so
// Vite never invalidates it: `resolveTrackedSourceFiles` runs once per dev-server
// lifetime while the glob refreshes, and the two drift apart.
// NOISY — a file CREATED mid-session is in neither list, so the cross-check reddens
// with a message naming causes that are not the cause; `git add` does not clear it.
// BLIND — a file already UNTRACKED when the watcher started keeps its `others`
// classification when it is staged mid-session, so it never enters the population and
// its call sites are never scanned. MEASURED: whole suite green across both phases,
// while `npx vitest run` on that identical tree reports `UNREGISTERED (1)`. This window
// is NEW — before #819 the population was the walk, which always contained the file.
// So a watch-mode green is not authoritative for this guard. Confirm with
// `npm test -- --run`; restarting the watcher clears the noisy case and OPENS the blind
// one, which is why it is not the remedy.
//
// The `configureServer` fix is REJECTED on measurement, not on taste — but the trade is
// narrower than "noisy for blind": invalidating on the watcher's `add`/`unlink` 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, because `git add`
// touches no file and so fires no watcher event in either design. MEASURED with the hook
// in place: a tracked, planted `pageSize: 100` goes unseen, green.
//
// Their DIRECTIONS differ, and the difference is the whole argument for tolerating each — so it
// is stated per residual and each one was MEASURED by planting a real `pageSize` call site,
// never inferred:
// * (2), (4) and (5) are fail-NOISY. They redden a checkout; they cannot hide a call site.
// * (1) and (3) are BLIND — each hid a planted `pageSize: 100` with the whole suite green.
// * (6) is BOTH, by sequence — see its entry; it is the only one that is not one or the other.
// They are tolerated for different reasons, and neither reason is "harmless". (1) is a
// deliberate policy change spanning three separate sites, which is review-visible in a way a
// one-line slip is not — that multi-site edit IS the signal, since nothing here reddens.
// (3) is blind only for a misdescription restricted to paths the WALK cannot see. A
// mispartition moving paths from `tracked` to `others` preserves the union the cross-check
// compares, so nothing computed here sees it; a FATTENED `absentFromDisk` is caught wherever it
// overlaps a walked file, by the assertion in `every in-scope path the WALK sees is accounted
// for by git` — do not delete that assertion as decoration. What is left is answered by testing
// the derivation itself, in `vite-plugins/trackedSourceFiles.realgit.test.ts`, and those tests
// catch only an UNCONDITIONAL misdescription, not one keyed on a path pattern.
const byComponents = trackedSources.tracked.filter((path) => {
// Inlined deliberately — see the criterion above. There is no shared basename helper to
// factor this into any more; do not reintroduce one for it.
const name = path.slice(path.lastIndexOf('/') + 1);
return (
!path.includes('/generated/') &&
/\.(ts|tsx|mts|cts)$/.test(name) &&
!/\.test\.(tsx?|mts|cts)$/.test(name) &&
// Subsumed by the clause above (`.guard.test.ts` ends `.test.ts`) and mirrored anyway:
// this must match the predicate clause for clause, or the comparison stops meaning
// "these two agree" and starts meaning "these two happen to coincide".
!name.endsWith('.guard.test.ts')
);
});
expect(trackedSources.tracked.filter(isInScopeSourcePath)).toEqual(byComponents);
});
it('expectedTrackedSources keeps in-scope, on-disk paths and drops each other kind', () => {
// The clause proof for the OTHER direction, mirroring the two `listSourceFilesFrom` fixture
// tests. Without it every term here is unpinned: the whole body could `return []` — or the
// `absentFromDisk` subtraction, the entire point of the plugin's second field, could be
// deleted — with the full suite green, which is the shape `testing.guard-ships-with-mutation-proof`
// exists to forbid, and the one #819 had to fix a layer down.
const tracked = [
'src/a.ts',
'src/screens/Keep.tsx',
'src/gone.ts',
'src/api/generated/v1.ts',
'src/a.test.ts',
'src/api/pageSizeCallSites.guard.test.ts',
'src/shell.css'
];
expect(expectedTrackedSources(tracked, new Set(['src/gone.ts']))).toEqual(['src/a.ts', 'src/screens/Keep.tsx']);
});
it('unaccountedWalkedPaths returns only what git accounted for in NEITHER list', () => {
// Pins the `others` term, which is a structural no-op on a clean checkout and so cannot be
// pinned by the real-population test below. The middle case is the load-bearing one: drop
// `others` from the union and an ordinary untracked scratch file becomes "unaccounted for",
// reddening the developer's checkout while CI — which never has one — stays green.
const tracked = ['src/a.ts'];
const others = ['src/scratch.ts'];
expect(unaccountedWalkedPaths(['src/ghost.ts'], tracked, others)).toEqual(['src/ghost.ts']);
expect(unaccountedWalkedPaths(['src/scratch.ts'], tracked, others)).toEqual([]);
expect(unaccountedWalkedPaths(['src/a.ts'], tracked, others)).toEqual([]);
});
it('unaccountedWalkedPaths treats an untracked-directory entry as covering its subtree', () => {
// `git ls-files --others` collapses an untracked NESTED git repository to one directory entry.
// Without this, an embedded repo under web/src is a permanent spurious red.
expect(unaccountedWalkedPaths(['src/nested/x.ts'], [], ['src/nested/'])).toEqual([]);
// ...and it must not swallow a sibling that merely shares a prefix.
expect(unaccountedWalkedPaths(['src/nestedOther.ts'], [], ['src/nested/'])).toEqual([
'src/nestedOther.ts'
]);
// The trailing-slash RESTRICTION is the clause under test here, not decoration: drop it and
// every `others` entry becomes a prefix mask, so an untracked `src/scratch.ts` would account
// for a walked `src/scratch.tsx` that git never mentioned.
expect(unaccountedWalkedPaths(['src/scratch.tsx'], [], ['src/scratch.ts'])).toEqual([
'src/scratch.tsx'
]);
});
it.each([
[126, 141, true],
[127, 141, false],
[141, 141, false],
[0, 141, true]
])(
'populationIsDegenerate(%i, %i) === %s — the floor, pinned rather than asserted inline',
(present, inScope, expected) => {
// Two identical floor assertions used to sit side by side here, so deleting either left the
// suite green — `duplicate guards mask each other`. One assertion now, over a named function
// with its own fixtures, so disarming it reddens by name.
expect(populationIsDegenerate(present, inScope)).toBe(expected);
}
);
it('every in-scope path the WALK sees is accounted for by git — tracked, or reported untracked', () => {
// The population SOURCE's own clause proof, and the one comparison here that is not derived
// from `trackedSources.tracked` on both sides (#819 round 6).
//
// Everything else in this file compares two things computed from that one array: the scope
// restatement, the shortfall direction, the floor's denominator. So a filter applied inside the
// PLUGIN — `raw.split('\0').filter(p => p.length > 0 && !p.startsWith('src/components/'))` —
// shrinks both sides of every one of them and cancels. MEASURED: that one-line edit dropped 8
// real in-scope files and a `pageSize: 100` planted in one of them went entirely unseen, with
// the whole suite green. Blind, not merely quiet.
//
// `tracked others` is what git says is on disk under `src/`, from two SEPARATE queries. A
// path the walk found that is in neither has been filtered out of the index list on its way
// here, and narrowing `tracked` cannot suppress this signal because it adds nothing to
// `others`. An untracked scratch file stays green — it lands in `others` — which is the #819
// property itself.
// The `isInScopeSourcePath` filter here is a narrowing CONVENIENCE, not a load-bearing term:
// dropping it only widens `walked`, and every extra path is still accounted for by git —
// tracked, or reported untracked — so it cannot hide anything. Measured green when removed;
// recorded so a later round does not re-litigate it as an unpinned clause.
const walked = Object.keys(rawSourceModules).map(globKeyToRootRelativePath).filter(isInScopeSourcePath);
expect(
unaccountedWalkedPaths(walked, trackedSources.tracked, trackedSources.others),
`In-scope file(s) the walk found that git accounted for in neither list. Three causes, in ` +
`the order worth checking: (1) the file's disk spelling differs from its index spelling ` +
`(case under core.ignorecase, or NFD/NFC) — the shortfall test below names the same file ` +
`and is the better diagnosis; (2) an untracked NESTED git repository under web/src, which ` +
`git reports as a directory rather than as files; (3) the index list was narrowed between ` +
`git and this guard — check web/vite-plugins/trackedSourceFiles.ts.`
).toEqual([]);
// A path the WALK found is on disk by construction — the glob only yields files that exist —
// so it can never legitimately be reported absent. Without this, FATTENING the plugin's third
// output silently disarms the hole-detection direction: MEASURED, `absentFromDisk` widened by
// `|| path.startsWith('src/components/')` subtracted those files from `expected`, so a tracked
// `src/components/.probe819.ts` holding `pageSize: 100` — invisible to the glob, which is the
// whole case the shortfall test exists for — went from red to 43/43 green. That is residual
// (3) reaching a THIRD output, not the tracked/others pair.
//
// What this closes and what it does not, measured both ways: it catches any fattening that
// overlaps a walked file. A fattening restricted to paths the walk cannot see anyway (a
// dotfile, a case-divergent name) is still not caught here, because those never enter `walked`
// — that narrower case stays inside residual (3).
expect(
walked.filter((path) => ABSENT_FROM_DISK.has(path)),
'Path(s) the walk FOUND that the plugin also reports as absent from disk. Both cannot be ' +
'true, so the absent-set derivation in web/vite-plugins/trackedSourceFiles.ts is reporting ' +
'files that exist — which subtracts them from the shortfall check and disarms it.'
).toEqual([]);
// Anti-vacuity: an empty walk would satisfy the filters above trivially.
expect(walked.length).toBeGreaterThan(50);
});
it('supplies content for every tracked source file on disk (a shortfall is a HOLE, not a skip)', () => {
// The other direction of the set equality. `listSourceFilesFrom` drops any key the index does
// not carry; this asserts nothing was dropped that the index DOES carry and that exists on
// disk. Two real holes it closes, both measured on this repo: `import.meta.glob` matches no
// dotfile or dot-directory, and on `core.ignorecase=true` a file whose disk spelling diverges
// from its index spelling (`caseprobe.tsx` vs `CaseProbe.tsx`) is tracked, present, and
// invisible to the intersection — the guard went blind to it rather than reporting it.
const produced = new Set(listSourceFiles().map((source) => `src/${source.file}`));
const expected = expectedTrackedSources(trackedSources.tracked, ABSENT_FROM_DISK);
// `expectedTrackedSources` gets the same closed-form treatment as the scope predicate, and for
// the same measured reason: its clause proof is a fixture table, and a table only covers the
// paths whoever wrote it thought of. MEASURED — adding `&& !path.includes('/media/')` inside
// that function left the whole suite green. Restated closed-form over the raw path, sharing no
// helper with it, so any added term makes the two sets differ.
expect(
expected,
'expectedTrackedSources applied a term beyond its two documented components.'
).toEqual(
trackedSources.tracked.filter((path) => {
const name = path.slice(path.lastIndexOf('/') + 1);
return (
!path.includes('/generated/') &&
/\.(ts|tsx|mts|cts)$/.test(name) &&
!/\.test\.(tsx?|mts|cts)$/.test(name) &&
!name.endsWith('.guard.test.ts') &&
!ABSENT_FROM_DISK.has(path)
);
})
);
// Anti-vacuity over the REAL plugin output, which is the part the fixture test above
// structurally cannot reach: it drives `expectedTrackedSources` with injected inputs, so an
// `absentFromDisk` that swallowed the whole index, or a plugin returning a degenerate set,
// would leave it green. Bounded relative to the index rather than by a literal, so the floor
// stays meaningful as the SPA grows instead of pinning a number that was true once.
// Bounded against the IN-SCOPE tracked count, not against every tracked path: the numerator is
// source files, so a denominator counting tests and CSS too would slacken as the suite grows.
// Carries its OWN message: this fires on a large number of unstaged deletions, which is a real
// developer state, and the shortfall message below would then blame a dotfile or a case
// divergence and send the reader looking for the wrong thing.
const inScope = trackedSources.tracked.filter(isInScopeSourcePath).length;
expect(
populationIsDegenerate(expected.length, inScope),
`Only ${expected.length} of ${inScope} tracked in-scope source files are present on disk. ` +
`If you have deleted a batch of files, stage the deletions — the population follows the ` +
`index. Otherwise the plugin's absent-from-disk set is degenerate (a sparse checkout, or an ` +
`unreadable tree), and every completeness claim below is running on a shrunken population.`
).toBe(false);
const missing = expected.filter((path) => !produced.has(path));
expect(
missing,
`Tracked, on-disk, in-scope source file(s) that never reached the scanner. The walk cannot ` +
`see them, so the population silently shrank. Usual causes: a dotfile or dot-directory ` +
`(import.meta.glob matches neither), or a name whose disk spelling diverges from its index ` +
`spelling — case under core.ignorecase, or NFD/NFC under core.precomposeunicode. Rename the ` +
`file to match the index, or widen the glob — do NOT drop this assertion.`
).toEqual([]);
});
it('normalises a root-absolute glob key to the root-relative path the index uses', () => {
expect(globKeyToRootRelativePath('/src/api/pageSizeScan.ts')).toBe('src/api/pageSizeScan.ts');
// Already relative — idempotent rather than stripping a second character.
expect(globKeyToRootRelativePath('src/api/pageSizeScan.ts')).toBe('src/api/pageSizeScan.ts');
});
it('receives a REAL git index from the Vite plugin (the population is wired, not just declared)', () => {
// "The guard being WIRED is not the guard RUNNING." If `vite-plugins/trackedSourceFiles.ts`
// stopped being registered, or handed back an empty list, every completeness claim below would
// pass over an empty population while looking identical to a satisfied one. The plugin throws
// on a failed or empty `git ls-files`; this is the assertion on the consuming side.
expect(TRACKED_SOURCE_PATHS.size).toBeGreaterThan(50);
expect(TRACKED_SOURCE_PATHS.has('src/api/pageSizeScan.ts')).toBe(true);
expect(TRACKED_SOURCE_PATHS.has('src/api/pageSizeCallSites.guard.test.ts')).toBe(true);
// ...and it is a real index rather than "every path anyone asks about".
expect(TRACKED_SOURCE_PATHS.has('src/screens/NoSuchFile819.tsx')).toBe(false);
});
it('scans a healthy number of TRACKED source files (anti-vacuity: a broken glob or a broken index read must not pass on zero input)', () => {
const files = listSourceFiles(); const files = listSourceFiles();
expect(files.length).toBeGreaterThan(50); expect(files.length).toBeGreaterThan(50);
}); });
+23
View File
@@ -1 +1,24 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
// ersatztv#819: the tracked-source-file population, supplied by `vite-plugins/trackedSourceFiles.ts`
// from `git ls-files`. Declared here rather than imported from the plugin so the app project never
// pulls in `@types/node` — see that plugin's doc comment for why that matters.
//
// Residual: this is a HAND-WRITTEN mirror of that plugin's `TrackedSources`, with no
// compile-time link to it, so a renamed or retyped field type-checks here and fails at
// runtime. Importing the real type would drag `@types/node` into the app project, which is
// the whole thing this arrangement exists to avoid. The runtime failure is loud and immediate
// for `tracked` (a rename yields `new Set(undefined)`, an empty population the anti-vacuity floor
// reddens on) and for `others` (a TypeError). It is NOT loud for `absentFromDisk`: renaming that
// one is silent, which is the same fail-noisy-only tolerance recorded for it in the guard.
declare module 'virtual:etv-tracked-source-files' {
const trackedSources: {
/** Paths relative to the Vite root (`web/`), e.g. `src/api/pageSizeScan.ts`. */
tracked: string[];
/** Tracked paths with no READABLE file: usually an unstaged deletion, also a broken symlink. */
absentFromDisk: string[];
/** Untracked paths under `src/` (ignored included), from a separate `ls-files --others` query. */
others: string[];
};
export default trackedSources;
}
+1 -1
View File
@@ -14,5 +14,5 @@
// The Playwright UI-E2E config + specs (ersatztv#445) belong here, not in tsconfig.app.json: they // The Playwright UI-E2E config + specs (ersatztv#445) belong here, not in tsconfig.app.json: they
// run in Node (not the bundled browser app), and `tsc -b` then type-checks them as part of // run in Node (not the bundled browser app), and `tsc -b` then type-checks them as part of
// `npm run build`, so a broken spec fails the build instead of only failing at test time. // `npm run build`, so a broken spec fails the build instead of only failing at test time.
"include": ["vite.config.ts", "eslint.config.js", "playwright.config.ts", "e2e/**/*.ts"] "include": ["vite.config.ts", "eslint.config.js", "playwright.config.ts", "e2e/**/*.ts", "vite-plugins/**/*.ts"]
} }
@@ -0,0 +1,97 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, describe, expect, it } from 'vitest';
import { type GitRunner, resolveTrackedSourceFiles } from './trackedSourceFiles';
/**
* SEPARATE FILE because these are the only tests in the SPA suite that need the `git` BINARY but NO
* CHECKOUT they build their own repository (ersatztv#819). That is not the same as saying the
* guard needs only a checkout: it needs a checkout AND, through it, the binary. Measured with
* `.git` present and git off `PATH`, `pageSizeCallSites.guard.test.ts` still fails with the
* plugin's refusal. Reading the two exclusions as checkout-versus-binary is what broke the Docker
* stage once already; see `docker/Dockerfile`, which retracts that framing.
*
* `docker/Dockerfile`'s web-build stage runs on `node:22-bookworm-slim`, which ships no git at all,
* so this file is excluded there alongside the guard. Keeping them apart means
* that exclusion costs two files rather than the thirteen injected-runner tests in
* `trackedSourceFiles.test.ts`, which run fine with no git and no repository.
*
* Do NOT make these skip when git is missing. A check that answers "could not tell" by passing is a
* permanent no-op, which is the whole failure mode this plugin exists to avoid.
*/
describe('resolveTrackedSourceFiles against a REAL repository (#819)', () => {
// Every test in the sibling `trackedSourceFiles.test.ts` injects a fake `GitRunner`, which proves
// the derivation's SHAPE and never that
// it works against git. `scripts/tests/test_guard_populations_derive_from_git.py` makes exactly
// this point about its own primitive — "It proves the mechanism by EXECUTING it rather than by
// recognising its shape — no monkeypatching, no stand-in for git" — and the population this
// plugin feeds is asserted for completeness, so it earns the same treatment.
const repos: string[] = [];
afterAll(() => {
for (const dir of repos) {
rmSync(dir, { recursive: true, force: true });
}
});
function repoWithOneTrackedAndOneUntracked(): string {
const dir = mkdtempSync(join(tmpdir(), 'etv-819-'));
repos.push(dir);
const git = (...args: string[]) => execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
git('init', '-q');
mkdirSync(join(dir, 'src'), { recursive: true });
writeFileSync(join(dir, 'src', 'tracked.ts'), 'export const a = 1;\n');
writeFileSync(join(dir, 'src', 'untracked.ts'), 'export const b = 2;\n');
// STAGED, not committed. `ls-files` reads the INDEX, so a commit adds nothing — and committing
// makes the fixture depend on the developer's global git config: under a `commit.gpgsign = true`
// that many people set, `git commit` fails and these tests go red on their machine while CI
// stays green. That is the very shape #819 exists to remove, so it must not be reintroduced by
// #819's own tests. `git add` is unaffected by signing.
git('add', 'src/tracked.ts');
return dir;
}
it('really separates a tracked file from an identical untracked sibling', () => {
const result = resolveTrackedSourceFiles(repoWithOneTrackedAndOneUntracked());
expect(result.tracked).toEqual(['src/tracked.ts']);
expect(result.others).toEqual(['src/untracked.ts']);
expect(result.absentFromDisk).toEqual([]);
});
it('really reports a tracked file deleted from the working tree as absent', () => {
const dir = repoWithOneTrackedAndOneUntracked();
rmSync(join(dir, 'src', 'tracked.ts'));
const result = resolveTrackedSourceFiles(dir);
expect(result.tracked).toEqual(['src/tracked.ts']);
expect(result.absentFromDisk).toEqual(['src/tracked.ts']);
});
// Residual: this asserts the message the MISSING-BINARY path also emits, so in an environment with
// no git it passes for the wrong reason. Not silent overall — its two siblings fail ENOENT there,
// loudly — and that is exactly the environment this file is excluded from in `docker/Dockerfile`.
it('really throws outside a repository, rather than returning an empty population', () => {
const notARepo = mkdtempSync(join(tmpdir(), 'etv-819-bare-'));
repos.push(notARepo);
mkdirSync(join(notARepo, 'src'), { recursive: true });
// `GIT_CEILING_DIRECTORIES` stops git walking up into an enclosing repository — without it this
// test depends on where TMPDIR happens to point, and reports the wrong throw when TMPDIR sits
// inside a checkout.
const ceiling: GitRunner = (args) =>
execFileSync('git', [...args], {
encoding: 'utf8',
// Same stderr suppression `runGitDefault` sets, for the same reason: without it git's own
// `fatal: not a git repository` prints on every run of this file.
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_CEILING_DIRECTORIES: tmpdir() }
});
expect(() => resolveTrackedSourceFiles(notARepo, ceiling)).toThrow(/could not read the git index/);
});
});
+176
View File
@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest';
import {
TRACKED_SOURCE_FILES_ID,
type FileExists,
type GitRunner,
resolveTrackedSourceFiles,
trackedSourceFilesPlugin
} from './trackedSourceFiles';
/**
* The population this plugin supplies is what `web/src/api/pageSizeCallSites.guard.test.ts` asserts
* completeness over (#819), so the plugin is verification code and carries its own proof rather
* than inheriting the guard's `testing.verification-code-needs-its-own-proof`.
*/
const RESOLVED_ID = `\0${TRACKED_SOURCE_FILES_ID}`;
const allPresent: FileExists = () => true;
/** `ls-files` answers the tracked query; `--others` answers the untracked one. */
function runnerReturning(tracked: string, others = ''): { run: GitRunner; calls: (readonly string[])[] } {
const calls: (readonly string[])[] = [];
return {
calls,
run: (args) => {
calls.push(args);
return args.includes('--others') ? others : tracked;
}
};
}
/** Drives `load()` through the plugin's own `configResolved`, as Vite does. */
function loadedBy(plugin: ReturnType<typeof trackedSourceFilesPlugin>, root = '/repo/web'): string | undefined {
const configResolved = plugin.configResolved as (config: { root: string }) => void;
configResolved.call(plugin, { root });
return (plugin.load as (id: string) => string | undefined).call(plugin, RESOLVED_ID);
}
describe('resolveTrackedSourceFiles (#819)', () => {
it("splits git's NUL-separated output into paths, dropping the trailing empty field", () => {
const { run } = runnerReturning('src/a.ts\0src/b.tsx\0');
expect(resolveTrackedSourceFiles('/repo/web', run, allPresent).tracked).toEqual(['src/a.ts', 'src/b.tsx']);
});
it('reports UNTRACKED paths from a SEPARATE query, so a narrowed index cannot hide behind them', () => {
// #819 round 6: every other comparison in the guard is between two things derived from
// `tracked`, so a filter applied to `tracked` cancels out of all of them. `others` comes from a
// different git query and is what makes that filter visible — narrowing `tracked` adds nothing
// here, so the walk finds a file in neither list.
const { run } = runnerReturning('src/a.ts\0', 'src/scratch.ts\0src/notes.ts\0');
const result = resolveTrackedSourceFiles('/repo/web', run, allPresent);
expect(result.tracked).toEqual(['src/a.ts']);
expect(result.others).toEqual(['src/scratch.ts', 'src/notes.ts']);
});
it('THROWS when the untracked query fails, rather than degrading to an empty set', () => {
// An empty `others` would make a narrowed index look like a real hole AND, worse, is the
// direction that could be used to silence the cross-check.
const run: GitRunner = (args) => {
if (args.includes('--others')) throw new Error('git exploded');
return 'src/a.ts\0';
};
expect(() => resolveTrackedSourceFiles('/repo/web', run, allPresent)).toThrow(
/could not enumerate untracked files/
);
});
it('asks git for NUL-separated output, scoped to src/, in the given root', () => {
// Pins `-z` specifically. Without it git applies `core.quotePath` to a non-ASCII name and
// returns a C-quoted string that no longer equals the path Vite reports, which would drop that
// file from the population silently — the guard would then under-enumerate while looking green.
const { run, calls } = runnerReturning('src/a.ts\0');
resolveTrackedSourceFiles('/repo/web', run, allPresent);
expect(calls).toEqual([
['-C', '/repo/web', 'ls-files', '-z', '--', 'src'],
['-C', '/repo/web', 'ls-files', '-z', '--others', '--', 'src']
]);
});
it('keeps a path containing spaces intact rather than splitting on whitespace', () => {
const { run } = runnerReturning('src/screens/My Screen.tsx\0src/b.ts\0');
expect(resolveTrackedSourceFiles('/repo/web', run, allPresent).tracked).toEqual([
'src/screens/My Screen.tsx',
'src/b.ts'
]);
});
it('REPORTS tracked paths with no file on disk rather than dropping them', () => {
// The consumer needs a mid-edit deletion (transient, tolerated) told apart from a tracked file
// the walk cannot see (a real hole). Collapsing them is how a population silently shrinks.
const { run } = runnerReturning('src/present.ts\0src/deleted.ts\0');
const seen: string[] = [];
const exists: FileExists = (path) => {
seen.push(path);
return !path.endsWith('deleted.ts');
};
const result = resolveTrackedSourceFiles('/repo/web', run, exists);
expect(result.tracked).toEqual(['src/present.ts', 'src/deleted.ts']);
expect(result.absentFromDisk).toEqual(['src/deleted.ts']);
// Existence is checked against the ROOT-joined path, not the repo-relative one.
expect(seen).toEqual(['/repo/web/src/present.ts', '/repo/web/src/deleted.ts']);
});
it('deduplicates a path git emits once per merge-conflict stage', () => {
// `git ls-files` lists an unmerged path once per stage. Nothing downstream needs the repetition.
const { run } = runnerReturning('src/a.ts\0src/a.ts\0src/b.ts\0');
expect(resolveTrackedSourceFiles('/repo/web', run, allPresent).tracked).toEqual([
'src/a.ts',
'src/b.ts'
]);
});
it('THROWS when git fails, instead of degrading to an unfiltered walk', () => {
// The failure this rejects is the fail-open one: answering "could not tell" with the very
// population the index was brought in to replace is a permanent no-op that reports green.
const boom: GitRunner = () => {
throw new Error('not a git repository');
};
expect(() => resolveTrackedSourceFiles('/repo/web', boom, allPresent)).toThrow(/could not read the git index/);
});
it('THROWS when git reports zero tracked files (anti-vacuity at the source)', () => {
const { run } = runnerReturning('');
expect(() => resolveTrackedSourceFiles('/repo/web', run, allPresent)).toThrow(/ZERO tracked files/);
});
});
describe('trackedSourceFilesPlugin (#819)', () => {
it('resolves only its own virtual id', () => {
const plugin = trackedSourceFilesPlugin(runnerReturning('src/a.ts\0').run, allPresent);
const resolveId = plugin.resolveId as (id: string) => string | undefined;
expect(resolveId.call(plugin, TRACKED_SOURCE_FILES_ID)).toBe(RESOLVED_ID);
expect(resolveId.call(plugin, 'react')).toBeUndefined();
expect(resolveId.call(plugin, './pageSizeScan')).toBeUndefined();
});
it('emits the tracked list and the absent set as a default-exported module', () => {
const plugin = trackedSourceFilesPlugin(runnerReturning('src/a.ts\0src/b.tsx\0').run, allPresent);
expect(loadedBy(plugin)).toBe(
'export default {"tracked":["src/a.ts","src/b.tsx"],"absentFromDisk":[],"others":[]};\n'
);
});
it('REFUSES rather than guessing when configResolved never ran', () => {
// A wrong root is a silent wrong answer. Seeding it with `process.cwd()` would happen to work
// in this repo's layout and quietly derive the population from the wrong tree elsewhere.
const { run, calls } = runnerReturning('src/a.ts\0');
const plugin = trackedSourceFilesPlugin(run, allPresent);
const load = plugin.load as (id: string) => string | undefined;
expect(() => load.call(plugin, RESOLVED_ID)).toThrow(/configResolved` hook never ran/);
expect(calls).toHaveLength(0);
});
it('does NOT shell out to git unless the virtual module is actually loaded', () => {
// Constructing the plugin, or loading anything else, must not run git — otherwise registering
// it in vite.config.ts would break every build in a context without a repository.
const { run, calls } = runnerReturning('src/a.ts\0');
const plugin = trackedSourceFilesPlugin(run, allPresent);
const configResolved = plugin.configResolved as (config: { root: string }) => void;
const load = plugin.load as (id: string) => string | undefined;
configResolved.call(plugin, { root: '/repo/web' });
expect(calls).toHaveLength(0);
expect(load.call(plugin, '\0some-other-virtual-module')).toBeUndefined();
expect(calls).toHaveLength(0);
load.call(plugin, RESOLVED_ID);
// Two: the tracked query and the separate untracked one.
expect(calls).toHaveLength(2);
});
});
+167
View File
@@ -0,0 +1,167 @@
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import type { Plugin } from 'vite';
/**
* ersatztv#819: hands `web/src/api/pageSizeCallSites.guard.test.ts` the one SPA guard that derives a
* file population the set of files under `src/` that git actually tracks.
*
* That guard asserts EXACT completeness over the files it discovers, so its population has to be
* the git index `testing.guard-derives-population-from-source`. A directory walk is not an
* authoritative source: an untracked `.ts`/`.tsx` under `web/src/` (a scratch file, a half-finished
* component, an editor dropping) enters the population and fails as unregistered on that checkout
* while CI, which only ever sees tracked files, stays green. That is the developer-red / CI-green
* shape #778 named and #806 removed from every other guard here.
*
* Why a Vite plugin rather than `node:child_process` in the guard itself: `@types/node` is
* deliberately absent from `tsconfig.app.json`, which covers production browser code too, and
* wiring it in was tried and reverted under `tsc -b`'s single-program compilation it leaked
* Node's ambient `setTimeout` into the whole app project and broke three unrelated
* `window.setTimeout` mocks expecting the DOM signature. A plugin runs in Vite's own Node context,
* where `@types/node` is already available via `tsconfig.node.json`, and hands the result to the
* app project as plain data. No app-project type change, no `/// <reference types="node" />`.
*
* SCOPE: the pathspec is `src/` only. A future guard over `web/e2e`, `web/scripts` or
* `web/vite-plugins` must not import this module expecting its own files it would receive a
* silently truncated population, which is the defect this exists to remove. Widen the pathspec
* (and say so here) rather than reusing it out of scope.
*/
export const TRACKED_SOURCE_FILES_ID = 'virtual:etv-tracked-source-files';
/** Rollup convention: a resolved virtual id is `\0`-prefixed so no other plugin claims it. */
const RESOLVED_ID = `\0${TRACKED_SOURCE_FILES_ID}`;
export interface TrackedSources {
/** Tracked paths under `src/`, relative to the Vite root (`web/`), e.g. `src/api/pageSizeScan.ts`. */
tracked: string[];
/**
* The subset of `tracked` with NO READABLE FILE at that path. Usually a working-tree deletion
* that is not yet staged, which is the case the consumer tolerates but the test is
* `existsSync`, so it also covers a tracked BROKEN SYMLINK (a committed, permanent state, not a
* mid-edit one), a sparse checkout that excludes the path, and an unreadable parent directory.
* Named for what it measures rather than for the common cause, because a consumer subtracts this
* set and a wrong name there licenses subtracting more than intended.
*
* Reported rather than silently removed: the consumer needs to tell these apart from a tracked
* file the WALK cannot see, which is a real hole. Collapsing the two is how a population shrinks
* without anyone noticing.
*/
absentFromDisk: string[];
/**
* Paths under `src/` that git reports as UNTRACKED (ignored ones included), from a SEPARATE
* `ls-files --others` query.
*
* This exists so the consumer can catch a narrowing of `tracked` itself (#819 round 6). Every
* comparison the guard makes is between two things derived from `tracked`, so a filter applied
* HERE shrinks both sides and cancels real files leave the population with the whole suite
* green, and the guard goes blind rather than merely quiet. `tracked others` is what is on
* disk under `src/`, so a path the walk sees that is in NEITHER is the signal, and it is a signal
* this list cannot suppress: narrowing `tracked` does not add anything to `others`.
*/
others: string[];
}
/** Injectable so `resolveTrackedSourceFiles` is testable without a real repository. */
export type GitRunner = (args: readonly string[]) => string;
/** Injectable for the same reason; defaults to a real on-disk existence check. */
export type FileExists = (path: string) => boolean;
const runGitDefault: GitRunner = (args) =>
execFileSync('git', [...args], {
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
// Keep git's own `fatal: not a git repository` off the terminal so the framed error below is
// the only message a reader sees.
stdio: ['ignore', 'pipe', 'pipe']
});
export function resolveTrackedSourceFiles(
root: string,
runGit: GitRunner = runGitDefault,
fileExists: FileExists = existsSync
): TrackedSources {
let raw: string;
try {
raw = runGit(['-C', root, 'ls-files', '-z', '--', 'src']);
} catch (cause) {
// FAIL, never degrade to the unfiltered walk. "Could not tell" is a third outcome distinct from
// pass and fail, and a check that answers it by falling back to the population it exists to
// replace is a permanent no-op that reports green — the shape ersatztv#772 was filed for.
throw new Error(
`${TRACKED_SOURCE_FILES_ID}: could not read the git index under ${root}. The SPA guard derives ` +
`its population from it and must not fall back to a filesystem walk — fix the checkout ` +
`rather than the guard.`,
{ cause }
);
}
// `-z` rather than plain `ls-files`: git applies `core.quotePath` to non-ASCII names by default
// and would hand back a C-quoted string that no longer matches the path Vite reports, silently
// dropping that file from the population. NUL-separated output is never quoted.
// Deduplicated: during a merge conflict `git ls-files` emits an unmerged path once per STAGE —
// two for a both-added conflict, three for a content conflict. Nothing downstream needs the
// repetition. It is tidiness rather than a fix: the ratio the floor computes uses the same list
// on both sides, so duplicates cancel out of it (measured green with and without this).
const tracked = [...new Set(raw.split('\0').filter((path) => path.length > 0))];
if (tracked.length === 0) {
// Anti-vacuity at the source. An empty population makes every completeness claim downstream
// pass while proving nothing, which is the characteristic failure of a completeness check.
throw new Error(
`${TRACKED_SOURCE_FILES_ID}: git reported ZERO tracked files under ${root}/src. That is never ` +
`true of this repository, so it means the index was not readable rather than that the ` +
`population is genuinely empty.`
);
}
let rawOthers: string;
try {
// Deliberately WITHOUT `--exclude-standard`: an ignored file is still a file on disk, and the
// consumer's question is "is this path accounted for", not "should git have tracked it".
rawOthers = runGit(['-C', root, 'ls-files', '-z', '--others', '--', 'src']);
} catch (cause) {
throw new Error(
`${TRACKED_SOURCE_FILES_ID}: could not enumerate untracked files under ${root}. This list is ` +
`what lets the consumer tell a narrowed index from a genuinely absent file, so it fails ` +
`rather than degrading to an empty set.`,
{ cause }
);
}
return {
tracked,
absentFromDisk: tracked.filter((path) => !fileExists(join(root, path))),
others: rawOthers.split('\0').filter((path) => path.length > 0)
};
}
export function trackedSourceFilesPlugin(runGit?: GitRunner, fileExists?: FileExists): Plugin {
// Deliberately NOT seeded with `process.cwd()`. A wrong root is a silent wrong answer — git would
// report zero files and the anti-vacuity throw would fire, but only by luck of this repo's
// layout. Refusing is the honest outcome.
let root: string | undefined;
return {
name: 'etv:tracked-source-files',
configResolved(config) {
root = config.root;
},
resolveId(id) {
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.
load(id) {
if (id !== RESOLVED_ID) {
return undefined;
}
if (root === undefined) {
throw new Error(
`${TRACKED_SOURCE_FILES_ID}: the plugin's \`configResolved\` hook never ran, so the Vite ` +
`root is unknown. Refusing to guess a root rather than derive a population from the wrong one.`
);
}
return `export default ${JSON.stringify(resolveTrackedSourceFiles(root, runGit, fileExists))};\n`;
}
};
}
+2 -1
View File
@@ -1,12 +1,13 @@
import { fileURLToPath, URL } from 'node:url'; import { fileURLToPath, URL } from 'node:url';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { configDefaults, defineConfig } from 'vitest/config'; import { configDefaults, defineConfig } from 'vitest/config';
import { trackedSourceFilesPlugin } from './vite-plugins/trackedSourceFiles';
const aspNetHost = 'http://localhost:8409'; const aspNetHost = 'http://localhost:8409';
export default defineConfig({ export default defineConfig({
base: '/app/', base: '/app/',
plugins: [react()], plugins: [react(), trackedSourceFilesPlugin()],
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)), '@': fileURLToPath(new URL('./src', import.meta.url)),