Files
ersatztv/web/vite-plugins/trackedSourceFiles.ts
T
timothyandClaude Opus 5 3ec73f3769 fix(887): a pin assumes it is pinning the thing that still DECIDES
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
2026-08-30 14:07:47 +02:00

170 lines
8.6 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-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`;
}
};
}