Round 6 found three more false greens and named the class they share, which is worth
more than any of the three fixes:
* `web/vitest.config.ts` OUTRANKS the pinned `vite.config.ts` — closed in the previous
commit, found by probing vitest rather than reading about it.
* A DECOY first `test: {` block. The comparison took `text.index("test: {")`, so a copy
of the pin placed above `defineConfig` satisfied it while the real block was narrowed.
Exactly one is now required — the same assertion this file already made about the
gating step's NAME, for the same reason, not carried across.
* A `needs:` edge matched by bare job id. `needs:` resolves within its own workflow, so
a SECOND workflow publishing this Dockerfile while needing its own unrelated job
called `test` satisfied it. Now bound to `GATING_WORKFLOW`. (The reviewer downgraded
this to MEDIUM on measuring that `test_remote_state_inventory.py` forces a human to
classify any new workflow — so the hole is "the guard is blind", not "silent". The
forced review asks about remote state, not about whether the image is gated, so the
one-line fix stands.)
* A vite PLUGIN can shell out to the suite from `buildStart()`. The plugin ARRAY is
pinned; the plugin BODIES are a stated residual, mitigated because
`trackedSourceFilesPlugin` is deliberately lazy — a fact its own comment now marks as
LOAD-BEARING for the image build rather than leaving as an optimisation note.
THE CLASS: **a pin assumes it is pinning the artifact that still decides.** Every route
found so far is authority moving where the pin is not looking — to another FILE, another
OCCURRENCE in the same file, another WORKFLOW, or a HOOK the pinned command invokes. That
question is now written down for the next person adding a pin, because a list of four
instances is not what generalises.
Prose, all refuted by execution: the residual naming the uncovered COPY shapes was wrong a
THIRD time at the same site (`/source/web /elsewhere` IS recognised — only the destination
is renamed — and the file's own test 700 lines below said so); "only an `ENV` is
unmodelled" was an absolute and is now a list; "Reach: N mutants, 0 missed" is restated as
a DEVELOPMENT BATTERY, since it is not in the repo, nothing re-derives it, and an
independent battery found misses against an earlier head; and `PUBLISH_ACTION` was claimed
covered by anti-vacuity, which proves the selector is non-empty and cannot prove it
complete.
Battery 61 -> 64, 0 missed.
Refs: #887
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019T79beF1Ufid3dXju4yqkF
170 lines
8.6 KiB
TypeScript
170 lines
8.6 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-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`;
|
||
}
|
||
};
|
||
}
|