Files
ersatztv/web/vite-plugins/trackedSourceFiles.ts
T
timothyandClaude Fable 5.1 a7d91bf15a
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 35s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 57s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 1m0s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ a7d91bf (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
fix(876): sweep session narrative out of hooks, workflows, scripts, tests and code comments; grow the detector to the process corpus
`docs.no-session-narrative` reaches every durable artifact, but its detector scanned only
`docs/**/*.md` and root markdown, and nothing had ever swept the rest. The issue named four sites
from one grep and called them a floor. Deriving the population instead — a whitespace-joined sweep
over every tracked file outside the detector, for the detector's own phrasings plus the attribution
and review-round class #812 found — gave 453 sites in 108 files at `fb5592971`, and a second pass
for phrasings the first list missed (hyphenated `round-N`, "an earlier version", "the reviewer
proved") added residuals in the same files. Every site was classified with #812's three
dispositions (CUT / SEVER / KEEP with its sub-kind) under the who-benefits test; the per-site
manifests are on the PR. The rejected designs, tested-and-rejected fixtures, measurements and
traps stay; the attribution of who found them and the round in which they were found go.

The detector's population grows to `.claude/`, `.gitea/`, `.husky/` and `scripts/` regardless
of extension, minus the detector and its own test (whose fixtures ARE the phrasings) and minus
`scripts/tests/fixtures/` (test data, including decision-record copies — the same reasoning as
the records' own exemption, and what keeps the record's depth measurement true), and `--all`
lists tracked REGULAR files only — a symlink's content is its target and a gitlink has none. The #812
argument for leaving `docs/superpowers/**` in the population runs the other way here: `--diff`
sees only ADDED lines, and 287 of the 453 sites were under 30 days old — this corpus is where
narrative is being added, so the advisory nudge has reach. Density agrees: 56 line-mode hits over
the 113 regular files the predicate admits, against 9 over 66 docs files before #812. `web/` and C# stay out on the same
measurement (3 of 74 PATTERNS-matching sites, ~4,600 files). The predicate did not grow: PATTERNS
matched 74 of 453 sites, and widening the word list to the attribution class is the treadmill
the withdrawn parity test ran on. The population oracle is restated over segments with the new
arms, the synthetic cross product gains the process heads and non-markdown extensions, a fixture
witnesses that a tracked symlink is neither scanned nor counted, a `.py.bak` axis separates a
by-name exemption from a `startswith` over the same tuple, and eight mutants (drop the process
arm, drop the by-name exemption, exempt by `startswith`, drop or add a prefix, drop the fixtures
exemption, list only markdown, drop the symlink filter, test the mode per row instead of per
path) each
redden it. A pre-existing silent drop in `--diff` goes with it: git tab-terminates a `+++`
filename that contains a space, and the kept tab made `is_scanned_path` refuse the file with no
notice — fixed, with a positive control and its own mutant.

Code is unchanged by construction, measured per file type against `origin/main`: Python modules
are AST-equal with docstrings stripped, except `#` lines inside the embedded fixture programs
(string literals) of three test modules; workflows differ only in `#` lines inside `run:` block
scalars; shell, C#, TypeScript and jq are equal with comment lines stripped. The stated
exceptions: the detector and its test, 26 vitest titles that carried review-round or severity
labels or a reviewer attribution (call sites whose title changed — every changed title line
walked back to its `it(` / `it.each(...)(` anchor, so a `' + '` concatenation counts once), two
registry note strings and the mutation manifest's prose fields. scripts/tests: 1565 passed.
Web: lint, typecheck, 1319 tests green. Closes #876.

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEcBoFw7ctrf3Nb7R7x7wk
2026-09-03 20:51:39 +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). 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`;
}
};
}