Files
ersatztv/web/vite-plugins/trackedSourceFiles.ts
T
timothyandtimothy 8aeacd534a
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
fix(819): derive the SPA page-size guard population from the git index (#875)
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>
2026-08-29 09:14:15 +00:00

168 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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`;
}
};
}