@gate-forge/pack-playwright 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/discovery/discover.d.ts +22 -0
  2. package/dist/discovery/discover.d.ts.map +1 -1
  3. package/dist/discovery/discover.js +220 -28
  4. package/dist/discovery/discover.js.map +1 -1
  5. package/dist/discovery/index.d.ts +1 -0
  6. package/dist/discovery/index.d.ts.map +1 -1
  7. package/dist/discovery/index.js +1 -0
  8. package/dist/discovery/index.js.map +1 -1
  9. package/dist/discovery/reconcile.d.ts +38 -0
  10. package/dist/discovery/reconcile.d.ts.map +1 -1
  11. package/dist/discovery/reconcile.js +77 -1
  12. package/dist/discovery/reconcile.js.map +1 -1
  13. package/dist/discovery/runner-file-scope.d.ts +118 -0
  14. package/dist/discovery/runner-file-scope.d.ts.map +1 -0
  15. package/dist/discovery/runner-file-scope.js +753 -0
  16. package/dist/discovery/runner-file-scope.js.map +1 -0
  17. package/dist/discovery/static-discovery.d.ts +11 -0
  18. package/dist/discovery/static-discovery.d.ts.map +1 -1
  19. package/dist/discovery/static-discovery.js +23 -3
  20. package/dist/discovery/static-discovery.js.map +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/reporter/project-graph-reporter.d.ts +45 -16
  26. package/dist/reporter/project-graph-reporter.d.ts.map +1 -1
  27. package/dist/reporter/project-graph-reporter.js +56 -16
  28. package/dist/reporter/project-graph-reporter.js.map +1 -1
  29. package/package.json +7 -4
  30. package/python/__pycache__/gateforge_persistence_intents.cpython-312.pyc +0 -0
  31. package/python/__pycache__/gateforge_persistence_intents.cpython-314.pyc +0 -0
@@ -0,0 +1,753 @@
1
+ /**
2
+ * Runner file scopes: WHICH files a runner's own configuration collects.
3
+ *
4
+ * The static scan is seeded from Gateforge's own include globs, so it
5
+ * can find test-shaped calls anywhere in the repository — including
6
+ * files the configured runner would never run (a vitest suite in a
7
+ * playwright-configured repository, a spec under a `testDir` the config
8
+ * excludes). Cataloguing those as that runner's tests is wrong twice
9
+ * over: the runner will not execute them, and their assertions were
10
+ * never proved.
11
+ *
12
+ * This module answers the file question from DATA, never from a guess:
13
+ * - Playwright: the enumeration's own project-graph reporter hands us
14
+ * the resolved per-project `testDir`/`testMatch`/`testIgnore` (see
15
+ * `reporter/project-graph-reporter.ts`) — the runner's own words.
16
+ * - Vitest: a bounded, AST-only read of the repository's own
17
+ * `vitest.config.*`/`vite.config.*` literal `test.include` /
18
+ * `test.exclude` / `test.globals` values. A spread of
19
+ * vitest's own imported defaults (`...configDefaults.exclude`,
20
+ * the idiom the vitest docs recommend) reads as those
21
+ * defaults — but only when the name is imported from
22
+ * `vitest/config` or `vitest` in the same file. No consumer
23
+ * code runs and no expression is evaluated.
24
+ *
25
+ * Every scope is FAIL-OPEN: when a selection cannot be read faithfully
26
+ * (a computed glob, a function-valued selector, a missing config), the
27
+ * scope is not authoritative and selects every file, which is exactly
28
+ * the pre-existing behaviour. Narrowing a catalog on a mis-read glob is
29
+ * the one failure mode this module must never have.
30
+ */
31
+ import { readdirSync, readFileSync } from 'node:fs';
32
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
33
+ import ts from 'typescript';
34
+ /** Vitest's documented default `test.include`. */
35
+ const VITEST_DEFAULT_INCLUDE = ['**/*.{test,spec}.?(c|m)[jt]s?(x)'];
36
+ /** Vitest's documented default `test.exclude`. */
37
+ const VITEST_DEFAULT_EXCLUDE = [
38
+ '**/node_modules/**',
39
+ '**/dist/**',
40
+ '**/cypress/**',
41
+ '**/.{idea,git,cache,output,temp}/**',
42
+ '**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*',
43
+ ];
44
+ /** Config file names a vitest repository may declare, in resolution order. */
45
+ const VITEST_CONFIG_NAMES = [
46
+ 'vitest.config.ts',
47
+ 'vitest.config.mts',
48
+ 'vitest.config.cts',
49
+ 'vitest.config.js',
50
+ 'vitest.config.mjs',
51
+ 'vitest.config.cjs',
52
+ 'vite.config.ts',
53
+ 'vite.config.mts',
54
+ 'vite.config.cts',
55
+ 'vite.config.js',
56
+ 'vite.config.mjs',
57
+ 'vite.config.cjs',
58
+ ];
59
+ /** Escapes one character for use inside a regular expression. */
60
+ function escapeRegExp(character) {
61
+ return character.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
62
+ }
63
+ /**
64
+ * Reads a parenthesized group starting at `openIndex` (which must hold
65
+ * `(`), honouring nesting and backslash escapes.
66
+ *
67
+ * Args:
68
+ * pattern: the glob being translated.
69
+ * openIndex: index of the group's opening parenthesis.
70
+ *
71
+ * Returns:
72
+ * {body, end}: the group's body and the index just past its `)`; null
73
+ * when the group is unbalanced.
74
+ */
75
+ function readGroup(pattern, openIndex) {
76
+ let depth = 0;
77
+ for (let index = openIndex; index < pattern.length; index += 1) {
78
+ const character = pattern[index];
79
+ if (character === '\\') {
80
+ index += 1;
81
+ continue;
82
+ }
83
+ if (character === '(')
84
+ depth += 1;
85
+ else if (character === ')') {
86
+ depth -= 1;
87
+ if (depth === 0) {
88
+ return { body: pattern.slice(openIndex + 1, index), end: index + 1 };
89
+ }
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+ /**
95
+ * Finds the `}` closing the `{` at `openIndex`, honouring nesting.
96
+ *
97
+ * Args:
98
+ * pattern: the glob being translated.
99
+ * openIndex: index of the brace group's opening `{`.
100
+ *
101
+ * Returns:
102
+ * number: the index of the matching `}`, or -1 when unbalanced.
103
+ */
104
+ function findClosingBrace(pattern, openIndex) {
105
+ let depth = 0;
106
+ for (let index = openIndex; index < pattern.length; index += 1) {
107
+ const character = pattern[index];
108
+ if (character === '\\') {
109
+ index += 1;
110
+ continue;
111
+ }
112
+ if (character === '{')
113
+ depth += 1;
114
+ else if (character === '}') {
115
+ depth -= 1;
116
+ if (depth === 0)
117
+ return index;
118
+ }
119
+ }
120
+ return -1;
121
+ }
122
+ /**
123
+ * Splits a brace group body on its top-level commas.
124
+ *
125
+ * Args:
126
+ * body: the text between the braces.
127
+ *
128
+ * Returns:
129
+ * string[]: the alternatives; null when a nested group is unbalanced.
130
+ */
131
+ function splitAlternatives(body) {
132
+ const parts = [];
133
+ let depth = 0;
134
+ let current = '';
135
+ for (let index = 0; index < body.length; index += 1) {
136
+ const character = body[index] ?? '';
137
+ if (character === '\\') {
138
+ current += character + (body[index + 1] ?? '');
139
+ index += 1;
140
+ continue;
141
+ }
142
+ if (character === '(' || character === '[')
143
+ depth += 1;
144
+ else if (character === ')' || character === ']')
145
+ depth -= 1;
146
+ else if (character === ',' && depth === 0) {
147
+ parts.push(current);
148
+ current = '';
149
+ continue;
150
+ }
151
+ if (depth < 0)
152
+ return null;
153
+ current += character;
154
+ }
155
+ if (depth !== 0)
156
+ return null;
157
+ parts.push(current);
158
+ return parts;
159
+ }
160
+ /**
161
+ * Translates one glob into a regular-expression source.
162
+ *
163
+ * Supported: `**`, `*`, `?`, character classes, brace alternation
164
+ * (`{a,b}`), and the extglob forms `@(a|b)`, `?(a|b)`, `+(a|b)` — which
165
+ * together cover the runners' own documented selection globs, such as
166
+ * playwright's default test glob (a globstar, `*.@(spec|test)` and an
167
+ * optional `.(c|m)[jt]s?(x)` extension). Anything else (a negated
168
+ * class or extglob, an unbalanced group) yields null, and null means
169
+ * "this pattern cannot narrow" — never "this pattern matches
170
+ * nothing".
171
+ *
172
+ * Args:
173
+ * pattern: the glob to translate.
174
+ *
175
+ * Returns:
176
+ * string: the regexp source, or null when it cannot be translated.
177
+ */
178
+ function translateGlob(pattern) {
179
+ let source = '';
180
+ let index = 0;
181
+ while (index < pattern.length) {
182
+ const character = pattern[index] ?? '';
183
+ if (character === '\\') {
184
+ const next = pattern[index + 1];
185
+ if (next === undefined)
186
+ return null;
187
+ source += escapeRegExp(next);
188
+ index += 2;
189
+ continue;
190
+ }
191
+ if (character === '*') {
192
+ let stars = 0;
193
+ while (pattern[index] === '*') {
194
+ stars += 1;
195
+ index += 1;
196
+ }
197
+ if (stars >= 2) {
198
+ if (pattern[index] === '/') {
199
+ source += '(?:.*/)?';
200
+ index += 1;
201
+ }
202
+ else {
203
+ source += '.*';
204
+ }
205
+ }
206
+ else {
207
+ source += '[^/]*';
208
+ }
209
+ continue;
210
+ }
211
+ if (character === '?') {
212
+ if (pattern[index + 1] === '(') {
213
+ const group = readGroup(pattern, index + 1);
214
+ if (group === null)
215
+ return null;
216
+ const inner = translateGlob(group.body);
217
+ if (inner === null)
218
+ return null;
219
+ source += `(?:${inner})?`;
220
+ index = group.end;
221
+ continue;
222
+ }
223
+ source += '[^/]';
224
+ index += 1;
225
+ continue;
226
+ }
227
+ if ((character === '@' || character === '+') && pattern[index + 1] === '(') {
228
+ const group = readGroup(pattern, index + 1);
229
+ if (group === null)
230
+ return null;
231
+ const inner = translateGlob(group.body);
232
+ if (inner === null)
233
+ return null;
234
+ source += character === '@' ? `(?:${inner})` : `(?:${inner})+`;
235
+ index = group.end;
236
+ continue;
237
+ }
238
+ if (character === '[') {
239
+ const end = pattern.indexOf(']', index + 1);
240
+ if (end === -1)
241
+ return null;
242
+ source += pattern.slice(index, end + 1);
243
+ index = end + 1;
244
+ continue;
245
+ }
246
+ if (character === '{') {
247
+ const end = findClosingBrace(pattern, index);
248
+ if (end === -1)
249
+ return null;
250
+ const alternatives = splitAlternatives(pattern.slice(index + 1, end));
251
+ if (alternatives === null)
252
+ return null;
253
+ const parts = [];
254
+ for (const alternative of alternatives) {
255
+ const fragment = translateGlob(alternative);
256
+ if (fragment === null)
257
+ return null;
258
+ parts.push(fragment);
259
+ }
260
+ source += `(?:${parts.join('|')})`;
261
+ index = end + 1;
262
+ continue;
263
+ }
264
+ // A `!` introduces a negated class or a `!(…)` extglob; neither has a
265
+ // sound translation here, and a wrong one would drop real tests.
266
+ if (character === '!')
267
+ return null;
268
+ source += escapeRegExp(character);
269
+ index += 1;
270
+ }
271
+ return source;
272
+ }
273
+ /** Compiled selection globs, memoized per pattern. */
274
+ const compiledGlobs = new Map();
275
+ /**
276
+ * Compiles one selection glob, memoized per pattern.
277
+ *
278
+ * Args:
279
+ glob: the selection glob.
280
+ *
281
+ * Returns:
282
+ RegExp | null: the compiled matcher, or null when the pattern cannot
283
+ be translated faithfully (never "matches nothing").
284
+ */
285
+ function compileGlobPattern(glob) {
286
+ if (compiledGlobs.has(glob))
287
+ return compiledGlobs.get(glob) ?? null;
288
+ const source = translateGlob(glob);
289
+ let compiled = null;
290
+ if (source !== null) {
291
+ try {
292
+ compiled = new RegExp(`^${source}$`);
293
+ }
294
+ catch {
295
+ compiled = null;
296
+ }
297
+ }
298
+ compiledGlobs.set(glob, compiled);
299
+ return compiled;
300
+ }
301
+ /**
302
+ * Whether one glob selects a repo-relative path.
303
+ *
304
+ * Args:
305
+ * glob: the selection glob.
306
+ * file: repo-relative posix path, relative to the scope root.
307
+ *
308
+ * Returns:
309
+ * boolean: false only when the pattern is translatable and does NOT
310
+ * match. An untranslatable pattern returns true so a mis-read glob can
311
+ * never narrow the catalog.
312
+ */
313
+ function globSelectsFile(glob, file) {
314
+ const compiled = compileGlobPattern(glob);
315
+ if (compiled === null)
316
+ return true;
317
+ return compiled.test(file);
318
+ }
319
+ /**
320
+ * Whether a runner's own configuration collects this file.
321
+ *
322
+ * Args:
323
+ * scope: the runner's selection.
324
+ * cwd: absolute repo root.
325
+ * repoRelativeFile: repo-relative posix path.
326
+ *
327
+ * Returns:
328
+ * boolean: true when the file is one of the runner's test files. A
329
+ * non-authoritative scope always returns true (fail open).
330
+ */
331
+ export function scopeSelectsFile(scope, cwd, repoRelativeFile) {
332
+ if (!scope.authoritative)
333
+ return true;
334
+ const absolute = isAbsolute(repoRelativeFile) ? repoRelativeFile : resolve(cwd, repoRelativeFile);
335
+ const file = relative(scope.root, absolute).split('\\').join('/');
336
+ if (file === '' || file.startsWith('../'))
337
+ return false;
338
+ if (scope.include.length > 0 && !scope.include.some((glob) => globSelectsFile(glob, file))) {
339
+ return false;
340
+ }
341
+ return !scope.exclude.some((glob) => globSelectsFile(glob, file));
342
+ }
343
+ /**
344
+ * Whether ANY of the runner's scopes collects the file — the union over
345
+ * projects, which is how a runner collects a file at all.
346
+ */
347
+ export function anyScopeSelectsFile(scopes, cwd, repoRelativeFile) {
348
+ if (scopes.length === 0)
349
+ return true;
350
+ return scopes.some((scope) => scopeSelectsFile(scope, cwd, repoRelativeFile));
351
+ }
352
+ /**
353
+ * The playwright scopes the enumeration's runner resolved.
354
+ *
355
+ * Args:
356
+ * projects: the per-project selection the project-graph reporter read.
357
+ *
358
+ * Returns:
359
+ * RunnerFileScope[]: one scope per project whose selection was data.
360
+ * Empty when the runner resolved none, which selects nothing away.
361
+ */
362
+ export function playwrightFileScopes(projects) {
363
+ const scopes = [];
364
+ for (const project of projects) {
365
+ const include = project.testMatch.map((glob) => glob.replace(/^\.\//, ''));
366
+ const exclude = project.testIgnore.map((glob) => glob.replace(/^\.\//, ''));
367
+ // A selection this boundary cannot read as DATA — a root that is not
368
+ // a location, a pattern with no sound translation — makes the whole
369
+ // scope fail open (every file) rather than narrow on a guess, and
370
+ // says so in `note` so a wide selection is never mistaken for a
371
+ // narrow one.
372
+ const unreadable = [
373
+ ...(isAbsolute(project.testDir) ? [] : [`testDir '${project.testDir}' is not an absolute path`]),
374
+ ...[...include, ...exclude]
375
+ .filter((glob) => compileGlobPattern(glob) === null)
376
+ .map((glob) => `selection pattern '${glob}' is not a translatable glob`),
377
+ ];
378
+ scopes.push({
379
+ runner: 'playwright',
380
+ root: project.testDir,
381
+ include,
382
+ exclude,
383
+ globals: false,
384
+ authoritative: unreadable.length === 0,
385
+ ...(unreadable.length > 0 ? { note: `${unreadable.join('; ')} — this project collects every file` } : {}),
386
+ });
387
+ }
388
+ return scopes;
389
+ }
390
+ /** Reads a string-literal node, else undefined. */
391
+ function literalString(node) {
392
+ if (node === undefined)
393
+ return undefined;
394
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
395
+ return node.text;
396
+ return undefined;
397
+ }
398
+ /** Reads a boolean-literal node, else undefined. */
399
+ function literalBoolean(node) {
400
+ if (node === undefined)
401
+ return undefined;
402
+ if (node.kind === ts.SyntaxKind.TrueKeyword)
403
+ return true;
404
+ if (node.kind === ts.SyntaxKind.FalseKeyword)
405
+ return false;
406
+ return undefined;
407
+ }
408
+ /**
409
+ * Which vitest default selections the file imports by name,
410
+ * read from its import declarations only.
411
+ *
412
+ * Args:
413
+ * parsed: the parsed config source file.
414
+ *
415
+ * Returns:
416
+ * VitestDefaultImports: the local names bound to vitest's
417
+ * own defaults; empty sets when the file imports none.
418
+ */
419
+ function vitestDefaultImportsOf(parsed) {
420
+ const imported = {
421
+ configDefaults: new Set(),
422
+ defaultInclude: new Set(),
423
+ defaultExclude: new Set(),
424
+ };
425
+ for (const statement of parsed.statements) {
426
+ if (!ts.isImportDeclaration(statement))
427
+ continue;
428
+ const specifier = statement.moduleSpecifier;
429
+ if (!ts.isStringLiteral(specifier))
430
+ continue;
431
+ // Only vitest's own entry points hand out its defaults.
432
+ if (specifier.text !== 'vitest/config' && specifier.text !== 'vitest')
433
+ continue;
434
+ const named = statement.importClause?.namedBindings;
435
+ if (named === undefined || !ts.isNamedImports(named))
436
+ continue;
437
+ for (const binding of named.elements) {
438
+ const source = binding.propertyName?.text ?? binding.name.text;
439
+ if (source === 'configDefaults')
440
+ imported.configDefaults.add(binding.name.text);
441
+ else if (source === 'defaultInclude')
442
+ imported.defaultInclude.add(binding.name.text);
443
+ else if (source === 'defaultExclude')
444
+ imported.defaultExclude.add(binding.name.text);
445
+ }
446
+ }
447
+ return imported;
448
+ }
449
+ /**
450
+ * The vitest default selection one expression names: a bare
451
+ * `configDefaults.include`, an imported `defaultExclude`, or
452
+ * the inner expression of a spread of either. Any other
453
+ * identifier or property is NOT data this boundary may use.
454
+ *
455
+ * Args:
456
+ * node: the expression to read.
457
+ * imported: the vitest defaults the file imports.
458
+ *
459
+ * Returns:
460
+ * readonly string[] | undefined: the named default
461
+ * selection, or undefined when the expression names none.
462
+ */
463
+ function vitestDefaultSelection(node, imported) {
464
+ // A default imported by its own name: `exclude: defaultExclude`.
465
+ if (ts.isIdentifier(node)) {
466
+ if (imported.defaultInclude.has(node.text))
467
+ return VITEST_DEFAULT_INCLUDE;
468
+ if (imported.defaultExclude.has(node.text))
469
+ return VITEST_DEFAULT_EXCLUDE;
470
+ return undefined;
471
+ }
472
+ // A member of the imported defaults object:
473
+ // `exclude: configDefaults.exclude`.
474
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression)) {
475
+ if (!imported.configDefaults.has(node.expression.text))
476
+ return undefined;
477
+ if (node.name.text === 'include')
478
+ return VITEST_DEFAULT_INCLUDE;
479
+ if (node.name.text === 'exclude')
480
+ return VITEST_DEFAULT_EXCLUDE;
481
+ }
482
+ return undefined;
483
+ }
484
+ /**
485
+ * Reads a vitest `include`/`exclude` selection as data:
486
+ * string literals, a spread of vitest's own defaults
487
+ * (`[...configDefaults.exclude, …]` — the idiom the
488
+ * vitest docs recommend), or a bare default
489
+ * (`configDefaults.exclude`). Any other element makes the
490
+ * selection computed, which reads as undefined (fail-open).
491
+ *
492
+ * Args:
493
+ * node: the `include`/`exclude` initializer.
494
+ * imported: the vitest defaults the file imports.
495
+ *
496
+ * Returns:
497
+ * readonly string[] | undefined: the literal selection, or
498
+ * undefined when it is computed.
499
+ */
500
+ function selectionStrings(node, imported) {
501
+ if (node === undefined)
502
+ return undefined;
503
+ const bare = vitestDefaultSelection(node, imported);
504
+ if (bare !== undefined)
505
+ return bare;
506
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
507
+ return [node.text];
508
+ if (!ts.isArrayLiteralExpression(node))
509
+ return undefined;
510
+ const values = [];
511
+ for (const element of node.elements) {
512
+ // `...configDefaults.exclude` widens the selection with
513
+ // vitest's own defaults instead of replacing them.
514
+ if (ts.isSpreadElement(element)) {
515
+ const spread = vitestDefaultSelection(element.expression, imported);
516
+ if (spread === undefined)
517
+ return undefined;
518
+ values.push(...spread);
519
+ continue;
520
+ }
521
+ const value = literalString(element);
522
+ if (value === undefined)
523
+ return undefined;
524
+ values.push(value);
525
+ }
526
+ return values;
527
+ }
528
+ /** The `test` property of an object literal, when it is one itself. */
529
+ function propertyOf(object, name) {
530
+ for (const member of object.properties) {
531
+ if (!ts.isPropertyAssignment(member))
532
+ continue;
533
+ const key = member.name;
534
+ const keyName = ts.isIdentifier(key) || ts.isStringLiteral(key) ? key.text : undefined;
535
+ if (keyName === name)
536
+ return member.initializer;
537
+ }
538
+ return undefined;
539
+ }
540
+ /** Directories never descended into when looking for runner configs. */
541
+ const CONFIG_SEARCH_PRUNED_DIRS = new Set([
542
+ '.git',
543
+ 'node_modules',
544
+ 'dist',
545
+ 'build',
546
+ 'out',
547
+ 'coverage',
548
+ 'test-results',
549
+ 'playwright-report',
550
+ ]);
551
+ /** How deep below the repo root a runner config is still read from. */
552
+ const MAX_CONFIG_SEARCH_DEPTH = 4;
553
+ /**
554
+ * Every vitest configuration the repository declares, at any depth a
555
+ * monorepo keeps one (a repo whose frontend owns `frontend/vitest.config.js`
556
+ * selects files under `frontend/`, not under the repo root).
557
+ *
558
+ * Args:
559
+ * cwd: absolute repo root.
560
+ *
561
+ * Returns:
562
+ * string[]: absolute config paths, sorted; empty when the repository
563
+ * declares none (the scope is then unknown, never empty).
564
+ */
565
+ function findVitestConfigFiles(cwd) {
566
+ const found = [];
567
+ const walk = (dir, depth) => {
568
+ if (depth > MAX_CONFIG_SEARCH_DEPTH)
569
+ return;
570
+ let entries;
571
+ try {
572
+ entries = readdirSync(dir, { withFileTypes: true });
573
+ }
574
+ catch {
575
+ return; // unreadable dir: invisible, never a failure of other configs
576
+ }
577
+ for (const entry of [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
578
+ // Symlinked directories are NOT followed: a link can leave the
579
+ // repository, and a config outside the repo root selects nothing here.
580
+ if (entry.isDirectory()) {
581
+ if (!CONFIG_SEARCH_PRUNED_DIRS.has(entry.name))
582
+ walk(join(dir, entry.name), depth + 1);
583
+ }
584
+ else if (entry.isFile() && VITEST_CONFIG_NAMES.includes(entry.name)) {
585
+ found.push(join(dir, entry.name));
586
+ }
587
+ }
588
+ };
589
+ walk(cwd, 0);
590
+ return found.sort();
591
+ }
592
+ /**
593
+ * The config object literal of a runner config file, or null when it is
594
+ * not one literal (`module.exports = merge(a, b)`).
595
+ */
596
+ function configObjectOf(parsed) {
597
+ let config = null;
598
+ const visit = (node) => {
599
+ if (config !== null)
600
+ return;
601
+ // `export default { … }` — the object literal is the config itself.
602
+ if (ts.isExportAssignment(node) && ts.isObjectLiteralExpression(node.expression)) {
603
+ config = node.expression;
604
+ return;
605
+ }
606
+ // `export default defineConfig({ … })` / `mergeConfig(a, { … })`:
607
+ // unwrap the first object literal argument.
608
+ if (ts.isExportAssignment(node) &&
609
+ ts.isCallExpression(node.expression) &&
610
+ node.expression.arguments[0] !== undefined &&
611
+ ts.isObjectLiteralExpression(node.expression.arguments[0])) {
612
+ config = node.expression.arguments[0];
613
+ return;
614
+ }
615
+ // The CommonJS transpiled shape: `exports.default = { … }`. `=` is the
616
+ // only assignment operator; anything else is a comparison.
617
+ if (ts.isBinaryExpression(node) &&
618
+ node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
619
+ ts.isObjectLiteralExpression(node.right) &&
620
+ ts.isPropertyAccessExpression(node.left) &&
621
+ node.left.name.text === 'default') {
622
+ config = node.right;
623
+ return;
624
+ }
625
+ ts.forEachChild(node, visit);
626
+ };
627
+ visit(parsed);
628
+ return config;
629
+ }
630
+ /**
631
+ * The vitest selection one config file declares, read with the
632
+ * TypeScript AST only — the same bounded, non-executing read the static
633
+ * test scan already performs on candidate source. No consumer code runs
634
+ * and no expression is evaluated.
635
+ *
636
+ * A config that cannot be read as DATA (unreadable file, a computed
637
+ * `include: buildGlobs()`, `mergeConfig(a, b)`) yields NO scope at all:
638
+ * for a foreign runner, claiming nothing leaves the file with the
639
+ * configured runner exactly as before, which is the fail-open answer.
640
+ *
641
+ * The documented defaults idiom — `exclude` set to a spread
642
+ * of `configDefaults.exclude` beside extra string literals,
643
+ * with `configDefaults` imported from `vitest/config` — reads
644
+ * as vitest's own defaults widened by the literals beside it;
645
+ * a same-named local or a foreign module's export does NOT.
646
+ *
647
+ * Args:
648
+ * absolute: absolute path of the config file.
649
+ *
650
+ * Returns:
651
+ * RunnerFileScope | null: the declared selection, or null when this
652
+ * file declares none that can be read (a `vite.config.*` without a
653
+ * `test` block is a BUILD config: it configures no test runner).
654
+ */
655
+ function vitestScopeOf(absolute) {
656
+ const name = basename(absolute);
657
+ // `vitest.config.*` declares a vitest run even without a `test` block
658
+ // (its defaults then apply); `vite.config.*` only does once it
659
+ // configures `test`.
660
+ const declaresVitest = name.startsWith('vitest.config.');
661
+ let source;
662
+ try {
663
+ source = readFileSync(absolute, 'utf8');
664
+ }
665
+ catch {
666
+ return null;
667
+ }
668
+ const configRoot = dirname(absolute);
669
+ const defaultScope = {
670
+ runner: 'vitest',
671
+ root: configRoot,
672
+ include: [...VITEST_DEFAULT_INCLUDE],
673
+ exclude: [...VITEST_DEFAULT_EXCLUDE],
674
+ globals: false,
675
+ authoritative: true,
676
+ };
677
+ const parsed = ts.createSourceFile(name, source, ts.ScriptTarget.Latest, true);
678
+ const config = configObjectOf(parsed);
679
+ const testNode = config === null ? undefined : propertyOf(config, 'test');
680
+ if (testNode === undefined || !ts.isObjectLiteralExpression(testNode)) {
681
+ // A config with no readable `test` block still runs vitest's
682
+ // documented defaults when the file names vitest itself; a
683
+ // `vite.config.*` without `test` configures a bundler, not a runner.
684
+ return declaresVitest ? defaultScope : null;
685
+ }
686
+ const root = literalString(config === null ? undefined : propertyOf(config, 'root'));
687
+ const base = root === undefined ? configRoot : isAbsolute(root) ? root : resolve(configRoot, root);
688
+ const dir = literalString(propertyOf(testNode, 'dir'));
689
+ const scopeRoot = dir === undefined ? base : isAbsolute(dir) ? dir : resolve(base, dir);
690
+ const includeNode = propertyOf(testNode, 'include');
691
+ const excludeNode = propertyOf(testNode, 'exclude');
692
+ const globalsNode = propertyOf(testNode, 'globals');
693
+ const imported = vitestDefaultImportsOf(parsed);
694
+ const include = includeNode === undefined ? [...VITEST_DEFAULT_INCLUDE] : selectionStrings(includeNode, imported);
695
+ const exclude = excludeNode === undefined ? [...VITEST_DEFAULT_EXCLUDE] : selectionStrings(excludeNode, imported);
696
+ const globals = globalsNode === undefined ? false : literalBoolean(globalsNode);
697
+ // A computed selection is not data this boundary may narrow on.
698
+ if (include === undefined || exclude === undefined || globals === undefined)
699
+ return null;
700
+ return { runner: 'vitest', root: scopeRoot, include, exclude, globals, authoritative: true };
701
+ }
702
+ /**
703
+ * The repository's own vitest test-file selections, read from every
704
+ * vitest config it declares (root first in resolution order, then nested
705
+ * configs, each scoped to its own directory).
706
+ *
707
+ * Args:
708
+ * cwd: absolute repo root.
709
+ *
710
+ * Returns:
711
+ * RunnerFileScope[]: one scope per readable config; empty when the
712
+ * repository declares no vitest selection this boundary can read.
713
+ */
714
+ export function vitestFileScopes(cwd) {
715
+ const scopes = [];
716
+ for (const absolute of findVitestConfigFiles(cwd)) {
717
+ const scope = vitestScopeOf(absolute);
718
+ if (scope !== null)
719
+ scopes.push(scope);
720
+ }
721
+ return scopes;
722
+ }
723
+ /**
724
+ * Which runner owns a statically discovered file.
725
+ *
726
+ * A foreign scope that is not authoritative claims NOTHING: fail-open
727
+ * for the configured runner means "keep today's behaviour", and a
728
+ * mis-read foreign config must never steal that runner's files.
729
+ *
730
+ * Args:
731
+ * configuredRunner: the repository's configured runner.
732
+ * configuredScopes: that runner's own scopes (empty ⇒ unknown).
733
+ * otherScopes: the other runners' own scopes.
734
+ * cwd: absolute repo root.
735
+ * file: repo-relative posix path.
736
+ *
737
+ * Returns:
738
+ * string | null: the owning runner, or null when no runner's own
739
+ * configuration claims the file — the file is then not that runner's
740
+ * test, and the caller reports it instead of inventing a runner.
741
+ */
742
+ export function runnerForFile(configuredRunner, configuredScopes, otherScopes, cwd, file) {
743
+ if (anyScopeSelectsFile(configuredScopes, cwd, file))
744
+ return configuredRunner;
745
+ for (const scope of otherScopes) {
746
+ if (!scope.authoritative)
747
+ continue;
748
+ if (scopeSelectsFile(scope, cwd, file))
749
+ return scope.runner;
750
+ }
751
+ return null;
752
+ }
753
+ //# sourceMappingURL=runner-file-scope.js.map