Skip to content

Commit c5cbf7f

Browse files
objectstack-fleet[bot]claudeNitjsefnie
authored
test: scope the tree-wide pin scans to the extensions they judge, one shared scan per file (#25410)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DTUQ4EBM66nvHr5WDUjGwSA)_ Fixes #25321 Clause-②: no Metadata-protocol: none The four tree-scan pins that still handed `git grep` all of `packages/` and filtered to `.ts` afterwards now give git the pathspec of the extension they judge, and run that scan once per file, shared by every assertion that reads it. This is the shape PR #25319 gave the `@objectstack/runtime` twin. `SCAN_TIMEOUT_MS = 60_000` is unchanged in every file: this is not a timeout raise. Test-only; `skip-changeset` (all four files are `src/**/*.test.ts`, which neither package ships: both `files[]` ship `dist`). ## Adopted from a fork PR The core of this diff is the fork PR #25389 by Nitjsefnie, adopted onto an internal branch per the repository's card-first rule for outside contributions. Its first commit carries their diff unchanged, with `Co-authored-by: Nitjsefnie`. The fork PR itself is not landed; it is closed with thanks when this card closes. The second commit is the review change. The fork made the `@objectstack/objectql` twin one scan too, but kept a second file read per symbol and dropped the attribution check. That file now carries the runtime twin's half D as it stands on `main`: - one multi-needle `git grep` over `SCANNED_PATHSPECS` in the describe's `beforeAll`; - the candidate files are read once, recording each symbol's lines; - a file git lists but the read cannot attribute throws (a broken scan, never a clean file); - the two tree tests drop their own `SCAN_TIMEOUT_MS`, because they no longer scan; - `SCAN_TIMEOUT_MS` keeps the runtime twin's "not a dial" note. The two copies are kept parallel on purpose (PR #25319's acceptance note). The remaining differences from the runtime twin are the package-specific ones (`findUp` for the CJS root, `PIN_FILES[0]`, the runtime site as the reach target, no half lettering) and comment wording that already differed on `main`. ## Per file | file | before | after | | :-- | :-- | :-- | | `packages/objectql/src/action-owner-key-single-source.test.ts` | 3 greps over `packages` | 1 grep, `:(glob)packages/**/*.ts`, both needles | | `packages/core/src/security/row-serving-door-exposure.pin.test.ts` | 2 greps over `packages` | 1 grep, `*.ts`, in `beforeAll` | | `packages/core/src/security/second-object-read-exposure.pin.test.ts` | 2 greps over `packages` | 1 grep, `*.ts`, in `beforeAll` | | `packages/core/src/security/operation-private-keys.pin.test.ts` | 4 prefilter greps over `packages` (+3 `ls-files`) | 1 prefilter grep, `*.ts` + `*.tsx`, in `beforeAll` (the `ls-files` census is untouched) | | `packages/core/src/security/granted-permissions-not-enforced.pin.test.ts` | already `*.ts` + `*.mts` | **unchanged** | `operation-private-keys` keeps `.tsx` in its prefilter on purpose: the boundary-expiry assertion judges `.tsx` declarations, so that population must still be read. ## Readings (local container, base `830a63a`, head `98ac499`) Bytes are `rchar` from `/proc/PID/io` for the git processes each file starts, with the test's own arguments. Wall is the vitest JSON reporter's file duration, 3 runs each, under `scripts/pm/os-verify-lock.sh`. | file | git invocations | bytes git reads | file wall (3 runs) | | :-- | --: | --: | --: | | objectql twin | 3 → 1 | 639.7 → 151.8 MB | 391 / 405 / 402 → 156 / 181 / 176 ms | | row-serving | 2 → 1 | 426.5 → 151.8 MB | 374 / 337 / 371 → 141 / 139 / 120 ms | | second-object | 2 → 1 | 426.5 → 151.8 MB | 501 / 475 / 467 → 233 / 259 / 231 ms | | operation-private-keys | 4 greps + 3 `ls-files` → 1 + 3 | 852.9 → 151.9 MB | 856 / 863 / 892 → 142 / 151 / 145 ms | | granted-permissions (unchanged) | 7 | 1,065.3 MB | 529 / 519 / 509 → 547 / 533 / 545 ms | The local wall cannot reproduce a loaded shard. **Estimate, not measured on a shard:** if a shard's cost scales with the bytes read, as the runtime twin's 86.24 s across three walks suggests, each edited file now reads what one walk read there. ### The judged file set did not shrink: measured Old shape (grep over `packages`, then the file's own filter) against new shape (pathspec grep, then the same filter), on the head tree plus untracked plants under `packages/zz-eq-25321/` holding every needle: `top.ts` (depth 1), `a/b/deep.ts`, `a/b/deep.d.ts`, `a/b/z.test.ts`, `a/b/x.tsx`, `a/b/y.mts`, `a/b/note.md` (depth 3). | pin | judged set | old | new | | plants kept | | :-- | :-- | --: | --: | :-- | :-- | | objectql twin | `actionObjectKey` | 6 | 6 | identical | `top.ts`, `deep.ts`, `deep.d.ts`, `z.test.ts` | | objectql twin | `standaloneActionOwnerKey` | 15 | 15 | identical | the same four | | row-serving | `canReadObject` | 19 | 19 | identical | `top.ts`, `deep.ts` | | second-object | `referenceTargetOf` | 24 | 24 | identical | `top.ts`, `deep.ts` | | operation-private-keys | offender population (`.ts` minus `.d.ts`) | 17 | 17 | identical | `top.ts`, `deep.ts`, `z.test.ts` | | operation-private-keys | boundary population (`.tsx`) | 1 | 1 | identical | `x.tsx` | The plants were removed by a trap, and `git status` was clean afterwards. ### A planted mention in a still-scanned path goes red Predicted direction: red, naming the planted file. Each leg plants one untracked file under `packages/plugins/plugin-auth/src/zz-ablation-25321/deep/er/` and runs that pin file alone at head `98ac499`, after the fix was committed. | pin | plant | result | | :-- | :-- | :-- | | objectql twin | `planted.ts`, a comment naming `actionObjectKey` | 1 failed, 4 passed: "is named nowhere outside the release record…", listing `…/deep/er/planted.ts:1` | | objectql twin | control: the same text as `planted.md` | 5 passed | | row-serving | `planted.ts` calling `svc.canReadObject(…)` | 1 failed, 2 passed: "every caller of the read admission is classified…", listing the plant | | row-serving | control: `.md` | 3 passed | | second-object | `planted.ts` calling `referenceTargetOf(…)` | 1 failed, 3 passed: "every caller of the reference-target arbiter is classified…", listing the plant | | second-object | control: `.md` | 4 passed | | operation-private-keys | `planted.ts` declaring `OPERATION_PRIVATE_KEY_PREFIX` | 1 failed, 4 passed: "is declared in exactly one file…", listing the plant | | operation-private-keys | the same declaration as `planted.tsx` | 1 failed, 4 passed: the boundary assertion "the extension boundary is `.ts` alone…", listing the plant; the offender assertion stays green | | operation-private-keys | control: `.md` | 5 passed | The plant directory was removed by a trap after every leg; `git status --porcelain` and `git diff HEAD` were empty afterwards. ## Local verification (head `98ac499`) - The five pin files pass: objectql 5/5, row-serving 3/3, second-object 4/4, operation-private-keys 5/5, granted-permissions 6/6 (`vitest run --project repo`, per file). - `pnpm --filter @objectstack/core run typecheck`: exit 0. `pnpm --filter @objectstack/objectql run typecheck`: exit 0. - `pnpm exec eslint --no-inline-config` on the four files: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands` derived 58 families; `--ran` reconciliation: 58 accounted for, 53 run, 5 NOT MEASURED (exit 3, no `dist/` in this container: `check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:sourcemap-no-sources-content`; `check-plugin-teardown-shape --self-test`, whose pinned fixture commit is outside the shallow clone). `check-engine-split-ratio --days 90` exited 2, refusing the shallow clone's 90-day window: also NOT MEASURED here. Their verdicts are this PR's CI checks. - `scripts/pm/check-governed-merges.mjs --test` on the four paths: 0 of 4 hit the register, NOT governed. ## Acceptance notes - **`granted-permissions-not-enforced.pin.test.ts` is left unchanged, as the card asks** (its pathspecs are already `.ts` + `.mts`). It is now the largest reader of the five: seven separate scoped greps, 1,065.3 MB per file run. The test "leaves the fs and network grant classes with no enforcement surface at all" runs three of them (about 456 MB) inside one 60 s budget, which is more than the 426 MB a single test of the runtime twin read before it timed out on a loaded shard. It has not been seen red. The same one-shared-scan shape would fit it. Noted, not filed; carrier: none. - The fork PR showed 0 check runs at its head `f38d888`, read as NOT MEASURED. This branch's CI is the first measurement of the adopted diff. - **Branch name.** This PR's branch is `claude/project-thread-37l85m`, the name the session was given, not `claude/issue-25321-…`. The Duplicate Fix Guard may say so. The claim (comment 6364199079) names it. --- _Generated by [Claude Code](https://claude.ai/code/session_01J61suxUmEiTYnKbc7EKFYs)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01J61suxUmEiTYnKbc7EKFYs --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Nitjsefnie <zmatek.peter@gmail.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent d44c893 commit c5cbf7f

4 files changed

Lines changed: 160 additions & 49 deletions

File tree

‎packages/core/src/security/operation-private-keys.pin.test.ts‎

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -69,19 +69,18 @@
6969
* identifiers as FIXED STRINGS, which is a provably exact superset of what
7070
* `declarationMatcher()` can match (the regex cannot match text that lacks the literal
7171
* identifier), so no judgement moves into git's regex dialect — the JS regex
72-
* below remains the sole authority on what counts as a declaration. Reading only
73-
* the handful of files that mention a symbol, rather than all ~3,600, is also
74-
* what removes the timeout exposure: one `git grep` process instead of ~3,600
75-
* sequential `readFileSync` round-trips, which is the part that degrades
76-
* non-linearly when a runner is I/O-saturated.
72+
* below remains the sole authority on what counts as a declaration. One
73+
* extension-scoped `git grep` supplies the shared prefilter result; only files
74+
* that mention a symbol are read for declaration matching. The scan hook's
75+
* budget still applies when the runner is busy.
7776
*/
7877

7978
import { execFileSync } from 'node:child_process';
8079
import { readFileSync } from 'node:fs';
8180
import { dirname, join, relative, resolve, sep } from 'node:path';
8281
import { fileURLToPath } from 'node:url';
8382

84-
import { describe, it, expect } from 'vitest';
83+
import { beforeAll, describe, it, expect } from 'vitest';
8584

8685
const HERE = dirname(fileURLToPath(import.meta.url));
8786
/** …/packages/core/src/security → repo root */
@@ -111,7 +110,7 @@ const HELPER_SYMBOL = 'withoutOperationPrivateKeys';
111110
* a docblock line begins with ` * `, and a string-literal or regex-source line
112111
* begins with something other than `const`/`let`/`var`/`function` followed by a
113112
* guarded name. That property is load-bearing, so it is asserted rather than
114-
* trusted — see the last test.
113+
* trusted — see "this pin quotes both symbols without declaring them".
115114
*
116115
* Returned as a FRESH regex per call, never shared. A `/g` regex carries
117116
* `lastIndex` across calls, so one shared instance makes the result depend on
@@ -125,11 +124,10 @@ const declarationMatcher = (): RegExp =>
125124
/^\s*(?:export\s+)?(?:const|let|var|function)\s+(OPERATION_PRIVATE_KEY_PREFIX|withoutOperationPrivateKeys)\b\s*[=(<]/gm;
126125

127126
/**
128-
* Generous on purpose (#7706). The scan costs tens of milliseconds, so this is
129-
* not a budget — it is headroom against a merge-queue runner that is doing a
130-
* full monorepo build at the same time. The default 5s was the whole failure:
131-
* the pin timed out before the assertion ran, and a pin that reports nothing is
132-
* indistinguishable from a pin that found nothing.
127+
* The scan hook's budget includes headroom for a merge-queue runner doing a
128+
* full monorepo build at the same time. The earlier filesystem crawl (#7706)
129+
* timed out under the default test budget before its assertion ran. A pin that
130+
* reports nothing is indistinguishable from a pin that found nothing.
133131
*/
134132
const SCAN_TIMEOUT_MS = 60_000;
135133

@@ -235,13 +233,12 @@ function scannedFiles(): string[] {
235233
}
236234

237235
/**
238-
* Every path the fixed-string prefilter returns, BEFORE the extension boundary
239-
* — so it includes the `.tsx` (and `.md`, and config) files the scan does not
240-
* judge. Split out for the same reason as `authoredPaths()`: the trade the
241-
* boundary rests on is a claim about the files it drops, and a claim about
242-
* dropped files cannot be made from the set they were dropped from.
236+
* The fixed-string prefilter covers `.ts` for the offender scan AND `.tsx`
237+
* for the boundary-expiry assertion. Other extensions feed neither judgement,
238+
* so git need not read their bytes. The `.tsx` hits must stay available before
239+
* `isScannedSource` drops them from the offender population.
243240
*/
244-
function prefilterHits(): string[] {
241+
function scanTree(): string[] {
245242
return git([
246243
'grep',
247244
'--files-with-matches',
@@ -254,7 +251,8 @@ function prefilterHits(): string[] {
254251
'-e',
255252
HELPER_SYMBOL,
256253
'--',
257-
'packages',
254+
':(glob)packages/**/*.ts',
255+
':(glob)packages/**/*.tsx',
258256
]);
259257
}
260258

@@ -273,7 +271,18 @@ function declaringFiles(): string[] {
273271
);
274272
}
275273

274+
/** One prefilter result shared by the offender, reach and boundary assertions. */
275+
let treeMentions: string[];
276+
277+
function prefilterHits(): string[] {
278+
return treeMentions;
279+
}
280+
276281
describe('the `__` operation-private-key convention has one owner (#7284)', () => {
282+
beforeAll(() => {
283+
treeMentions = scanTree();
284+
}, SCAN_TIMEOUT_MS);
285+
277286
it(
278287
'is declared in exactly one file, and that file is the shared home',
279288
() => {

‎packages/core/src/security/row-serving-door-exposure.pin.test.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import { existsSync, readFileSync } from 'node:fs';
4747
import { dirname, join, resolve } from 'node:path';
4848
import { fileURLToPath } from 'node:url';
4949

50-
import { describe, it, expect } from 'vitest';
50+
import { beforeAll, describe, it, expect } from 'vitest';
5151

5252
const HERE = dirname(fileURLToPath(import.meta.url));
5353
/** …/packages/core/src/security → repo root */
@@ -132,16 +132,27 @@ const codeLines = (file: string) =>
132132
const calls = (file: string, matcher: () => RegExp) => codeLines(file).some((line) => matcher().test(line));
133133

134134
/** Fixed-string prefilter over tracked plus untracked sources; the regex decides. */
135-
function filesMentioningTheAdmission(): string[] {
136-
return git(['grep', '--files-with-matches', '-z', '--untracked', '--fixed-strings', '-e', ADMISSION, '--', 'packages'])
135+
function scanTree(): string[] {
136+
return git(['grep', '--files-with-matches', '-z', '--untracked', '--fixed-strings', '-e', ADMISSION, '--', ':(glob)packages/**/*.ts'])
137137
.filter(isScannedSource);
138138
}
139139

140140
function admissionCallers(): string[] {
141141
return filesMentioningTheAdmission().filter((file) => calls(file, admissionCall)).sort();
142142
}
143143

144+
/** Shared by the census and its reach control; each file pays for one grep. */
145+
let treeMentions: string[];
146+
147+
function filesMentioningTheAdmission(): string[] {
148+
return treeMentions;
149+
}
150+
144151
describe('[#22640] every row-serving door outside the data routes asks the exposure decision', () => {
152+
beforeAll(() => {
153+
treeMentions = scanTree();
154+
}, SCAN_TIMEOUT_MS);
155+
145156
it(
146157
'every caller of the read admission is classified, and every classified file still calls it',
147158
() => {

‎packages/core/src/security/second-object-read-exposure.pin.test.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ import { existsSync, readFileSync } from 'node:fs';
7575
import { dirname, join, resolve } from 'node:path';
7676
import { fileURLToPath } from 'node:url';
7777

78-
import { describe, it, expect } from 'vitest';
78+
import { beforeAll, describe, it, expect } from 'vitest';
7979

8080
const HERE = dirname(fileURLToPath(import.meta.url));
8181
/** …/packages/core/src/security → repo root */
@@ -272,8 +272,8 @@ const codeLines = (file: string) =>
272272
const callsArbiter = (file: string) => codeLines(file).some(arbiterCall);
273273

274274
/** Fixed-string prefilter over tracked plus untracked sources; the matcher decides. */
275-
function filesMentioningTheArbiter(): string[] {
276-
return git(['grep', '--files-with-matches', '-z', '--untracked', '--fixed-strings', '-e', ARBITER, '--', 'packages'])
275+
function scanTree(): string[] {
276+
return git(['grep', '--files-with-matches', '-z', '--untracked', '--fixed-strings', '-e', ARBITER, '--', ':(glob)packages/**/*.ts'])
277277
.filter(isScannedSource);
278278
}
279279

@@ -306,7 +306,18 @@ function functionBody(file: string, fn: string): string[] {
306306
return body;
307307
}
308308

309+
/** Shared by the census and its reach control; each file pays for one grep. */
310+
let treeMentions: string[];
311+
312+
function filesMentioningTheArbiter(): string[] {
313+
return treeMentions;
314+
}
315+
309316
describe('[#22661] every read that reaches a second object asks that object its exposure, or is named', () => {
317+
beforeAll(() => {
318+
treeMentions = scanTree();
319+
}, SCAN_TIMEOUT_MS);
320+
310321
it(
311322
'every caller of the reference-target arbiter is classified, and every classified file still calls it',
312323
() => {

‎packages/objectql/src/action-owner-key-single-source.test.ts‎

Lines changed: 104 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import { execFileSync } from 'node:child_process';
2121
import { existsSync, readFileSync, readdirSync } from 'node:fs';
2222
import { dirname, join, resolve } from 'node:path';
23-
import { describe, it, expect } from 'vitest';
23+
import { beforeAll, describe, it, expect } from 'vitest';
2424
import { GLOBAL_ACTION_OBJECT_KEY, standaloneActionOwnerKey } from './action-governance.js';
2525

2626
/** Rung 1 exactly as `action-governance.ts` writes it. */
@@ -264,11 +264,34 @@ const SCANNED_ROOTS: readonly string[] = ['packages'];
264264
const SCANNED_EXTENSION = '.ts';
265265

266266
/**
267-
* Generous on purpose. The scan is one `git grep` and a handful of file reads —
268-
* tens of milliseconds — so this is not a budget, it is headroom against a
269-
* merge-queue runner doing a full monorepo build at the same time. A pin that
270-
* times out before its assertion runs reports nothing, and reporting nothing is
271-
* indistinguishable from finding nothing.
267+
* The scanned roots as git pathspecs: every `SCANNED_EXTENSION` file at any
268+
* depth under each root, which is the very glob this package declares in
269+
* `scripts/cross-package-test-inputs.mjs`. Handed to git rather than applied to
270+
* its answer, so git reads only the files the scan judges. The set is the one
271+
* the `.endsWith('.ts')` filter on git's output used to keep, file for file, so
272+
* nothing the scan proves narrows; what changes is that the other quarter of
273+
* the bytes under `packages/` (the `CHANGELOG.md`s alone are 55 MB) is no
274+
* longer read just to be thrown away.
275+
*
276+
* The `glob` magic is spelled out so `GIT_GLOB_PATHSPECS` and
277+
* `GIT_NOGLOB_PATHSPECS` cannot change what it matches. A setting that turns
278+
* pathspec magic off altogether makes the scan match nothing, and the reach
279+
* control below reds on that.
280+
*/
281+
const SCANNED_PATHSPECS: readonly string[] = SCANNED_ROOTS.map(
282+
(root) => `:(glob)${root}/**/*${SCANNED_EXTENSION}`,
283+
);
284+
285+
/**
286+
* The budget of the one tree scan, which `beforeAll` runs (see `scanTree`).
287+
* Generous on purpose: the scan is one `git grep` and a handful of file reads,
288+
* so this is headroom against a merge-queue runner doing a full monorepo build
289+
* at the same time. A pin that times out before its assertion runs reports
290+
* nothing, and reporting nothing is indistinguishable from finding nothing.
291+
*
292+
* ⛔ Not a dial. When a loaded runner pushes the scan toward this figure, cut
293+
* the bytes the scan reads; raising the figure only lets a slower scan hide
294+
* for longer.
272295
*/
273296
const SCAN_TIMEOUT_MS = 60_000;
274297

@@ -290,43 +313,102 @@ function git(args: string[]): string[] {
290313
return stdout.split('\0').filter((entry) => entry.length > 0);
291314
}
292315

316+
/** Every symbol the tree scan asks about: the dead member, and the reach control's live spelling. */
317+
const HUNTED_SYMBOLS: readonly string[] = [DELETED_PLUGIN_MEMBER, CANONICAL_HELPER];
318+
319+
/** Per hunted symbol: every scanned file that mentions it, with the 1-based lines that do. */
320+
type TreeScan = ReadonlyMap<string, ReadonlyMap<string, readonly number[]>>;
321+
293322
/**
294-
* Every scanned file that so much as mentions `symbol`.
323+
* ONE walk of the scanned tree answers every question the tree tests ask.
295324
*
296-
* Tracked files PLUS untracked ones with ignored paths excluded (`--untracked`)
297-
* — i.e. exactly the files a human authored, never build output. A file written
298-
* but not yet `git add`ed still reddens, which is what makes this a local-loop
299-
* guard rather than something you find out about in the merge queue.
325+
* Each question used to pay its own walk: three `git grep`s per run, each
326+
* reading every file under `packages/`, 3 × 213.2 MB. On a quiet machine that
327+
* is a fraction of a second; on the most loaded Test Core shard the runtime
328+
* twin of this file took 86.24 s against the same 60 s budget. A tree scan
329+
* costs the bytes it reads, and the bytes are what a loaded runner slows, so
330+
* the remedy is fewer bytes: one `git grep` for every hunted symbol at once,
331+
* over `SCANNED_PATHSPECS`, is 151.8 MB, four times less (both measured with
332+
* the scan's own arguments at 830a63a). Reading it once also tightens the
333+
* reach control, which now proves the very scan whose emptiness the first test
334+
* reads, not a sibling run of it.
335+
*
336+
* Scans tracked files PLUS untracked ones with ignored paths excluded
337+
* (`--untracked`): exactly the files a human authored, never build output. A
338+
* file written but not yet `git add`ed still reddens, which keeps this a
339+
* local-loop guard.
300340
*/
301-
function filesMentioning(symbol: string): string[] {
302-
return git([
341+
function scanTree(): TreeScan {
342+
const candidates = git([
303343
'grep',
304344
'--files-with-matches',
305345
'-z',
306346
'--untracked',
307347
'--text',
308348
'--fixed-strings',
309-
'-e',
310-
symbol,
349+
...HUNTED_SYMBOLS.flatMap((symbol) => ['-e', symbol]),
311350
'--',
312-
...SCANNED_ROOTS,
313-
]).filter((file) => file.endsWith(SCANNED_EXTENSION));
351+
...SCANNED_PATHSPECS,
352+
]);
353+
const scan = new Map<string, Map<string, number[]>>();
354+
for (const symbol of HUNTED_SYMBOLS) scan.set(symbol, new Map());
355+
for (const file of candidates) {
356+
const lines = readFileSync(join(REPO_ROOT, file), 'utf8').split('\n');
357+
let attributed = false;
358+
for (const [symbol, files] of scan) {
359+
const hits = lines.flatMap((text, index) => (text.includes(symbol) ? [index + 1] : []));
360+
if (hits.length === 0) continue;
361+
files.set(file, hits);
362+
attributed = true;
363+
}
364+
// git matched this file, so a read that finds none of the needles
365+
// means the two disagree about its bytes: a broken scan, never a clean
366+
// file.
367+
if (!attributed) {
368+
throw new Error(
369+
`git grep listed ${file} as mentioning one of ${HUNTED_SYMBOLS.join(', ')}, `
370+
+ 'and reading it finds none of them.',
371+
);
372+
}
373+
}
374+
return scan;
375+
}
376+
377+
/** Filled by the tree tests' `beforeAll`; read by both of them. */
378+
let treeScan: TreeScan | undefined;
379+
380+
function mentionsOf(symbol: string): ReadonlyMap<string, readonly number[]> {
381+
const mentions = treeScan?.get(symbol);
382+
if (!mentions) {
383+
throw new Error(
384+
treeScan
385+
? `${symbol} is not one of the symbols the tree scan hunts (HUNTED_SYMBOLS).`
386+
: 'A tree test read the tree scan before its beforeAll ran it.',
387+
);
388+
}
389+
return mentions;
390+
}
391+
392+
/** Every scanned file that so much as mentions `symbol`. */
393+
function filesMentioning(symbol: string): string[] {
394+
return [...mentionsOf(symbol).keys()];
314395
}
315396

316397
/** `<file>:<line>` for every mention that no rule above excuses. */
317398
function staleMentionSites(symbol: string): string[] {
318399
const sites: string[] = [];
319-
for (const file of filesMentioning(symbol)) {
400+
for (const [file, lines] of mentionsOf(symbol)) {
320401
if (NOT_A_STALE_MENTION.some((rule) => rule.covers(file))) continue;
321-
const lines = readFileSync(join(REPO_ROOT, file), 'utf8').split('\n');
322-
lines.forEach((text, index) => {
323-
if (text.includes(symbol)) sites.push(`${file}:${index + 1}`);
324-
});
402+
for (const line of lines) sites.push(`${file}:${line}`);
325403
}
326404
return sites;
327405
}
328406

329407
describe('standalone-action owner key — the deleted member is dead TREE-WIDE (#14878)', () => {
408+
beforeAll(() => {
409+
treeScan = scanTree();
410+
}, SCAN_TIMEOUT_MS);
411+
330412
it(
331413
'is named nowhere outside the release record and the two pins',
332414
() => {
@@ -356,7 +438,6 @@ describe('standalone-action owner key — the deleted member is dead TREE-WIDE (
356438
].join('\n'),
357439
).toEqual([]);
358440
},
359-
SCAN_TIMEOUT_MS,
360441
);
361442

362443
it(
@@ -380,6 +461,5 @@ describe('standalone-action owner key — the deleted member is dead TREE-WIDE (
380461
expect(reached).toContain('packages/cli/src/commands/lint.ts');
381462
expect(reached).toContain('packages/runtime/src/action-execution.ts');
382463
},
383-
SCAN_TIMEOUT_MS,
384464
);
385465
});

0 commit comments

Comments
 (0)