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>
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`;
|
||
}
|
||
};
|
||
}
|