Round 8 found a BLOCKER, and it was my verification that was wrong rather than an argument. The previous commit re-armed `npm test` in the web-build stage and excluded the one file that needs a git CHECKOUT. It did not exclude the file added in round 6 that needs the git BINARY: `trackedSourceFiles`' real-repository tests shell out to `git` to prove the derivation by executing it, and `node:22-bookworm-slim` ships no git. So `docker build` would have failed on every push to `main` and every `v*` tag — no image ships. Why I did not catch it: every "gitless replica" I ran deleted `.git` and left the git binary on `PATH`. That reproduces one of the two preconditions. Re-measured with `PATH` stripped, the previous head gives `spawnSync git ENOENT`, `Test Files 1 failed | 116 passed`. The Dockerfile comment stated the right premise — "no `.git`, AND node:22-bookworm-slim ships no git binary" — two lines above the line that only acted on the first half. The real-git block is now its own file, `trackedSourceFiles.realgit.test.ts`, so excluding it costs two tests rather than the thirteen injected-runner ones that run fine with no git at all. Both exclusions are named in the Dockerfile with their DIFFERENT reasons, and with the instruction to verify any change there with the binary off `PATH`. MEASURED in a replica with neither `.git` nor `git`: 117 files / 1225 tests pass, lint, typecheck and build all exit 0. Also from round 8: - `docs/testing.md`'s row asserted the suite "cannot run against an exported tree with no index" and that the Dockerfile "no longer runs it" — both false at the previous head. It now distinguishes the checkout requirement from the binary requirement, which is the distinction the blocker turned on. - Two clauses this PR added had no proof and MEASURED green when disarmed: the trailing-slash restriction in `unaccountedWalkedPaths` (without it every `others` entry becomes a prefix mask) and the plugin's dedupe. Both now have fixtures, and both mutations redden by name. - The dedupe's stated rationale was wrong: duplicates appear on both sides of the ratio the floor computes, so they cancel. Re-stated as tidiness, with the measurement, and "three times" corrected to two-or-three (a both-added conflict has two stages). - Three JSDoc blocks were stacked above one function, so two of them documented functions defined 15 and 50 lines below and attached to the wrong ones. Pure reordering, plus the blank lines that were detaching them. - `vite-env.d.ts` named the wrong mechanism for its loud-failure claim: a renamed field yields an empty Set, caught by the anti-vacuity floor rather than by a throw at first read. - The Dockerfile no longer hardcodes a test-file count that nothing reddens on. Refs: #819
168 lines
8.4 KiB
TypeScript
168 lines
8.4 KiB
TypeScript
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`;
|
||
}
|
||
};
|
||
}
|