Skip to content

Commit 711c4ec

Browse files
ci(test-ui): let scripts/ and eslint-rules/ own their tests, and keep a test that runs a library program always-on (#31639)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb77KgkSa7rZ7QmbAP6hZdVB)_ Part-of #31520 (slice 3 of 3 planned; the card's under-200 line is with the maintainer for re-scoping, and this slice does not wait on that answer) Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Test UI 选测脚本把 `scripts/` 和 `eslint-rules/` 当作各自测试的「所有者目录」:改动这两个目录才跑其中的测试;`scripts/invoked-as.mjs` 不再被算作读磁盘。同时补上一个早已存在的盲点:不导入 `fs`、却运行 TypeScript 程序、ESLint 或 vite 配置的测试,现在一律按读磁盘处理,每次必跑。空改动下的必跑集从 519 降到 509:放出 32 个,新增 22 个(都是这个盲点)。 - 为什么改:这是 #31520 计划的第 3 片。盲点修正由席位决定一起做:这 22 个测试此前只在自己的包改动时才跑,但它们读取的 tsconfig 和源码远超出那个包。 - 风险与回滚:风险有两处,一是某个测试在它读的东西变化时没被选中,二是根配置引用的脚本不再触发全量。前者用 36 次单目录改动比对旧新两版,去掉的 1,017 处选中全部落在 32 个放出的测试上,每处都对照了导入闭包和运行时实际读取的文件;后者有专门的钉子(`vitest-invocation-guard.mjs` 改动仍是 `mode=full`)。回滚即还原这两个文件。 - 先看:`ui/scripts/select-affected-tests.mjs` 头部的 2c、2d 两项,`selectAffectedTests` 里 `workspacePackages` 与 `packages` 的区分(`configReach` 只看前者),然后是 `libraryReadsDisk` 与 `isBenignDiskModule`。 - 两分钟验证:`cd ui && node scripts/select-affected-tests.mjs --base HEAD --explain`,预期 `always-on test files ...: 509`。本 PR 自身改了 `scripts/`,所以 `--base origin/main` 会把整个 `scripts/` 目录选进来,不是空改动的读数。 - 需要你做的:无,席位落地。 ## What changes Two new items in the selector's header. **2c, owner directories.** `scripts/` and `eslint-rules/` join the package graph as dependency-free pseudo-packages, `<dir:scripts>` and `<dir:eslint-rules>` (`OWNER_DIRS`): - A change to a file in one selects that directory as a filter. - A test elsewhere that imports or reads into one gets it as an input package, through the S1 and S2 rules. - An owner directory declares no dependencies, so every workspace package its tests import is an input package. - A change to the path walker (`PATH_WALKER_FILE`, which arrives as `outside`) selects `scripts/`, the directory of the selector that imports it. - `scripts/invoked-as.mjs` is exempt from the disk rule only while its `fs` and `child_process` imports are exactly the pinned bindings (`BENIGN_DISK_MODULES`, `isBenignDiskModule`). A new read would have to be imported, and that revokes the exemption. The root config's reach, the item-3 full-run trigger, is still computed against the workspace packages alone. Without that, `scripts/vitest-invocation-guard.mjs` would have become an ordinary `scripts/` change. The S3 plan found this trap, and a pin now holds it. **2d, library programs.** A module that imports `typescript`, `eslint`, `vite`, `tailwindcss`, `postcss`, `esbuild`, `rollup` or `ts-morph` now counts as reading the disk whether or not it imports `fs` (`libraryReadsDisk`). Only in-memory uses are free: - TypeScript's parser and node helpers (`createSourceFile`, `forEachChild`, `is*`), plus types and enums; - ESLint's `RuleTester` and `Linter`; - Tailwind's core `compile`, which loads stylesheets only through the caller's own `loadStylesheet`. Any other member, a binding used other than as `ts.member`, a dynamic `import()` and a `require` all count as reads. Comments are stripped first with the shared `scripts/js-comment-mask.mjs` (`stripComments`), as `check-hand-rolled-comment-mask` requires, so a JSDoc `{import('eslint').Rule.RuleModule}` or a CSS `@import 'tailwindcss'` in prose names nothing. A source that names no library is answered before it is tokenised. Empty-diff count (`--base HEAD`, so this PR's own `scripts/` diff is not read as a change): | head | always-on | selected of tracked | |---|---:|---:| | `95df3459` (main after slice 2, branch base) | 519 | 519 of 6,658 | | `ab309a32` (this PR, `origin/main` `69244406` merged in) | **509** | 509 of 6,658 | That is 32 freed (11 `eslint-rules/`, 6 `scripts/__tests__`, 15 package tests whose only out-of-package edges land in `scripts/`) and 22 newly kept. All 22 are 2d: 21 build a TypeScript program (`createProgram`, `createCompilerHost`, `ts.sys`, `readConfigFile`, `resolveModuleName`), and 1 imports `defineConfig` from `vite`. Every one of the 509 now reads the disk or spawns, except 2 that reach the tree root. None is "outside every package" any more. 166 of them are in `scripts/`, where most tests spawn a gate, run a TypeScript program, or walk the tree. ## Why Slice 3 of the plan the `domain:ui#3` seat amended into #31520 (amendment 6724374834). It ships with the blind-spot fix the seat asked for: before this PR, a test that runs a TypeScript program without `fs` was selected only by its own package, as it had been since before #31520. The plan measured 21 such tests and projected about 504; the shipped rule keeps 22, which gives 509. ## Tests In `ui/scripts/__tests__/select-affected-tests.test.ts`: - **Owner directories.** - Freed: `scripts/__tests__/gate.test.ts` (imports a `scripts/` helper), `own-dir.test.ts` (reads only `scripts/`), `entry.test.ts` (imports a pinned `invoked-as.mjs`) and `eslint-rules/rule.test.js` (`RuleTester`). - A `scripts/` change selects the `scripts/` filter, and an `eslint-rules/` change selects `eslint-rules/`. Neither selects the other's directory, and a package change selects neither. - `packages/c/src/escapes.test.ts` (imports `scripts/helper.mjs`) is attributed via `<dir:scripts>`. - Kept always-on: `spawns.test.ts` and `walks.test.ts` (an `opendirSync(process.cwd())` walk). The walk uses `opendirSync`, not `readdirSync`: `markdown-test-inputs`' scanner treats any `readdirSync` beside a `.md` fixture name as a markdown walk to adjudicate, and this test reads no markdown. Adding a ledger entry instead would mean a file outside this claim. - The old pins move to fixtures outside every owner directory: `tools/loose.test.ts` is "outside every workspace package", and `escapes-root.test.ts` "reaches the repository root". - **configReach on real packages.** `scripts/guard.mjs` (imported by the fixture root config) still gives `mode=full`. On the live tree, `scripts/vitest-invocation-guard.mjs` gives `mode=full` with the root-config reason. - **Walker control, now non-trivial.** On the live tree, a change to the walker: - selects `changedPackages = ['<dir:scripts>']`; - gives the same filters as a change to the selector; - includes the `scripts/` filter, which an empty change does not, and that filter covers the selector's own test file. - **The `invoked-as` pin.** - Each of these revokes the exemption: an added `readFileSync`, an added `execSync`, a namespace `fs` import, or another path. - The live `scripts/invoked-as.mjs` still matches the pin. - **Library programs.** - `program.test.ts` (`ts.createProgram`), `via-program.test.ts` (a helper reading `ts.sys`) and `eslint-api.test.ts` (`new ESLint()`) are kept. - `parse.test.ts` (`createSourceFile`) and `comment-lib.test.ts` (a JSDoc `import('eslint')`) are not. - `libraryReadsDisk` cases cover each in-memory and disk shape. <details><summary>Ablations: each rule removed, its controls red</summary> Each was run through `node scripts/ablation-replace.mjs --file ui/scripts/select-affected-tests.mjs` against `pnpm --dir ui exec vitest run scripts/__tests__/select-affected-tests.test.ts`, at `fcbef701` (blob `53bebf8f`). After the comment-mask commit `ff2cf01a`, the `OWNER_DIRS` and `libraryReadsDisk` rows were run again, with the same red counts (blob `d3589ee9`). After every run, the blob was restored to HEAD and `git diff HEAD` was empty. | rule removed | result | red | |---|---|---| | `OWNER_DIRS` → `[]` | 5 failed / 58 passed | the four owner-directory controls that free or keep, the attribution, and the live walker control | | `configReach` against `packages` | 3 failed / 60 passed | "a script the root config imports still forces a full run", `scripts/guard.mjs`, and the live `vitest-invocation-guard.mjs` pin | | walker → `scripts` owner | 1 failed / 62 passed | the live walker control | | `isBenignDiskModule` → `false` | 1 failed / 62 passed | "frees a scripts/ test whose closure stays in scripts/" (`entry.test.ts`) | | `libraryReadsDisk` → `false` | 1 failed / 62 passed | "keeps a test that runs a TypeScript program or ESLint ..." | </details> <details><summary>Exactness sweep against 95df345: no test loses a selection it needs</summary> The selector at `95df3459` and this one were each run on 36 one-file changes in affected mode: one per workspace package with a tracked `src/` file, plus `scripts/check-i18n-en-drift.mjs` and an `eslint-rules/` rule. Together they dropped 1,017 test-file selections and added 670. The adds are the 22 newly kept tests and the owner-directory filters. Every drop belongs to the 32 freed tests, and none belongs to any other test. Each dropped (test, change) pair was checked three ways: the test's relative closure has no file in an affected directory, it imports no affected package by name, and its runtime reads hit no affected directory. The runtime reads came from running the 32 freed tests under an `fs` tracer, under the verify lock: 30 files passed and 2 were skipped (existing conditional skips), and all 32 are in the trace. The check flagged 0 pairs. </details> ## Acceptance notes - **The floor.** As the S3 plan (6724362736) measured, no selector, walker or path-seed route reaches under 200; every lever combined reaches about 408. The re-scope of that line is with the maintainer. Routes (a) and (b) are not started, as the amendment directs. - **Test UI's matrix is still `test-ui 16 literal`**, so the shard count does not move with this count. - **Line budget.** The plan said about 170 lines; the PR has 313 (+297/−16). The in-memory allow-lists of 2d take about 60 lines of the selector, and 5 rules with both-direction controls take about 145 lines of tests. ## Verification Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `ff2cf01a`: 20 families. All 20 were run locally on that head and reconciled with `--ran`, which reported "20 derived, 20 run, 0 NOT-MEASURED, 0 UNRUN", with every exit code recorded. `node scripts/check-sdui-manifest.mjs` and the walker's `--self-test` (263 cases, unchanged) were also run. The first loop, at `882a2347`, was not clean: `pnpm --dir ui exec vitest run scripts/` went red in two suites. `check-hand-rolled-comment-mask` refused the private comment skip, and `markdown-test-inputs` flagged the `readdirSync` fixture. Commit `ff2cf01a` fixed both, and the loop above is the rerun. <details><summary>Gate exit codes at ff2cf01</summary> ```text node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-cross-package-test-inputs.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 ``` </details> - `pnpm --dir ui exec vitest run scripts/` (under the verify lock): 170 test files passed and 2 were skipped, the skips being existing conditional skips. 5,021 tests passed. - After merging `origin/main` `69244406` into `ab309a32` (no change under `ui/` or in the walker): `--base HEAD --explain` printed 509 again, and the selector's two suites passed again (73 tests). - No changeset: `ui/` is the private `object-ui-monorepo` root, and `ui/scripts/` ships in no package, so this PR carries `skip-changeset`. - CI runs the rest: the PR's required check-runs. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jREy1UXbgGdQRBvZji)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3fed8ce commit 711c4ec

2 files changed

Lines changed: 297 additions & 16 deletions

File tree

‎ui/scripts/__tests__/select-affected-tests.test.ts‎

Lines changed: 136 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ import { fileURLToPath } from 'node:url';
88
import {
99
changedFiles,
1010
changedOutsideTree,
11+
BENIGN_DISK_MODULES,
1112
closureSummaries,
1213
dedupeFilters,
14+
isBenignDiskModule,
15+
libraryReadsDisk,
1316
listWorkspacePackages,
1417
moduleSpecifiers,
18+
ownerDirName,
1519
PATH_WALKER,
1620
PATH_WALKER_FILE,
1721
outsideTreeReasons,
@@ -97,6 +101,27 @@ function workspace() {
97101
'scripts/guard.mjs': 'export function guard() {}\n',
98102
'scripts/helper.mjs': 'export const x = 1;\n',
99103
'scripts/__tests__/gate.test.ts': "import '../helper.mjs';\n",
104+
// Item 2c: scripts/ and eslint-rules/ own their tests (objectstack#31520).
105+
'scripts/invoked-as.mjs':
106+
"import { spawnSync } from 'node:child_process';\n" +
107+
"import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';\n" +
108+
'export const isEntrypoint = () => false;\n',
109+
'scripts/__tests__/entry.test.ts': "import '../invoked-as.mjs';\n",
110+
'scripts/__tests__/own-dir.test.ts': reader("readFileSync(join(HERE, '..', 'helper.mjs'), 'utf8')"),
111+
'scripts/__tests__/spawns.test.ts': "import { spawnSync } from 'node:child_process';\nspawnSync('node', ['x.mjs']);\n",
112+
'scripts/__tests__/walks.test.ts': "import { opendirSync } from 'node:fs';\nfor (const e of opendirSync(process.cwd())) void e;\n",
113+
'eslint-rules/rule.js': 'export default { create: () => ({}) };\n',
114+
'eslint-rules/rule.test.js': "import { RuleTester } from 'eslint';\nimport rule from './rule.js';\nexport { RuleTester, rule };\n",
115+
'tools/helper.mjs': 'export const t = 1;\n',
116+
'tools/loose.test.ts': "import './helper.mjs';\n",
117+
// Item 2d: a library program reads the disk with no fs in sight.
118+
'packages/c/src/program.test.ts': "import ts from 'typescript';\nts.createProgram(['x.ts'], {});\n",
119+
'packages/c/src/helpers/host.ts': "import ts from 'typescript';\nexport const cwd = ts.sys.getCurrentDirectory();\n",
120+
'packages/c/src/via-program.test.ts': "import { cwd } from './helpers/host';\nexport { cwd };\n",
121+
'packages/c/src/eslint-api.test.ts': "import { ESLint } from 'eslint';\nexport const e = new ESLint();\n",
122+
'packages/c/src/parse.test.ts':
123+
"import ts from 'typescript';\nexport const f = ts.createSourceFile('x.ts', '', ts.ScriptTarget.Latest);\n",
124+
'packages/c/src/comment-lib.test.ts': "/** @type {import('eslint').Rule.RuleModule} */\nexport const r = {};\n",
100125
'README.md': '# fixture\n',
101126
'content/page.md': 'docs\n',
102127
'packages/locales/package.json': manifest('@fx/locales'),
@@ -129,6 +154,7 @@ function workspace() {
129154
"readFileSync(join(HERE, 'x.json'), 'utf8');\nconst r = readFileSync;\nr('/etc/hosts')",
130155
),
131156
'packages/c/src/escapes.test.ts': "import '../../../scripts/helper.mjs';\n",
157+
'packages/c/src/escapes-root.test.ts': "import '../../../tools/helper.mjs';\n",
132158
'packages/heavy-user/package.json': manifest('@fx/heavy-user'),
133159
'packages/heavy-user/src/heavy.test.tsx': 'export {};\n',
134160
});
@@ -178,16 +204,16 @@ describe('select-affected-tests — the always-on set', () => {
178204
expect(v.filters.length).toBeGreaterThan(0);
179205
});
180206

181-
it('keeps a test outside every package', () => {
182-
expect(why.get('scripts/__tests__/gate.test.ts')).toBe('outside every workspace package');
207+
it('keeps a test outside every package and every owner directory', () => {
208+
expect(why.get('tools/loose.test.ts')).toBe('outside every workspace package');
183209
});
184210

185211
it('keeps a test whose RELATIVE closure reads the disk, not only one that imports fs itself', () => {
186212
expect(why.get('packages/c/src/reads.test.ts')).toBe('reads the disk or spawns a process');
187213
});
188214

189-
it('keeps a test that reaches a file outside every package', () => {
190-
expect(why.get('packages/c/src/escapes.test.ts')).toMatch(/reaches the repository root outside packages\/c/);
215+
it('keeps a test that reaches a file outside every package and every owner directory', () => {
216+
expect(why.get('packages/c/src/escapes-root.test.ts')).toMatch(/reaches the repository root outside packages\/c/);
191217
});
192218

193219
it('does not keep a test whose only edges past its package are into other packages (objectstack#31520)', () => {
@@ -291,6 +317,86 @@ describe('select-affected-tests — bounded disk readers (objectstack#31520)', (
291317
});
292318
});
293319

320+
describe('select-affected-tests — owner directories (objectstack#31520)', () => {
321+
const { root, tracked } = workspace();
322+
const docsOnly = affected(root, tracked, ['content/page.md']);
323+
const why = new Map(docsOnly.alwaysOn.map((a) => [a.file, a.why]));
324+
const scriptsChange = affected(root, tracked, ['scripts/helper.mjs']);
325+
326+
it('frees a scripts/ test whose closure stays in scripts/, and a scripts/ change selects the directory', () => {
327+
for (const file of ['gate', 'own-dir', 'entry']) expect(why.has(`scripts/__tests__/${file}.test.ts`), file).toBe(false);
328+
expect(docsOnly.filters).not.toContain('scripts/');
329+
expect(docsOnly.filters.some((f) => /gate|own-dir|entry/.test(f))).toBe(false);
330+
expect(scriptsChange.changedPackages).toEqual([ownerDirName('scripts')]);
331+
expect(scriptsChange.filters).toContain('scripts/');
332+
});
333+
334+
it('frees an eslint-rules/ RuleTester test, selected by an eslint-rules/ change only', () => {
335+
expect(why.has('eslint-rules/rule.test.js')).toBe(false);
336+
expect(affected(root, tracked, ['eslint-rules/rule.js']).filters).toContain('eslint-rules/');
337+
expect(scriptsChange.filters).not.toContain('eslint-rules/');
338+
});
339+
340+
it('a package change does not select an owner directory', () => {
341+
const v = affected(root, tracked, ['packages/a/src/index.ts']);
342+
expect(v.filters).not.toContain('scripts/');
343+
expect(v.filters).not.toContain('eslint-rules/');
344+
});
345+
346+
it('keeps a scripts/ test that spawns, or walks from process.cwd()', () => {
347+
for (const file of ['spawns', 'walks']) {
348+
expect(why.get(`scripts/__tests__/${file}.test.ts`), file).toBe('reads the disk or spawns a process');
349+
}
350+
});
351+
352+
it('selects a package test that imports into scripts/ through the owner directory', () => {
353+
expect(why.has('packages/c/src/escapes.test.ts')).toBe(false);
354+
expect(scriptsChange.attributed).toContainEqual({ file: 'packages/c/src/escapes.test.ts', via: [ownerDirName('scripts')] });
355+
expect(affected(root, tracked, ['eslint-rules/rule.js']).filters.some((f) => f.includes('escapes.test'))).toBe(false);
356+
});
357+
358+
it('a script the root config imports still forces a full run', () => {
359+
expect(full(root, tracked, ['scripts/guard.mjs']).join('\n')).toMatch(/root vitest config or a setup file imports it/);
360+
});
361+
362+
it('exempts invoked-as.mjs only while its disk imports are exactly the pinned ones', () => {
363+
const pinned = fs.readFileSync(path.join(root, 'scripts/invoked-as.mjs'), 'utf8');
364+
expect(isBenignDiskModule('scripts/invoked-as.mjs', pinned)).toBe(true);
365+
expect(isBenignDiskModule('scripts/invoked-as.mjs', `import { readFileSync } from 'node:fs';\n${pinned}`)).toBe(false);
366+
expect(isBenignDiskModule('scripts/invoked-as.mjs', pinned.replace('spawnSync }', 'spawnSync, execSync }'))).toBe(false);
367+
expect(isBenignDiskModule('scripts/invoked-as.mjs', `import * as fs from 'node:fs';\n${pinned}`)).toBe(false);
368+
expect(isBenignDiskModule('scripts/helper.mjs', pinned)).toBe(false);
369+
});
370+
});
371+
372+
describe('select-affected-tests — library programs (objectstack#31520)', () => {
373+
const { root, tracked } = workspace();
374+
const docsOnly = affected(root, tracked, ['content/page.md']);
375+
const why = new Map(docsOnly.alwaysOn.map((a) => [a.file, a.why]));
376+
377+
it('keeps a test that runs a TypeScript program or ESLint, itself or through a helper, with no fs import', () => {
378+
for (const file of ['program', 'via-program', 'eslint-api']) {
379+
expect(why.get(`packages/c/src/${file}.test.ts`), file).toBe('reads the disk or spawns a process');
380+
}
381+
});
382+
383+
it('leaves out a test that only parses, or names a library in a comment', () => {
384+
for (const file of ['parse', 'comment-lib']) expect(why.has(`packages/c/src/${file}.test.ts`), file).toBe(false);
385+
});
386+
387+
it('reads a library use fail-closed', () => {
388+
expect(libraryReadsDisk("import ts from 'typescript';\nts.isIdentifier(n);\nts.SyntaxKind.Block;\n")).toBe(false);
389+
expect(libraryReadsDisk("import ts from 'typescript';\nts.sys.readFile('x');\n")).toBe(true);
390+
expect(libraryReadsDisk("import ts from 'typescript';\nconst t = ts;\n")).toBe(true);
391+
expect(libraryReadsDisk("import { createProgram } from 'typescript';\n")).toBe(true);
392+
expect(libraryReadsDisk("import type { Plugin } from 'vite';\n")).toBe(false);
393+
expect(libraryReadsDisk("const v = await import('vite');\n")).toBe(true);
394+
expect(libraryReadsDisk("import { compile } from 'tailwindcss';\n")).toBe(false);
395+
expect(libraryReadsDisk("import postcss from 'postcss';\n")).toBe(true);
396+
expect(libraryReadsDisk("import { Linter } from 'eslint';\n")).toBe(false);
397+
});
398+
});
399+
294400
describe('select-affected-tests — what forces a full run', () => {
295401
const { root, tracked } = workspace();
296402

@@ -510,14 +616,35 @@ describe('select-affected-tests — the live tree', () => {
510616
// The walker lives outside ui/, so a change there arrives as `outside`. It
511617
// must keep reaching this file's own tests, or an edit to it could narrow
512618
// the selection with nothing here running to notice.
619+
// Since item 2c this is not the always-on set twice: both select the whole
620+
// scripts/ owner directory, which an empty change does not.
513621
const self = 'scripts/__tests__/select-affected-tests.test.ts';
514622
const walker = selectAffectedTests({ changed: [], outside: [PATH_WALKER_FILE] });
515623
const selector = selectAffectedTests({ changed: ['scripts/select-affected-tests.mjs'] });
516-
expect(walker.mode).toBe(selector.mode);
517-
if (walker.mode === 'affected' && selector.mode === 'affected') {
518-
expect(walker.filters).toEqual(selector.filters);
519-
expect(walker.filters).toContain(self);
520-
}
624+
const empty = selectAffectedTests({ changed: [] });
625+
expect(walker.mode).toBe('affected');
626+
expect(selector.mode).toBe('affected');
627+
expect(empty.mode).toBe('affected');
628+
if (walker.mode !== 'affected' || selector.mode !== 'affected' || empty.mode !== 'affected') return;
629+
expect(walker.changedPackages).toEqual([ownerDirName('scripts')]);
630+
expect(walker.filters).toEqual(selector.filters);
631+
expect(walker.filters).toContain('scripts/');
632+
expect(walker.filters.some((f) => self.includes(f))).toBe(true);
633+
expect(empty.filters).not.toContain('scripts/');
634+
});
635+
636+
it('a file the root config reaches inside an owner directory still forces a full run (objectstack#31520)', () => {
637+
// The owner directories are not workspace packages: computing the root
638+
// config's reach against them would turn this guard into a scripts/ change.
639+
const verdict = selectAffectedTests({ changed: ['scripts/vitest-invocation-guard.mjs'] });
640+
expect(verdict.mode).toBe('full');
641+
expect((verdict as { reasons: string[] }).reasons.join('\n')).toMatch(/root vitest config or a setup file imports it/);
642+
});
643+
644+
it('the live invoked-as.mjs still imports exactly the disk bindings its exemption pins (objectstack#31520)', () => {
645+
const rel = Object.keys(BENIGN_DISK_MODULES)[0];
646+
expect(rel).toBe('scripts/invoked-as.mjs');
647+
expect(isBenignDiskModule(rel, fs.readFileSync(path.join(repoRoot, rel), 'utf8'))).toBe(true);
521648
});
522649

523650
it('reaches a verdict from the command line, and an empty change still selects the always-on set', () => {

0 commit comments

Comments
 (0)