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>
177 lines
8.1 KiB
TypeScript
177 lines
8.1 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
TRACKED_SOURCE_FILES_ID,
|
|
type FileExists,
|
|
type GitRunner,
|
|
resolveTrackedSourceFiles,
|
|
trackedSourceFilesPlugin
|
|
} from './trackedSourceFiles';
|
|
|
|
/**
|
|
* The population this plugin supplies is what `web/src/api/pageSizeCallSites.guard.test.ts` asserts
|
|
* completeness over (#819), so the plugin is verification code and carries its own proof rather
|
|
* than inheriting the guard's — `testing.verification-code-needs-its-own-proof`.
|
|
*/
|
|
|
|
const RESOLVED_ID = `\0${TRACKED_SOURCE_FILES_ID}`;
|
|
const allPresent: FileExists = () => true;
|
|
|
|
/** `ls-files` answers the tracked query; `--others` answers the untracked one. */
|
|
function runnerReturning(tracked: string, others = ''): { run: GitRunner; calls: (readonly string[])[] } {
|
|
const calls: (readonly string[])[] = [];
|
|
return {
|
|
calls,
|
|
run: (args) => {
|
|
calls.push(args);
|
|
return args.includes('--others') ? others : tracked;
|
|
}
|
|
};
|
|
}
|
|
|
|
/** Drives `load()` through the plugin's own `configResolved`, as Vite does. */
|
|
function loadedBy(plugin: ReturnType<typeof trackedSourceFilesPlugin>, root = '/repo/web'): string | undefined {
|
|
const configResolved = plugin.configResolved as (config: { root: string }) => void;
|
|
configResolved.call(plugin, { root });
|
|
return (plugin.load as (id: string) => string | undefined).call(plugin, RESOLVED_ID);
|
|
}
|
|
|
|
describe('resolveTrackedSourceFiles (#819)', () => {
|
|
it("splits git's NUL-separated output into paths, dropping the trailing empty field", () => {
|
|
const { run } = runnerReturning('src/a.ts\0src/b.tsx\0');
|
|
expect(resolveTrackedSourceFiles('/repo/web', run, allPresent).tracked).toEqual(['src/a.ts', 'src/b.tsx']);
|
|
});
|
|
|
|
it('reports UNTRACKED paths from a SEPARATE query, so a narrowed index cannot hide behind them', () => {
|
|
// #819 round 6: every other comparison in the guard is between two things derived from
|
|
// `tracked`, so a filter applied to `tracked` cancels out of all of them. `others` comes from a
|
|
// different git query and is what makes that filter visible — narrowing `tracked` adds nothing
|
|
// here, so the walk finds a file in neither list.
|
|
const { run } = runnerReturning('src/a.ts\0', 'src/scratch.ts\0src/notes.ts\0');
|
|
const result = resolveTrackedSourceFiles('/repo/web', run, allPresent);
|
|
|
|
expect(result.tracked).toEqual(['src/a.ts']);
|
|
expect(result.others).toEqual(['src/scratch.ts', 'src/notes.ts']);
|
|
});
|
|
|
|
it('THROWS when the untracked query fails, rather than degrading to an empty set', () => {
|
|
// An empty `others` would make a narrowed index look like a real hole AND, worse, is the
|
|
// direction that could be used to silence the cross-check.
|
|
const run: GitRunner = (args) => {
|
|
if (args.includes('--others')) throw new Error('git exploded');
|
|
return 'src/a.ts\0';
|
|
};
|
|
expect(() => resolveTrackedSourceFiles('/repo/web', run, allPresent)).toThrow(
|
|
/could not enumerate untracked files/
|
|
);
|
|
});
|
|
|
|
it('asks git for NUL-separated output, scoped to src/, in the given root', () => {
|
|
// Pins `-z` specifically. Without it git applies `core.quotePath` to a non-ASCII name and
|
|
// returns a C-quoted string that no longer equals the path Vite reports, which would drop that
|
|
// file from the population silently — the guard would then under-enumerate while looking green.
|
|
const { run, calls } = runnerReturning('src/a.ts\0');
|
|
resolveTrackedSourceFiles('/repo/web', run, allPresent);
|
|
expect(calls).toEqual([
|
|
['-C', '/repo/web', 'ls-files', '-z', '--', 'src'],
|
|
['-C', '/repo/web', 'ls-files', '-z', '--others', '--', 'src']
|
|
]);
|
|
});
|
|
|
|
it('keeps a path containing spaces intact rather than splitting on whitespace', () => {
|
|
const { run } = runnerReturning('src/screens/My Screen.tsx\0src/b.ts\0');
|
|
expect(resolveTrackedSourceFiles('/repo/web', run, allPresent).tracked).toEqual([
|
|
'src/screens/My Screen.tsx',
|
|
'src/b.ts'
|
|
]);
|
|
});
|
|
|
|
it('REPORTS tracked paths with no file on disk rather than dropping them', () => {
|
|
// The consumer needs a mid-edit deletion (transient, tolerated) told apart from a tracked file
|
|
// the walk cannot see (a real hole). Collapsing them is how a population silently shrinks.
|
|
const { run } = runnerReturning('src/present.ts\0src/deleted.ts\0');
|
|
const seen: string[] = [];
|
|
const exists: FileExists = (path) => {
|
|
seen.push(path);
|
|
return !path.endsWith('deleted.ts');
|
|
};
|
|
|
|
const result = resolveTrackedSourceFiles('/repo/web', run, exists);
|
|
|
|
expect(result.tracked).toEqual(['src/present.ts', 'src/deleted.ts']);
|
|
expect(result.absentFromDisk).toEqual(['src/deleted.ts']);
|
|
// Existence is checked against the ROOT-joined path, not the repo-relative one.
|
|
expect(seen).toEqual(['/repo/web/src/present.ts', '/repo/web/src/deleted.ts']);
|
|
});
|
|
|
|
it('deduplicates a path git emits once per merge-conflict stage', () => {
|
|
// `git ls-files` lists an unmerged path once per stage. Nothing downstream needs the repetition.
|
|
const { run } = runnerReturning('src/a.ts\0src/a.ts\0src/b.ts\0');
|
|
expect(resolveTrackedSourceFiles('/repo/web', run, allPresent).tracked).toEqual([
|
|
'src/a.ts',
|
|
'src/b.ts'
|
|
]);
|
|
});
|
|
|
|
it('THROWS when git fails, instead of degrading to an unfiltered walk', () => {
|
|
// The failure this rejects is the fail-open one: answering "could not tell" with the very
|
|
// population the index was brought in to replace is a permanent no-op that reports green.
|
|
const boom: GitRunner = () => {
|
|
throw new Error('not a git repository');
|
|
};
|
|
expect(() => resolveTrackedSourceFiles('/repo/web', boom, allPresent)).toThrow(/could not read the git index/);
|
|
});
|
|
|
|
it('THROWS when git reports zero tracked files (anti-vacuity at the source)', () => {
|
|
const { run } = runnerReturning('');
|
|
expect(() => resolveTrackedSourceFiles('/repo/web', run, allPresent)).toThrow(/ZERO tracked files/);
|
|
});
|
|
});
|
|
|
|
describe('trackedSourceFilesPlugin (#819)', () => {
|
|
it('resolves only its own virtual id', () => {
|
|
const plugin = trackedSourceFilesPlugin(runnerReturning('src/a.ts\0').run, allPresent);
|
|
const resolveId = plugin.resolveId as (id: string) => string | undefined;
|
|
|
|
expect(resolveId.call(plugin, TRACKED_SOURCE_FILES_ID)).toBe(RESOLVED_ID);
|
|
expect(resolveId.call(plugin, 'react')).toBeUndefined();
|
|
expect(resolveId.call(plugin, './pageSizeScan')).toBeUndefined();
|
|
});
|
|
|
|
it('emits the tracked list and the absent set as a default-exported module', () => {
|
|
const plugin = trackedSourceFilesPlugin(runnerReturning('src/a.ts\0src/b.tsx\0').run, allPresent);
|
|
|
|
expect(loadedBy(plugin)).toBe(
|
|
'export default {"tracked":["src/a.ts","src/b.tsx"],"absentFromDisk":[],"others":[]};\n'
|
|
);
|
|
});
|
|
|
|
it('REFUSES rather than guessing when configResolved never ran', () => {
|
|
// A wrong root is a silent wrong answer. Seeding it with `process.cwd()` would happen to work
|
|
// in this repo's layout and quietly derive the population from the wrong tree elsewhere.
|
|
const { run, calls } = runnerReturning('src/a.ts\0');
|
|
const plugin = trackedSourceFilesPlugin(run, allPresent);
|
|
const load = plugin.load as (id: string) => string | undefined;
|
|
|
|
expect(() => load.call(plugin, RESOLVED_ID)).toThrow(/configResolved` hook never ran/);
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
|
|
it('does NOT shell out to git unless the virtual module is actually loaded', () => {
|
|
// Constructing the plugin, or loading anything else, must not run git — otherwise registering
|
|
// it in vite.config.ts would break every build in a context without a repository.
|
|
const { run, calls } = runnerReturning('src/a.ts\0');
|
|
const plugin = trackedSourceFilesPlugin(run, allPresent);
|
|
const configResolved = plugin.configResolved as (config: { root: string }) => void;
|
|
const load = plugin.load as (id: string) => string | undefined;
|
|
|
|
configResolved.call(plugin, { root: '/repo/web' });
|
|
expect(calls).toHaveLength(0);
|
|
expect(load.call(plugin, '\0some-other-virtual-module')).toBeUndefined();
|
|
expect(calls).toHaveLength(0);
|
|
|
|
load.call(plugin, RESOLVED_ID);
|
|
// Two: the tracked query and the separate untracked one.
|
|
expect(calls).toHaveLength(2);
|
|
});
|
|
});
|