From bcbc0448347ee3a54b633b191b28a9339bbdc373 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 6 Sep 2026 07:50:07 +0000 Subject: [PATCH] fix: extend CI test-plan basename lookup to every text-reading contract test (#6363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/ci-test-plan.js` already computed a `git grep`-by-basename fallback for python sidecar scripts, since Vitest's import graph can never reach them. The same gap applied to any JS-side test that reads its subject as text instead of importing it — the server/client mirror parity tests (30+ `*.mirror.test.js` / `*.parity.test.js`) and `server/lib/navManifest.test.js`, which reads 20+ client files by path. A PR editing only one side of a mirror, or adding a page tab, merged green while the actual contract test never ran. Generalizes that lookup (`sourceReferencePattern`, `pathContractTests`) from python-only to every changed executable source, additively for non-python sources so an empty hit list is not an error. Adds a structural rule for `mirrorCoverage.test.js`, which reads directories rather than naming a file and so stays unreachable even by basename. Extends `scripts/repo-scan-guards.test.js` with a second classifier (`readsUnnamedCrossRootFile`) so a future text-reading guard that never names its target can't land unreachable the same way. --- scripts/ci-test-plan.js | 61 +++++++++++--- scripts/ci-test-plan.test.js | 69 +++++++++++++-- scripts/repo-scan-guards.test.js | 139 +++++++++++++++++++++++++------ 3 files changed, 226 insertions(+), 43 deletions(-) diff --git a/scripts/ci-test-plan.js b/scripts/ci-test-plan.js index 174c4f45b9..335f2ef239 100644 --- a/scripts/ci-test-plan.js +++ b/scripts/ci-test-plan.js @@ -5,6 +5,13 @@ import { isDirectlyInvoked } from './lib/directInvocation.js'; import { writeStepOutput } from './lib/githubOutput.js'; const TEST_FILE_RE = /\.(?:test|spec)\.[cm]?[jt]sx?$/i; +// `git grep` pathspecs matching every extension TEST_FILE_RE recognizes. A +// hardcoded `*.test.js`/`.jsx` pair would silently stop finding a `.test.ts(x)` +// or `.spec.*` contract test added later, reopening the exact under-selection +// bug the basename lookup below exists to close — the later trackedSet + +// runnerForTest(isTestFile) filter already narrows a broad hit back down, so +// over-matching here costs nothing. +const TEST_FILE_GLOBS = ['*.test.*', '*.spec.*']; const CLIENT_LINT_RE = /^client\/src\/.*\.(?:js|jsx)$/i; const EXECUTABLE_RE = /\.(?:cjs|css|html|js|jsx|json|mjs|sql|ts|tsx|ya?ml)$/i; const MAX_CHANGED_CODE_FILES = 30; @@ -16,9 +23,16 @@ const MAX_TARGETED_TEST_FILES = 120; // with `git grep` and the plan runs exactly those — failing closed to the full // suite for a script nothing names. const PYTHON_SCRIPT_RE = /^scripts\/[^/]+\.py$/; -/** `git grep -E` pattern for a test that names this one script. */ -export const pythonReferencePattern = (scriptPath) => ( - `(^|[^A-Za-z0-9_])${scriptPath.slice(scriptPath.lastIndexOf('/') + 1).replace(/[.]/g, '\\.')}([^A-Za-z0-9_]|$)` + +// Contract tests read a subject file with `readFileSync` (a cross-tree mirror +// parity test, `server/lib/navManifest.js` reading 20+ client files by path) +// instead of importing it, so no `vitest related` import edge reaches them +// either. `main()` finds the tests naming a changed file's basename with +// `git grep`, the same mechanism the python case below uses, and generalizes +// it to every changed source — not only `.py` (issue #6363). +/** `git grep -E` pattern for a test that names this one source file by basename. */ +export const sourceReferencePattern = (sourcePath) => ( + `(^|[^A-Za-z0-9_])${sourcePath.slice(sourcePath.lastIndexOf('/') + 1).replace(/[.]/g, '\\.')}([^A-Za-z0-9_]|$)` ); // Parallel runners per test job on a full plan. Decided here rather than in @@ -348,6 +362,13 @@ const structuralTestsFor = (changedFiles, trackedSet) => { if (changedFiles.some((path) => /^client\/src\/lib\//.test(path))) { add('client/src/lib/index.test.js'); } + // mirrorCoverage.test.js walks both directories and diffs their README mirror + // catalogs against the actual test files present; it names no file itself, so + // no import edge or basename `git grep` (pathContractTests, see rule 1 above) + // can reach it either. + if (changedFiles.some((path) => /^(?:server|client\/src)\/lib\//.test(path))) { + add('server/lib/mirrorCoverage.test.js'); + } if (changedFiles.some((path) => /^client\/src\/hooks\//.test(path))) { add('client/src/hooks/index.test.js'); } @@ -466,8 +487,10 @@ export function buildCiTestPlan(changedFiles, { forceFull = false, forceFullReason = 'full CI requested', appRouteOnly = false, - // Changed python script → tracked test files naming it (see PYTHON_SCRIPT_RE). - pythonContractTests = {}, + // Changed source path → tracked test files naming its basename (see + // sourceReferencePattern). Covers every changed source, not only python + // scripts — see rule 1 of issue #6363. + pathContractTests = {}, } = {}) { const changed = uniqueSorted(changedFiles.filter(Boolean)); const trackedSet = new Set(trackedFiles); @@ -570,7 +593,7 @@ export function buildCiTestPlan(changedFiles, { ]; for (const script of pythonSources) { - const pythonTests = (pythonContractTests[script] || []) + const pythonTests = (pathContractTests[script] || []) .filter((path) => trackedSet.has(path) && runnerForTest(path)); if (pythonTests.length === 0) { return fullPlan(changed, `python script with no parsing contract: ${script}`, { appRouteOnly }); @@ -578,6 +601,16 @@ export function buildCiTestPlan(changedFiles, { selectedTests.push(...pythonTests); } + // Every other changed source rides the same basename `git grep` lookup, but + // additively: unlike python, a JS/TS source is already reachable through the + // import graph or a feature directory, so an empty hit list here is not an + // error — it just means nothing reads this file as text. + for (const source of jsSources) { + const namedTests = (pathContractTests[source] || []) + .filter((path) => trackedSet.has(path) && runnerForTest(path)); + selectedTests.push(...namedTests); + } + for (const testFile of trackedFiles.filter(isTestFile)) { if (features.some((feature) => pathMatchesFeature(testFile, feature))) { selectedTests.push(testFile); @@ -760,16 +793,22 @@ function main() { const appDiff = forceFull || !changedFiles.includes('client/src/App.jsx') ? null : execFileSync('git', ['diff', '--unified=0', `${base}...HEAD`, '--', 'client/src/App.jsx'], { encoding: 'utf8' }); - const pythonContractTests = Object.fromEntries(changedFiles.filter(isPythonScript).map((script) => [ - script, - gitGrepFiles(pythonReferencePattern(script), ['*.test.js', '*.test.jsx']), - ])); + // Every changed executable, non-test source — not only python scripts — gets + // a basename lookup: `git grep`-ing tracked test files for its filename finds + // the text-reading contract tests (mirror parity, navManifest.js's 20+ client + // reads) that no import edge or feature-directory match can reach. Basename + // matching is deliberate: a mirror test names the client copy only as e.g. + // 'canonPrompt.js', never by its full path. Over-selection here costs + // seconds; under-selection is the bug this closes (issue #6363). + const pathContractTests = Object.fromEntries(changedFiles + .filter((path) => isExecutable(path) && !isTestFile(path)) + .map((path) => [path, gitGrepFiles(sourceReferencePattern(path), TEST_FILE_GLOBS)])); emitGitHubPlan(buildCiTestPlan(changedFiles, { trackedFiles, forceFull, forceFullReason, appRouteOnly: isRouteOnlyAppDiff(appDiff), - pythonContractTests, + pathContractTests, })); } diff --git a/scripts/ci-test-plan.test.js b/scripts/ci-test-plan.test.js index 4969b0ce61..31749da85f 100644 --- a/scripts/ci-test-plan.test.js +++ b/scripts/ci-test-plan.test.js @@ -7,9 +7,9 @@ import { FULL_SUITE_SHARDS, isRouteOnlyAppDiff, needsSlashdoSubmodule, - pythonReferencePattern, shardIndexes, SLASHDO_GITLINK_PATH, + sourceReferencePattern, splitByRunner, WINDOWS_CONTRACT_TESTS, } from './ci-test-plan.js'; @@ -400,7 +400,7 @@ describe('CI test impact planner', () => { 'server/services/videoGen/runtimes.test.js', 'client/src/lib/videoRenderPhase.test.js', ]; - const pythonContractTests = { + const pathContractTests = { 'scripts/_runner_common.py': [ 'scripts/generate_ltx2.test.js', 'server/services/videoGen/runtimes.test.js', @@ -409,7 +409,7 @@ describe('CI test impact planner', () => { ], }; - const plan = buildCiTestPlan(['scripts/_runner_common.py'], { trackedFiles: tracked, pythonContractTests }); + const plan = buildCiTestPlan(['scripts/_runner_common.py'], { trackedFiles: tracked, pathContractTests }); expect(plan).toMatchObject({ full: false, @@ -432,7 +432,7 @@ describe('CI test impact planner', () => { // python contracts ride along as explicit files. const mixed = buildCiTestPlan(['scripts/_runner_common.py', 'server/services/auth.js'], { trackedFiles: tracked, - pythonContractTests, + pathContractTests, }); expect(mixed.reason).toBe('Vitest related-test fallback'); expect(mixed.server).toMatchObject({ mode: 'related', sources: ['server/services/auth.js'] }); @@ -440,6 +440,63 @@ describe('CI test impact planner', () => { expect(mixed.smoke).toBe(true); }); + it('selects a text-reading contract test by the changed file\'s basename (#6363)', () => { + const tracked = [ + ...TRACKED, + 'client/src/pages/Calendar.jsx', + 'server/lib/navManifest.js', + 'server/lib/navManifest.test.js', + ]; + // navManifest.test.js reads client/src/pages/Calendar.jsx with readFileSync + // rather than importing it, so no `vitest related` edge reaches it — only + // the basename lookup computed by main() and threaded through as + // pathContractTests can. + const pathContractTests = { + 'client/src/pages/Calendar.jsx': ['server/lib/navManifest.test.js'], + }; + + const plan = buildCiTestPlan(['client/src/pages/Calendar.jsx'], { trackedFiles: tracked, pathContractTests }); + + expect(plan.full).toBe(false); + expect(plan.server.files).toContain('server/lib/navManifest.test.js'); + }); + + it('reaches a mirror-parity test from either side of the mirror by basename (#6363)', () => { + const tracked = [ + ...TRACKED, + 'server/lib/seasonStructure.js', + 'server/lib/seasonStructure.mirror.test.js', + 'client/src/lib/seasonStructure.js', + ]; + // Both copies share one basename, and the mirror test names the OTHER copy + // only by that basename — a mirror test living in server/lib is what a + // change to either side must select. + const pathContractTests = { + 'server/lib/seasonStructure.js': ['server/lib/seasonStructure.mirror.test.js'], + 'client/src/lib/seasonStructure.js': ['server/lib/seasonStructure.mirror.test.js'], + }; + + const serverSide = buildCiTestPlan(['server/lib/seasonStructure.js'], { trackedFiles: tracked, pathContractTests }); + expect(serverSide.server.files).toContain('server/lib/seasonStructure.mirror.test.js'); + + const clientSide = buildCiTestPlan(['client/src/lib/seasonStructure.js'], { trackedFiles: tracked, pathContractTests }); + expect(clientSide.server.files).toContain('server/lib/seasonStructure.mirror.test.js'); + }); + + it('runs mirrorCoverage.test.js whenever a mirrored directory changes, since it names no file (#6363)', () => { + const tracked = [...TRACKED, 'server/lib/mirrorCoverage.test.js']; + + const serverLib = buildCiTestPlan(['server/lib/bufferedSpawn.js'], { trackedFiles: tracked }); + expect(serverLib.server.files).toContain('server/lib/mirrorCoverage.test.js'); + + const clientLib = buildCiTestPlan(['client/src/lib/catalogLinks.js'], { trackedFiles: tracked }); + expect(clientLib.server.files).toContain('server/lib/mirrorCoverage.test.js'); + + // Unrelated directories don't force it. + const unrelated = buildCiTestPlan(['server/services/auth.js'], { trackedFiles: tracked }); + expect(unrelated.server.files).not.toContain('server/lib/mirrorCoverage.test.js'); + }); + it('runs the generated-manifest drift tests whenever a server source changes', () => { const tracked = [ ...TRACKED, @@ -465,7 +522,7 @@ describe('CI test impact planner', () => { // Per script: a pinned sibling in the same diff does not vouch for the orphan. const plan = buildCiTestPlan(['scripts/generate_ltx2.py', 'scripts/orphan.py'], { trackedFiles: [...TRACKED, 'scripts/generate_ltx2.py', 'scripts/orphan.py', 'scripts/generate_ltx2.test.js'], - pythonContractTests: { 'scripts/generate_ltx2.py': ['scripts/generate_ltx2.test.js'] }, + pathContractTests: { 'scripts/generate_ltx2.py': ['scripts/generate_ltx2.test.js'] }, }); expect(plan.full).toBe(true); expect(plan.reason).toMatch(/python script with no parsing contract: scripts\/orphan\.py/); @@ -475,7 +532,7 @@ describe('CI test impact planner', () => { }); it('matches the way tests name one python script, not every one', () => { - const re = new RegExp(pythonReferencePattern('scripts/generate_ltx2.py')); + const re = new RegExp(sourceReferencePattern('scripts/generate_ltx2.py')); expect(re.test("join(SCRIPTS, 'generate_ltx2.py')")).toBe(true); expect(re.test('readFileSync("scripts/generate_ltx2.py", "utf8")')).toBe(true); expect(re.test('"generate_ltx2.py"')).toBe(true); diff --git a/scripts/repo-scan-guards.test.js b/scripts/repo-scan-guards.test.js index 6e7d9c7e17..cafd7540c8 100644 --- a/scripts/repo-scan-guards.test.js +++ b/scripts/repo-scan-guards.test.js @@ -1,23 +1,38 @@ /** - * Coverage guard for repo-scanning guards (issue #5055). + * Coverage guard for repo-scanning guards (issue #5055, extended by #6363). * * A handful of tests assert over the *tracked tree* rather than over anything * they import: they shell out to `git grep` / `git ls-files`, read the matched * files as text, and fail when some unrelated file anywhere in the repo breaks * a convention. `scripts/agent-instructions-files.test.js` is the archetype. * + * A second shape has the same problem from the opposite direction: a test + * reads ONE specific file from the *other* runner's tree with `readFileSync` + * (a server test reading a client file, or vice versa) instead of importing + * it — `server/lib/navManifest.js` reading 20+ client files by path, or any + * `*.mirror.test.js` / `*.parity.test.js` diffing a server module against its + * client copy. `pathContractTests` in `ci-test-plan.js` reaches most of these + * by `git grep`-ing tracked tests for the changed file's basename, but only + * when the test actually spells that basename out as a literal — a test that + * instead resolves the path through a bare directory constant (`join(DIR, + * someVariable)`) never names it and stays as unreachable as a #5055 scanner. + * * CI selects tests by impact (`scripts/ci-test-plan.js`) through Vitest's - * changed-source import graph or feature-path matching. Neither can reach a - * scanner: the file that violates the convention - * is never imported by the guard, so no edge exists to follow. The consequence - * is not a flaky selection, it is a structural one — a scanner can sit red on - * `main` indefinitely while every PR reports green, which is exactly what - * happened to the agent-instructions guard. + * changed-source import graph, feature-path matching, or (for a named + * text-reading contract) the basename lookup above. None of those can reach a + * scanner or an unnamed cross-root reader: the file that violates the + * convention is never imported by the guard and its basename never appears in + * the guard's own source, so no edge exists to follow. The consequence is not + * a flaky selection, it is a structural one — a scanner can sit red on `main` + * indefinitely while every PR reports green, which is exactly what happened + * to the agent-instructions guard. * - * `ALWAYS_RUN_TESTS` is the only mechanism that can reach them. This test - * re-derives the scanner set from the tree on every run, so a newly added - * scanner fails here until it is registered rather than joining the list of - * guards nobody notices has stopped running. + * `ALWAYS_RUN_TESTS` (or a `STRUCTURALLY_SELECTED` entry naming a real + * selector) is the only mechanism that can reach either shape. This test + * re-derives both sets from the tree on every run, so a newly added scanner — + * or a newly added unnamed cross-root reader — fails here until it is + * registered, rather than joining the list of guards nobody notices has + * stopped running. */ import { describe, it, expect } from 'vitest'; import { execFileSync } from 'child_process'; @@ -29,9 +44,10 @@ import { ALWAYS_RUN_TESTS } from './ci-test-plan.js'; const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); /** - * Scanners that another selector already reaches whenever they can newly fail, - * so they do not need the always-run list. Each entry names that selector — - * an entry without a live mechanism behind it is worse than no entry. + * Scanners/readers that another selector already reaches whenever they can + * newly fail, so they do not need the always-run list. Each entry names that + * selector — an entry without a live mechanism behind it is worse than no + * entry. */ const STRUCTURALLY_SELECTED = new Map([ // structuralTestsFor() in ci-test-plan.js adds these whenever any @@ -50,6 +66,10 @@ const STRUCTURALLY_SELECTED = new Map([ // file" and forces the complete suite. The guard also rides the Windows // contract list. ['scripts/ps1-bom.test.js', 'unclassified-file full-suite trigger: *.ps1'], + // Walks both lib directories and diffs their README mirror catalogs against + // whichever test files exist; it names no file itself (#6363), so the + // basename lookup can't reach it either. + ['server/lib/mirrorCoverage.test.js', 'structuralTestsFor: server/lib/** or client/src/lib/** changed'], ]); /** A `git` invocation… */ @@ -64,6 +84,38 @@ export const scansTrackedTree = (source) => ( (GIT_CALL.test(source) && GIT_ENUMERATION.test(source)) || TRACKED_HELPER.test(source) ); +/** Which runner owns `relPath` (a `git ls-files`-relative path): 'client' or 'server'. */ +const ownerRootFor = (relPath) => (relPath.startsWith('client/src/') ? 'client' : 'server'); + +/** The directory prefix, as a regex fragment, of the *other* runner's tree. */ +const OPPOSITE_ROOT_PREFIX = { + server: 'client\\/src\\/', + client: '(?:server|scripts|autofixer)\\/', +}; + +/** A quoted/backtick string literal containing a cross-root directory reference. */ +const crossRootLiteral = (ownRoot) => new RegExp(`(['"\`])(?:\\.\\.\\/)*${OPPOSITE_ROOT_PREFIX[ownRoot]}[^'"\`]*\\1`); +/** Same, but the literal also names a file (ends in an extension) before the closing quote. */ +const crossRootNamedFile = (ownRoot) => new RegExp( + `(['"\`])(?:\\.\\.\\/)*${OPPOSITE_ROOT_PREFIX[ownRoot]}[^'"\`]*\\.[A-Za-z0-9]+\\1`, +); + +/** + * True when `source` (the test at `relPath`) reads a file from the *other* + * runner's tree with `readFileSync` but never spells that file's name out as + * a single literal anywhere in its own source — the shape `pathContractTests` + * (ci-test-plan.js, #6363) cannot reach by `git grep`-ing for a basename that + * is never written down. A test that instead names the target directly + * (`join(here, '../../client/src/lib/x.js')`, one literal carrying both the + * cross-root prefix and the extension) passes, because that literal is + * exactly what the basename lookup matches. + */ +export const readsUnnamedCrossRootFile = (source, relPath) => { + if (!/readFileSync\s*\(/.test(source)) return false; + const ownRoot = ownerRootFor(relPath); + return crossRootLiteral(ownRoot).test(source) && !crossRootNamedFile(ownRoot).test(source); +}; + const trackedTests = execFileSync('git', ['ls-files', '*.test.js', '*.test.jsx'], { cwd: REPO_ROOT, encoding: 'utf8', @@ -72,7 +124,10 @@ const trackedTests = execFileSync('git', ['ls-files', '*.test.js', '*.test.jsx'] .split('\n') .filter(Boolean); -const scanners = trackedTests.filter((rel) => scansTrackedTree(readFileSync(join(REPO_ROOT, rel), 'utf8'))); +const testSources = new Map(trackedTests.map((rel) => [rel, readFileSync(join(REPO_ROOT, rel), 'utf8')])); + +const scanners = trackedTests.filter((rel) => scansTrackedTree(testSources.get(rel))); +const crossRootReaders = trackedTests.filter((rel) => readsUnnamedCrossRootFile(testSources.get(rel), rel)); describe('repo-scanning guards are reachable by CI selection (#5055)', () => { it('finds tracked test files to scan', () => { @@ -98,15 +153,47 @@ describe('repo-scanning guards are reachable by CI selection (#5055)', () => { expect(scanners.length).toBeGreaterThanOrEqual(8); }); - it('registers every scanner in ALWAYS_RUN_TESTS or names the selector that reaches it', () => { - const unreachable = scanners.filter((rel) => ( + it('detects the unnamed-cross-root-read shape it registers, and leaves a named read alone (#6363)', () => { + // Bypass probe: proves the detector bites before the assertions below rely + // on it having stopped matching anything. + expect(readsUnnamedCrossRootFile( + "const DIR = join(here, '../../client/src/lib');\nreadFileSync(join(DIR, name), 'utf8');", + 'server/lib/example.test.js', + )).toBe(true); + // Same directory constant, but the file IS named as one literal elsewhere — + // that literal is what the basename `git grep` lookup matches. + expect(readsUnnamedCrossRootFile( + "const DIR = join(here, '../../client/src/lib');\n" + + "readFileSync(join(DIR, name), 'utf8');\n" + + "readFileSync(join(here, '../../client/src/lib/x.js'), 'utf8');", + 'server/lib/example.test.js', + )).toBe(false); + // No readFileSync at all — an ordinary import-graph-reachable test. + expect(readsUnnamedCrossRootFile( + "import { x } from '../../client/src/lib/x.js';", + 'server/lib/example.test.js', + )).toBe(false); + // readFileSync of a same-root file is not a cross-root read. + expect(readsUnnamedCrossRootFile( + "readFileSync(join(here, 'sibling.js'), 'utf8');", + 'server/lib/example.test.js', + )).toBe(false); + }); + + it('finds the known unnamed cross-root readers', () => { + expect(crossRootReaders).toContain('server/lib/mirrorCoverage.test.js'); + }); + + it('registers every scanner and unnamed cross-root reader in ALWAYS_RUN_TESTS or names the selector that reaches it', () => { + const unreachable = [...scanners, ...crossRootReaders].filter((rel) => ( !ALWAYS_RUN_TESTS.includes(rel) && !STRUCTURALLY_SELECTED.has(rel) )); expect( unreachable, - 'These tests assert over the tracked tree, so CI\'s import-graph selection can never reach them. ' - + 'Add each to ALWAYS_RUN_TESTS in scripts/ci-test-plan.js, or to STRUCTURALLY_SELECTED here with ' - + `the selector that already covers it: ${unreachable.join(', ')}`, + 'These tests assert over the tracked tree, or read a cross-root file without naming it, so CI\'s ' + + 'import-graph and basename selection can never reach them. Add each to ALWAYS_RUN_TESTS in ' + + 'scripts/ci-test-plan.js, or to STRUCTURALLY_SELECTED here with the selector that already covers it: ' + + `${unreachable.join(', ')}`, ).toEqual([]); }); @@ -120,12 +207,12 @@ describe('repo-scanning guards are reachable by CI selection (#5055)', () => { ).toEqual([]); }); - it('does not park a scanner in STRUCTURALLY_SELECTED that is no longer one', () => { + it('does not park a scanner or cross-root reader in STRUCTURALLY_SELECTED that is no longer one', () => { // An entry here is a claim that some other selector covers the file. Once - // the file stops scanning the tree the claim is meaningless, and leaving it - // hides the fact that nothing is being asserted. - const scannerSet = new Set(scanners); - const obsolete = [...STRUCTURALLY_SELECTED.keys()].filter((rel) => !scannerSet.has(rel)); - expect(obsolete, `No longer scans the tracked tree — drop the entry: ${obsolete.join(', ')}`).toEqual([]); + // the file stops matching either shape the claim is meaningless, and + // leaving it hides the fact that nothing is being asserted. + const stillMatches = new Set([...scanners, ...crossRootReaders]); + const obsolete = [...STRUCTURALLY_SELECTED.keys()].filter((rel) => !stillMatches.has(rel)); + expect(obsolete, `No longer scans the tracked tree or reads an unnamed cross-root file — drop the entry: ${obsolete.join(', ')}`).toEqual([]); }); });