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 `/// `. * * 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). 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-BEARING FOR THE IMAGE BUILD since // ersatztv#887: `docker/Dockerfile`'s web-build stage runs `npm run build` in a stage with no git // binary, so making this eager would break every image build for #887's original root cause. load(id) { if (id !== RESOLVED_ID) { return undefined; } 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`; } }; }