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
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:
+31
-1
@@ -9,7 +9,37 @@ WORKDIR /source
|
||||
COPY design-system/. ./design-system/
|
||||
COPY web/. ./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
|
||||
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
+17
-6
File diff suppressed because one or more lines are too long
@@ -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
|
||||
`*.test.tsx`.
|
||||
- **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
|
||||
`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
|
||||
|
||||
+8
-2
@@ -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.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`). |
|
||||
| `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". |
|
||||
|
||||
## Golden-file nets
|
||||
@@ -98,7 +98,13 @@ dotnet test ErsatzTV.Core.Tests --filter FullyQualifiedName~ChannelPlaylistGolde
|
||||
Web (`web/`):
|
||||
|
||||
```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 lint # eslint .
|
||||
npm run build # tsc -b && vite build
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import trackedSources from 'virtual:etv-tracked-source-files';
|
||||
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).
|
||||
// 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
|
||||
@@ -347,17 +348,123 @@ const REGISTRY: RegistryEntry[] = [
|
||||
// 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
|
||||
// .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
|
||||
// source files. Keys are POSIX paths from the project root, e.g. `/src/api/pageSizeScan.ts`.
|
||||
// natively available in the browser/app project. Keys are POSIX paths from the project root, e.g.
|
||||
// `/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}', {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
eager: true
|
||||
}) as Record<string, string>;
|
||||
|
||||
function basename(path: string): string {
|
||||
const idx = path.lastIndexOf('/');
|
||||
return idx === -1 ? path : path.slice(idx + 1);
|
||||
/**
|
||||
* The git index, as the guard compares it: paths relative to the Vite root (`web/`), e.g.
|
||||
* `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
|
||||
@@ -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
|
||||
// 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
|
||||
// function, not a second copy of the logic.
|
||||
// function (via `isInScopeSourcePath`, which `listSourceFilesFrom` calls), not a second copy.
|
||||
export function isScannableSourceFileName(name: string): boolean {
|
||||
// `.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
|
||||
@@ -386,13 +493,43 @@ interface ScannableSource {
|
||||
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[] = [];
|
||||
for (const [key, text] of Object.entries(rawSourceModules)) {
|
||||
if (key.includes('/generated/')) {
|
||||
for (const [key, text] of Object.entries(modules)) {
|
||||
if (!isInScopeSourcePath(globKeyToRootRelativePath(key))) {
|
||||
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;
|
||||
}
|
||||
out.push({ file: key.replace(/^\/src\//, ''), text });
|
||||
@@ -400,6 +537,66 @@ function listSourceFiles(): ScannableSource[] {
|
||||
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 {
|
||||
file: string;
|
||||
line: number;
|
||||
@@ -493,7 +690,7 @@ describe('pageSize call-site guard (#650)', () => {
|
||||
['builder/libraryBrowse.test.mts', false],
|
||||
['api/pageSizeScan.test.cts', 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],
|
||||
['data.json', 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();
|
||||
expect(files.length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
Vendored
+23
@@ -1 +1,24 @@
|
||||
/// <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;
|
||||
}
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
// 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
|
||||
// `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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -1,12 +1,13 @@
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { configDefaults, defineConfig } from 'vitest/config';
|
||||
import { trackedSourceFilesPlugin } from './vite-plugins/trackedSourceFiles';
|
||||
|
||||
const aspNetHost = 'http://localhost:8409';
|
||||
|
||||
export default defineConfig({
|
||||
base: '/app/',
|
||||
plugins: [react()],
|
||||
plugins: [react(), trackedSourceFilesPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
|
||||
Reference in New Issue
Block a user