ucn 5.5.2 → 5.6.0

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 (74) hide show
  1. package/.claude/skills/ucn/SKILL.md +35 -12
  2. package/.claude/skills/ucn/references/commands.md +21 -17
  3. package/.claude/skills/ucn/references/resolution-rules.md +4 -0
  4. package/.claude/skills/ucn/references/trust-contract.md +4 -4
  5. package/CHANGELOG.md +474 -0
  6. package/README.md +71 -29
  7. package/cli/flags.js +184 -0
  8. package/cli/index.js +138 -324
  9. package/core/account.js +44 -87
  10. package/core/analysis.js +273 -107
  11. package/core/ast-analysis.js +2 -0
  12. package/core/bridge.js +29 -65
  13. package/core/cache.js +101 -16
  14. package/core/callers.js +352 -70
  15. package/core/capture.js +331 -0
  16. package/core/change-kinds.js +888 -0
  17. package/core/check.js +1117 -139
  18. package/core/client-receivers.js +189 -0
  19. package/core/command-contracts.js +7 -6
  20. package/core/constructed-class.js +1195 -0
  21. package/core/contract-membership.js +142 -0
  22. package/core/deadcode.js +388 -85
  23. package/core/deletion.js +401 -0
  24. package/core/discovery.js +104 -9
  25. package/core/entrypoints.js +12 -6
  26. package/core/evidence-bands.js +68 -0
  27. package/core/evidence-bands.json +44 -0
  28. package/core/execute.js +172 -161
  29. package/core/hot-summary.js +241 -0
  30. package/core/index-ir.js +3 -1
  31. package/core/inprocess-clients.js +41 -15
  32. package/core/ir.js +5 -1
  33. package/core/member-signatures.js +789 -0
  34. package/core/output/analysis-ext.js +20 -10
  35. package/core/output/analysis.js +98 -60
  36. package/core/output/check.js +98 -37
  37. package/core/output/doctor.js +34 -1
  38. package/core/output/find.js +2 -1
  39. package/core/output/lines.js +63 -3
  40. package/core/output/public.js +30 -11
  41. package/core/output/refactoring.js +32 -0
  42. package/core/output/reporting.js +21 -2
  43. package/core/output/search.js +11 -2
  44. package/core/output/tracing.js +35 -7
  45. package/core/output-budget.js +124 -57
  46. package/core/parallel-build.js +3 -3
  47. package/core/project.js +106 -23
  48. package/core/provenance-type.js +12 -5
  49. package/core/public-command.js +35 -9
  50. package/core/python-modules.js +6 -0
  51. package/core/reflection.js +166 -23
  52. package/core/registry.js +623 -72
  53. package/core/reporting.js +69 -34
  54. package/core/request-helpers.js +63 -7
  55. package/core/review-items.js +75 -0
  56. package/core/search.js +89 -128
  57. package/core/shared.js +0 -45
  58. package/core/snapshot.js +350 -0
  59. package/core/source-universe.js +707 -0
  60. package/core/test-units.js +3624 -0
  61. package/core/tracing.js +150 -96
  62. package/core/verify.js +27 -11
  63. package/languages/adapter.js +8 -3
  64. package/languages/c-family.js +12 -0
  65. package/languages/csharp.js +634 -0
  66. package/languages/go.js +120 -0
  67. package/languages/index.js +422 -4
  68. package/languages/java.js +712 -0
  69. package/languages/javascript.js +1460 -2
  70. package/languages/lexical-scope.js +68 -0
  71. package/languages/python.js +1672 -1
  72. package/languages/rust.js +91 -0
  73. package/mcp/server.js +44 -110
  74. package/package.json +11 -6
@@ -0,0 +1,3624 @@
1
+ /**
2
+ * core/test-units.js — what a test is, where it is, and how to run it.
3
+ *
4
+ * One identity for a test, shared by `tests`, the affected-test selection
5
+ * and `check`: a UNIT is (file, suite chain, name, line), read from the
6
+ * declaration the language's runner collects (the language module's
7
+ * `testUnits`). A line of test code belongs to the innermost unit whose
8
+ * source range holds it; code outside every unit belongs to the file.
9
+ *
10
+ * A RUNNER is named only on evidence: the test file imports the runner's
11
+ * own module, or the nearest build manifest names it. A SELECTOR (the
12
+ * command that runs one unit, or one file) is built only for a runner whose
13
+ * selection syntax is modeled here and only when everything the command
14
+ * depends on is known; otherwise the unit is reported without a command and
15
+ * with the reason. The runner's own configuration is part of that: what
16
+ * is data is read (pytest's options and naming rules, jest's roots and
17
+ * path patterns, vitest's directory, the options a mocha or node script
18
+ * passes), a configuration written as code only where it is one object
19
+ * written out that nothing else in the file can change, and a
20
+ * configuration that can change what a command selects and is not read
21
+ * withholds the command. Nothing here executes anything.
22
+ *
23
+ * A COUNT of tests is of the units listed and not skipped. It is exact
24
+ * only where every test the runner would report is listed: a file that
25
+ * holds tests the reader does not list, or a declaration that stands for
26
+ * several of the runner's tests, makes it a lower bound (`moreTests`),
27
+ * said wherever the count is printed.
28
+ *
29
+ * A test-path file is SUPPORT when it declares no unit, declares no class
30
+ * that could inherit one, and is not named the way the runner names test
31
+ * files: helpers, fixtures, shared setup. It is listed, never counted as a
32
+ * test and never offered as something to run.
33
+ *
34
+ * State lives in the model object, created per operation: manifests can
35
+ * change between two questions asked of one long-lived index.
36
+ */
37
+
38
+ 'use strict';
39
+
40
+ const fs = require('fs');
41
+ const path = require('path');
42
+ const { langTraits, getParser, safeParse, getLanguageAdapter } = require('../languages');
43
+ const { codeUnitCompare, escapeRegExp } = require('./shared');
44
+ const { createSignatures } = require('./member-signatures');
45
+ const { csharpUsings } = require('./type-denotation');
46
+
47
+ const CONTAINER_KINDS = new Set(['class', 'struct', 'module', 'namespace', 'interface', 'impl',
48
+ 'record', 'enum', 'trait', 'object']);
49
+ const CALLABLE_KINDS = new Set(['function', 'method', 'static', 'constructor', 'classmethod',
50
+ 'getter', 'setter', 'arrow', 'generator']);
51
+
52
+ /** A shell word: bare when it needs no quoting, single-quoted otherwise. */
53
+ function shellWord(value) {
54
+ const text = String(value);
55
+ if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(text)) return text;
56
+ return `'${text.replace(/'/g, `'\\''`)}'`;
57
+ }
58
+
59
+ function regexLiteral(text) {
60
+ return String(text).replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
61
+ }
62
+
63
+ function posixDir(relativePath) {
64
+ const dir = path.posix.dirname(String(relativePath).replace(/\\/g, '/'));
65
+ return dir === '.' ? '' : dir;
66
+ }
67
+
68
+ function relativeTo(dir, relativePath) {
69
+ const rel = String(relativePath).replace(/\\/g, '/');
70
+ if (!dir) return rel;
71
+ return rel.startsWith(dir + '/') ? rel.slice(dir.length + 1) : null;
72
+ }
73
+
74
+ /** Directories from the file's own up to the project root, nearest first. */
75
+ function ancestorDirs(relativePath) {
76
+ const out = [];
77
+ let dir = posixDir(relativePath);
78
+ for (;;) {
79
+ out.push(dir);
80
+ if (!dir) break;
81
+ dir = posixDir(dir);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Manifests
88
+ // ---------------------------------------------------------------------------
89
+
90
+ function createManifests(index) {
91
+ const texts = new Map();
92
+ const listings = new Map();
93
+ const read = (dir, name) => {
94
+ const key = `${dir}\0${name}`;
95
+ if (texts.has(key)) return texts.get(key);
96
+ let text;
97
+ try {
98
+ text = fs.readFileSync(path.join(index.root, dir, name), 'utf8');
99
+ } catch (e) {
100
+ text = null;
101
+ }
102
+ texts.set(key, text);
103
+ return text;
104
+ };
105
+ const list = dir => {
106
+ if (listings.has(dir)) return listings.get(dir);
107
+ let names;
108
+ try {
109
+ names = fs.readdirSync(path.join(index.root, dir)).sort(codeUnitCompare);
110
+ } catch (e) {
111
+ names = [];
112
+ }
113
+ listings.set(dir, names);
114
+ return names;
115
+ };
116
+ const json = (dir, name) => {
117
+ const text = read(dir, name);
118
+ if (text == null) return null;
119
+ try {
120
+ const value = JSON.parse(text);
121
+ return value && typeof value === 'object' ? value : null;
122
+ } catch (e) {
123
+ return null;
124
+ }
125
+ };
126
+ return { read, list, json };
127
+ }
128
+
129
+ /** The table `[name]` of a TOML or INI text, up to the next table header. */
130
+ function tableText(text, header) {
131
+ const lines = String(text).split('\n');
132
+ const out = [];
133
+ let inside = false;
134
+ for (const line of lines) {
135
+ const head = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:[#;].*)?$/.exec(line);
136
+ if (head) {
137
+ inside = head[1] === header;
138
+ continue;
139
+ }
140
+ if (inside) out.push(line);
141
+ }
142
+ return inside || out.length > 0 ? out.join('\n') : null;
143
+ }
144
+
145
+ function hasTable(text, header) {
146
+ if (text == null) return false;
147
+ return String(text).split('\n').some(line => {
148
+ const head = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:[#;].*)?$/.exec(line);
149
+ return !!head && head[1] === header;
150
+ });
151
+ }
152
+
153
+ /**
154
+ * The values of `key` in a pytest configuration table: a TOML array or
155
+ * string, or INI words continued on indented lines. Null when not set.
156
+ */
157
+ function configWords(table, key) {
158
+ if (table == null) return null;
159
+ const lines = table.split('\n');
160
+ for (let i = 0; i < lines.length; i++) {
161
+ const match = new RegExp(`^${key}\\s*[=:]\\s*(.*)$`).exec(lines[i]);
162
+ if (!match) continue;
163
+ let value = match[1];
164
+ if (value.trimStart().startsWith('[')) {
165
+ // A TOML array, possibly over several lines.
166
+ let j = i;
167
+ while (!value.includes(']') && j + 1 < lines.length) value += '\n' + lines[++j];
168
+ } else {
169
+ for (let j = i + 1; j < lines.length && /^\s+\S/.test(lines[j]); j++) value += '\n' + lines[j];
170
+ }
171
+ const quoted = [...value.matchAll(/"([^"]*)"|'([^']*)'/g)].map(m => m[1] != null ? m[1] : m[2]);
172
+ const words = quoted.length > 0
173
+ ? quoted.flatMap(word => word.split(/\s+/))
174
+ : value.replace(/[#;].*$/gm, '').split(/[\s,[\]]+/);
175
+ return words.filter(Boolean);
176
+ }
177
+ return null;
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Runners
182
+ // ---------------------------------------------------------------------------
183
+
184
+ // Package and command names of JavaScript runners; `true` marks the ones
185
+ // whose selection syntax is modeled.
186
+ const JS_RUNNER_PACKAGES = new Map([
187
+ ['jest', 'jest'], ['vitest', 'vitest'], ['mocha', 'mocha'],
188
+ ['ava', 'ava'], ['tap', 'tap'], ['jasmine', 'jasmine'], ['karma', 'karma'], ['uvu', 'uvu'],
189
+ ['@playwright/test', 'playwright'], ['playwright', 'playwright'], ['cypress', 'cypress'],
190
+ ['@japa/runner', 'japa'], ['tape', 'tape'], ['qunit', 'qunit'], ['lab', 'lab'], ['@hapi/lab', 'lab'],
191
+ ['bun', 'bun'], ['deno', 'deno'], ['wdio', 'wdio'], ['@web/test-runner', 'wtr'], ['wtr', 'wtr'],
192
+ ['react-scripts', 'react-scripts'], ['ng', 'angular'], ['nx', 'nx'], ['turbo', 'turbo'], ['lerna', 'lerna'],
193
+ ]);
194
+ const MODELED_JS = new Set(['node-test', 'jest', 'vitest', 'mocha']);
195
+ /**
196
+ * The tests of a file that a focus (`.only`) keeps from running, by each
197
+ * runner's own rule (read from the runners and measured). `units` are the
198
+ * reader's, each with `parent` (the index of the suite it stands in, -1
199
+ * for the file) and `only` as written.
200
+ * - vitest: a test runs when it or a suite around it is marked.
201
+ * - jest: a suite passes its mark down to suites inside it, and to its own
202
+ * tests unless one of those is marked itself.
203
+ * - mocha: a suite with marked tests of its own runs those and nothing
204
+ * else of it; otherwise it runs its marked suites (narrowed further when
205
+ * they hold marks) and the unmarked ones that hold a mark.
206
+ * node:test honours the mark only under --test-only: nothing is left out.
207
+ * @returns {Set<object>} the units that do not run
208
+ */
209
+ function focusedOut(units, runner) {
210
+ if (!units.some(unit => unit.only) || !['jest', 'vitest', 'mocha'].includes(runner)) return new Set();
211
+ const children = new Map();
212
+ units.forEach((unit, i) => {
213
+ const parent = unit.parent == null ? -1 : unit.parent;
214
+ if (!children.has(parent)) children.set(parent, []);
215
+ children.get(parent).push(i);
216
+ });
217
+ const kids = i => children.get(i) || [];
218
+ const isSuite = i => units[i].kind === 'suite';
219
+ const runs = new Set();
220
+ if (runner === 'vitest') {
221
+ const visit = (parent, included) => {
222
+ for (const i of kids(parent)) {
223
+ const include = included || !!units[i].only;
224
+ if (isSuite(i)) visit(i, include);
225
+ else if (include) runs.add(i);
226
+ }
227
+ };
228
+ visit(-1, false);
229
+ } else if (runner === 'jest') {
230
+ const modes = new Map();
231
+ const modeOf = i => {
232
+ if (i === -1) return null;
233
+ if (!modes.has(i)) modes.set(i, units[i].only ? 'only' : modeOf(units[i].parent == null ? -1 : units[i].parent));
234
+ return modes.get(i);
235
+ };
236
+ units.forEach((unit, i) => {
237
+ if (unit.kind !== 'test') return;
238
+ const parent = unit.parent == null ? -1 : unit.parent;
239
+ const narrowed = modeOf(parent) === 'only' && kids(parent).some(k => !isSuite(k) && units[k].only);
240
+ if (unit.only || (!narrowed && modeOf(parent) === 'only')) runs.add(i);
241
+ });
242
+ // No test ends up focused (an empty marked suite): nothing is left out.
243
+ if (runs.size === 0) return new Set();
244
+ } else {
245
+ const holdsMark = i => kids(i).some(k => units[k].only || (isSuite(k) && holdsMark(k)));
246
+ const all = i => {
247
+ for (const k of kids(i)) {
248
+ if (isSuite(k)) all(k);
249
+ else runs.add(k);
250
+ }
251
+ };
252
+ const narrow = i => {
253
+ const own = kids(i).filter(k => !isSuite(k) && units[k].only);
254
+ if (own.length > 0) {
255
+ for (const k of own) runs.add(k);
256
+ return;
257
+ }
258
+ for (const k of kids(i)) {
259
+ if (!isSuite(k)) continue;
260
+ if (units[k].only && !holdsMark(k)) all(k);
261
+ else if (units[k].only || holdsMark(k)) narrow(k);
262
+ }
263
+ };
264
+ narrow(-1);
265
+ }
266
+ return new Set(units.filter((unit, i) => unit.kind === 'test' && !runs.has(i)));
267
+ }
268
+ // Node options that take the next word as their value.
269
+ const NODE_VALUE_OPTIONS = new Set(['--import', '--require', '-r', '--loader', '--experimental-loader',
270
+ '--conditions', '-C', '--env-file', '--test-reporter', '--test-reporter-destination',
271
+ '--test-concurrency', '--test-timeout', '--test-shard', '--test-name-pattern', '--test-skip-pattern',
272
+ '--test-coverage-include', '--test-coverage-exclude', '--watch-path', '--input-type', '--title']);
273
+ // Node options that decide how a test file loads; everything else the
274
+ // script passes (reporters, coverage, concurrency) is not needed to run one.
275
+ const NODE_LOADING_OPTIONS = /^(--import|--require|-r|--loader|--experimental-loader|--conditions|-C|--env-file|--experimental-[\w-]+|--no-experimental-[\w-]+|--enable-source-maps|--preserve-symlinks|--no-warnings)(=|$)/;
276
+
277
+ /**
278
+ * Runner commands named by a package.json script: the first word of each
279
+ * command of the script, and `--test` after `node`.
280
+ */
281
+ function scriptRunners(script) {
282
+ const runners = [];
283
+ if (typeof script !== 'string') return runners;
284
+ for (const command of script.split(/&&|\|\||;|\|/)) {
285
+ const words = command.trim().split(/\s+/).filter(Boolean);
286
+ // Leading environment assignments and wrappers name no runner.
287
+ let i = 0;
288
+ while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]) ||
289
+ ['npx', 'cross-env', 'env', 'c8', 'nyc', 'dotenv', '--', 'exec', 'pnpm', 'yarn', 'npm', 'run', 'bunx', 'time'].includes(words[i]))) i++;
290
+ const head = words[i];
291
+ if (!head) continue;
292
+ const base = head.split('/').pop();
293
+ if ((base === 'node' || base === 'tsx' || base === 'ts-node') &&
294
+ words.slice(i + 1).some(word => word === '--test' || word.startsWith('--test='))) {
295
+ runners.push('node-test');
296
+ continue;
297
+ }
298
+ if (base === 'bun' && words[i + 1] === 'test') { runners.push('bun'); continue; }
299
+ if (base === 'deno' && words[i + 1] === 'test') { runners.push('deno'); continue; }
300
+ if (JS_RUNNER_PACKAGES.has(base)) runners.push(JS_RUNNER_PACKAGES.get(base));
301
+ else runners.push(`?${base}`);
302
+ }
303
+ return runners;
304
+ }
305
+
306
+ /**
307
+ * The `node` options of a script that is one `node ... --test ...` command,
308
+ * kept when they decide how a file loads. Null when the script is anything
309
+ * else: then nothing says how the project's tests are loaded.
310
+ */
311
+ function nodeTestOptions(script) {
312
+ if (typeof script !== 'string' || /[&|;$`<>(){}*]/.test(script.replace(/\*/g, '')) && /[&|;$`<>(){}]/.test(script)) return null;
313
+ const words = script.trim().split(/\s+/).filter(Boolean);
314
+ if (words[0] !== 'node' || !words.includes('--test')) return null;
315
+ const kept = [];
316
+ for (let i = 1; i < words.length; i++) {
317
+ const word = words[i];
318
+ if (!word.startsWith('-')) continue;
319
+ const takesValue = NODE_VALUE_OPTIONS.has(word);
320
+ if (NODE_LOADING_OPTIONS.test(word)) {
321
+ kept.push(word);
322
+ if (takesValue && i + 1 < words.length) kept.push(words[i + 1]);
323
+ }
324
+ if (takesValue) i++;
325
+ }
326
+ return kept;
327
+ }
328
+
329
+ function jsRunner(model, fileEntry) {
330
+ // 1. The file's own imports.
331
+ const modules = langTraits(fileEntry.language)?.testRunnerModules?.() || new Map();
332
+ const imported = new Set();
333
+ for (const mod of fileEntry.imports || []) {
334
+ const runner = modules.get(mod);
335
+ if (runner) imported.add(runner);
336
+ }
337
+ if (imported.size > 1) return { id: null, reason: 'ambiguous-runner' };
338
+ let fromFile = imported.size === 1 ? [...imported][0] : null;
339
+ if (fromFile && !MODELED_JS.has(fromFile)) return { id: null, reason: 'runner-not-modeled', runner: fromFile };
340
+
341
+ // 2. The nearest package manifest.
342
+ let dir = null;
343
+ let manifest = null;
344
+ for (const candidate of ancestorDirs(fileEntry.relativePath)) {
345
+ manifest = model.manifests.json(candidate, 'package.json');
346
+ if (manifest) { dir = candidate; break; }
347
+ }
348
+ const script = manifest && manifest.scripts && typeof manifest.scripts.test === 'string'
349
+ ? manifest.scripts.test : null;
350
+ if (fromFile) {
351
+ return { id: fromFile, dir: dir || '', evidence: 'test-file-import', script, scripts: manifest ? manifest.scripts : null };
352
+ }
353
+ if (!manifest) return { id: null, reason: 'no-runner-evidence' };
354
+ const named = [...new Set(scriptRunners(script).filter(name => !name.startsWith('?')))];
355
+ if (named.length === 1) {
356
+ if (!MODELED_JS.has(named[0])) return { id: null, reason: 'runner-not-modeled', runner: named[0] };
357
+ return { id: named[0], dir, evidence: 'package-script', script, scripts: manifest.scripts };
358
+ }
359
+ if (named.length > 1) return { id: null, reason: 'ambiguous-runner' };
360
+ const declared = new Set();
361
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
362
+ for (const name of Object.keys(manifest[field] || {})) {
363
+ if (JS_RUNNER_PACKAGES.has(name)) declared.add(JS_RUNNER_PACKAGES.get(name));
364
+ }
365
+ }
366
+ if (declared.size === 1) {
367
+ const only = [...declared][0];
368
+ if (!MODELED_JS.has(only)) return { id: null, reason: 'runner-not-modeled', runner: only };
369
+ return { id: only, dir, evidence: 'package-dependency', script, scripts: manifest.scripts };
370
+ }
371
+ return { id: null, reason: declared.size > 1 ? 'ambiguous-runner' : 'no-runner-evidence' };
372
+ }
373
+
374
+ /** pytest's configuration file for a directory, by pytest's own order. */
375
+ function pytestConfigIn(model, dir) {
376
+ const ini = model.manifests.read(dir, 'pytest.ini');
377
+ if (ini != null) return { file: 'pytest.ini', table: tableText(ini, 'pytest') || '' };
378
+ const project = model.manifests.read(dir, 'pyproject.toml');
379
+ if (project != null) {
380
+ for (const header of ['tool.pytest.ini_options', 'tool.pytest']) {
381
+ if (hasTable(project, header)) return { file: 'pyproject.toml', table: tableText(project, header) || '' };
382
+ }
383
+ }
384
+ const tox = model.manifests.read(dir, 'tox.ini');
385
+ if (tox != null && hasTable(tox, 'pytest')) return { file: 'tox.ini', table: tableText(tox, 'pytest') || '' };
386
+ const setup = model.manifests.read(dir, 'setup.cfg');
387
+ if (setup != null && hasTable(setup, 'tool:pytest')) {
388
+ return { file: 'setup.cfg', table: tableText(setup, 'tool:pytest') || '' };
389
+ }
390
+ return null;
391
+ }
392
+
393
+ /**
394
+ * Does a requirements file or a manifest name a package as a requirement:
395
+ * the name where a requirement starts (a line, a list item, a quoted
396
+ * string, a key), up to a version or the end. A word in a comment or in
397
+ * another package's name is not one.
398
+ */
399
+ function namesRequirement(text, name) {
400
+ const shape = new RegExp(`(^|["'\\s,\\[])${name}(\\[[^\\]]*\\])?\\s*([<>=!~;,"'\\]]|$)`);
401
+ return String(text).split('\n').some(raw => shape.test(raw.replace(/(^|\s)[#;].*$/, '').trim()));
402
+ }
403
+
404
+ function pythonRunner(model, fileEntry) {
405
+ const dirs = ancestorDirs(fileEntry.relativePath);
406
+ for (const dir of dirs) {
407
+ const config = pytestConfigIn(model, dir);
408
+ if (config) {
409
+ return {
410
+ id: 'pytest', dir, evidence: config.file,
411
+ collection: {
412
+ files: configWords(config.table, 'python_files'),
413
+ classes: configWords(config.table, 'python_classes'),
414
+ functions: configWords(config.table, 'python_functions'),
415
+ options: pytestOptions(configArguments(config, 'addopts')),
416
+ // Fixtures every test is given, and whether a test a
417
+ // module imports is collected there too.
418
+ usefixtures: configWords(config.table, 'usefixtures') || [],
419
+ importedTests: !/^(false|no|off|0)$/i.test((configWords(config.table, 'collect_imported_tests') || [])[0] || ''),
420
+ },
421
+ };
422
+ }
423
+ }
424
+ if ((fileEntry.imports || []).some(mod => mod === 'pytest' || String(mod).startsWith('pytest.'))) {
425
+ return { id: 'pytest', dir: '', evidence: 'test-file-import', collection: {} };
426
+ }
427
+ for (const dir of dirs) {
428
+ const conftest = dir ? `${dir}/conftest.py` : 'conftest.py';
429
+ if (model.hasFile(conftest)) return { id: 'pytest', dir: '', evidence: 'conftest.py', collection: {} };
430
+ }
431
+ for (const dir of dirs) {
432
+ for (const name of ['pyproject.toml', 'requirements.txt', 'setup.cfg', 'tox.ini']) {
433
+ const text = model.manifests.read(dir, name);
434
+ if (text != null && namesRequirement(text, 'pytest')) {
435
+ return { id: 'pytest', dir, evidence: name, collection: {} };
436
+ }
437
+ }
438
+ }
439
+ return { id: null, reason: 'no-runner-evidence' };
440
+ }
441
+
442
+ function goRunner(model, fileEntry) {
443
+ for (const dir of ancestorDirs(fileEntry.relativePath)) {
444
+ if (model.manifests.read(dir, 'go.mod') != null) return { id: 'go-test', dir, evidence: 'go.mod' };
445
+ }
446
+ return { id: null, reason: 'no-runner-evidence' };
447
+ }
448
+
449
+ function rustRunner(model, fileEntry) {
450
+ for (const dir of ancestorDirs(fileEntry.relativePath)) {
451
+ const text = model.manifests.read(dir, 'Cargo.toml');
452
+ if (text == null) continue;
453
+ if (!hasTable(text, 'package')) return { id: null, reason: 'no-package-manifest' };
454
+ const name = /^\s*name\s*=\s*"([^"]+)"/m.exec(tableText(text, 'package') || '');
455
+ // Targets the manifest lays out itself are not where cargo's
456
+ // conventions put them.
457
+ const customTargets = ['lib', 'bin', 'test'].some(header => {
458
+ const table = tableText(text, header);
459
+ return table != null && /^\s*(path|name|harness)\s*=/m.test(table);
460
+ });
461
+ return {
462
+ id: 'cargo-test', dir, evidence: 'Cargo.toml',
463
+ packageName: name ? name[1] : null,
464
+ customTargets,
465
+ };
466
+ }
467
+ return { id: null, reason: 'no-runner-evidence' };
468
+ }
469
+
470
+ function javaRunner(model, fileEntry) {
471
+ for (const dir of ancestorDirs(fileEntry.relativePath)) {
472
+ if (model.manifests.read(dir, 'pom.xml') != null) return { id: 'maven', dir, evidence: 'pom.xml' };
473
+ for (const name of ['build.gradle', 'build.gradle.kts']) {
474
+ if (model.manifests.read(dir, name) != null) return { id: null, reason: 'runner-not-modeled', runner: 'gradle' };
475
+ }
476
+ }
477
+ return { id: null, reason: 'no-runner-evidence' };
478
+ }
479
+
480
+ function dotnetRunner(model, fileEntry) {
481
+ for (const dir of ancestorDirs(fileEntry.relativePath)) {
482
+ const projects = model.manifests.list(dir).filter(name => name.endsWith('.csproj'));
483
+ if (projects.length === 0) continue;
484
+ if (projects.length > 1) return { id: null, reason: 'ambiguous-runner' };
485
+ const text = model.manifests.read(dir, projects[0]) || '';
486
+ if (!/Microsoft\.NET\.Test\.Sdk|xunit|nunit|MSTest/i.test(text)) return { id: null, reason: 'no-runner-evidence' };
487
+ return { id: 'dotnet-test', dir, evidence: projects[0] };
488
+ }
489
+ return { id: null, reason: 'no-runner-evidence' };
490
+ }
491
+
492
+ // ---------------------------------------------------------------------------
493
+ // What the runner's own configuration changes
494
+ // ---------------------------------------------------------------------------
495
+
496
+ /** Words of a shell-like option string: quotes group, a backslash escapes. */
497
+ function shellSplit(text) {
498
+ const words = [];
499
+ let current = null;
500
+ let quote = null;
501
+ for (let i = 0; i < text.length; i++) {
502
+ const ch = text[i];
503
+ if (quote) {
504
+ if (ch === quote) quote = null;
505
+ else if (ch === '\\' && quote === '"' && i + 1 < text.length) current += text[++i];
506
+ else current += ch;
507
+ } else if (ch === '"' || ch === "'") {
508
+ quote = ch;
509
+ if (current == null) current = '';
510
+ } else if (/\s/.test(ch)) {
511
+ if (current != null) words.push(current);
512
+ current = null;
513
+ } else if (ch === '\\' && i + 1 < text.length) {
514
+ current = (current == null ? '' : current) + text[++i];
515
+ } else {
516
+ current = (current == null ? '' : current) + ch;
517
+ }
518
+ }
519
+ if (current != null) words.push(current);
520
+ return words;
521
+ }
522
+
523
+ /** A TOML line without its comment. */
524
+ function withoutTomlComment(line) {
525
+ let quote = null;
526
+ for (let i = 0; i < line.length; i++) {
527
+ const ch = line[i];
528
+ if (quote) {
529
+ if (ch === '\\' && quote === '"') i++;
530
+ else if (ch === quote) quote = null;
531
+ } else if (ch === '"' || ch === "'") {
532
+ quote = ch;
533
+ } else if (ch === '#') {
534
+ return line.slice(0, i);
535
+ }
536
+ }
537
+ return line;
538
+ }
539
+
540
+ /**
541
+ * The arguments a pytest configuration gives for `key` (addopts): a TOML
542
+ * array holds one argument per string; a string, and an INI value with its
543
+ * continuation lines, is split the way a shell would. [] when the key is
544
+ * not set, null when its value is not read.
545
+ */
546
+ function configArguments(config, key) {
547
+ if (!config || config.table == null) return [];
548
+ const lines = config.table.split('\n');
549
+ const toml = config.file === 'pyproject.toml';
550
+ for (let i = 0; i < lines.length; i++) {
551
+ const match = new RegExp(`^${key}\\s*[=:]\\s*(.*)$`).exec(lines[i]);
552
+ if (!match) continue;
553
+ if (!toml) {
554
+ let value = match[1];
555
+ for (let j = i + 1; j < lines.length && /^\s+\S/.test(lines[j]); j++) value += ' ' + lines[j].trim();
556
+ return shellSplit(value);
557
+ }
558
+ let value = withoutTomlComment(match[1]);
559
+ if (/"""|'''/.test(value)) return null;
560
+ const strings = text => [...text.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g)]
561
+ .map(found => found[1] != null ? found[1].replace(/\\(.)/g, '$1') : found[2]);
562
+ if (value.trimStart().startsWith('[')) {
563
+ for (let j = i + 1; !/\]\s*$/.test(value) && j < lines.length; j++) value += '\n' + withoutTomlComment(lines[j]);
564
+ return /\]\s*$/.test(value) ? strings(value) : null;
565
+ }
566
+ const whole = strings(value);
567
+ return whole.length === 1 ? shellSplit(whole[0]) : null;
568
+ }
569
+ return [];
570
+ }
571
+
572
+ // pytest options that change neither what is collected nor what a command
573
+ // that names a test runs. --ignore leaves a path out of a whole run only:
574
+ // a path given to the command is still collected (measured).
575
+ const PYTEST_INERT_FLAGS = new Set(['-v', '-vv', '-vvv', '--verbose', '-q', '-qq', '--quiet', '-s', '-l',
576
+ '--showlocals', '-x', '--exitfirst', '--strict-markers', '--strict-config', '--strict', '--no-header',
577
+ '--no-summary', '--disable-warnings', '--disable-pytest-warnings', '--ff', '--failed-first', '--nf',
578
+ '--new-first', '--cache-clear', '--full-trace', '--runxfail', '--no-cov-on-fail', '--cov-branch',
579
+ '--cov-append', '--no-cov', '--cov']);
580
+ const PYTEST_INERT_VALUED = new Set(['--tb', '--color', '--durations', '--durations-min', '--maxfail',
581
+ '--capture', '-W', '--pythonwarnings', '--cov', '--cov-report', '--cov-config', '--cov-fail-under',
582
+ '--cov-context', '--junitxml', '--junit-xml', '--junit-prefix', '--log-level', '--log-cli-level',
583
+ '--log-format', '--log-date-format', '--log-file', '--log-file-level', '--basetemp', '--import-mode',
584
+ '--timeout', '--timeout-method', '-n', '--numprocesses', '--dist', '--maxprocesses', '--reruns',
585
+ '--reruns-delay', '--show-capture', '--code-highlight', '--verbosity', '--asyncio-mode', '--ignore',
586
+ '--ignore-glob', '-r']);
587
+ // pytest's own plugins that decide what is collected, selected or skipped:
588
+ // switching one of these off changes which tests run. Any other plugin
589
+ // switched off (-p no:name) changes none of that; one switched ON is code
590
+ // that is not read.
591
+ const PYTEST_SELECTING_PLUGINS = new Set(['python', 'unittest', 'mark', 'skipping', 'main', 'runner', 'fixtures',
592
+ 'stepwise', 'setuponly', 'setupplan', 'nose']);
593
+
594
+ /**
595
+ * What pytest's configured options do to selection: `markExpr` (-m) and
596
+ * `deselect` (--deselect) are applied to the units; `unread` says an
597
+ * option is there that may select, add or leave out tests and is not
598
+ * modeled (-k, a path, a plugin, an override): no command is exact then.
599
+ */
600
+ function pytestOptions(words) {
601
+ const out = { markExpr: null, deselect: [], unread: false };
602
+ if (words == null) return { ...out, unread: true };
603
+ for (let i = 0; i < words.length; i++) {
604
+ const word = words[i];
605
+ const cut = word.startsWith('--') ? word.indexOf('=') : -1;
606
+ const name = cut > 0 ? word.slice(0, cut) : word;
607
+ const inline = cut > 0 ? word.slice(cut + 1) : null;
608
+ const value = () => (inline != null ? inline : words[++i]);
609
+ if (name === '-m') {
610
+ const expression = value();
611
+ if (expression == null) out.unread = true;
612
+ else out.markExpr = expression;
613
+ } else if (name === '--deselect') {
614
+ const id = value();
615
+ if (id) out.deselect.push(id);
616
+ else out.unread = true;
617
+ } else if (name === '-p') {
618
+ const plugin = String(value() || '');
619
+ if (!plugin.startsWith('no:') || PYTEST_SELECTING_PLUGINS.has(plugin.slice(3))) out.unread = true;
620
+ } else if (/^-r[A-Za-z]+$/.test(word) || /^-W\S+$/.test(word) || /^-n\S+$/.test(word)) {
621
+ // -ra, -Werror, -n4: the value is in the word.
622
+ } else if (inline != null ? PYTEST_INERT_VALUED.has(name) : PYTEST_INERT_FLAGS.has(name) && !PYTEST_INERT_VALUED.has(name)) {
623
+ // nothing
624
+ } else if (inline == null && name === '--cov') {
625
+ // --cov takes an optional value: a word after it that is no
626
+ // option would be read as a path otherwise.
627
+ if (i + 1 < words.length && !words[i + 1].startsWith('-')) i++;
628
+ } else if (inline == null && PYTEST_INERT_VALUED.has(name)) {
629
+ i++;
630
+ } else {
631
+ out.unread = true;
632
+ }
633
+ }
634
+ return out;
635
+ }
636
+
637
+ /**
638
+ * Does pytest's mark expression select a test with these marks: true,
639
+ * false, or null when it cannot be said (`open`: the test may carry marks
640
+ * that are not read; an expression in a form not modeled).
641
+ */
642
+ function markSelects(expression, marks, open) {
643
+ const tokens = String(expression).match(/\(|\)|[^\s()]+/g) || [];
644
+ let at = 0;
645
+ const not = value => (value == null ? null : !value);
646
+ const primary = () => {
647
+ const token = tokens[at++];
648
+ if (token === 'not') return not(primary());
649
+ if (token === '(') {
650
+ const inner = either();
651
+ if (tokens[at++] !== ')') throw new Error('unbalanced');
652
+ return inner;
653
+ }
654
+ if (token == null || token === ')' || token === 'and' || token === 'or') throw new Error('unexpected');
655
+ // name(arg=value): a mark with arguments, not modeled.
656
+ if (tokens[at] === '(') throw new Error('arguments');
657
+ return marks.has(token) ? true : open ? null : false;
658
+ };
659
+ const both = () => {
660
+ let value = primary();
661
+ while (tokens[at] === 'and') {
662
+ at++;
663
+ const next = primary();
664
+ value = value === false || next === false ? false : value == null || next == null ? null : true;
665
+ }
666
+ return value;
667
+ };
668
+ const either = () => {
669
+ let value = both();
670
+ while (tokens[at] === 'or') {
671
+ at++;
672
+ const next = both();
673
+ value = value === true || next === true ? true : value == null || next == null ? null : false;
674
+ }
675
+ return value;
676
+ };
677
+ try {
678
+ const value = either();
679
+ return at === tokens.length ? value : null;
680
+ } catch (e) {
681
+ return null;
682
+ }
683
+ }
684
+
685
+ /** A path glob as a regular expression: `**`, `*`, `?`, `{a,b}`, `[...]`. */
686
+ function pathGlob(glob) {
687
+ const text = String(glob).replace(/^\.\//, '');
688
+ let source = '';
689
+ for (let i = 0; i < text.length; i++) {
690
+ const ch = text[i];
691
+ if (ch === '*' && text[i + 1] === '*') {
692
+ i++;
693
+ if (text[i + 1] === '/') {
694
+ i++;
695
+ source += '(?:.*/)?';
696
+ } else {
697
+ source += '.*';
698
+ }
699
+ } else if (ch === '*') {
700
+ source += '[^/]*';
701
+ } else if (ch === '?') {
702
+ source += '[^/]';
703
+ } else if (ch === '{' || ch === '[') {
704
+ const end = text.indexOf(ch === '{' ? '}' : ']', i);
705
+ if (end < 0) return null;
706
+ source += ch === '[' ? text.slice(i, end + 1)
707
+ : `(?:${text.slice(i + 1, end).split(',').map(part => part.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')).join('|')})`;
708
+ i = end;
709
+ } else {
710
+ source += ch.replace(/[.+^$()|\\]/, '\\$&');
711
+ }
712
+ }
713
+ try {
714
+ return new RegExp(`^${source}$`);
715
+ } catch (e) {
716
+ return null;
717
+ }
718
+ }
719
+
720
+ // The files `node --test` runs when it is given none (node's own list).
721
+ const NODE_DEFAULT_TEST_FILES = [/(^|\/)[^/]*[._-]test\.[cm]?js$/, /(^|\/)test-[^/]*\.[cm]?js$/,
722
+ /(^|\/)test\.[cm]?js$/, /(^|\/)test\/.*\.[cm]?js$/];
723
+
724
+ /**
725
+ * The paths a script that is one `node ... --test ...` command names, as
726
+ * written (a glob stays a glob). Null when the script is anything else.
727
+ */
728
+ function nodeTestPaths(script) {
729
+ if (nodeTestOptions(script) == null) return null;
730
+ const words = script.trim().split(/\s+/).filter(Boolean);
731
+ const paths = [];
732
+ for (let i = 1; i < words.length; i++) {
733
+ const word = words[i];
734
+ if (word.startsWith('-')) {
735
+ if (NODE_VALUE_OPTIONS.has(word)) i++;
736
+ continue;
737
+ }
738
+ paths.push(word.replace(/^(['"])(.*)\1$/, '$2'));
739
+ }
740
+ return paths;
741
+ }
742
+
743
+ /**
744
+ * Does node run a file as a test under the project's own script: one of
745
+ * the paths the script names, or, when it names none, one node looks for
746
+ * by itself (every file under a `test` directory among them). node runs a
747
+ * file that declares no test as one test. Null: the script does not say.
748
+ */
749
+ function nodeRunsFile(runner, file) {
750
+ const paths = nodeTestPaths(runner.script);
751
+ if (paths == null || file == null) return null;
752
+ if (paths.length === 0) return !/(^|\/)node_modules\//.test(file) && NODE_DEFAULT_TEST_FILES.some(pattern => pattern.test(file));
753
+ return paths.some(written => {
754
+ const target = written.replace(/^\.\//, '');
755
+ if (target === file) return true;
756
+ if (!/[*?{[]/.test(target)) return false;
757
+ const pattern = pathGlob(target);
758
+ return !!pattern && pattern.test(file);
759
+ });
760
+ }
761
+
762
+ /**
763
+ * The words a project's test script passes to a runner: those of the
764
+ * `test` script and of the scripts it names, after the runner's own name.
765
+ * [] when the script does not call the runner by name.
766
+ */
767
+ function runnerScriptWords(runner, name) {
768
+ const scripts = runner.scripts && typeof runner.scripts === 'object' ? runner.scripts : {};
769
+ const texts = [];
770
+ if (typeof runner.script === 'string') {
771
+ texts.push(runner.script);
772
+ for (const word of runner.script.split(/[\s"'&|;]+/)) {
773
+ if (word !== 'test' && typeof scripts[word] === 'string') texts.push(scripts[word]);
774
+ }
775
+ }
776
+ const out = [];
777
+ for (const text of texts) {
778
+ for (const segment of text.split(/&&|\|\||;|\|/)) {
779
+ const words = segment.trim().split(/\s+/).filter(Boolean);
780
+ const at = words.findIndex(word => word.split('/').pop() === name || word.split('/').pop() === `_${name}`);
781
+ if (at >= 0) out.push(...words.slice(at + 1));
782
+ }
783
+ }
784
+ return out;
785
+ }
786
+
787
+ // Options of a runner's command line that point it at another
788
+ // configuration or select files: a script that passes one runs something
789
+ // a command built here does not.
790
+ const SCRIPT_CONFIG_FLAGS = /^(--config|-c|--rootDir|--roots|--projects|--selectProjects|--project|--workspace|--root|--dir|--testMatch|--testRegex|--testPathPattern|--testPathPatterns|--package|--opts)(=|$)/;
791
+ // Options of a mocha command line that decide how a test file loads.
792
+ const MOCHA_LOADING_OPTIONS = new Set(['--require', '-r', '--loader', '--import', '--ui', '-u', '--file', '--node-option', '-n']);
793
+ const MOCHA_VALUE_OPTIONS = new Set([...MOCHA_LOADING_OPTIONS, '--reporter', '-R', '--reporter-option', '--reporter-options',
794
+ '-O', '--timeout', '-t', '--timeouts', '--slow', '-s', '--retries', '--grep', '-g', '--fgrep', '-f', '--ignore',
795
+ '--exclude', '--spec', '--jobs', '-j', '--watch-files', '--watch-ignore', '--extension', '--config', '--package']);
796
+
797
+ /**
798
+ * The options of the project's mocha script that a command for one file
799
+ * needs to load it the same way (--require, --ui, --loader, --file).
800
+ */
801
+ function mochaLoadingOptions(runner) {
802
+ const words = runnerScriptWords(runner, 'mocha');
803
+ const kept = [];
804
+ for (let i = 0; i < words.length; i++) {
805
+ const word = words[i].replace(/^(['"])(.*)\1$/, '$2');
806
+ const cut = word.startsWith('--') ? word.indexOf('=') : -1;
807
+ const name = cut > 0 ? word.slice(0, cut) : word;
808
+ if (MOCHA_LOADING_OPTIONS.has(name)) {
809
+ kept.push(word);
810
+ if (cut < 0 && i + 1 < words.length) kept.push(words[++i].replace(/^(['"])(.*)\1$/, '$2'));
811
+ } else if (cut < 0 && MOCHA_VALUE_OPTIONS.has(name)) {
812
+ i++;
813
+ }
814
+ }
815
+ return kept;
816
+ }
817
+
818
+ // Configuration files of the JavaScript runners.
819
+ const JS_CONFIG_NAMES = {
820
+ jest: new Set(['jest.config.js', 'jest.config.cjs', 'jest.config.mjs', 'jest.config.ts', 'jest.config.cts',
821
+ 'jest.config.mts', 'jest.config.json']),
822
+ vitest: new Set(['vitest.config.js', 'vitest.config.cjs', 'vitest.config.mjs', 'vitest.config.ts',
823
+ 'vitest.config.cts', 'vitest.config.mts', 'vite.config.js', 'vite.config.cjs', 'vite.config.mjs',
824
+ 'vite.config.ts', 'vite.config.cts', 'vite.config.mts', 'vitest.workspace.js', 'vitest.workspace.mjs',
825
+ 'vitest.workspace.ts', 'vitest.workspace.mts', 'vitest.workspace.json', 'vitest.projects.js',
826
+ 'vitest.projects.ts', 'vitest.projects.json']),
827
+ mocha: new Set(['.mocharc', '.mocharc.js', '.mocharc.cjs', '.mocharc.mjs', '.mocharc.json', '.mocharc.jsonc',
828
+ '.mocharc.yaml', '.mocharc.yml']),
829
+ };
830
+ const JS_CONFIG_PACKAGE_KEY = { jest: 'jest', mocha: 'mocha' };
831
+ // The keys of a configuration that are known to leave alone which tests a
832
+ // command for one file runs. Every other key may select: it is evaluated
833
+ // where it is modeled (JS_MODELED_KEYS), and the configuration is not read
834
+ // otherwise. vitest's are the keys of `test`; mocha's are compared with
835
+ // dashes and case folded (it takes both spellings).
836
+ const JS_INERT_KEYS = {
837
+ jest: new Set(['automock', 'bail', 'cache', 'cacheDirectory', 'ci', 'clearMocks', 'collectCoverage',
838
+ 'collectCoverageFrom', 'coverageDirectory', 'coveragePathIgnorePatterns', 'coverageProvider',
839
+ 'coverageReporters', 'coverageThreshold', 'detectLeaks', 'detectOpenHandles', 'displayName',
840
+ 'errorOnDeprecated', 'expand', 'extensionsToTreatAsEsm', 'fakeTimers', 'forceCoverageMatch', 'forceExit',
841
+ 'globalSetup', 'globalTeardown', 'globals', 'json', 'logHeapUsage', 'maxConcurrency', 'maxWorkers',
842
+ 'moduleDirectories', 'moduleNameMapper', 'modulePaths', 'noStackTrace', 'notify', 'notifyMode',
843
+ 'openHandlesTimeout', 'outputFile', 'passWithNoTests', 'prettierPath', 'randomize', 'reporters',
844
+ 'resetMocks', 'resetModules', 'resolver', 'restoreMocks', 'sandboxInjectedGlobals', 'setupFiles',
845
+ 'setupFilesAfterEnv', 'showSeed', 'silent', 'slowTestThreshold', 'snapshotFormat', 'snapshotResolver',
846
+ 'snapshotSerializers', 'testEnvironment', 'testEnvironmentOptions', 'testFailureExitCode',
847
+ 'testLocationInResults', 'testResultsProcessor', 'testTimeout', 'transform', 'transformIgnorePatterns',
848
+ 'unmockedModulePathPatterns', 'useStderr', 'verbose', 'watchPathIgnorePatterns', 'watchPlugins',
849
+ 'watchman', 'workerIdleMemoryLimit', 'workerThreads', 'dependencyExtractor', 'waitForUnhandledRejections',
850
+ '//',
851
+ // Names earlier versions of jest used for keys of this list.
852
+ 'testURL', 'timers', 'setupTestFrameworkScriptFile', 'mapCoverage', 'name', 'extraGlobals', 'browser']),
853
+ vitest: new Set(['alias', 'api', 'attachmentsDir', 'bail', 'benchmark', 'cache', 'chaiConfig', 'clearMocks',
854
+ 'coverage', 'css', 'dangerouslyIgnoreUnhandledErrors', 'deps', 'diff', 'disableConsoleIntercept', 'env',
855
+ 'environment', 'environmentMatchGlobs', 'environmentOptions', 'expandSnapshotDiff', 'expect', 'fakeTimers',
856
+ 'fileParallelism', 'globals', 'globalSetup', 'hookTimeout', 'includeTaskLocation', 'isolate',
857
+ 'logHeapUsage', 'maxConcurrency', 'maxWorkers', 'minWorkers', 'mockReset', 'name', 'onConsoleLog',
858
+ 'onStackTrace', 'open', 'outputFile', 'passWithNoTests', 'pool', 'poolMatchGlobs', 'poolOptions',
859
+ 'printConsoleTrace', 'provide', 'reporters', 'resolveSnapshotPath', 'restoreMocks', 'retry', 'sequence',
860
+ 'server', 'setupFiles', 'silent', 'slowTestThreshold', 'snapshotFormat', 'snapshotSerializers',
861
+ 'teardownTimeout', 'testTimeout', 'ui', 'unstubEnvs', 'unstubGlobals', 'update', 'watch']),
862
+ mocha: new Set(['allowuncaught', 'asynconly', 'bail', 'checkleaks', 'color', 'colors', 'diff',
863
+ 'enablesourcemaps', 'exit', 'failzero', 'fulltrace', 'global', 'globals', 'import', 'inlinediffs', 'jobs',
864
+ 'loader', 'nodeoption', 'nodeoptions', 'parallel', 'recursive', 'reporter', 'reporteroption',
865
+ 'reporteroptions', 'require', 'retries', 'slow', 'sort', 'timeout', 'timeouts', 'watch', 'watchfiles',
866
+ 'watchignore']),
867
+ };
868
+ const JS_MODELED_KEYS = {
869
+ jest: new Set(['roots', 'testRegex', 'testPathIgnorePatterns']),
870
+ vitest: new Set(['dir']),
871
+ mocha: new Set(),
872
+ };
873
+ // The keys of a vite configuration, beside `test` and `plugins`, that
874
+ // leave vitest's file selection alone.
875
+ const VITE_INERT_KEYS = new Set(['appType', 'assetsInclude', 'base', 'build', 'cacheDir', 'clearScreen', 'css',
876
+ 'define', 'dev', 'envDir', 'envPrefix', 'environments', 'esbuild', 'experimental', 'future', 'html', 'json',
877
+ 'legacy', 'logLevel', 'mode', 'optimizeDeps', 'oxc', 'preview', 'publicDir', 'resolve', 'server', 'ssr',
878
+ 'worker']);
879
+ // The functions a runner's own package gives a configuration to pass
880
+ // through unchanged: the name alone proves nothing, the import does.
881
+ const CONFIG_IDENTITY = {
882
+ jest: { modules: new Set(['jest']), names: new Set(['defineConfig']) },
883
+ vitest: { modules: new Set(['vitest/config', 'vite', 'vitest']), names: new Set(['defineConfig', 'defineProject']) },
884
+ };
885
+ const CONFIG_WRAPPERS = new Set(['parenthesized_expression', 'as_expression', 'satisfies_expression',
886
+ 'type_assertion', 'non_null_expression']);
887
+ // Modules whose functions act on the process or the disk, and the
888
+ // functions of them that only read.
889
+ const CONFIG_ACTING_MODULES = new Set(['fs', 'fs/promises', 'child_process', 'module', 'vm', 'worker_threads',
890
+ 'cluster', 'process', 'inspector', 'repl']);
891
+ const CONFIG_READING_CALLS = new Set(['readFileSync', 'existsSync', 'readdirSync', 'statSync', 'lstatSync',
892
+ 'realpathSync', 'createRequire']);
893
+ const CONFIG_GLOBAL_NAMESPACES = new Set(['JSON', 'Math', 'Object', 'Array', 'String', 'Number', 'Boolean']);
894
+ const CONFIG_CONSTRUCTORS = new Set(['URL', 'RegExp', 'Set', 'Map', 'Date']);
895
+ const CONFIG_NAME_NODES = new Set(['identifier', 'shorthand_property_identifier', 'shorthand_property_identifier_pattern']);
896
+
897
+ /** The value of a string literal node (no substitutions), or null. */
898
+ function stringLiteralValue(node) {
899
+ if (!node || (node.type !== 'string' && node.type !== 'template_string')) return null;
900
+ if (node.namedChildren.some(child => child.type === 'template_substitution')) return null;
901
+ const escapes = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', v: '\v', 0: '\0' };
902
+ return node.text.slice(1, -1).replace(/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])/g, (whole, escape) => {
903
+ if (escape[0] === 'u' || escape[0] === 'x') {
904
+ const code = parseInt(escape.replace(/^[ux]\{?|\}$/g, ''), 16);
905
+ return Number.isNaN(code) ? whole : String.fromCodePoint(code);
906
+ }
907
+ return Object.prototype.hasOwnProperty.call(escapes, escape) ? escapes[escape] : escape;
908
+ });
909
+ }
910
+
911
+ /** JSON text with its comments taken out; what a string holds is left as it is. */
912
+ function withoutJsonComments(text) {
913
+ let out = '';
914
+ let i = 0;
915
+ while (i < text.length) {
916
+ const ch = text[i];
917
+ if (ch === '"') {
918
+ let end = i + 1;
919
+ while (end < text.length && text[end] !== '"') end += text[end] === '\\' ? 2 : 1;
920
+ out += text.slice(i, end + 1);
921
+ i = end + 1;
922
+ } else if (ch === '/' && text[i + 1] === '/') {
923
+ while (i < text.length && text[i] !== '\n') i++;
924
+ } else if (ch === '/' && text[i + 1] === '*') {
925
+ const end = text.indexOf('*/', i + 2);
926
+ i = end === -1 ? text.length : end + 2;
927
+ out += ' ';
928
+ } else {
929
+ out += ch;
930
+ i++;
931
+ }
932
+ }
933
+ return out;
934
+ }
935
+
936
+ /**
937
+ * The keys a configuration sets, each with readers of its value: as
938
+ * strings (null when the value is not a string or a list of strings
939
+ * written out), as an object of keys, and whether it is a list of calls
940
+ * of functions that packages provide. Null when the value is no object.
941
+ */
942
+ function configKeysOfJson(value) {
943
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
944
+ const keys = new Map();
945
+ for (const [key, held] of Object.entries(value)) {
946
+ keys.set(key, {
947
+ strings: () => typeof held === 'string' ? [held]
948
+ : Array.isArray(held) && held.every(item => typeof item === 'string') ? held : null,
949
+ object: () => configKeysOfJson(held),
950
+ packageCalls: () => Array.isArray(held) && held.length === 0,
951
+ });
952
+ }
953
+ return keys;
954
+ }
955
+
956
+ /**
957
+ * The top-level keys of a YAML mapping written as a block, one `key:` to
958
+ * a line at the left margin; their values are not read. Null for every
959
+ * other way of writing one (a flow mapping, indented keys, a merge key, a
960
+ * key that is not a plain or quoted word): what such a file sets is not
961
+ * read.
962
+ */
963
+ function configKeysOfYaml(text) {
964
+ const keys = new Map();
965
+ let started = false;
966
+ for (const line of String(text).split(/\r?\n/)) {
967
+ if (/^\s*(#.*)?$/.test(line)) continue;
968
+ if (!started && keys.size === 0 && /^---\s*(#.*)?$/.test(line)) {
969
+ started = true;
970
+ continue;
971
+ }
972
+ // The value of the key above: indented lines, or a list at the margin.
973
+ if (/^[ \t]/.test(line) || /^-(\s|$)/.test(line)) {
974
+ if (keys.size === 0) return null;
975
+ continue;
976
+ }
977
+ const found = /^([A-Za-z][\w-]*|"[A-Za-z][\w-]*"|'[A-Za-z][\w-]*')\s*:(\s|$)/.exec(line);
978
+ if (!found) return null;
979
+ keys.set(found[1].replace(/^['"]|['"]$/g, ''), { strings: () => null, object: () => null, packageCalls: () => false });
980
+ }
981
+ return keys;
982
+ }
983
+
984
+ /**
985
+ * The keys a configuration written as code sets, when the file is one
986
+ * object written out and nothing else in it can change that object or act
987
+ * on the run. The file may hold only: imports that bind names, variable
988
+ * declarations whose values are built without running code of the file
989
+ * (literals, names, operators, calls of functions that packages and the
990
+ * language provide), type declarations, and ONE export (`module.exports =`
991
+ * or `export default`) of an object literal: written in place, held by a
992
+ * variable that is named nowhere else, or passed through the function the
993
+ * runner's own package provides for that (proved by the import). A second
994
+ * statement that writes the export, a name for the object that is used
995
+ * again, a function of the file that is called, a write anywhere: the file
996
+ * is code whose effect is not read (null).
997
+ */
998
+ function configKeysOfCode(text, typed, runnerId) {
999
+ const tree = safeParse(getParser(typed ? 'typescript' : 'javascript'), text);
1000
+ if (!tree || tree.rootNode.hasError) return null;
1001
+ const root = tree.rootNode;
1002
+ const bindings = new Map(); // local name -> { module, imported } (imported null: the module itself)
1003
+ const declared = new Map(); // local name -> value nodes of its declarators
1004
+ const packaged = name => {
1005
+ const binding = bindings.get(name);
1006
+ return !!binding && !/^\.{0,2}\//.test(binding.module) && !/^[A-Za-z]:[\\/]/.test(binding.module);
1007
+ };
1008
+ const moduleName = binding => binding.module.replace(/^node:/, '');
1009
+ const requiredModule = node => {
1010
+ if (!node || node.type !== 'call_expression') return null;
1011
+ const callee = node.childForFieldName('function');
1012
+ const args = node.childForFieldName('arguments');
1013
+ if (!callee || callee.type !== 'identifier' || callee.text !== 'require' || !args || args.namedChildren.length !== 1) return null;
1014
+ return stringLiteralValue(args.namedChildren[0]);
1015
+ };
1016
+ /** Is a callee a function that a package or the language provides? */
1017
+ const calleeProvided = callee => {
1018
+ if (!callee) return false;
1019
+ if (CONFIG_WRAPPERS.has(callee.type)) return calleeProvided(callee.namedChildren[0]);
1020
+ if (callee.type === 'identifier') {
1021
+ const binding = bindings.get(callee.text);
1022
+ return packaged(callee.text) && !CONFIG_ACTING_MODULES.has(moduleName(binding)) ||
1023
+ (!!binding && packaged(callee.text) && CONFIG_READING_CALLS.has(binding.imported));
1024
+ }
1025
+ if (callee.type === 'call_expression') {
1026
+ // require('path').join, a function a package's function returns
1027
+ return requiredModule(callee) != null ? !CONFIG_ACTING_MODULES.has(requiredModule(callee).replace(/^node:/, ''))
1028
+ : calleeProvided(callee.childForFieldName('function')) && inert(callee.childForFieldName('arguments'));
1029
+ }
1030
+ if (callee.type !== 'member_expression') return false;
1031
+ const property = callee.childForFieldName('property');
1032
+ let base = callee;
1033
+ while (base && (base.type === 'member_expression' || CONFIG_WRAPPERS.has(base.type))) {
1034
+ base = base.type === 'member_expression' ? base.childForFieldName('object') : base.namedChildren[0];
1035
+ }
1036
+ if (!base || !property) return false;
1037
+ if (base.type === 'call_expression') {
1038
+ const required = requiredModule(base);
1039
+ if (required != null) {
1040
+ return !CONFIG_ACTING_MODULES.has(required.replace(/^node:/, '')) || CONFIG_READING_CALLS.has(property.text);
1041
+ }
1042
+ return calleeProvided(base.childForFieldName('function')) && inert(base.childForFieldName('arguments'));
1043
+ }
1044
+ if (base.type === 'meta_property') return true;
1045
+ if (base.type !== 'identifier') return false;
1046
+ if (base.text === 'process') {
1047
+ // process.cwd() and nothing else of the process is called.
1048
+ return sameNodeText(callee.childForFieldName('object'), 'process') && property.text === 'cwd';
1049
+ }
1050
+ if (CONFIG_GLOBAL_NAMESPACES.has(base.text) && !bindings.has(base.text) && !declared.has(base.text)) return true;
1051
+ const binding = bindings.get(base.text);
1052
+ if (!binding || !packaged(base.text)) return false;
1053
+ return !CONFIG_ACTING_MODULES.has(moduleName(binding)) || CONFIG_READING_CALLS.has(property.text);
1054
+ };
1055
+ const sameNodeText = (node, name) => !!node && node.type === 'identifier' && node.text === name;
1056
+ /** Does evaluating an expression run nothing of this file and write nothing? */
1057
+ function inert(node) {
1058
+ if (!node) return true;
1059
+ switch (node.type) {
1060
+ case 'string': case 'number': case 'true': case 'false': case 'null': case 'undefined': case 'regex':
1061
+ case 'identifier': case 'property_identifier': case 'shorthand_property_identifier':
1062
+ case 'private_property_identifier': case 'comment': case 'meta_property': case 'this':
1063
+ case 'string_fragment': case 'escape_sequence': case 'optional_chain':
1064
+ return true;
1065
+ // A function written here is not called here.
1066
+ case 'arrow_function': case 'function_expression': case 'function': case 'generator_function':
1067
+ return true;
1068
+ case 'template_string': case 'template_substitution': case 'parenthesized_expression': case 'array':
1069
+ case 'object': case 'pair': case 'spread_element': case 'binary_expression': case 'ternary_expression':
1070
+ case 'subscript_expression': case 'member_expression': case 'computed_property_name': case 'arguments':
1071
+ case 'as_expression': case 'satisfies_expression': case 'non_null_expression': case 'type_assertion':
1072
+ return node.namedChildren.every(child => /(^|_)type(s|_arguments|_annotation)?$|^type_|_type$/.test(child.type) || inert(child));
1073
+ case 'unary_expression':
1074
+ return node.children[0].type !== 'delete' && node.namedChildren.every(inert);
1075
+ case 'new_expression': {
1076
+ const made = node.childForFieldName('constructor');
1077
+ return !!made && made.type === 'identifier' && CONFIG_CONSTRUCTORS.has(made.text) &&
1078
+ !bindings.has(made.text) && !declared.has(made.text) && inert(node.childForFieldName('arguments'));
1079
+ }
1080
+ case 'call_expression': {
1081
+ if (requiredModule(node) != null) return true;
1082
+ return calleeProvided(node.childForFieldName('function')) && inert(node.childForFieldName('arguments'));
1083
+ }
1084
+ default:
1085
+ return false;
1086
+ }
1087
+ }
1088
+
1089
+ let exported = null;
1090
+ let exports = 0;
1091
+ let commonJs = false;
1092
+ const values = []; // every value a declaration evaluates
1093
+ for (const statement of root.namedChildren) {
1094
+ switch (statement.type) {
1095
+ case 'comment': case 'hash_bang_line': case 'empty_statement':
1096
+ case 'type_alias_declaration': case 'interface_declaration':
1097
+ break;
1098
+ case 'import_statement': {
1099
+ const clause = statement.namedChildren.find(child => child.type === 'import_clause');
1100
+ const source = stringLiteralValue(statement.childForFieldName('source'));
1101
+ // An import for its effect runs code that is not read.
1102
+ if (!clause || source == null) return null;
1103
+ for (const part of clause.namedChildren) {
1104
+ if (part.type === 'identifier') bindings.set(part.text, { module: source, imported: 'default' });
1105
+ else if (part.type === 'namespace_import') {
1106
+ const name = part.namedChildren.find(child => child.type === 'identifier');
1107
+ if (name) bindings.set(name.text, { module: source, imported: null });
1108
+ } else if (part.type === 'named_imports') {
1109
+ for (const specifier of part.namedChildren) {
1110
+ if (specifier.type !== 'import_specifier') continue;
1111
+ const name = specifier.childForFieldName('name');
1112
+ const alias = specifier.childForFieldName('alias');
1113
+ if (name) bindings.set((alias || name).text, { module: source, imported: name.text });
1114
+ }
1115
+ }
1116
+ }
1117
+ break;
1118
+ }
1119
+ case 'lexical_declaration': case 'variable_declaration': {
1120
+ for (const declarator of statement.namedChildren) {
1121
+ if (declarator.type !== 'variable_declarator') return null;
1122
+ const target = declarator.childForFieldName('name');
1123
+ const value = declarator.childForFieldName('value');
1124
+ const required = requiredModule(value);
1125
+ if (target && target.type === 'identifier') {
1126
+ if (required != null) bindings.set(target.text, { module: required, imported: null });
1127
+ else {
1128
+ if (!declared.has(target.text)) declared.set(target.text, []);
1129
+ declared.get(target.text).push(value || null);
1130
+ if (value) values.push(value);
1131
+ }
1132
+ } else if (target && target.type === 'object_pattern' && required != null) {
1133
+ for (const part of target.namedChildren) {
1134
+ if (part.type === 'shorthand_property_identifier_pattern') {
1135
+ bindings.set(part.text, { module: required, imported: part.text });
1136
+ } else if (part.type === 'pair_pattern') {
1137
+ const key = part.childForFieldName('key');
1138
+ const local = part.childForFieldName('value');
1139
+ if (!key || !local || local.type !== 'identifier' || key.type !== 'property_identifier') return null;
1140
+ bindings.set(local.text, { module: required, imported: key.text });
1141
+ } else {
1142
+ return null;
1143
+ }
1144
+ }
1145
+ } else {
1146
+ return null;
1147
+ }
1148
+ }
1149
+ break;
1150
+ }
1151
+ case 'expression_statement': {
1152
+ const expression = statement.namedChildren[0];
1153
+ // A directive ("use strict").
1154
+ if (expression && expression.type === 'string') break;
1155
+ if (!expression || expression.type !== 'assignment_expression') return null;
1156
+ const left = expression.childForFieldName('left');
1157
+ if (!left || left.type !== 'member_expression' || !sameNodeText(left.childForFieldName('object'), 'module') ||
1158
+ left.childForFieldName('property')?.text !== 'exports') return null;
1159
+ exported = expression.childForFieldName('right');
1160
+ commonJs = true;
1161
+ exports++;
1162
+ break;
1163
+ }
1164
+ case 'export_statement': {
1165
+ if (!statement.children.some(child => child.type === 'default')) return null;
1166
+ exported = statement.childForFieldName('value');
1167
+ exports++;
1168
+ break;
1169
+ }
1170
+ default:
1171
+ return null;
1172
+ }
1173
+ }
1174
+ if (exports !== 1 || !exported) return null;
1175
+ if (!values.every(inert)) return null;
1176
+ // The names that reach the exported object: each is used exactly as
1177
+ // often as reading it once needs.
1178
+ const uses = new Map();
1179
+ const count = node => {
1180
+ if (CONFIG_NAME_NODES.has(node.type)) uses.set(node.text, (uses.get(node.text) || 0) + 1);
1181
+ for (const child of node.namedChildren) count(child);
1182
+ };
1183
+ count(root);
1184
+ if ((uses.get('module') || 0) !== (commonJs ? 1 : 0) || uses.has('exports') || uses.has('eval') ||
1185
+ uses.has('globalThis') || uses.has('global')) return null;
1186
+ // `require` only as the call of one written-out module name.
1187
+ let requires = 0;
1188
+ const countRequires = node => {
1189
+ if (requiredModule(node) != null) requires++;
1190
+ for (const child of node.namedChildren) countRequires(child);
1191
+ };
1192
+ countRequires(root);
1193
+ if ((uses.get('require') || 0) !== requires) return null;
1194
+ const identity = CONFIG_IDENTITY[runnerId];
1195
+ const objectOf = (node, depth = 0) => {
1196
+ if (!node || depth > 6) return null;
1197
+ if (CONFIG_WRAPPERS.has(node.type)) return objectOf(node.namedChildren[0], depth + 1);
1198
+ if (node.type === 'object') return node;
1199
+ if (node.type === 'identifier') {
1200
+ // One declaration, and this the only other place it is named.
1201
+ const held = declared.get(node.text);
1202
+ if (!held || held.length !== 1 || bindings.has(node.text) || uses.get(node.text) !== 2) return null;
1203
+ return objectOf(held[0], depth + 1);
1204
+ }
1205
+ if (node.type === 'call_expression' && identity) {
1206
+ const callee = node.childForFieldName('function');
1207
+ const args = node.childForFieldName('arguments');
1208
+ const binding = callee && callee.type === 'identifier' && !declared.has(callee.text) ? bindings.get(callee.text) : null;
1209
+ if (binding && identity.modules.has(binding.module) && identity.names.has(binding.imported) &&
1210
+ args && args.namedChildren.length === 1) return objectOf(args.namedChildren[0], depth + 1);
1211
+ }
1212
+ return null;
1213
+ };
1214
+ const fromObject = node => {
1215
+ const object = objectOf(node);
1216
+ if (!object) return null;
1217
+ const keys = new Map();
1218
+ for (const member of object.namedChildren) {
1219
+ if (member.type === 'comment') continue;
1220
+ if (member.type !== 'pair') return null;
1221
+ const key = member.childForFieldName('key');
1222
+ const value = member.childForFieldName('value');
1223
+ const written = !key ? null : key.type === 'property_identifier' ? key.text : stringLiteralValue(key);
1224
+ if (written == null || written === '__proto__' || !inert(value)) return null;
1225
+ keys.set(written, {
1226
+ strings: () => {
1227
+ const one = stringLiteralValue(value);
1228
+ if (one != null) return [one];
1229
+ if (!value || value.type !== 'array') return null;
1230
+ const all = value.namedChildren.filter(item => item.type !== 'comment').map(stringLiteralValue);
1231
+ return all.every(item => item != null) ? all : null;
1232
+ },
1233
+ object: () => fromObject(value),
1234
+ // A list of what functions of packages return (plugins).
1235
+ packageCalls: () => !!value && value.type === 'array' &&
1236
+ value.namedChildren.every(item => item.type === 'comment' ||
1237
+ (item.type === 'call_expression' && requiredModule(item) == null && inert(item))),
1238
+ });
1239
+ }
1240
+ return keys;
1241
+ };
1242
+ return fromObject(exported);
1243
+ }
1244
+
1245
+ function configKeysOf(model, dir, name, runnerId) {
1246
+ const text = model.manifests.read(dir, name);
1247
+ if (text == null) return null;
1248
+ if (/\.(json|jsonc)$/.test(name)) {
1249
+ try {
1250
+ return configKeysOfJson(JSON.parse(withoutJsonComments(text)));
1251
+ } catch (e) {
1252
+ return null;
1253
+ }
1254
+ }
1255
+ if (/\.(yaml|yml)$/.test(name)) return configKeysOfYaml(text);
1256
+ if (!/\.[cm]?[jt]s$/.test(name)) return null;
1257
+ return configKeysOfCode(text, /\.[cm]?ts$/.test(name), runnerId);
1258
+ }
1259
+
1260
+ /**
1261
+ * What a JavaScript runner's configuration does to a command that names a
1262
+ * test file: `inert` (every key it sets is known to leave the selection
1263
+ * alone), `modeled` (`accepts(file)` says whether the configuration
1264
+ * leaves the file in; `ownPattern`: it replaces the runner's default file
1265
+ * pattern), or `unread` (a key that may select is set and not modeled, the
1266
+ * configuration is code that is not one written-out object, or one stands
1267
+ * above the runner's directory).
1268
+ */
1269
+ function jsRunnerConfig(model, runner) {
1270
+ const memoKey = `${runner.id}\0${runner.dir || ''}`;
1271
+ if (model.jsConfigs.has(memoKey)) return model.jsConfigs.get(memoKey);
1272
+ const answer = (() => {
1273
+ // The project's own script points the runner somewhere else.
1274
+ if (runnerScriptWords(runner, runner.id).some(word => SCRIPT_CONFIG_FLAGS.test(word))) return { state: 'unread' };
1275
+ const names = JS_CONFIG_NAMES[runner.id];
1276
+ const packageKey = JS_CONFIG_PACKAGE_KEY[runner.id];
1277
+ const here = runner.dir || '';
1278
+ for (const dir of ancestorDirs(here ? `${here}/x` : 'x').slice(1)) {
1279
+ if (model.manifests.list(dir).some(name => names.has(name))) return { state: 'unread' };
1280
+ const above = packageKey ? model.manifests.json(dir, 'package.json') : null;
1281
+ if (above && above[packageKey] != null) return { state: 'unread' };
1282
+ }
1283
+ let files = model.manifests.list(here).filter(name => names.has(name));
1284
+ if (runner.id === 'vitest') {
1285
+ if (files.some(name => /^vitest\.(workspace|projects)\./.test(name))) return { state: 'unread' };
1286
+ // vitest's own file is the one read when both are there.
1287
+ if (files.some(name => /^vitest\.config\./.test(name))) files = files.filter(name => /^vitest\.config\./.test(name));
1288
+ }
1289
+ const sources = files.map(name => configKeysOf(model, here, name, runner.id));
1290
+ const manifest = packageKey ? model.manifests.json(here, 'package.json') : null;
1291
+ if (manifest && manifest[packageKey] != null) {
1292
+ const held = manifest[packageKey];
1293
+ sources.push(configKeysOfJson(held));
1294
+ }
1295
+ if (sources.length === 0) return { state: 'inert' };
1296
+ if (sources.some(source => source == null) || (runner.id !== 'mocha' && sources.length > 1)) return { state: 'unread' };
1297
+ let keys = new Map();
1298
+ for (const source of sources) for (const [key, reader] of source) keys.set(key, reader);
1299
+ if (runner.id === 'vitest') {
1300
+ // The runner's part of a vite configuration is its `test` key;
1301
+ // a plugin can rewrite the configuration, so each one has to
1302
+ // be what a package's function returns.
1303
+ for (const [key, reader] of keys) {
1304
+ if (key === 'test' || VITE_INERT_KEYS.has(key)) continue;
1305
+ if (key === 'plugins' && reader.packageCalls()) continue;
1306
+ return { state: 'unread' };
1307
+ }
1308
+ const test = keys.get('test');
1309
+ if (!test) return { state: 'inert' };
1310
+ keys = test.object();
1311
+ if (!keys) return { state: 'unread' };
1312
+ }
1313
+ const fold = key => runner.id === 'mocha' ? key.replace(/-/g, '').toLowerCase() : key;
1314
+ const selecting = [...keys.keys()].filter(key => !JS_INERT_KEYS[runner.id].has(fold(key)));
1315
+ if (selecting.length === 0) return { state: 'inert', keys: new Set(keys.keys()) };
1316
+ if (!selecting.every(key => JS_MODELED_KEYS[runner.id].has(key))) return { state: 'unread' };
1317
+ const absolute = file => path.posix.join(String(model.root).replace(/\\/g, '/'), here, file);
1318
+ if (runner.id === 'jest') {
1319
+ const read = key => keys.has(key) ? keys.get(key).strings() : undefined;
1320
+ const roots = read('roots');
1321
+ const regex = read('testRegex');
1322
+ const ignored = read('testPathIgnorePatterns');
1323
+ if (roots === null || regex === null || ignored === null) return { state: 'unread' };
1324
+ let patterns;
1325
+ let ignores;
1326
+ const base = absolute('').replace(/\/$/, '');
1327
+ // jest puts the root directory where a pattern says <rootDir>.
1328
+ const pattern = source => new RegExp(source.replace(/<rootDir>/g, base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
1329
+ try {
1330
+ patterns = regex ? regex.map(pattern) : null;
1331
+ ignores = (ignored || ['/node_modules/']).map(pattern);
1332
+ } catch (e) {
1333
+ return { state: 'unread' };
1334
+ }
1335
+ const rootDirs = roots ? roots.map(root => path.posix.join(base, root.replace(/^<rootDir>\/?/, ''))) : null;
1336
+ if (roots && roots.some(root => path.posix.isAbsolute(root.replace(/^<rootDir>/, '')) && !root.startsWith('<rootDir>'))) {
1337
+ return { state: 'unread' };
1338
+ }
1339
+ return {
1340
+ state: 'modeled', ownPattern: !!patterns,
1341
+ accepts: file => {
1342
+ const full = absolute(file);
1343
+ if (rootDirs && !rootDirs.some(dir => full.startsWith(dir.replace(/\/$/, '') + '/'))) return false;
1344
+ if (ignores.some(pattern => pattern.test(full))) return false;
1345
+ return !patterns || patterns.some(pattern => pattern.test(full));
1346
+ },
1347
+ };
1348
+ }
1349
+ if (runner.id === 'vitest') {
1350
+ const dir = keys.get('dir').strings();
1351
+ if (!dir || dir.length !== 1 || path.posix.isAbsolute(dir[0]) || dir[0].includes('..')) return { state: 'unread' };
1352
+ const under = dir[0].replace(/^\.\//, '').replace(/\/$/, '');
1353
+ return { state: 'modeled', ownPattern: false, accepts: file => !under || file.startsWith(`${under}/`) };
1354
+ }
1355
+ return { state: 'unread' };
1356
+ })();
1357
+ model.jsConfigs.set(memoKey, answer);
1358
+ return answer;
1359
+ }
1360
+
1361
+ const RUNNER_OF_FAMILY = {
1362
+ javascript: jsRunner,
1363
+ python: pythonRunner,
1364
+ go: goRunner,
1365
+ rust: rustRunner,
1366
+ java: javaRunner,
1367
+ dotnet: dotnetRunner,
1368
+ };
1369
+
1370
+ // ---------------------------------------------------------------------------
1371
+ // Selectors
1372
+ // ---------------------------------------------------------------------------
1373
+
1374
+ function withheld(reason) {
1375
+ return { selector: null, reason };
1376
+ }
1377
+
1378
+ function command(runner, words) {
1379
+ return {
1380
+ selector: {
1381
+ command: words.join(' '),
1382
+ ...(runner.dir && { cwd: runner.dir }),
1383
+ },
1384
+ };
1385
+ }
1386
+
1387
+ // The files jest and vitest look for when the project configures nothing.
1388
+ // Neither runs a file outside its pattern, even when given the path
1389
+ // (measured): for such a file the command depends on a configuration that
1390
+ // is not read here.
1391
+ const DEFAULT_TEST_FILES = {
1392
+ jest: [/(^|\/)__tests__\/.*\.[jt]sx?$/, /(^|\/)([^/]*\.)?(spec|test)+\.[jt]sx?$/],
1393
+ vitest: [/\.(test|spec)\.[cm]?[jt]sx?$/],
1394
+ };
1395
+
1396
+ function lookedFor(runnerId, relativePath) {
1397
+ return DEFAULT_TEST_FILES[runnerId].some(pattern => pattern.test(relativePath));
1398
+ }
1399
+
1400
+ function titlePattern(unit) {
1401
+ return `^${regexLiteral([...unit.suite, unit.name].join(' '))}$`;
1402
+ }
1403
+
1404
+ const SELECTORS = {
1405
+ 'pytest'(model, runner, fileEntry, unit) {
1406
+ const file = relativeTo(runner.dir, fileEntry.relativePath);
1407
+ if (file == null) return withheld('outside-runner-root');
1408
+ // An option of the project's that selects, adds or leaves out
1409
+ // tests and is not modeled: no command is known to be exact.
1410
+ const options = runner.collection && runner.collection.options;
1411
+ if (options && options.unread) return withheld('runner-options-not-read');
1412
+ if (unit && unit.deselected) return withheld('deselected-by-options');
1413
+ if (!unit) return command(runner, ['pytest', shellWord(file)]);
1414
+ return command(runner, ['pytest', shellWord([file, ...unit.suite, unit.name].join('::'))]);
1415
+ },
1416
+ 'node-test'(model, runner, fileEntry, unit, filePath) {
1417
+ const file = relativeTo(runner.dir, fileEntry.relativePath);
1418
+ if (file == null) return withheld('outside-runner-root');
1419
+ const options = runner.evidence === 'package-script' || runner.script
1420
+ ? nodeTestOptions(runner.script) : [];
1421
+ const typed = langTraits(fileEntry.language)?.typeSystem === 'structural' &&
1422
+ /\.[cm]?tsx?$/.test(fileEntry.relativePath);
1423
+ // A typed file runs only with what the project's own command loads.
1424
+ if (typed && (!options || options.length === 0)) return withheld('loader-unknown');
1425
+ const words = ['node', '--test', ...(options || []).map(shellWord)];
1426
+ if (unit) {
1427
+ // node tries a pattern on a test's own name and on its full
1428
+ // path (ancestors joined by spaces), and a matching suite runs
1429
+ // all it holds: the full path selects one test unless another
1430
+ // name in the file reads the same.
1431
+ const full = [...unit.suite, unit.name].join(' ');
1432
+ const key = [...unit.suite, unit.name].join('\0');
1433
+ const clash = model.unitsOf(filePath).units.some(other => other !== unit && other.name != null &&
1434
+ (other.name === full || [...other.suite, other.name].join(' ') === full) &&
1435
+ [...other.suite, other.name].join('\0') !== key);
1436
+ if (clash) return withheld('name-not-unique');
1437
+ words.push(`--test-name-pattern=${shellWord(`^${regexLiteral(full)}$`)}`);
1438
+ }
1439
+ words.push(shellWord(file));
1440
+ return command(runner, words);
1441
+ },
1442
+ 'jest'(model, runner, fileEntry, unit) {
1443
+ const file = relativeTo(runner.dir, fileEntry.relativePath);
1444
+ if (file == null) return withheld('outside-runner-root');
1445
+ // jest holds a path it is given to its configuration: the file
1446
+ // pattern, the roots, the ignored paths (measured).
1447
+ const config = jsRunnerConfig(model, runner);
1448
+ if (config.state === 'unread') return withheld('runner-config-not-read');
1449
+ if (config.state === 'modeled' && !config.accepts(file)) return withheld('outside-runner-config');
1450
+ if (!(config.state === 'modeled' && config.ownPattern) && !lookedFor('jest', file)) {
1451
+ return withheld('runner-config-not-read');
1452
+ }
1453
+ // A bare path is a pattern jest tries on every test file's path.
1454
+ const words = ['npx', 'jest', '--runTestsByPath', shellWord(file)];
1455
+ if (unit) words.push('-t', shellWord(titlePattern(unit)));
1456
+ return command(runner, words);
1457
+ },
1458
+ 'vitest'(model, runner, fileEntry, unit, filePath) {
1459
+ const file = relativeTo(runner.dir, fileEntry.relativePath);
1460
+ if (file == null) return withheld('outside-runner-root');
1461
+ const config = jsRunnerConfig(model, runner);
1462
+ if (config.state === 'unread') return withheld('runner-config-not-read');
1463
+ if (config.state === 'modeled' && !config.accepts(file)) return withheld('outside-runner-config');
1464
+ if (!lookedFor('vitest', file)) return withheld('runner-config-not-read');
1465
+ // vitest runs every file whose path contains the one given.
1466
+ if (model.pathsContaining(runner.dir, file) > 1) return withheld('path-not-unique');
1467
+ // vitest refuses a focused test where CI is set unless told to
1468
+ // allow it (measured).
1469
+ const focus = model.declared(filePath)?.focus ? ['--allowOnly'] : [];
1470
+ const words = ['npx', 'vitest', 'run', ...focus, shellWord(file)];
1471
+ if (unit) words.push('-t', shellWord(titlePattern(unit)));
1472
+ return command(runner, words);
1473
+ },
1474
+ 'mocha'(model, runner, fileEntry, unit) {
1475
+ const file = relativeTo(runner.dir, fileEntry.relativePath);
1476
+ if (file == null) return withheld('outside-runner-root');
1477
+ // mocha adds the files its configuration names to the one given,
1478
+ // and applies its grep and ignore to it (measured).
1479
+ const config = jsRunnerConfig(model, runner);
1480
+ if (config.state !== 'inert') return withheld('runner-config-not-read');
1481
+ const loading = mochaLoadingOptions(runner);
1482
+ // A typed file loads only with what the project's own command or
1483
+ // configuration registers.
1484
+ const registers = loading.length > 0 || ['require', 'loader', 'import', 'node-option'].some(key => config.keys && config.keys.has(key));
1485
+ if (/\.[cm]?tsx?$/.test(file) && !registers) return withheld('loader-unknown');
1486
+ const words = ['npx', 'mocha', ...loading.map(shellWord), shellWord(file)];
1487
+ if (unit) words.push('--grep', shellWord(titlePattern(unit)));
1488
+ return command(runner, words);
1489
+ },
1490
+ 'go-test'(model, runner, fileEntry, unit) {
1491
+ const file = relativeTo(runner.dir, fileEntry.relativePath);
1492
+ if (file == null) return withheld('outside-runner-root');
1493
+ if (fileEntry.buildConstraint || (unit && unit.notInEveryBuild)) return withheld('build-constraint');
1494
+ const dir = posixDir(file);
1495
+ const pkg = dir ? `./${dir}` : '.';
1496
+ if (!unit) return command(runner, ['go', 'test', shellWord(pkg)]);
1497
+ return command(runner, ['go', 'test', shellWord(pkg), '-run', shellWord(`^${regexLiteral(unit.name)}$`)]);
1498
+ },
1499
+ 'cargo-test'(model, runner, fileEntry, unit) {
1500
+ if (runner.customTargets) return withheld('custom-targets');
1501
+ const located = model.rustTarget(runner, fileEntry, unit ? unit.line : 1);
1502
+ if (!located.target) return withheld(located.reason);
1503
+ const words = ['cargo', 'test', ...located.target];
1504
+ if (!unit) {
1505
+ if (located.segs.length > 0) words.push('--', shellWord(`${located.segs.join('::')}::`));
1506
+ return command(runner, words);
1507
+ }
1508
+ if (unit.notInEveryBuild) return withheld('conditional-compilation');
1509
+ words.push('--', shellWord([...located.segs, unit.name].join('::')), '--exact');
1510
+ if (unit.ignored) words.push('--include-ignored');
1511
+ return command(runner, words);
1512
+ },
1513
+ 'maven'(model, runner, fileEntry, unit, filePath) {
1514
+ if (!unit) {
1515
+ const classes = topLevelTestClasses(model, filePath);
1516
+ if (classes.length === 0) return withheld('no-test-class');
1517
+ return command(runner, ['mvn', 'test', shellWord(`-Dtest=${classes.join(',')}`)]);
1518
+ }
1519
+ const owner = `${unit.namespace ? unit.namespace + '.' : ''}${unit.suite.join('$')}`;
1520
+ return command(runner, ['mvn', 'test', shellWord(`-Dtest=${owner}#${unit.name}`)]);
1521
+ },
1522
+ 'dotnet-test'(model, runner, fileEntry, unit, filePath) {
1523
+ // The filter's own operators are escaped; '+' (a nested type) is not one.
1524
+ const quoted = text => String(text).replace(/[()&|=!~\\]/g, '\\$&');
1525
+ if (!unit) {
1526
+ const classes = topLevelTestClasses(model, filePath);
1527
+ if (classes.length === 0) return withheld('no-test-class');
1528
+ // A fixture built once for each set of arguments runs under
1529
+ // names that carry them: no filter by the class's name holds them.
1530
+ if ((model.declared(filePath)?.classes || []).some(cls => cls.dynamic === 'generated-cases')) {
1531
+ return withheld('generated-cases');
1532
+ }
1533
+ const terms = classes.flatMap(name => [`FullyQualifiedName~${quoted(name)}.`, `FullyQualifiedName~${quoted(name)}+`]);
1534
+ return command(runner, ['dotnet', 'test', '--filter', shellWord(terms.join('|'))]);
1535
+ }
1536
+ const owner = `${unit.namespace ? unit.namespace + '.' : ''}${unit.suite.join('+')}`;
1537
+ return command(runner, ['dotnet', 'test', '--filter', shellWord(`FullyQualifiedName=${quoted(`${owner}.${unit.name}`)}`)]);
1538
+ },
1539
+ };
1540
+
1541
+ /** Qualified names of the outermost classes of a file that hold tests. */
1542
+ function topLevelTestClasses(model, filePath) {
1543
+ const names = new Set();
1544
+ for (const unit of model.unitsOf(filePath).units) {
1545
+ if (unit.kind !== 'test' || unit.suite.length === 0) continue;
1546
+ names.add(`${unit.namespace ? unit.namespace + '.' : ''}${unit.suite[0]}`);
1547
+ }
1548
+ return [...names].sort(codeUnitCompare);
1549
+ }
1550
+
1551
+ // ---------------------------------------------------------------------------
1552
+ // The model
1553
+ // ---------------------------------------------------------------------------
1554
+
1555
+ /**
1556
+ * @param {object} index - ProjectIndex
1557
+ * @returns {object} per-operation test model
1558
+ */
1559
+ function createTestModel(index, options = {}) {
1560
+ const model = {
1561
+ manifests: createManifests(index),
1562
+ byFile: new Map(),
1563
+ rawByFile: new Map(),
1564
+ exports: new Map(),
1565
+ runners: new Map(),
1566
+ jsConfigs: new Map(),
1567
+ gates: new Map(),
1568
+ plugins: new Map(),
1569
+ guards: new Map(),
1570
+ foreign: new Map(),
1571
+ ranges: new Map(),
1572
+ readerCaches: new Map(),
1573
+ root: index.root,
1574
+ relPaths: null,
1575
+ };
1576
+
1577
+ model.hasFile = relativePath => {
1578
+ if (!model.relPaths) {
1579
+ model.relPaths = new Set();
1580
+ for (const [, entry] of index.files) model.relPaths.add(entry.relativePath);
1581
+ }
1582
+ return model.relPaths.has(relativePath);
1583
+ };
1584
+
1585
+ /** How many indexed files under `dir` have a path that contains `part`. */
1586
+ model.pathsContaining = (dir, part) => {
1587
+ model.hasFile('');
1588
+ let count = 0;
1589
+ for (const relativePath of model.relPaths) {
1590
+ const inside = relativeTo(dir, relativePath);
1591
+ if (inside != null && inside.includes(part)) count++;
1592
+ }
1593
+ return count;
1594
+ };
1595
+
1596
+ model.runnerOf = filePath => {
1597
+ if (model.runners.has(filePath)) return model.runners.get(filePath);
1598
+ const fileEntry = index.files.get(filePath);
1599
+ const family = fileEntry && langTraits(fileEntry.language)?.testRunnerFamily;
1600
+ const find = family && RUNNER_OF_FAMILY[family];
1601
+ const runner = find ? find(model, fileEntry) : { id: null, reason: 'runner-not-modeled' };
1602
+ model.runners.set(filePath, runner);
1603
+ return runner;
1604
+ };
1605
+
1606
+ /**
1607
+ * The cargo target that compiles a file and the module path of a line
1608
+ * in it, from the module tree: `--lib`, `--bin NAME` or `--test NAME`.
1609
+ */
1610
+ model.rustTarget = (runner, fileEntry, line) => {
1611
+ const { rustModulePathAt } = require('./rust-modules');
1612
+ const filePath = path.join(index.root, fileEntry.relativePath);
1613
+ const located = rustModulePathAt(index, index.files.has(filePath) ? filePath : fileEntry.path || filePath, line);
1614
+ if (!located) return { target: null, reason: 'target-unknown' };
1615
+ const rootEntry = index.files.get(located.root);
1616
+ const rootRel = rootEntry && relativeTo(runner.dir, rootEntry.relativePath);
1617
+ if (rootRel == null) return { target: null, reason: 'target-unknown' };
1618
+ const segs = located.segs;
1619
+ if (rootRel === 'src/lib.rs') return { target: ['--lib'], segs };
1620
+ if (rootRel === 'src/main.rs') {
1621
+ return runner.packageName
1622
+ ? { target: ['--bin', shellWord(runner.packageName)], segs }
1623
+ : { target: null, reason: 'target-unknown' };
1624
+ }
1625
+ let match = /^src\/bin\/([^/]+)\.rs$/.exec(rootRel) || /^src\/bin\/([^/]+)\/main\.rs$/.exec(rootRel);
1626
+ if (match) return { target: ['--bin', shellWord(match[1])], segs };
1627
+ match = /^tests\/([^/]+)\.rs$/.exec(rootRel) || /^tests\/([^/]+)\/main\.rs$/.exec(rootRel);
1628
+ if (match) return { target: ['--test', shellWord(match[1])], segs };
1629
+ return { target: null, reason: 'target-unknown' };
1630
+ };
1631
+
1632
+ const helpersFor = (filePath, fileEntry, depth = 0) => {
1633
+ const symbols = fileEntry.symbols || [];
1634
+ let lines = null;
1635
+ return {
1636
+ /**
1637
+ * What a module this file imports exports under a name, when
1638
+ * the module is a file of the project whose language reads
1639
+ * exports: followed a few modules deep, never around a cycle.
1640
+ */
1641
+ exported(moduleName, name) {
1642
+ if (depth >= 4) return null;
1643
+ const relativePath = (fileEntry.moduleResolved || {})[moduleName];
1644
+ if (!relativePath) return null;
1645
+ const target = path.join(index.root, relativePath);
1646
+ const targetEntry = index.files.get(target);
1647
+ const read = targetEntry && langTraits(targetEntry.language)?.testExport;
1648
+ if (typeof read !== 'function') return null;
1649
+ const key = `${target}\0${name}`;
1650
+ if (model.exports.has(key)) return model.exports.get(key);
1651
+ model.exports.set(key, null);
1652
+ let answer;
1653
+ try {
1654
+ answer = read(targetEntry, helpersFor(target, targetEntry, depth + 1), name);
1655
+ } catch (e) {
1656
+ answer = null;
1657
+ }
1658
+ model.exports.set(key, answer);
1659
+ return answer;
1660
+ },
1661
+ /** Symbols whose range holds this one, outermost first. */
1662
+ enclosing(symbol) {
1663
+ const out = [];
1664
+ for (const other of symbols) {
1665
+ if (other === symbol) continue;
1666
+ const end = other.endLine ?? other.startLine;
1667
+ const ownEnd = symbol.endLine ?? symbol.startLine;
1668
+ if (other.startLine > symbol.startLine || end < ownEnd) continue;
1669
+ if (other.startLine === symbol.startLine && end === ownEnd) {
1670
+ // Same lines: the holder is the one named as the owner.
1671
+ if (!(CONTAINER_KINDS.has(other.type) && (symbol.className === other.name ||
1672
+ symbol.enclosingType === other.name))) continue;
1673
+ }
1674
+ if (CONTAINER_KINDS.has(other.type) || CALLABLE_KINDS.has(other.type)) out.push(other);
1675
+ }
1676
+ return out.sort((a, b) => a.startLine - b.startLine ||
1677
+ (b.endLine ?? b.startLine) - (a.endLine ?? a.startLine));
1678
+ },
1679
+ /**
1680
+ * The annotations a type of the project carries itself, when
1681
+ * one type of that name is declared: what an annotation of
1682
+ * the project stands for. Null otherwise.
1683
+ */
1684
+ metaAnnotations(name) {
1685
+ const defs = (index.symbols.get(String(name).split('.').pop()) || [])
1686
+ .filter(def => CONTAINER_KINDS.has(def.type));
1687
+ if (defs.length !== 1) return null;
1688
+ return (defs[0].annotationsWithArgs || []).map(annotation => String(annotation.name).split('.').pop());
1689
+ },
1690
+ /** The types of this name the project declares: the namespace (package) and file of each. */
1691
+ typesNamed: name => (index.symbols.get(String(name).replace(/[<[(].*$/, '').split('.').pop().trim()) || [])
1692
+ .filter(def => CONTAINER_KINDS.has(def.type))
1693
+ .map(def => ({ namespace: def.namespace || '', file: index.files.get(def.file)?.relativePath || null })),
1694
+ /** Does the project declare a type of this name? */
1695
+ declaresType: name => (index.symbols.get(String(name).replace(/[<[(].*$/, '').split('.').pop().trim()) || [])
1696
+ .some(def => CONTAINER_KINDS.has(def.type)),
1697
+ isContainer: symbol => CONTAINER_KINDS.has(symbol.type),
1698
+ isCallable: symbol => CALLABLE_KINDS.has(symbol.type),
1699
+ /** Names of the types a class derives from, through the project. */
1700
+ ancestors(className) {
1701
+ const seen = new Set();
1702
+ const queue = [className];
1703
+ while (queue.length > 0 && seen.size < 64) {
1704
+ const parents = index.extendsGraph?.get(queue.shift()) || [];
1705
+ // One entry per declaration of the name, each with
1706
+ // the bases it writes.
1707
+ const names = parents.flatMap(parent => typeof parent === 'string' ? [parent]
1708
+ : Array.isArray(parent?.parents) ? parent.parents : parent?.name ? [parent.name] : []);
1709
+ for (const written of names) {
1710
+ const name = String(written).replace(/[<[(].*$/, '').trim();
1711
+ if (!name || seen.has(name)) continue;
1712
+ seen.add(name);
1713
+ queue.push(name);
1714
+ // A written `pkg.Base` is also known by its last name.
1715
+ const last = name.split('.').pop();
1716
+ if (last !== name && !seen.has(last)) { seen.add(last); queue.push(last); }
1717
+ }
1718
+ }
1719
+ return seen;
1720
+ },
1721
+ tree() {
1722
+ try {
1723
+ const content = index._readFile(filePath);
1724
+ return index._getParsedTree(filePath, content, fileEntry.language) ||
1725
+ safeParse(getParser(fileEntry.language), content) || null;
1726
+ } catch (e) {
1727
+ return null;
1728
+ }
1729
+ },
1730
+ lines() {
1731
+ if (!lines) {
1732
+ try {
1733
+ lines = index._readFile(filePath).split('\n');
1734
+ } catch (e) {
1735
+ lines = [];
1736
+ }
1737
+ }
1738
+ return lines;
1739
+ },
1740
+ collection: collectionFor(filePath, fileEntry),
1741
+ glob: globMatches,
1742
+ // What a reader keeps between two questions about one file.
1743
+ cache: (() => {
1744
+ if (!model.readerCaches.has(filePath)) model.readerCaches.set(filePath, new Map());
1745
+ return model.readerCaches.get(filePath);
1746
+ })(),
1747
+ };
1748
+ };
1749
+
1750
+ /**
1751
+ * What the runner's configuration says about collecting a file: its
1752
+ * naming rules, its options, and whether the runner collects a module
1753
+ * of this name at all (pytest: python_files, by the file's own name).
1754
+ */
1755
+ const collectionFor = (filePath, fileEntry) => {
1756
+ const runner = model.runnerOf(filePath) || {};
1757
+ const collection = runner.collection || {};
1758
+ if (runner.id !== 'pytest') return collection;
1759
+ const base = path.posix.basename(fileEntry.relativePath);
1760
+ const patterns = collection.files && collection.files.length > 0 ? collection.files : ['test_*.py', '*_test.py'];
1761
+ return { ...collection, collected: patterns.some(glob => globMatches(glob, base)) };
1762
+ };
1763
+
1764
+ /** What the language's reader finds in a file; null when not modeled. */
1765
+ model.declared = filePath => {
1766
+ if (model.rawByFile.has(filePath)) return model.rawByFile.get(filePath);
1767
+ const fileEntry = index.files.get(filePath);
1768
+ const read = fileEntry && langTraits(fileEntry.language)?.testUnits;
1769
+ let found = null;
1770
+ if (typeof read === 'function') {
1771
+ try {
1772
+ found = read(fileEntry, helpersFor(filePath, fileEntry));
1773
+ } catch (e) {
1774
+ found = null;
1775
+ }
1776
+ }
1777
+ model.rawByFile.set(filePath, found);
1778
+ return found;
1779
+ };
1780
+
1781
+ /**
1782
+ * The class a base name written in `filePath` denotes: one declared in
1783
+ * the same file, else the only one of that name in files of the same
1784
+ * runner family. Null when none or several.
1785
+ */
1786
+ const baseClass = (filePath, written) => {
1787
+ const name = String(written).replace(/<.*$/, '').split('.').pop().trim();
1788
+ if (!name) return null;
1789
+ const own = (model.declared(filePath)?.classes || []).filter(cls => cls.name === name);
1790
+ if (own.length === 1) return { filePath, cls: own[0] };
1791
+ if (own.length > 1) return null;
1792
+ const family = langTraits(index.files.get(filePath)?.language)?.testRunnerFamily;
1793
+ const hits = [];
1794
+ const seenFiles = new Set();
1795
+ for (const def of index.symbols.get(name) || []) {
1796
+ if (!CONTAINER_KINDS.has(def.type) || seenFiles.has(def.file)) continue;
1797
+ if (langTraits(index.files.get(def.file)?.language)?.testRunnerFamily !== family) continue;
1798
+ seenFiles.add(def.file);
1799
+ for (const cls of model.declared(def.file)?.classes || []) {
1800
+ if (cls.name === name) hits.push({ filePath: def.file, cls });
1801
+ }
1802
+ }
1803
+ return hits.length === 1 ? hits[0] : null;
1804
+ };
1805
+
1806
+ // What a written type name denotes and whether two methods are one
1807
+ // member, by the language's lookup where it is modeled.
1808
+ const signatures = createSignatures(index, {
1809
+ classes: filePath => model.declared(filePath)?.classes || [],
1810
+ parents: (filePath, cls) => parentsOf(filePath, cls),
1811
+ aliasTerm: (filePath, line) => ((model.declared(filePath)?.aliases || []).find(alias => alias.line === line) || {}).term || null,
1812
+ // Files one runner's manifest builds are compiled together.
1813
+ unitOf: filePath => {
1814
+ const runner = model.runnerOf(filePath);
1815
+ return runner && runner.id && runner.dir != null ? `${runner.id}\0${runner.dir}` : null;
1816
+ },
1817
+ });
1818
+ const parentMemo = new Map();
1819
+ const resolving = new Set();
1820
+ /**
1821
+ * The class each base written on a class denotes, in the order
1822
+ * written. A language whose name lookup is modeled says which class
1823
+ * a name is at the place it is written (a class of the project that
1824
+ * shares the spelling of a type from outside is not it); otherwise the
1825
+ * one class of that name. A base that is no class the project declares
1826
+ * once stands as an opaque entry, `safe` when the language's reader
1827
+ * knows it brings neither a test nor a constructor.
1828
+ */
1829
+ const parentsOf = (filePath, cls) => {
1830
+ const key = `${filePath}\0${cls.startLine}\0${cls.name}`;
1831
+ if (parentMemo.has(key)) return parentMemo.get(key);
1832
+ const opaque = written => ({
1833
+ opaque: true, name: String(written).replace(/[<[(].*$/, '').split('.').pop().trim(),
1834
+ safe: (cls.plainBases || []).includes(written),
1835
+ });
1836
+ // A class whose bases are being resolved inside its own
1837
+ // resolution: the language rejects the cycle.
1838
+ if (resolving.has(key)) return cls.bases.map(written => ({ written, node: opaque(written) }));
1839
+ resolving.add(key);
1840
+ const out = cls.bases.map((written, i) => {
1841
+ const looked = signatures.baseOf(filePath, cls, (cls.baseTerms || [])[i]);
1842
+ const base = looked === undefined ? baseClass(filePath, written) : looked;
1843
+ return { written, node: base || opaque(written) };
1844
+ });
1845
+ resolving.delete(key);
1846
+ parentMemo.set(key, out);
1847
+ return out;
1848
+ };
1849
+
1850
+ const nodeKey = node => node.opaque ? `?${node.name}` : `${node.filePath}\0${node.cls.startLine}\0${node.cls.name}`;
1851
+ const linearMemo = new Map();
1852
+
1853
+ /**
1854
+ * The order in which a class's members are looked up: the class, then
1855
+ * the classes it derives from. `c3` is Python's method resolution
1856
+ * order; otherwise depth first in the order written (a superclass
1857
+ * before the interfaces). A base that is no class of the project
1858
+ * stands in the order as an opaque entry (`parentsOf`).
1859
+ */
1860
+ const linearize = (filePath, cls, mode, trail = new Set()) => {
1861
+ const key = `${filePath}\0${cls.startLine}\0${cls.name}`;
1862
+ if (linearMemo.has(key)) return linearMemo.get(key);
1863
+ const self = { filePath, cls };
1864
+ if (trail.has(key)) return [self];
1865
+ if (cls.imported) {
1866
+ // A class bound by an import is the class it names.
1867
+ const order = [self, ...linearize(cls.imported.filePath, cls.imported.cls, mode, trail)];
1868
+ linearMemo.set(key, order);
1869
+ return order;
1870
+ }
1871
+ trail.add(key);
1872
+ const parents = parentsOf(filePath, cls).map(parent => parent.node);
1873
+ const below = parent => parent.opaque ? [parent] : linearize(parent.filePath, parent.cls, mode, trail);
1874
+ let order = [self];
1875
+ if (mode === 'c3') {
1876
+ const sequences = [...parents.map(parent => below(parent).slice()), parents.slice()];
1877
+ while (sequences.some(sequence => sequence.length > 0)) {
1878
+ const head = sequences.filter(sequence => sequence.length > 0).map(sequence => sequence[0])
1879
+ .find(candidate => !sequences.some(other =>
1880
+ other.slice(1).some(node => nodeKey(node) === nodeKey(candidate))));
1881
+ if (!head) {
1882
+ // No consistent order: the language rejects the class.
1883
+ order = [self, { opaque: true, name: '', safe: false }];
1884
+ break;
1885
+ }
1886
+ order.push(head);
1887
+ for (const sequence of sequences) {
1888
+ if (sequence.length > 0 && nodeKey(sequence[0]) === nodeKey(head)) sequence.shift();
1889
+ }
1890
+ }
1891
+ } else {
1892
+ const seen = new Set([key]);
1893
+ for (const parent of parents) {
1894
+ for (const node of below(parent)) {
1895
+ if (seen.has(nodeKey(node))) continue;
1896
+ seen.add(nodeKey(node));
1897
+ order.push(node);
1898
+ }
1899
+ }
1900
+ }
1901
+ trail.delete(key);
1902
+ linearMemo.set(key, order);
1903
+ return order;
1904
+ };
1905
+ const orderOf = (filePath, cls) => linearize(filePath, cls, model.declared(filePath)?.linearization || null);
1906
+
1907
+ /**
1908
+ * Tests a runnable class runs without declaring them: for each
1909
+ * test-shaped name, the first class in lookup order that declares a
1910
+ * member of that name. A test found behind a base that is not read is
1911
+ * not attributed (`withheld`): that base may declare the name itself.
1912
+ *
1913
+ * Which member of a nearer class is "the same member" is the
1914
+ * language's and the runner's to say (`hiddenBy` on a test): by name
1915
+ * where a name is all a member has (Python; and MSTest, which takes
1916
+ * one method of each name), by signature where a language overloads
1917
+ * (a method of the same name with other parameters is another method
1918
+ * and hides nothing), or only a declared override of that signature
1919
+ * (NUnit: a method that hides by `new` leaves the inherited test
1920
+ * running).
1921
+ *
1922
+ * Two signatures are the same when their parameters have the same
1923
+ * types, and a type is what its name denotes where it is written
1924
+ * (`signatures.relate`): the answer can be that this is not known.
1925
+ * Then the test may be the class's or may not (`uncertain`): it is
1926
+ * listed as one that runs only sometimes, under no command, with
1927
+ * every body it may run (`maybe`).
1928
+ *
1929
+ * Where the runner takes a test from the nearest declaration that
1930
+ * carries its mark (`byDeclaration`: JUnit 4, TestNG, xUnit), a class
1931
+ * nearer that overrides the method without the mark does not hide it:
1932
+ * the test runs with that class's body (`body`).
1933
+ */
1934
+ const inheritedBy = (filePath, cls, select = null) => {
1935
+ const out = [];
1936
+ // What the classes nearer than the one looked at declare, nearest
1937
+ // first: every member by name, the methods with what they are
1938
+ // compared by, and the tests.
1939
+ const names = new Set();
1940
+ const testedNames = new Set();
1941
+ const members = [];
1942
+ const tested = [];
1943
+ const declare = (node, each) => {
1944
+ for (const member of each.members || []) names.add(member);
1945
+ // A method that is a test is compared as one: its row among
1946
+ // the members is the one that shares its parameter list (two
1947
+ // methods can start on one line).
1948
+ const lists = new Set((each.tests || []).map(test => test.params).filter(Boolean));
1949
+ const lines = new Set((each.tests || []).filter(test => !test.params).map(test => test.startLine));
1950
+ for (const row of each.memberRows || []) {
1951
+ if (!(row.params ? lists.has(row.params) : lines.has(row.startLine))) members.push({ row, at: node });
1952
+ }
1953
+ };
1954
+ const sideOf = ({ row, at }) => ({ filePath: at.filePath, cls: at.cls, row });
1955
+ /**
1956
+ * A declared override overrides one method: where the classes
1957
+ * above it are all read and declare one method of that name with
1958
+ * as many parameters, it is that one, however its types are
1959
+ * written.
1960
+ */
1961
+ const onlySlot = (member, test, node) => {
1962
+ const order = orderOf(member.at.filePath, member.at.cls).slice(1);
1963
+ if (order.some(above => above.opaque)) return false;
1964
+ const arity = (member.row.params || []).length;
1965
+ const universal = langTraits(index.files.get(member.at.filePath)?.language)?.universalSupertypeMembers || {};
1966
+ if (universal[member.row.name] === arity) return false;
1967
+ const slots = order.flatMap(above => (above.cls.memberRows || [])
1968
+ .filter(row => row.name === member.row.name && (row.params || []).length === arity)
1969
+ .map(row => ({ row, at: above })));
1970
+ return slots.length > 0 && slots.every(slot =>
1971
+ (slot.at.cls === node.cls && slot.row.startLine === test.startLine) ||
1972
+ signatures.relate(sideOf(slot), { filePath: node.filePath, cls: node.cls, row: test }) === 'same');
1973
+ };
1974
+ const start = { filePath, cls };
1975
+ declare(start, cls);
1976
+ for (const test of cls.tests || []) {
1977
+ names.add(test.name);
1978
+ testedNames.add(test.name);
1979
+ tested.push({ row: test, at: start });
1980
+ }
1981
+ let behindUnknown = false;
1982
+ let withheldAny = false;
1983
+ for (const node of orderOf(filePath, cls).slice(1)) {
1984
+ if (node.opaque) {
1985
+ if (!node.safe) behindUnknown = true;
1986
+ continue;
1987
+ }
1988
+ for (const test of node.cls.tests) {
1989
+ // A test its runner runs in the declaring class only.
1990
+ if (test.ownOnly) continue;
1991
+ const rule = test.hiddenBy || 'name';
1992
+ const mine = { filePath: node.filePath, cls: node.cls, row: test };
1993
+ let body = null;
1994
+ let uncertain = false;
1995
+ const maybe = [];
1996
+ let again = false;
1997
+ if (rule === 'name') {
1998
+ // A nearer class declares the same test.
1999
+ if (testedNames.has(test.name)) {
2000
+ // A runner that takes one method of each name
2001
+ // reports the inherited test too under a test
2002
+ // that hides it without overriding it, and runs
2003
+ // the nearer body for it (`againUnderHiding`:
2004
+ // measured with one version of MSTest, so the
2005
+ // test is listed as one that runs only sometimes).
2006
+ const nearer = tested.find(other => other.row.name === test.name);
2007
+ if (!test.againUnderHiding || !nearer || nearer.row.overrides || nearer.again) continue;
2008
+ body = nearer;
2009
+ again = true;
2010
+ tested.push({ row: test, at: node, again: true });
2011
+ } else {
2012
+ testedNames.add(test.name);
2013
+ tested.push({ row: test, at: node });
2014
+ // A nearer class declares the same member without the mark.
2015
+ if (names.has(test.name) && !test.byDeclaration) continue;
2016
+ }
2017
+ } else {
2018
+ // A runner that takes a method as replaced by one whose
2019
+ // parameters could be passed to it: another signature
2020
+ // is no proof the test is still the class's.
2021
+ const relate = other => {
2022
+ const relation = signatures.relate(mine, sideOf(other));
2023
+ return relation === 'different' && test.replacedByAssignable &&
2024
+ (other.row.params || []).length === (test.params || []).length ? 'unknown' : relation;
2025
+ };
2026
+ // A method the language does not let a class override
2027
+ // (`sealed`: always; `sealedOutside`: from another
2028
+ // package) is not replaced by an unmarked one of its
2029
+ // signature: the two are two methods, and the test
2030
+ // keeps its body. (A marked one is the class's test of
2031
+ // that signature: measured.)
2032
+ const overrides = other => !test.sealed &&
2033
+ (test.sealedOutside == null || (other.at.cls.namespace || '') === test.sealedOutside);
2034
+ // A nearer class declares the same test. (Where only a
2035
+ // declared override takes a test away, a test that
2036
+ // hides it by another declaration runs beside it.)
2037
+ let hidden = false;
2038
+ for (const other of tested) {
2039
+ if (other.row.name !== test.name || (rule === 'override' && !other.row.overrides)) continue;
2040
+ const relation = relate(other);
2041
+ if (relation === 'same') {
2042
+ hidden = true;
2043
+ break;
2044
+ }
2045
+ if (relation === 'unknown') uncertain = true;
2046
+ }
2047
+ testedNames.add(test.name);
2048
+ tested.push({ row: test, at: node });
2049
+ if (hidden) continue;
2050
+ // A nearer class declares the same member without the
2051
+ // mark: the nearest that is it for certain, and the
2052
+ // nearer ones that may be.
2053
+ for (const other of members) {
2054
+ if (other.row.name !== test.name || (rule === 'override' && !other.row.overrides) || !overrides(other)) continue;
2055
+ let relation = relate(other);
2056
+ if (relation === 'unknown' && other.row.overrides && onlySlot(other, test, node)) relation = 'same';
2057
+ if (relation === 'same') {
2058
+ body = other;
2059
+ break;
2060
+ }
2061
+ if (relation === 'unknown') maybe.push(other);
2062
+ }
2063
+ if (body && !test.byDeclaration) continue;
2064
+ // A class declares one method of a signature: beside
2065
+ // the one that is it, another of that class is not.
2066
+ const open = maybe.filter(other => !body || other.at.cls !== body.at.cls);
2067
+ maybe.length = 0;
2068
+ maybe.push(...open);
2069
+ if (maybe.length > 0) uncertain = true;
2070
+ }
2071
+ // A member the runner does not take as a test still hides
2072
+ // the one of that name further up.
2073
+ if (select && !select(test)) continue;
2074
+ if (behindUnknown) withheldAny = true;
2075
+ else {
2076
+ out.push({
2077
+ test, base: node,
2078
+ ...((test.byDeclaration || again) && body && { body }),
2079
+ ...(uncertain && { uncertain: true, maybe }),
2080
+ ...(again && { again: true }),
2081
+ });
2082
+ }
2083
+ }
2084
+ declare(node, node.cls);
2085
+ for (const test of node.cls.tests) names.add(test.name);
2086
+ }
2087
+ return { tests: out, open: behindUnknown, withheld: withheldAny };
2088
+ };
2089
+
2090
+ /**
2091
+ * Is there code beside a test file that the runner lets decide which
2092
+ * tests run (the language's `testGate`): a TestMain in its Go package
2093
+ * that may not run the tests. (pytest's conftest.py files are read
2094
+ * with the modules they stand above: see guardsOf.)
2095
+ */
2096
+ const gatedAtRunTime = (filePath, runner) => {
2097
+ const relativePath = index.files.get(filePath).relativePath;
2098
+ const decides = candidate => {
2099
+ if (!model.gates.has(candidate)) {
2100
+ const entry = index.files.get(candidate);
2101
+ const gate = entry && langTraits(entry.language)?.testGate;
2102
+ let answer = false;
2103
+ if (typeof gate === 'function') {
2104
+ try {
2105
+ answer = !!gate(entry, helpersFor(candidate, entry));
2106
+ } catch (e) {
2107
+ answer = true;
2108
+ }
2109
+ }
2110
+ model.gates.set(candidate, answer);
2111
+ }
2112
+ return model.gates.get(candidate);
2113
+ };
2114
+ if (runner.id === 'go-test') {
2115
+ if (!model.goTestFiles) {
2116
+ model.goTestFiles = new Map();
2117
+ for (const [candidate, entry] of index.files) {
2118
+ if (!/_test\.go$/.test(entry.relativePath)) continue;
2119
+ const dir = posixDir(entry.relativePath);
2120
+ if (!model.goTestFiles.has(dir)) model.goTestFiles.set(dir, []);
2121
+ model.goTestFiles.get(dir).push(candidate);
2122
+ }
2123
+ }
2124
+ return (model.goTestFiles.get(posixDir(relativePath)) || []).some(decides);
2125
+ }
2126
+ return false;
2127
+ };
2128
+
2129
+ // ---- a reader that gives facts per file (`derived`): pytest ----------
2130
+
2131
+ /** The project file a module name written in a file resolves to, or null. */
2132
+ const moduleFile = (filePath, moduleName) => {
2133
+ const relative = (index.files.get(filePath)?.moduleResolved || {})[moduleName];
2134
+ const target = relative ? path.join(index.root, relative) : null;
2135
+ return target && index.files.has(target) ? target : null;
2136
+ };
2137
+
2138
+ /** The project file a dotted module name stands for, from the runner's directory or the project's. */
2139
+ const moduleNamed = (filePath, dotted) => {
2140
+ const runner = model.runnerOf(filePath) || {};
2141
+ const relative = String(dotted).replace(/\./g, '/');
2142
+ for (const base of new Set([runner.dir || '', ''])) {
2143
+ for (const tail of [`${relative}.py`, `${relative}/__init__.py`]) {
2144
+ const candidate = base ? `${base}/${tail}` : tail;
2145
+ if (model.hasFile(candidate)) return path.join(index.root, candidate);
2146
+ }
2147
+ }
2148
+ return null;
2149
+ };
2150
+
2151
+ /**
2152
+ * Does a module declare tests where one of its functions is called
2153
+ * (a declaration inside a function that is no suite's callback), itself
2154
+ * or through the modules whose functions it calls?
2155
+ */
2156
+ const declaresWhenCalled = (filePath, seen = new Set()) => {
2157
+ if (seen.has(filePath)) return false;
2158
+ seen.add(filePath);
2159
+ const found = model.declared(filePath);
2160
+ if (!found) return false;
2161
+ if ((found.units || []).some(unit => unit.kind === 'test' &&
2162
+ (unit.indirect || unit.dynamic === 'declared-indirectly'))) return true;
2163
+ return (found.calledWhenCalled || []).some(name => {
2164
+ const target = moduleFile(filePath, name);
2165
+ return !!target && declaresWhenCalled(target, seen);
2166
+ });
2167
+ };
2168
+
2169
+ /**
2170
+ * What a name of a module is, as far as tests go: a class or a function
2171
+ * of its top, or what another name of the module or another module
2172
+ * gives it (an alias, an import, a star import), a few steps deep.
2173
+ */
2174
+ const resolveName = (filePath, name, depth = 0) => {
2175
+ if (!filePath || depth > 6) return null;
2176
+ const found = model.declared(filePath);
2177
+ if (!found) return null;
2178
+ const cls = (found.classes || []).find(other => other.name === name && other.suite.length === 0);
2179
+ if (cls) return { kind: 'class', filePath, cls };
2180
+ const fn = (found.functions || []).find(other => other.name === name);
2181
+ if (fn) return { kind: 'function', filePath, fn };
2182
+ for (const entry of found.imports || []) {
2183
+ if (entry.name !== name) continue;
2184
+ const from = entry.module == null ? filePath : moduleFile(filePath, entry.module);
2185
+ if (from === filePath && entry.original === name) continue;
2186
+ const hit = resolveName(from, entry.original, depth + 1);
2187
+ if (hit) return hit;
2188
+ }
2189
+ for (const star of found.stars || []) {
2190
+ const from = moduleFile(filePath, star.module);
2191
+ const there = from ? model.declared(from) : null;
2192
+ if (!there || there.exportsAll === 'unknown') continue;
2193
+ if (there.exportsAll ? !there.exportsAll.includes(name) : name.startsWith('_')) continue;
2194
+ const hit = resolveName(from, name, depth + 1);
2195
+ if (hit) return hit;
2196
+ }
2197
+ return null;
2198
+ };
2199
+
2200
+ /**
2201
+ * Switches other modules write on the classes and functions of a
2202
+ * module, each on a name it took from it. The object is one: a module
2203
+ * the runner loads after the writer sees the write, so what such a
2204
+ * class or function is depends on the order of the run. Looked for
2205
+ * among the modules that import this one.
2206
+ */
2207
+ const switchedElsewhere = filePath => {
2208
+ if (model.foreign.has(filePath)) return model.foreign.get(filePath);
2209
+ const answer = { classes: new Map(), functions: new Map() };
2210
+ model.foreign.set(filePath, answer);
2211
+ const add = (map, key, write) => {
2212
+ if (!map.has(key)) map.set(key, []);
2213
+ map.get(key).push(write);
2214
+ };
2215
+ for (const importer of [...(index.exportGraph?.get(filePath) || [])].sort(codeUnitCompare)) {
2216
+ if (importer === filePath || !index.files.has(importer)) continue;
2217
+ let text;
2218
+ try {
2219
+ text = index._readFile(importer);
2220
+ } catch (e) {
2221
+ continue;
2222
+ }
2223
+ if (!text.includes('__test__')) continue;
2224
+ const there = model.declared(importer);
2225
+ const switches = there && there.derived && there.module ? there.module.switches || {} : {};
2226
+ for (const name of Object.keys(switches).sort(codeUnitCompare)) {
2227
+ const hit = resolveName(importer, name);
2228
+ if (!hit || hit.filePath !== filePath) continue;
2229
+ const write = { on: switches[name].on, forced: switches[name].forced, by: importer };
2230
+ if (hit.kind === 'class') add(answer.classes, hit.cls, write);
2231
+ else add(answer.functions, hit.fn.name, write);
2232
+ }
2233
+ }
2234
+ return answer;
2235
+ };
2236
+
2237
+ /**
2238
+ * Does one of the functions a file calls in other modules of the
2239
+ * project stop a test when called (it skips, raises, or calls a
2240
+ * function of its own module that does)? One module deep.
2241
+ */
2242
+ const callStops = (filePath, calls) => (calls || []).some(call => {
2243
+ const target = moduleFile(filePath, call.module) || moduleNamed(filePath, call.module);
2244
+ const there = target ? model.declared(target) : null;
2245
+ return !!there && (there.stopping || []).includes(call.name);
2246
+ });
2247
+ /**
2248
+ * What a name of a module is as a row of a table of parameters
2249
+ * ('run', 'skip', 'sometimes'), read in the module that binds it and,
2250
+ * where that module took it from another, in that one: a few modules
2251
+ * deep. Null: the module does not bind it.
2252
+ */
2253
+ const readRow = (file, name, member, call, depth) => {
2254
+ const there = file ? model.declared(file) : null;
2255
+ if (!there || typeof there.rowNamed !== 'function') return null;
2256
+ const state = there.rowNamed(name, member, call);
2257
+ if (!state || typeof state !== 'object') return state;
2258
+ if (depth >= 4) return 'sometimes';
2259
+ if (state.via) return viaLookup(file, state.via, depth + 1) ?? 'sometimes';
2260
+ // A name a star import brings: read in the module that binds it.
2261
+ for (const module of state.stars || []) {
2262
+ const found = viaLookup(file, { module, name, ...(member && { member }), call }, depth + 1);
2263
+ if (found != null) return found;
2264
+ }
2265
+ return null;
2266
+ };
2267
+ const viaLookup = (filePath, via, depth) => {
2268
+ const direct = moduleFile(filePath, via.module) || moduleNamed(filePath, via.module);
2269
+ const state = readRow(direct, via.name, via.member || null, !!via.call, depth);
2270
+ if (state != null) return state;
2271
+ // Not a name of that module: a module of that package.
2272
+ let inner = null;
2273
+ if (direct && path.basename(direct) === '__init__.py') {
2274
+ for (const tail of [`${via.name}.py`, path.join(via.name, '__init__.py')]) {
2275
+ const candidate = path.join(path.dirname(direct), tail);
2276
+ if (!inner && index.files.has(candidate)) inner = candidate;
2277
+ }
2278
+ } else if (!direct && !String(via.module).startsWith('.')) {
2279
+ inner = moduleNamed(filePath, `${via.module}.${via.name}`);
2280
+ }
2281
+ if (!inner || !model.declared(inner)) return null;
2282
+ // The module itself is a plain row; calling it is not read.
2283
+ if (!via.member) return via.call ? 'sometimes' : 'run';
2284
+ return readRow(inner, via.member, null, !!via.call, depth);
2285
+ };
2286
+ /** The same for a name written in `filePath`: what is not found may be anything. */
2287
+ const viaState = (filePath, via) => viaLookup(filePath, via, 0) ?? 'sometimes';
2288
+ /**
2289
+ * What tables of parameters whose rows other modules name leave of a
2290
+ * test written in `filePath`: 'skip' (one of them has no row that runs
2291
+ * the body), 'sometimes' (one may have none), or null.
2292
+ */
2293
+ const tablesState = (filePath, tables) => {
2294
+ let answer = null;
2295
+ for (const table of tables || []) {
2296
+ const states = table.via.map(via => viaState(filePath, via));
2297
+ if (states.includes('run')) continue;
2298
+ if (table.rest === 'skip' && states.every(state => state === 'skip')) return 'skip';
2299
+ answer = 'sometimes';
2300
+ }
2301
+ return answer;
2302
+ };
2303
+ /** A file's fixtures, with what the functions they call and the rows they name elsewhere add. */
2304
+ const fixturesOf = (filePath, fixtures) => (fixtures || []).map(fixture =>
2305
+ !fixture.decides && (callStops(filePath, fixture.calls) || tablesState(filePath, fixture.tables))
2306
+ ? { ...fixture, decides: true } : fixture);
2307
+
2308
+ /**
2309
+ * What a file does when the runner loads it as a plugin (a conftest.py,
2310
+ * a module another names as one), with the modules it loads in turn:
2311
+ * whether a hook can change what is collected or run, whether it has
2312
+ * the hook a test module may have too, and the fixtures it brings.
2313
+ */
2314
+ const pluginFacts = (filePath, seen = new Set()) => {
2315
+ const top = seen.size === 0;
2316
+ if (top && model.plugins.has(filePath)) return model.plugins.get(filePath);
2317
+ if (seen.has(filePath)) return { decides: false, everywhere: false, generates: false, fixtures: [] };
2318
+ seen.add(filePath);
2319
+ const found = model.declared(filePath);
2320
+ const runtime = found && found.runtime;
2321
+ // A plugin that is not read may do anything.
2322
+ const answer = {
2323
+ decides: !runtime || runtime.decides,
2324
+ // A hook pytest calls once for the whole run decides for
2325
+ // every test of it, wherever the file stands.
2326
+ everywhere: !runtime || !!runtime.everywhere,
2327
+ generates: !runtime || !!runtime.generates,
2328
+ fixtures: runtime ? fixturesOf(filePath, runtime.fixtures) : [],
2329
+ };
2330
+ // A hook that calls a function of another module of the project
2331
+ // that can stop a test decides as if it stopped it itself.
2332
+ for (const call of (runtime && runtime.hookCalls) || []) {
2333
+ if (!callStops(filePath, [call])) continue;
2334
+ answer.decides = true;
2335
+ if (call.everywhere) answer.everywhere = true;
2336
+ if (call.generates) answer.generates = true;
2337
+ }
2338
+ for (const plugin of runtime ? runtime.plugins : []) {
2339
+ const target = plugin.by === 'import' ? moduleFile(filePath, plugin.module) : moduleNamed(filePath, plugin.module);
2340
+ // A module outside the project is a plugin like any installed one.
2341
+ if (!target) continue;
2342
+ const inner = pluginFacts(target, seen);
2343
+ answer.decides = answer.decides || inner.decides;
2344
+ answer.generates = answer.generates || inner.generates;
2345
+ // A module named as a plugin is one for the whole run.
2346
+ if (inner.everywhere || (plugin.by === 'name' && inner.decides)) answer.everywhere = true;
2347
+ answer.fixtures.push(...inner.fixtures);
2348
+ }
2349
+ // Fixtures taken by name from other modules of the project.
2350
+ for (const entry of (found && found.imports) || []) {
2351
+ const target = entry.module == null ? null : moduleFile(filePath, entry.module);
2352
+ const there = target ? model.declared(target) : null;
2353
+ const fixture = there && there.runtime
2354
+ ? fixturesOf(target, there.runtime.fixtures).find(other => other.function === entry.original) : null;
2355
+ if (fixture) answer.fixtures.push(fixture.name === fixture.function ? { ...fixture, name: entry.name } : fixture);
2356
+ }
2357
+ if (top) model.plugins.set(filePath, answer);
2358
+ return answer;
2359
+ };
2360
+
2361
+ /**
2362
+ * The fixture names that can stop a test: those that decide, and those
2363
+ * that ask for one that does. With `is`: the names that reach a
2364
+ * fixture of that kind instead.
2365
+ */
2366
+ const stoppingNames = (fixtures, is = fixture => fixture.decides) => {
2367
+ const names = new Set();
2368
+ for (const fixture of fixtures) if (is(fixture) && fixture.name != null) names.add(fixture.name);
2369
+ let grew = true;
2370
+ while (grew) {
2371
+ grew = false;
2372
+ for (const fixture of fixtures) {
2373
+ if (fixture.name == null || names.has(fixture.name) || !fixture.requests.some(name => names.has(name))) continue;
2374
+ names.add(fixture.name);
2375
+ grew = true;
2376
+ }
2377
+ }
2378
+ return names;
2379
+ };
2380
+
2381
+ /** Does a fixture every test gets have a table of parameters, its own or one it asks for? */
2382
+ const everyTabled = (fixtures, tabled) => fixtures.some(fixture => (fixture.autouse || fixture.name == null) &&
2383
+ (!!fixture.params || fixture.requests.some(name => tabled.has(name))));
2384
+
2385
+ /**
2386
+ * What the code beside a test module can do to its tests before their
2387
+ * bodies run: the conftest.py files above it, the plugins those and the
2388
+ * module name, the module's own fixtures. `gated`: every test of the
2389
+ * module runs only sometimes (a hook can change what is collected or
2390
+ * run, a fixture every test gets can stop it). `fixtures`: the ones a
2391
+ * test of the module can ask for.
2392
+ */
2393
+ const guardsOf = filePath => {
2394
+ if (model.guards.has(filePath)) return model.guards.get(filePath);
2395
+ const relativePath = index.files.get(filePath).relativePath;
2396
+ const fixtures = [];
2397
+ let gated = false;
2398
+ let generates = false;
2399
+ // The conftest.py files a run of this file alone loads whose hooks
2400
+ // decide for the whole run.
2401
+ const wholeRun = [];
2402
+ for (const dir of ancestorDirs(relativePath)) {
2403
+ const conftest = path.join(index.root, dir, 'conftest.py');
2404
+ if (conftest === filePath || !index.files.has(conftest)) continue;
2405
+ const facts = pluginFacts(conftest);
2406
+ if (facts.decides) gated = true;
2407
+ if (facts.generates) generates = true;
2408
+ if (facts.everywhere) wholeRun.push(index.files.get(conftest).relativePath);
2409
+ fixtures.push(...facts.fixtures);
2410
+ }
2411
+ // The module itself is no plugin: of its own hooks pytest calls
2412
+ // the one that generates tests; the modules it names are plugins.
2413
+ const own = pluginFacts(filePath);
2414
+ const runtime = model.declared(filePath)?.runtime;
2415
+ if (own.generates || (own.everywhere && !(runtime && runtime.everywhere))) gated = true;
2416
+ if (own.generates) generates = true;
2417
+ fixtures.push(...own.fixtures);
2418
+ // A conftest.py anywhere under the runner's directory whose hook
2419
+ // is called for the whole run: it is loaded with any run that
2420
+ // collects a file below it, and decides for every test of that run.
2421
+ const runner = model.runnerOf(filePath) || {};
2422
+ if (!gated) {
2423
+ if (!model.conftests) {
2424
+ model.hasFile('');
2425
+ model.conftests = [...model.relPaths].filter(other => path.posix.basename(other) === 'conftest.py')
2426
+ .sort(codeUnitCompare);
2427
+ }
2428
+ for (const other of model.conftests) {
2429
+ if (relativeTo(runner.dir, other) == null) continue;
2430
+ const conftest = path.join(index.root, other);
2431
+ if (conftest !== filePath && pluginFacts(conftest).everywhere) {
2432
+ gated = true;
2433
+ break;
2434
+ }
2435
+ }
2436
+ }
2437
+ const stopping = stoppingNames(fixtures);
2438
+ if (fixtures.some(fixture => (fixture.autouse || fixture.name == null) &&
2439
+ (fixture.decides || fixture.requests.some(name => stopping.has(name))))) gated = true;
2440
+ const tabled = stoppingNames(fixtures, fixture => fixture.params);
2441
+ const answer = {
2442
+ gated, fixtures, stopping, tabled,
2443
+ // A fixture every test gets that has a table of parameters, or
2444
+ // asks for one that has: every test runs once for each row.
2445
+ everyTabled: everyTabled(fixtures, tabled),
2446
+ // A hook that makes tests from a test (pytest_generate_tests).
2447
+ generates,
2448
+ wholeRun: wholeRun.sort(codeUnitCompare),
2449
+ };
2450
+ model.guards.set(filePath, answer);
2451
+ return answer;
2452
+ };
2453
+
2454
+ /**
2455
+ * The units of a file whose reader gives facts (pytest): the module's
2456
+ * own tests, the tests of every class the runner collects (its own and
2457
+ * the ones it inherits, in the language's lookup order), and the tests
2458
+ * the module holds under names it took from elsewhere or bound again.
2459
+ *
2460
+ * Whether a class is collected is settled over its lookup order: the
2461
+ * nearest `__test__` (false: no; not a literal: sometimes; the literal
2462
+ * True: yes whatever the class is called), a constructor anywhere in
2463
+ * it (pytest's classes only), abstract members nothing nearer
2464
+ * implements, a name the runner takes or a TestCase of unittest among
2465
+ * its bases, and the classes around it. Marks, setups and fixtures of
2466
+ * every class in that order reach its tests, as do the module's.
2467
+ */
2468
+ const derivedFacts = (filePath, found) => {
2469
+ // A module switched off holds no test.
2470
+ if (found.off) return { modeled: true, units: [], tests: 0, inheriting: false };
2471
+ const relativePath = index.files.get(filePath).relativePath;
2472
+ const relativeOf = other => index.files.get(other).relativePath;
2473
+ const runner = model.runnerOf(filePath) || {};
2474
+ const naming = runner.collection || {};
2475
+ const named = (patterns, fallback, name) => (patterns && patterns.length > 0 ? patterns : fallback)
2476
+ .some(pattern => /[*?[]/.test(pattern) ? globMatches(pattern, name) : name.startsWith(pattern));
2477
+ const declaredModule = found.module || {};
2478
+ // What the module does when it is imported and before its tests,
2479
+ // with what the functions it calls in other modules add.
2480
+ const moduleTable = tablesState(filePath, declaredModule.tables);
2481
+ const mod = {
2482
+ ...declaredModule,
2483
+ ...(!declaredModule.skip && callStops(filePath, declaredModule.skipCalls) && { skip: 'sometimes' }),
2484
+ ...(moduleTable && declaredModule.skip !== 'skip' && { skip: moduleTable }),
2485
+ ...(!declaredModule.guard && callStops(filePath, declaredModule.guardCalls) && { guard: true }),
2486
+ };
2487
+ const guards = guardsOf(filePath);
2488
+ let more = !!found.inheriting;
2489
+
2490
+ // Names the module takes from other modules, and other names for
2491
+ // what it holds: a test class or function among them runs here too.
2492
+ const entries = [...(found.imports || [])];
2493
+ const own = new Set([...(found.functions || []).map(fn => fn.name),
2494
+ ...(found.classes || []).filter(cls => cls.suite.length === 0).map(cls => cls.name),
2495
+ ...entries.map(entry => entry.name)]);
2496
+ for (const star of naming.importedTests === false ? [] : found.stars || []) {
2497
+ const from = moduleFile(filePath, star.module);
2498
+ const there = from ? model.declared(from) : null;
2499
+ // What a module that is not read brings is not seen.
2500
+ if (!there || there.exportsAll === 'unknown') {
2501
+ more = true;
2502
+ continue;
2503
+ }
2504
+ if ((there.stars || []).length > 0) more = true;
2505
+ const names = there.exportsAll || [
2506
+ ...(there.functions || []).map(fn => fn.name),
2507
+ ...(there.classes || []).filter(cls => cls.suite.length === 0).map(cls => cls.name),
2508
+ ...(there.imports || []).map(entry => entry.name),
2509
+ ].filter(name => !name.startsWith('_'));
2510
+ for (const name of new Set(names)) {
2511
+ if (!own.has(name)) entries.push({ name, original: name, module: star.module, line: star.line });
2512
+ }
2513
+ }
2514
+ const classesHere = [...(found.classes || [])];
2515
+ const taken = [];
2516
+ const bound = new Set();
2517
+ // A switch this module writes on a class it took from another
2518
+ // module: the class is that object, so the switch holds for every
2519
+ // class here that derives from it.
2520
+ const switchedHere = new Map();
2521
+ // Classes taken from other modules whose constructor is written here.
2522
+ const constructorsOpen = new Set();
2523
+ for (const entry of entries) {
2524
+ if (bound.has(entry.name)) continue;
2525
+ const from = entry.module == null ? filePath : moduleFile(filePath, entry.module);
2526
+ if (!from || (from === filePath && entry.original === entry.name)) continue;
2527
+ const hit = resolveName(from, entry.original);
2528
+ if (!hit) continue;
2529
+ // collect_imported_tests = false: only what the module
2530
+ // defines itself is collected in it.
2531
+ if (naming.importedTests === false && hit.filePath !== filePath) continue;
2532
+ bound.add(entry.name);
2533
+ // A switch written here on the name.
2534
+ const written = (mod.switches || {})[entry.name];
2535
+ if (hit.kind === 'class' && written) switchedHere.set(hit.cls, written);
2536
+ if (hit.kind === 'class' && (mod.constructorWrites || []).includes(entry.name)) constructorsOpen.add(hit.cls);
2537
+ if (hit.kind === 'class') {
2538
+ classesHere.push({
2539
+ name: entry.name, suite: [], bases: [], imported: { filePath: hit.filePath, cls: hit.cls },
2540
+ named: named(naming.classes, ['Test'], entry.name),
2541
+ ...(written && { enabled: written.on }),
2542
+ ...(written && written.forced && { forced: written.forced }),
2543
+ line: entry.line, startLine: entry.line, endLine: entry.line,
2544
+ members: [], tests: [], marks: [], marksOpen: false,
2545
+ });
2546
+ } else {
2547
+ taken.push({ entry, fn: hit.fn, target: hit.filePath, written });
2548
+ }
2549
+ }
2550
+
2551
+ // The classes around a class, outermost first.
2552
+ const outerOf = cls => cls.imported ? [] : classesHere.filter(outer => !outer.imported && outer !== cls &&
2553
+ outer.startLine <= cls.startLine && outer.endLine >= cls.endLine).sort((a, b) => a.startLine - b.startLine);
2554
+ const NO = Object.freeze({ collected: 'no' });
2555
+ const standings = new Map();
2556
+ const standingOf = cls => {
2557
+ if (standings.has(cls)) return standings.get(cls);
2558
+ standings.set(cls, NO);
2559
+ const answer = (() => {
2560
+ const order = orderOf(filePath, cls);
2561
+ const nodes = order.filter(node => !node.opaque);
2562
+ const real = nodes.map(node => node.cls);
2563
+ const unread = order.some(node => node.opaque && !node.safe);
2564
+ const unittest = real.some(each => each.unittestBase);
2565
+ let collected = 'yes';
2566
+ let why = null;
2567
+ const weaken = reason => {
2568
+ if (collected === 'yes') {
2569
+ collected = 'maybe';
2570
+ why = reason;
2571
+ }
2572
+ };
2573
+ // The nearest switch: one written here on a class taken
2574
+ // from another module comes before that class's own. One
2575
+ // another module writes on it holds where that module was
2576
+ // loaded first: which, is the order of the run.
2577
+ const switchOf = node => {
2578
+ const each = node.cls;
2579
+ if (switchedHere.has(each)) return { enabled: switchedHere.get(each).on, forced: switchedHere.get(each).forced };
2580
+ const others = (switchedElsewhere(node.filePath).classes.get(each) || []).filter(write => write.by !== filePath);
2581
+ if (others.length > 0) {
2582
+ // Whether it is on is not settled; it is taken
2583
+ // whatever it is called only where a write says so.
2584
+ return { enabled: 'unknown', forced: others.some(write => write.forced) ? 'unknown' : each.forced };
2585
+ }
2586
+ return each.enabled !== undefined ? { enabled: each.enabled, forced: each.forced } : null;
2587
+ };
2588
+ const switched = nodes.map(switchOf).find(Boolean) || null;
2589
+ if (switched && switched.enabled === false) return NO;
2590
+ if (!unittest) {
2591
+ // The runner refuses a class whose constructor is not
2592
+ // the language's own. Which one a class has is what its
2593
+ // lookup order binds first: a class that writes the
2594
+ // builtin's back has it again, whatever stands behind.
2595
+ let custom = false;
2596
+ for (const name of found.constructors || []) {
2597
+ for (const node of order) {
2598
+ if (node.opaque) {
2599
+ if (node.safe) continue;
2600
+ // A base that is not read may bind one.
2601
+ weaken('base-not-resolved');
2602
+ break;
2603
+ }
2604
+ const kind = constructorsOpen.has(node.cls) ? 'unknown' : (node.cls.constructs || {})[name];
2605
+ if (!kind) continue;
2606
+ if (kind === 'custom') custom = true;
2607
+ else if (kind === 'unknown') weaken('constructor-not-read');
2608
+ break;
2609
+ }
2610
+ }
2611
+ if (custom) return NO;
2612
+ }
2613
+ // Abstract members that nothing nearer implements: abc
2614
+ // makes the class abstract and pytest leaves it out.
2615
+ const implemented = new Set();
2616
+ let abstractLeft = false;
2617
+ for (const each of real) {
2618
+ const abstract = each.abstract || [];
2619
+ if (abstract.some(name => !implemented.has(name))) abstractLeft = true;
2620
+ for (const member of each.members || []) if (!abstract.includes(member)) implemented.add(member);
2621
+ }
2622
+ if (abstractLeft) {
2623
+ if (real.some(each => each.abc === true)) return NO;
2624
+ if (real.some(each => each.abc === 'unknown') || unread) weaken('base-not-resolved');
2625
+ }
2626
+ if (!unittest && !cls.named) {
2627
+ // Not the runner's by its name, nor one of unittest's
2628
+ // (a base from outside the project is taken to be a
2629
+ // TestCase where it is named like one, and not otherwise).
2630
+ const forced = switched ? switched.forced : undefined;
2631
+ if (forced === 'unknown') weaken('collection-switch');
2632
+ else if (forced !== true) return NO;
2633
+ }
2634
+ if (switched && switched.enabled === 'unknown') weaken('collection-switch');
2635
+ if (real.some(each => each.unsettled)) weaken('defined-conditionally');
2636
+ for (const outer of outerOf(cls)) {
2637
+ const around = standingOf(outer);
2638
+ // pytest collects a class inside a class it collects;
2639
+ // a class of unittest holds methods only.
2640
+ if (around.collected === 'no' || around.regime === 'unittest') return NO;
2641
+ if (around.collected === 'maybe') weaken(around.why);
2642
+ }
2643
+ // Each class's fixtures and setups, read in its own file.
2644
+ const fixtures = nodes.flatMap(node => fixturesOf(node.filePath, node.cls.fixtures));
2645
+ // Tables of parameters on the classes, their rows read where each is written.
2646
+ const tables = nodes.map(node => tablesState(node.filePath, node.cls.tables));
2647
+ return {
2648
+ collected, why, regime: unittest ? 'unittest' : 'pytest',
2649
+ // What reaches a class through its lookup order.
2650
+ skip: real.some(each => each.skip === 'skip') || tables.includes('skip') ? 'skip'
2651
+ : real.some(each => each.skip === 'sometimes') || tables.includes('sometimes') ? 'sometimes' : null,
2652
+ guard: nodes.some(node => node.cls.guard || callStops(node.filePath, node.cls.guardCalls)),
2653
+ // A class's own hook that makes tests from a test.
2654
+ generates: real.some(each => each.generates),
2655
+ fixtures,
2656
+ usefixtures: real.flatMap(each => each.usefixtures || []),
2657
+ usefixturesOpen: real.some(each => each.usefixturesOpen),
2658
+ open: unread,
2659
+ };
2660
+ })();
2661
+ standings.set(cls, answer);
2662
+ return answer;
2663
+ };
2664
+
2665
+ const classesOf = new Map(); // unit -> the classes around it, for the runner's mark expression
2666
+ let units = [];
2667
+ /** A unit with what reaches it: its own state, its classes', the module's, the code beside it. */
2668
+ const finish = (base, row, around, alsoOpen = null, rowFile = filePath) => {
2669
+ const states = around.map(standingOf);
2670
+ const dynamic = row.dynamic || alsoOpen || states.map(each => each.collected === 'maybe' ? each.why : null).find(Boolean) ||
2671
+ (mod.unsettled ? 'collection-switch' : null);
2672
+ const classFixtures = states.flatMap(each => each.fixtures);
2673
+ const scoped = classFixtures.length > 0 ? [...guards.fixtures, ...classFixtures] : guards.fixtures;
2674
+ const stopping = classFixtures.length > 0 ? stoppingNames(scoped) : guards.stopping;
2675
+ const requests = [...(row.requests || []), ...(mod.usefixtures || []), ...(naming.usefixtures || []),
2676
+ ...states.flatMap(each => each.usefixtures)];
2677
+ // The rows of the test's own table that other modules name,
2678
+ // read from the file the test is written in.
2679
+ const rowTable = tablesState(rowFile, row.tables);
2680
+ // One declaration the runner reports as several tests: a
2681
+ // table of parameters, its own, its classes', its module's, a
2682
+ // fixture's (one it asks for, or one every test gets), or a
2683
+ // hook that makes tests from it.
2684
+ const tabled = classFixtures.length > 0 ? stoppingNames(scoped, fixture => fixture.params) : guards.tabled;
2685
+ const byFixture = requests.some(name => tabled.has(name)) ||
2686
+ (classFixtures.length > 0 ? everyTabled(scoped, tabled) : guards.everyTabled);
2687
+ const several = (row.marks || []).includes('parametrize') || (found.marks || []).includes('parametrize') ||
2688
+ around.some(cls => orderOf(filePath, cls).some(node => !node.opaque && (node.cls.marks || []).includes('parametrize'))) ||
2689
+ byFixture || guards.generates || states.some(each => each.generates);
2690
+ const skipped = !!row.skipped || rowTable === 'skip' || mod.skip === 'skip' || states.some(each => each.skip === 'skip');
2691
+ const sometimes = !!row.conditional || !!dynamic || rowTable === 'sometimes' || mod.skip === 'sometimes' ||
2692
+ !!mod.guard || guards.gated ||
2693
+ states.some(each => each.skip === 'sometimes' || each.guard || each.usefixturesOpen || each.generates) ||
2694
+ !!row.requestsOpen || !!mod.usefixturesOpen || requests.some(name => stopping.has(name)) ||
2695
+ // A test of unittest takes no parameters: under a
2696
+ // fixture's table the runner reports an error for it.
2697
+ (byFixture && states.some(each => each.regime === 'unittest'));
2698
+ const unit = {
2699
+ ...base,
2700
+ ...(skipped && { skipped: true }),
2701
+ ...(sometimes && { conditional: true }),
2702
+ ...(dynamic && { dynamic }),
2703
+ ...(several && { several: true }),
2704
+ ...(row.marks && { marks: row.marks, marksOpen: !!row.marksOpen }),
2705
+ };
2706
+ classesOf.set(unit, around);
2707
+ units.push(unit);
2708
+ };
2709
+ // A function the runner takes only by a switch that is not settled.
2710
+ const byForce = row => !row.named && row.forced === 'unknown' ? 'collection-switch' : null;
2711
+
2712
+ // Functions of this module whose switch another module writes.
2713
+ const switchedBy = switchedElsewhere(filePath).functions;
2714
+ for (const row of found.units) {
2715
+ if (row.kind !== 'test') continue;
2716
+ finish({ kind: 'test', name: row.name, suite: [], line: row.line, startLine: row.startLine, endLine: row.endLine },
2717
+ row, [], byForce(row) || (switchedBy.has(row.name) ? 'collection-switch' : null));
2718
+ }
2719
+ // One the module leaves switched off, or under a name the runner
2720
+ // does not take, that another module switches on.
2721
+ for (const fn of switchedBy.size > 0 ? found.functions || [] : []) {
2722
+ if (found.units.some(row => row.kind === 'test' && row.name === fn.name && row.line === fn.line)) continue;
2723
+ if (!switchedBy.get(fn.name)?.some(write => write.on !== false && (fn.named || write.forced))) continue;
2724
+ finish({ kind: 'test', name: fn.name, suite: [], line: fn.line, startLine: fn.startLine, endLine: fn.endLine },
2725
+ fn, [], 'collection-switch');
2726
+ }
2727
+ for (const cls of classesHere) {
2728
+ const standing = standingOf(cls);
2729
+ if (standing.collected === 'no') continue;
2730
+ // unittest takes the methods its loader names; pytest the
2731
+ // names its patterns take and the ones switched on.
2732
+ // A member its class switches off is none, unless a switch
2733
+ // this module writes in a way that is not read may have
2734
+ // turned it on again.
2735
+ const live = row => !row.off || !!found.switchesOpen;
2736
+ const select = standing.regime === 'unittest' ? row => live(row) && row.prefixed
2737
+ : row => live(row) && (row.named || !!row.forced);
2738
+ const reopened = row => row.off ? 'collection-switch' : null;
2739
+ const inherited = cls.bases.length > 0 || cls.imported
2740
+ ? inheritedBy(filePath, cls, select) : { tests: [], open: false, withheld: false };
2741
+ const rows = (cls.tests || []).filter(select);
2742
+ // unittest runs runTest when a class has no other test.
2743
+ if (standing.regime === 'unittest' && rows.length === 0 && inherited.tests.length === 0) {
2744
+ const fallback = (cls.tests || []).find(row => row.name === 'runTest');
2745
+ if (fallback) rows.push(fallback);
2746
+ // The nearest one in the lookup order, where the class has none.
2747
+ else if (cls.bases.length > 0 || cls.imported) {
2748
+ const above = inheritedBy(filePath, cls, row => row.name === 'runTest');
2749
+ inherited.tests.push(...above.tests);
2750
+ if (above.withheld) inherited.withheld = true;
2751
+ }
2752
+ }
2753
+ // A base that is not read may bring tests of its own.
2754
+ if (inherited.withheld || standing.open) more = true;
2755
+ const around = [...outerOf(cls), cls];
2756
+ const suite = [...cls.suite, cls.name];
2757
+ const before = units.length;
2758
+ for (const row of rows) {
2759
+ const open = reopened(row) || (standing.regime === 'unittest' ? null : byForce(row));
2760
+ if (row.alias) {
2761
+ // Another name for a test of the class: its body stands where that test is.
2762
+ finish({
2763
+ kind: 'test', name: row.name, suite, line: row.line, startLine: row.line, endLine: row.line,
2764
+ inherited: {
2765
+ file: relativePath, filePath, owner: cls.name, ownerLine: cls.startLine,
2766
+ line: row.of, startLine: row.startLine, endLine: row.endLine,
2767
+ },
2768
+ }, row, around, open);
2769
+ } else {
2770
+ finish({ kind: 'test', name: row.name, suite, line: row.line, startLine: row.startLine, endLine: row.endLine },
2771
+ row, around, open);
2772
+ }
2773
+ }
2774
+ for (const { test, base } of inherited.tests) {
2775
+ finish({
2776
+ kind: 'test', name: test.name, suite, line: cls.line, startLine: cls.line, endLine: cls.line,
2777
+ inherited: {
2778
+ file: relativeOf(base.filePath), filePath: base.filePath, owner: base.cls.name,
2779
+ ownerLine: base.cls.startLine, line: test.alias ? test.of : test.line,
2780
+ startLine: test.startLine, endLine: test.endLine,
2781
+ },
2782
+ }, test, around, reopened(test) || (standing.regime === 'unittest' ? null : byForce(test)), base.filePath);
2783
+ }
2784
+ if (units.length > before) {
2785
+ units.push({
2786
+ kind: 'suite', name: cls.name, suite: cls.suite,
2787
+ line: cls.line, startLine: cls.startLine, endLine: cls.endLine,
2788
+ });
2789
+ }
2790
+ }
2791
+ for (const { entry, fn, target, written } of taken) {
2792
+ // The switch is the function's own, whoever holds it: a write
2793
+ // made here comes after the ones of the module it is taken from.
2794
+ // Without one, a third module's write holds where that module
2795
+ // was loaded first.
2796
+ const others = written ? [] : (switchedElsewhere(target).functions.get(fn.name) || [])
2797
+ .filter(write => write.by !== filePath);
2798
+ // And where a switch this module writes is not read, one its
2799
+ // own module switched off may be on again, under any name.
2800
+ const reopened = !written && !!fn.off && !!found.switchesOpen;
2801
+ const on = written ? written.on : others.length > 0 || reopened ? 'unknown' : fn.off ? false : true;
2802
+ const row = {
2803
+ ...fn, named: named(naming.functions, ['test'], entry.name),
2804
+ forced: written ? written.forced : others.some(write => write.forced) || reopened ? 'unknown' : fn.forced,
2805
+ };
2806
+ if ((!row.named && !row.forced) || on === false) continue;
2807
+ finish({
2808
+ kind: 'test', name: entry.name, suite: [], line: entry.line, startLine: entry.line, endLine: entry.line,
2809
+ inherited: {
2810
+ file: relativeOf(target), filePath: target, owner: null, ownerLine: 0,
2811
+ line: fn.line, startLine: fn.startLine, endLine: fn.endLine,
2812
+ },
2813
+ }, row, [], (on === 'unknown' ? 'collection-switch' : null) || byForce(row), target);
2814
+ }
2815
+
2816
+ // The runner's configured options: a mark expression and tests
2817
+ // left out by name apply to a test however it is named on the
2818
+ // command line (measured).
2819
+ const options = naming.options;
2820
+ if (options && (options.markExpr || options.deselect.length > 0)) {
2821
+ const file = relativeTo(runner.dir, relativePath);
2822
+ units = units.map(unit => {
2823
+ if (unit.kind !== 'test' || unit.skipped) return unit;
2824
+ const id = [file, ...unit.suite, unit.name].join('::');
2825
+ let selected = !(file != null && options.deselect.some(prefix => id.startsWith(prefix)));
2826
+ if (selected && options.markExpr) {
2827
+ const marks = new Set([...(unit.marks || []), ...(found.marks || [])]);
2828
+ let open = !!unit.marksOpen || !!found.marksOpen;
2829
+ // Marks of the classes around the test, and of every
2830
+ // class those derive from.
2831
+ for (const cls of classesOf.get(unit) || []) {
2832
+ for (const node of orderOf(filePath, cls)) {
2833
+ if (node.opaque) {
2834
+ if (!node.safe) open = true;
2835
+ continue;
2836
+ }
2837
+ for (const mark of node.cls.marks || []) marks.add(mark);
2838
+ if (node.cls.marksOpen) open = true;
2839
+ }
2840
+ }
2841
+ selected = markSelects(options.markExpr, marks, open);
2842
+ }
2843
+ if (selected === false) return { ...unit, skipped: true, deselected: true };
2844
+ return selected == null ? { ...unit, conditional: true } : unit;
2845
+ });
2846
+ }
2847
+ units.sort((a, b) => a.startLine - b.startLine || (b.endLine - a.endLine) ||
2848
+ codeUnitCompare(a.name || '', b.name || ''));
2849
+ return {
2850
+ modeled: true,
2851
+ units,
2852
+ // A test that is declared and not executed is not counted.
2853
+ tests: units.filter(unit => unit.kind === 'test' && !unit.skipped).length,
2854
+ inheriting: more,
2855
+ };
2856
+ };
2857
+
2858
+ /**
2859
+ * A file's units: the tests and suites it declares, by line, and the
2860
+ * tests its runnable classes inherit (placed at the class). `modeled`
2861
+ * is false when the language's units are not read.
2862
+ */
2863
+ model.unitsOf = filePath => {
2864
+ if (model.byFile.has(filePath)) return model.byFile.get(filePath);
2865
+ const found = model.declared(filePath);
2866
+ let facts;
2867
+ if (!found) {
2868
+ facts = { modeled: false, units: [], tests: 0, inheriting: false };
2869
+ } else if (found.notCollected) {
2870
+ // A module its runner does not collect declares no test.
2871
+ facts = { modeled: true, units: [], tests: 0, inheriting: false };
2872
+ } else if (found.derived) {
2873
+ facts = derivedFacts(filePath, found);
2874
+ } else {
2875
+ const relativePath = index.files.get(filePath).relativePath;
2876
+ const runner = model.runnerOf(filePath) || {};
2877
+ const classesHere = [...(found.classes || [])];
2878
+ const within = (unit, cls) => unit.startLine >= cls.startLine && unit.endLine <= cls.endLine;
2879
+ // A setup that can stop a test, in a class the test's class
2880
+ // derives from: the test runs only sometimes.
2881
+ const guardedAbove = cls => cls.bases.length > 0 &&
2882
+ orderOf(filePath, cls).slice(1).some(node => !node.opaque && node.cls.guard);
2883
+ const guarded = classesHere.filter(cls => cls.runnable && guardedAbove(cls));
2884
+ // A class its runner refuses (one of its marked methods is one
2885
+ // the runner does not accept), or a class deriving from one:
2886
+ // none of its tests runs, its own or inherited.
2887
+ const refusalMemo = new Map();
2888
+ const refusalOf = cls => {
2889
+ if (!refusalMemo.has(cls)) {
2890
+ const order = orderOf(filePath, cls);
2891
+ const refused = order.some(node => !node.opaque && (node.cls.refused || node.cls.mayRefuse));
2892
+ // @RunWith is inherited, including through an
2893
+ // intermediate class that declares no tests. A runner
2894
+ // anywhere in the ancestry, or an unread base that may
2895
+ // name one, prevents a definite standard-runner refusal.
2896
+ const custom = order.some(node => node.opaque ? !node.safe : node.cls.runner || node.cls.mayRefuse);
2897
+ refusalMemo.set(cls, refused ? (custom ? 'conditional' : 'refused') : null);
2898
+ }
2899
+ return refusalMemo.get(cls);
2900
+ };
2901
+ const refusedHere = cls => refusalOf(cls) === 'refused';
2902
+ const maybeRefused = cls => refusalOf(cls) === 'conditional';
2903
+ const runsIn = unit => classesHere.filter(cls => within(unit, cls)).sort((a, b) => b.startLine - a.startLine)[0];
2904
+ // A focus leaves other tests of the file declared and not
2905
+ // executed, by the rule of the runner that runs the file.
2906
+ const left = found.focus ? focusedOut(found.units, model.runnerOf(filePath).id) : new Set();
2907
+ let units = found.units.map(unit => {
2908
+ let out = unit;
2909
+ if (unit.kind === 'test' && !unit.conditional && guarded.some(cls => within(unit, cls))) {
2910
+ out = { ...out, conditional: true };
2911
+ }
2912
+ if (left.has(unit)) out = { ...out, skipped: true };
2913
+ if (unit.kind === 'test' && !out.skipped) {
2914
+ const holder = runsIn(unit);
2915
+ if (holder && refusedHere(holder)) out = { ...out, skipped: true };
2916
+ else if (holder && maybeRefused(holder) && !out.conditional) out = { ...out, conditional: true };
2917
+ }
2918
+ return out;
2919
+ });
2920
+ // jest refuses a file that gives a table of cases no row
2921
+ // (measured): none of its tests runs.
2922
+ if (found.emptyTable && runner.id === 'jest') {
2923
+ units = units.map(unit => unit.kind === 'test' && !unit.skipped ? { ...unit, conditional: true } : unit);
2924
+ }
2925
+ // `open`: the file calls through a name that may be its
2926
+ // runner's declaring function, or hands that function on.
2927
+ let inheriting = !!found.inheriting || !!found.open;
2928
+ // A function of another module that declares tests when it is
2929
+ // called: the tests are this file's, and are not listed here.
2930
+ if ((found.calledModules || []).some(name => {
2931
+ const target = moduleFile(filePath, name);
2932
+ return !!target && target !== filePath && declaresWhenCalled(target);
2933
+ })) inheriting = true;
2934
+ // node runs a file that declares no test as one test.
2935
+ if (found.units.length === 0 && runner.id === 'node-test' &&
2936
+ nodeRunsFile(runner, relativeTo(runner.dir, relativePath))) inheriting = true;
2937
+ for (const cls of classesHere) {
2938
+ if (!cls.runnable || cls.bases.length === 0) continue;
2939
+ const inherited = inheritedBy(filePath, cls);
2940
+ const own = units.some(unit => unit.kind === 'test' && within(unit, cls));
2941
+ // A base that is not read may bring tests of its own.
2942
+ if (inherited.withheld || inherited.open) inheriting = true;
2943
+ if (inherited.tests.some(({ test }) => !test.needsClassMark || cls.marked) && !own) {
2944
+ units.push({
2945
+ kind: 'suite', name: cls.name, suite: cls.suite,
2946
+ line: cls.line, startLine: cls.startLine, endLine: cls.endLine,
2947
+ });
2948
+ }
2949
+ for (const { test, base, body, uncertain, maybe, again } of inherited.tests) {
2950
+ // A runner that wants the class that runs a test
2951
+ // marked takes none through one that is not.
2952
+ if (test.needsClassMark && !cls.marked) continue;
2953
+ // No command names a test whose runner cannot be told
2954
+ // which one it is under the class that inherits it, or
2955
+ // one that a method of the class may have replaced
2956
+ // (their parameter types are not resolved).
2957
+ const unnamed = test.dynamic || cls.dynamic || test.inheritedDynamic ||
2958
+ (uncertain && 'signature-not-resolved') || (again && 'reported-again');
2959
+ const state = {
2960
+ ...((test.skipped || cls.skipped || refusedHere(cls)) && { skipped: true }),
2961
+ ...((cls.sometimes || test.conditional || cls.guard || guardedAbove(cls) || uncertain || again || maybeRefused(cls)) && { conditional: true }),
2962
+ ...(unnamed && { dynamic: unnamed }),
2963
+ ...((test.several || cls.several) && { several: true }),
2964
+ // The bodies of the methods that may be the test's
2965
+ // instead, where the runner gives a test the body
2966
+ // of the method that replaces it: a change to one
2967
+ // of them may reach it.
2968
+ ...(uncertain && test.byDeclaration && maybe.length > 0 && {
2969
+ alternatives: maybe.map(other => ({
2970
+ file: index.files.get(other.at.filePath).relativePath,
2971
+ filePath: other.at.filePath, ownerLine: other.at.cls.startLine,
2972
+ startLine: other.row.startLine, endLine: other.row.endLine,
2973
+ })),
2974
+ }),
2975
+ };
2976
+ const suite = [...cls.suite, cls.name];
2977
+ // The class redeclares the member without the mark:
2978
+ // the test is the inherited one, its body the class's own.
2979
+ if (body && body.at.cls === cls && !uncertain && !again) {
2980
+ units.push({
2981
+ kind: 'test', name: test.name, suite, ...(cls.namespace && { namespace: cls.namespace }),
2982
+ line: body.row.line, startLine: body.row.startLine, endLine: body.row.endLine, ...state,
2983
+ });
2984
+ continue;
2985
+ }
2986
+ // Where the body stands: the declaration that makes the
2987
+ // test, or the nearest class that redeclares the member.
2988
+ const at = body ? body.at : base;
2989
+ const where = body ? body.row : test;
2990
+ units.push({
2991
+ kind: 'test', name: test.name, suite, ...(cls.namespace && { namespace: cls.namespace }),
2992
+ line: cls.line, startLine: cls.line, endLine: cls.line, ...state,
2993
+ inherited: {
2994
+ file: index.files.get(at.filePath).relativePath,
2995
+ filePath: at.filePath, owner: at.cls.name,
2996
+ ownerLine: at.cls.startLine, line: where.line,
2997
+ startLine: where.startLine, endLine: where.endLine,
2998
+ },
2999
+ });
3000
+ }
3001
+ // A mark on a class that makes its methods tests holds for
3002
+ // the classes deriving from it (`classMark`): the methods
3003
+ // this class declares that such a mark takes (`candidate`).
3004
+ if (!cls.classMark && orderOf(filePath, cls).slice(1).some(node => !node.opaque && node.cls.classMark)) {
3005
+ let added = false;
3006
+ for (const row of cls.memberRows || []) {
3007
+ if (!row.candidate || units.some(unit => unit.kind === 'test' && unit.name === row.name &&
3008
+ unit.startLine === row.startLine)) continue;
3009
+ added = true;
3010
+ units.push({
3011
+ kind: 'test', name: row.name, suite: [...cls.suite, cls.name],
3012
+ ...(cls.namespace && { namespace: cls.namespace }),
3013
+ line: row.line, startLine: row.startLine, endLine: row.endLine,
3014
+ ...(cls.skipped && { skipped: true }),
3015
+ ...((cls.sometimes || cls.guard || guardedAbove(cls)) && { conditional: true }),
3016
+ dynamic: 'declared-in-a-base-class',
3017
+ });
3018
+ }
3019
+ if (added && !own && inherited.tests.length === 0) {
3020
+ units.push({
3021
+ kind: 'suite', name: cls.name, suite: cls.suite,
3022
+ line: cls.line, startLine: cls.startLine, endLine: cls.endLine,
3023
+ });
3024
+ }
3025
+ }
3026
+ }
3027
+ // Code the runner hands the decision to: what it does is not
3028
+ // read, so a test under it runs only sometimes.
3029
+ if (gatedAtRunTime(filePath, runner)) {
3030
+ units = units.map(unit => unit.kind === 'test' && !unit.skipped ? { ...unit, conditional: true } : unit);
3031
+ }
3032
+ units.sort((a, b) => a.startLine - b.startLine || (b.endLine - a.endLine) ||
3033
+ codeUnitCompare(a.name || '', b.name || ''));
3034
+ facts = {
3035
+ modeled: true,
3036
+ units,
3037
+ // A test that is declared and not executed is not counted.
3038
+ tests: units.filter(unit => unit.kind === 'test' && !unit.skipped).length,
3039
+ inheriting,
3040
+ };
3041
+ }
3042
+ model.byFile.set(filePath, facts);
3043
+ return facts;
3044
+ };
3045
+
3046
+ /**
3047
+ * What has to be the same for two files to share one command: a hook
3048
+ * that decides for a whole run is loaded with every file below its
3049
+ * conftest.py, so a file from elsewhere joined to one of those would
3050
+ * run under a hook its own command never loads; a module that
3051
+ * switches a class of another module on or off does it for every
3052
+ * module loaded after it.
3053
+ */
3054
+ model.joinKey = filePath => {
3055
+ const found = model.declared(filePath);
3056
+ if (!found || !found.derived || found.notCollected) return '';
3057
+ // A module that writes a switch on a name it took from another
3058
+ // module changes that object for every module loaded after it:
3059
+ // it is run by itself.
3060
+ const alone = Object.keys((found.module && found.module.switches) || {}).length > 0 ||
3061
+ ((found.module && found.module.constructorWrites) || []).length > 0
3062
+ ? `\0switches:${index.files.get(filePath).relativePath}` : '';
3063
+ return guardsOf(filePath).wholeRun.join('\0') + alone;
3064
+ };
3065
+
3066
+ /** Classes that derive from the one named, through the project. */
3067
+ const descendantFiles = className => {
3068
+ const files = new Set();
3069
+ const seen = new Set([className]);
3070
+ const queue = [className];
3071
+ while (queue.length > 0 && seen.size < 512) {
3072
+ for (const child of index.extendedByGraph?.get(queue.shift()) || []) {
3073
+ const name = typeof child === 'string' ? child : child?.name;
3074
+ if (!name || seen.has(name)) continue;
3075
+ seen.add(name);
3076
+ queue.push(name);
3077
+ for (const def of index.symbols.get(name) || []) {
3078
+ if (CONTAINER_KINDS.has(def.type)) files.add(def.file);
3079
+ }
3080
+ }
3081
+ }
3082
+ return files;
3083
+ };
3084
+
3085
+ /**
3086
+ * The tests that execute a line of test code: the unit that holds it,
3087
+ * and every test a deriving class inherits from the method that holds
3088
+ * it. A line in a suite outside its tests answers with the suite. An
3089
+ * empty answer: the line belongs to no unit (file-level code).
3090
+ * @returns {Array<{filePath: string, unit: object}>}
3091
+ */
3092
+ model.runsAt = (filePath, line) => {
3093
+ const out = [];
3094
+ // The units of a file that may run this code. A module its runner
3095
+ // does not collect by its name declares none: it is not read for
3096
+ // the answer.
3097
+ const unitsIn = file => {
3098
+ if (!model.byFile.has(file)) {
3099
+ const entry = index.files.get(file);
3100
+ if (entry && collectionFor(file, entry).collected === false) return [];
3101
+ }
3102
+ return model.unitsOf(file).units;
3103
+ };
3104
+ const unit = model.unitAt(filePath, line);
3105
+ if (unit && unit.kind === 'test') out.push({ filePath, unit });
3106
+ const holder = (model.declared(filePath)?.classes || [])
3107
+ .filter(cls => cls.startLine <= line && cls.endLine >= line)
3108
+ .sort((a, b) => b.startLine - a.startLine)[0];
3109
+ // The method that holds the line: a test's declaration, or a method
3110
+ // a deriving class's test takes its body from.
3111
+ const template = holder && [...holder.tests, ...(holder.memberRows || [])]
3112
+ .find(row => row.startLine <= line && row.endLine >= line);
3113
+ const importers = [...(index.exportGraph?.get(filePath) || [])];
3114
+ if (template) {
3115
+ const held = from => !!from && from.filePath === filePath && from.ownerLine === holder.startLine &&
3116
+ from.startLine === template.startLine && from.endLine === template.endLine;
3117
+ for (const file of [...new Set([...descendantFiles(holder.name), ...importers, filePath])].sort(codeUnitCompare)) {
3118
+ for (const candidate of unitsIn(file)) {
3119
+ // Every test whose body this is: the one a deriving
3120
+ // class inherits, another name for it in its class;
3121
+ // and every test whose body it may be.
3122
+ if ((held(candidate.inherited) || (candidate.alternatives || []).some(held)) &&
3123
+ !out.some(seen => seen.filePath === file && seen.unit === candidate)) {
3124
+ out.push({ filePath: file, unit: candidate });
3125
+ }
3126
+ }
3127
+ }
3128
+ }
3129
+ // A function another module imports under a test's name runs there.
3130
+ const fn = holder ? null : (model.declared(filePath)?.functions || [])
3131
+ .find(other => other.startLine <= line && other.endLine >= line);
3132
+ if (fn) {
3133
+ for (const file of [...new Set([...importers, filePath])].sort(codeUnitCompare)) {
3134
+ for (const candidate of unitsIn(file)) {
3135
+ const from = candidate.inherited;
3136
+ if (from && from.filePath === filePath && from.owner == null && from.startLine === fn.startLine) {
3137
+ out.push({ filePath: file, unit: candidate });
3138
+ }
3139
+ }
3140
+ }
3141
+ }
3142
+ if (out.length === 0 && unit) out.push({ filePath, unit });
3143
+ return out;
3144
+ };
3145
+
3146
+ /**
3147
+ * Can a file hold test code at all? Asked before a file is read for
3148
+ * the tests in it. A language says so from what the index keeps
3149
+ * (`testCodeGate`): whether a declaration of the file carries a mark
3150
+ * that can make a test, and which types its classes derive from. No
3151
+ * mark here and none in the file of a class of the project one of its
3152
+ * classes derives from: no test, and no body of one. A language
3153
+ * without the gate is read. (`options.ungated` reads every file: the
3154
+ * gate must never change an answer.)
3155
+ */
3156
+ const gates = new Map();
3157
+ const projectDeclaresType = name => (index.symbols.get(name) || []).some(def => CONTAINER_KINDS.has(def.type));
3158
+ model.mayHoldTests = (filePath, trail = new Set()) => {
3159
+ if (options.ungated) return true;
3160
+ if (gates.has(filePath)) return gates.get(filePath);
3161
+ const entry = index.files.get(filePath);
3162
+ const gate = entry && langTraits(entry.language)?.testCodeGate;
3163
+ if (typeof gate !== 'function') return true;
3164
+ if (trail.has(filePath)) return false;
3165
+ let answer;
3166
+ try {
3167
+ const facts = gate(entry, projectDeclaresType);
3168
+ answer = !!facts.marked;
3169
+ // A base written under a using alias is the type the alias
3170
+ // names (the file's own, a global one, a project file's).
3171
+ const aliases = !answer && langTraits(entry.language)?.typeNameScoping === 'namespace-usings'
3172
+ ? csharpUsings(index, filePath) : null;
3173
+ if (aliases && aliases.unreadable) answer = true;
3174
+ const next = new Set(trail).add(filePath);
3175
+ for (const base of answer ? [] : facts.bases || []) {
3176
+ const names = [base];
3177
+ if (aliases && aliases.aliases.has(base)) names.push(String(aliases.aliases.get(base)).split('.').pop());
3178
+ for (const def of names.flatMap(name => index.symbols.get(name) || [])) {
3179
+ if (!CONTAINER_KINDS.has(def.type) || !def.file || def.file === filePath) continue;
3180
+ if (model.mayHoldTests(def.file, next)) {
3181
+ answer = true;
3182
+ break;
3183
+ }
3184
+ }
3185
+ if (answer) break;
3186
+ }
3187
+ } catch (e) {
3188
+ answer = true;
3189
+ }
3190
+ // An answer reached inside a cycle of classes is not kept: it may
3191
+ // be 'no' only because the walk came back to its start.
3192
+ if (answer || trail.size === 0) gates.set(filePath, answer);
3193
+ return answer;
3194
+ };
3195
+
3196
+ /**
3197
+ * The lines of a file a test executes, as [start, end] ranges: its own
3198
+ * tests and suites, and the methods and functions a test another file
3199
+ * lists runs as its body (a base class's test a deriving class
3200
+ * inherits, a method that gives an inherited test its body, a function
3201
+ * another module imports under a test's name). This is what makes code
3202
+ * test code, whatever its file is called: a base class in a file whose
3203
+ * name is no test file's holds the bodies of the tests that inherit
3204
+ * from it. Empty for a file the model knows no test in.
3205
+ */
3206
+ model.testRangesOf = filePath => {
3207
+ if (model.ranges.has(filePath)) return model.ranges.get(filePath);
3208
+ model.ranges.set(filePath, []);
3209
+ const ranges = [];
3210
+ const found = model.mayHoldTests(filePath) ? model.declared(filePath) : null;
3211
+ if (found) {
3212
+ for (const unit of model.unitsOf(filePath).units) {
3213
+ if (!unit.inherited) ranges.push([unit.startLine, unit.endLine]);
3214
+ }
3215
+ const covered = row => ranges.some(([start, end]) => row.startLine >= start && row.endLine <= end);
3216
+ const rows = [...(found.functions || [])];
3217
+ for (const cls of found.classes || []) {
3218
+ rows.push(...(cls.tests || []));
3219
+ // A method that is no test gives one its body only in a
3220
+ // class that inherits a test of its name.
3221
+ if (cls.bases.length === 0 || (cls.memberRows || []).length === 0) continue;
3222
+ const inherited = new Set();
3223
+ for (const node of orderOf(filePath, cls).slice(1)) {
3224
+ if (!node.opaque) for (const test of node.cls.tests || []) inherited.add(test.name);
3225
+ }
3226
+ if (inherited.size > 0) rows.push(...cls.memberRows.filter(row => inherited.has(row.name)));
3227
+ }
3228
+ rows.sort((a, b) => a.startLine - b.startLine || a.endLine - b.endLine);
3229
+ for (const row of rows) {
3230
+ if (row.startLine == null || row.endLine == null || covered(row)) continue;
3231
+ if (model.runsAt(filePath, row.startLine).length > 0) ranges.push([row.startLine, row.endLine]);
3232
+ }
3233
+ }
3234
+ model.ranges.set(filePath, ranges);
3235
+ return ranges;
3236
+ };
3237
+
3238
+ /** The innermost unit holding a line: a test before a suite. */
3239
+ model.unitAt = (filePath, line) => {
3240
+ let best = null;
3241
+ for (const unit of model.unitsOf(filePath).units) {
3242
+ if (unit.startLine > line) break;
3243
+ if (unit.endLine < line || unit.inherited) continue;
3244
+ if (!best || (unit.kind === 'test' && best.kind !== 'test') ||
3245
+ (unit.kind === best.kind && unit.line >= best.line)) best = unit;
3246
+ }
3247
+ return best;
3248
+ };
3249
+
3250
+ /** Tests a unit stands for: itself, or every test a suite holds. */
3251
+ model.testsWithin = (filePath, unit) => {
3252
+ const { units } = model.unitsOf(filePath);
3253
+ if (!unit) return units.filter(candidate => candidate.kind === 'test');
3254
+ if (unit.kind === 'test') return [unit];
3255
+ return units.filter(candidate => candidate.kind === 'test' &&
3256
+ candidate.startLine >= unit.startLine && candidate.endLine <= unit.endLine);
3257
+ };
3258
+
3259
+ /**
3260
+ * `tests`: declares a unit, or can hold tests the declarations do not
3261
+ * show. `support`: test-path code that is not a test. `unmodeled`: the
3262
+ * language's units are not read.
3263
+ */
3264
+ model.roleOf = filePath => {
3265
+ const facts = model.unitsOf(filePath);
3266
+ if (!facts.modeled) return 'unmodeled';
3267
+ if (facts.tests > 0 || facts.units.length > 0 || facts.inheriting) return 'tests';
3268
+ // A language whose tests are all declarations: none read, none there.
3269
+ if (model.declared(filePath)?.complete || model.declared(filePath)?.notCollected) return 'support';
3270
+ const fileEntry = index.files.get(filePath);
3271
+ // node runs every file it is told to, or looks for, as a test: a
3272
+ // script that declares none is one.
3273
+ const runner = model.runnerOf(filePath) || {};
3274
+ if (runner.id === 'node-test' && nodeRunsFile(runner, relativeTo(runner.dir, fileEntry.relativePath))) return 'tests';
3275
+ const named = langTraits(fileEntry.language)?.testFileNames || [];
3276
+ const base = path.posix.basename(fileEntry.relativePath);
3277
+ const collection = (model.runnerOf(filePath) || {}).collection || {};
3278
+ if (collection.files && collection.files.some(glob => globMatches(glob, base))) return 'tests';
3279
+ if (named.some(pattern => pattern.test(fileEntry.relativePath) || pattern.test(base))) return 'tests';
3280
+ return 'support';
3281
+ };
3282
+
3283
+ /** The command that runs a unit (or the file when `unit` is null). */
3284
+ model.selectorOf = (filePath, unit) => {
3285
+ const fileEntry = index.files.get(filePath);
3286
+ const runner = model.runnerOf(filePath);
3287
+ if (!runner.id) return withheld(runner.reason || 'no-runner-evidence');
3288
+ const build = SELECTORS[runner.id];
3289
+ if (!build) return withheld('runner-not-modeled');
3290
+ if (unit && unit.kind !== 'test') return withheld('not-a-test');
3291
+ if (unit && unit.name == null) return withheld('name-not-literal');
3292
+ if (unit && unit.dynamic) return withheld(unit.dynamic);
3293
+ // Runners select by name: two declarations of one name in one
3294
+ // suite are one selection.
3295
+ if (unit && model.unitsOf(filePath).units.some(other => other !== unit && other.kind === 'test' &&
3296
+ other.name === unit.name && other.suite.length === unit.suite.length &&
3297
+ other.suite.every((name, i) => name === unit.suite[i]) &&
3298
+ !(other.inherited && unit.inherited && other.startLine !== unit.startLine))) {
3299
+ return withheld('name-not-unique');
3300
+ }
3301
+ try {
3302
+ return build(model, runner, fileEntry, unit || null, filePath);
3303
+ } catch (e) {
3304
+ return withheld('selector-failed');
3305
+ }
3306
+ };
3307
+
3308
+ /** A unit as an answer row. */
3309
+ model.describe = (filePath, unit) => {
3310
+ const { selector, reason } = unit.kind === 'test' ? model.selectorOf(filePath, unit) : withheld('not-a-test');
3311
+ return {
3312
+ kind: unit.kind,
3313
+ name: unit.name,
3314
+ suite: unit.suite,
3315
+ line: unit.line,
3316
+ ...(unit.inherited && { inheritedFrom: `${unit.inherited.file}:${unit.inherited.line}` }),
3317
+ ...(unit.conditional && { conditional: true }),
3318
+ ...(unit.skipped && { skipped: true }),
3319
+ ...(unit.kind === 'test' && selector && { selector }),
3320
+ ...(unit.kind === 'test' && !selector && { selectorWithheld: reason }),
3321
+ };
3322
+ };
3323
+
3324
+ /** What a file is to a runner: role, runner, the command for the file. */
3325
+ model.fileFacts = filePath => {
3326
+ const role = model.roleOf(filePath);
3327
+ const runner = model.runnerOf(filePath);
3328
+ const facts = model.unitsOf(filePath);
3329
+ const out = { role, tests: facts.tests };
3330
+ // The runner reports more tests than are counted: the file holds
3331
+ // some that are not listed, or a listed one stands for several.
3332
+ if (role === 'tests' && (facts.inheriting ||
3333
+ facts.units.some(unit => unit.kind === 'test' && !unit.skipped && standsForSeveral(unit)))) out.moreTests = true;
3334
+ if (runner.id) out.runner = runner.id;
3335
+ if (role === 'tests') {
3336
+ const tests = facts.units.filter(unit => unit.kind === 'test');
3337
+ // A module its runner is told not to collect has no command.
3338
+ // A file whose tests are all declared inside functions has
3339
+ // them only where something calls those functions; one whose
3340
+ // every test is left out of some builds has no command that
3341
+ // is known to run it.
3342
+ const { selector, reason } = model.declared(filePath)?.off
3343
+ ? withheld('collection-switched-off')
3344
+ : tests.length > 0 && tests.every(unit => unit.dynamic === 'declared-indirectly')
3345
+ ? withheld('declared-indirectly')
3346
+ : tests.length > 0 && tests.every(unit => unit.notInEveryBuild)
3347
+ ? withheld('conditional-tests')
3348
+ : tests.length > 0 && tests.every(unit => unit.deselected)
3349
+ ? withheld('deselected-by-options') : model.selectorOf(filePath, null);
3350
+ if (selector) out.run = selector;
3351
+ else out.runWithheld = reason;
3352
+ }
3353
+ return out;
3354
+ };
3355
+
3356
+ return model;
3357
+ }
3358
+
3359
+ /**
3360
+ * Add a `test-case` row for every test that holds one of the matches that
3361
+ * count, and for every titled unit whose title names the term. The row
3362
+ * sits on the unit's first line; a line that already has a row keeps it.
3363
+ * @param {object[]} matches - rows of one file, extended in place
3364
+ * @param {{counts: function, titleNeedsMatch?: boolean, titles?: boolean, extra?: object}} options
3365
+ */
3366
+ function addTestCaseRows(model, index, filePath, term, matches, options) {
3367
+ const taken = new Set(matches.map(match => match.line));
3368
+ const counted = matches.filter(options.counts);
3369
+ const added = [];
3370
+ const add = unit => {
3371
+ if (taken.has(unit.startLine)) return;
3372
+ taken.add(unit.startLine);
3373
+ added.push({
3374
+ line: unit.startLine,
3375
+ content: (index.getLineContent(filePath, unit.startLine) || '').trim(),
3376
+ matchType: 'test-case',
3377
+ ...(options.extra || {}),
3378
+ });
3379
+ };
3380
+ if (!model.unitsOf(filePath).modeled) {
3381
+ // A language whose tests are not read as units: the function the
3382
+ // harness invokes around a match stands for the test. No name,
3383
+ // suite or command is claimed for it.
3384
+ const fileEntry = index.files.get(filePath);
3385
+ let kindOf;
3386
+ try {
3387
+ kindOf = getLanguageAdapter(fileEntry.language)?.getEntryPointKind || null;
3388
+ } catch (e) {
3389
+ kindOf = null;
3390
+ }
3391
+ for (const symbol of (kindOf && fileEntry.symbols) || []) {
3392
+ if (kindOf(symbol) !== 'test') continue;
3393
+ const end = symbol.endLine ?? symbol.startLine;
3394
+ if (counted.some(match => match.line >= symbol.startLine && match.line <= end)) {
3395
+ add({ startLine: symbol.startLine });
3396
+ }
3397
+ }
3398
+ matches.push(...added);
3399
+ return;
3400
+ }
3401
+ for (const match of counted) {
3402
+ const unit = model.unitAt(filePath, match.line);
3403
+ if (unit && unit.kind === 'test') add(unit);
3404
+ }
3405
+ if (options.titles !== false && term) {
3406
+ // A title is prose: the term counts where it stands as a word.
3407
+ const named = new RegExp('(^|[^\\w$])' + escapeRegExp(term) + '([^\\w$]|$)');
3408
+ for (const unit of model.unitsOf(filePath).units) {
3409
+ if (!unit.titled || unit.name == null || !named.test(unit.name)) continue;
3410
+ if (options.titleNeedsMatch &&
3411
+ !counted.some(match => match.line >= unit.startLine && match.line <= unit.endLine)) continue;
3412
+ add(unit);
3413
+ }
3414
+ }
3415
+ matches.push(...added);
3416
+ }
3417
+
3418
+ /**
3419
+ * One declaration: two tests of one name on two lines are two tests, and
3420
+ * so are two a class inherits under one name (two methods of that name
3421
+ * with other parameters), which stand on the class's line both.
3422
+ */
3423
+ function testKey(relativePath, unit) {
3424
+ return [relativePath, ...unit.suite, unit.name == null ? '' : unit.name, `@${unit.startLine}`,
3425
+ ...(unit.inherited ? [`<${unit.inherited.file}:${unit.inherited.startLine}`] : [])].join('\0');
3426
+ }
3427
+
3428
+ /**
3429
+ * What the lines of a test-path file that reach a symbol amount to: the
3430
+ * file's role and runner, the units of THIS file that execute those lines
3431
+ * (plus `extra`: units of this file that execute lines of another, handed
3432
+ * over by that file's answer), how many tests that is, and the command for
3433
+ * the file. `moreTests`: `tests` is a lower bound (the file holds tests
3434
+ * the reader does not list, or a counted declaration stands for several
3435
+ * of the runner's tests). `conditionalTests`: how many of the counted
3436
+ * tests run only sometimes. `carried` (not enumerable) holds the units of
3437
+ * OTHER files that execute these lines: a test a class inherits runs where
3438
+ * the class is.
3439
+ * @returns {{role: string, runner?: string, run?: object, runWithheld?: string,
3440
+ * units: object[], tests: number, moreTests?: boolean, conditionalTests?: number,
3441
+ * testKeys: string[], carried: Array<{filePath: string, unit: object}>}}
3442
+ */
3443
+ function linkFile(model, index, filePath, lines, extra = []) {
3444
+ const facts = model.fileFacts(filePath);
3445
+ const relativePath = index.files.get(filePath).relativePath;
3446
+ const rows = new Map();
3447
+ const tests = new Set();
3448
+ const sometimes = new Set();
3449
+ const carried = [];
3450
+ // Tests the file holds and does not list: which of them these lines
3451
+ // reach is not known.
3452
+ let more = facts.role === 'tests' && model.unitsOf(filePath).inheriting;
3453
+ const count = test => {
3454
+ if (test.skipped) return;
3455
+ tests.add(testKey(relativePath, test));
3456
+ if (test.conditional) sometimes.add(testKey(relativePath, test));
3457
+ if (standsForSeveral(test)) more = true;
3458
+ };
3459
+ const take = unit => {
3460
+ const key = `${unit.kind}\0${testKey(relativePath, unit)}`;
3461
+ if (!rows.has(key)) rows.set(key, model.describe(filePath, unit));
3462
+ for (const test of model.testsWithin(filePath, unit)) count(test);
3463
+ };
3464
+ let fileLevel = false;
3465
+ for (const line of [...new Set(lines)].sort((a, b) => a - b)) {
3466
+ const runs = model.runsAt(filePath, line);
3467
+ if (runs.length === 0) {
3468
+ fileLevel = true;
3469
+ continue;
3470
+ }
3471
+ for (const run of runs) {
3472
+ if (run.filePath === filePath) take(run.unit);
3473
+ else carried.push(run);
3474
+ }
3475
+ }
3476
+ for (const unit of extra) take(unit);
3477
+ // Code outside every unit runs with the file: all its tests count.
3478
+ if (fileLevel && facts.role === 'tests') {
3479
+ for (const test of model.testsWithin(filePath, null)) count(test);
3480
+ }
3481
+ const units = [...rows.values()].sort((a, b) => a.line - b.line || codeUnitCompare(a.name || '', b.name || ''));
3482
+ const out = {
3483
+ role: facts.role,
3484
+ ...(facts.runner && { runner: facts.runner }),
3485
+ ...(facts.run && { run: facts.run }),
3486
+ ...(facts.runWithheld && { runWithheld: facts.runWithheld }),
3487
+ units,
3488
+ tests: tests.size,
3489
+ // The runner reports more tests than are counted here: the
3490
+ // count is a lower bound.
3491
+ ...(more && { moreTests: true }),
3492
+ // Tests counted that run only sometimes.
3493
+ ...(sometimes.size > 0 && { conditionalTests: sometimes.size }),
3494
+ testKeys: [...tests].sort(codeUnitCompare),
3495
+ };
3496
+ Object.defineProperty(out, 'carried', { value: carried, enumerable: false });
3497
+ Object.defineProperty(out, 'conditionalKeys', { value: [...sometimes].sort(codeUnitCompare), enumerable: false });
3498
+ return out;
3499
+ }
3500
+
3501
+ /**
3502
+ * Link several files at once: each file's own lines, then the units that
3503
+ * other files' lines carried into it. A file that only receives carried
3504
+ * units (its class inherits a test whose body is elsewhere) gets a row of
3505
+ * its own.
3506
+ * @param {Map<string, number[]>} linesByFile - absolute path -> lines
3507
+ * @returns {Map<string, object>} absolute path -> linkFile answer, plus
3508
+ * `from`: the relative paths whose lines its carried units execute
3509
+ */
3510
+ function linkFiles(model, index, linesByFile) {
3511
+ const carriedInto = new Map(); // absolute path -> { units: Map, from: Set }
3512
+ for (const [filePath, lines] of linesByFile) {
3513
+ for (const run of linkFile(model, index, filePath, lines).carried) {
3514
+ let held = carriedInto.get(run.filePath);
3515
+ if (!held) {
3516
+ held = { units: new Map(), from: new Set() };
3517
+ carriedInto.set(run.filePath, held);
3518
+ }
3519
+ held.units.set(testKey('', run.unit), run.unit);
3520
+ held.from.add(index.files.get(filePath).relativePath);
3521
+ }
3522
+ }
3523
+ const out = new Map();
3524
+ const all = new Set([...linesByFile.keys(), ...carriedInto.keys()]);
3525
+ for (const filePath of [...all].sort(codeUnitCompare)) {
3526
+ const held = carriedInto.get(filePath);
3527
+ const linked = linkFile(model, index, filePath, linesByFile.get(filePath) || [],
3528
+ held ? [...held.units.values()] : []);
3529
+ if (held) linked.from = [...held.from].sort(codeUnitCompare);
3530
+ out.set(filePath, linked);
3531
+ }
3532
+ return out;
3533
+ }
3534
+
3535
+ // Runners whose file command ends in the path and takes several of them.
3536
+ const TAKES_MANY = new Set(['pytest', 'node-test', 'jest', 'vitest', 'mocha', 'go-test']);
3537
+ // Runners in which a focus in one file holds for every file of the run:
3538
+ // such a file is run by itself, or the others' tests would not run.
3539
+ const FOCUS_SPANS_THE_RUN = new Set(['mocha']);
3540
+
3541
+ /**
3542
+ * The commands that run a set of test files, files of one runner and one
3543
+ * working directory joined where the runner takes several paths.
3544
+ * @returns {string[]}
3545
+ */
3546
+ function commandsFor(model, filePaths) {
3547
+ const groups = new Map();
3548
+ const single = [];
3549
+ for (const filePath of filePaths) {
3550
+ // The file's own answer decides: a command it withholds is not
3551
+ // offered again here.
3552
+ const selector = model.fileFacts(filePath).run;
3553
+ if (!selector) continue;
3554
+ const runner = model.runnerOf(filePath);
3555
+ const cut = selector.command.lastIndexOf(' ');
3556
+ const last = selector.command.slice(cut + 1);
3557
+ if (!TAKES_MANY.has(runner.id) || cut < 0 || /['"\\]/.test(last) ||
3558
+ (FOCUS_SPANS_THE_RUN.has(runner.id) && model.declared(filePath)?.focus)) {
3559
+ if (!single.includes(selectorText(selector))) single.push(selectorText(selector));
3560
+ continue;
3561
+ }
3562
+ const head = selector.command.slice(0, cut);
3563
+ const key = `${selector.cwd || ''}\0${head}\0${model.joinKey(filePath)}`;
3564
+ if (!groups.has(key)) groups.set(key, { cwd: selector.cwd, head, paths: [] });
3565
+ const group = groups.get(key);
3566
+ if (!group.paths.includes(last)) group.paths.push(last);
3567
+ }
3568
+ const joined = [...groups.values()].map(group =>
3569
+ selectorText({ command: `${group.head} ${group.paths.join(' ')}`, ...(group.cwd && { cwd: group.cwd }) }));
3570
+ return [...joined, ...single];
3571
+ }
3572
+
3573
+ /** fnmatch as pytest applies it to a name: `*`, `?`, and `[...]`. */
3574
+ function globMatches(glob, name) {
3575
+ const source = String(glob).replace(/[.+^${}()|\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
3576
+ try {
3577
+ return new RegExp(`^${source}$`).test(name);
3578
+ } catch (e) {
3579
+ return false;
3580
+ }
3581
+ }
3582
+
3583
+ /** A selector as one shell line. */
3584
+ function selectorText(selector) {
3585
+ if (!selector) return null;
3586
+ return selector.cwd ? `(cd ${shellWord(selector.cwd)} && ${selector.command})` : selector.command;
3587
+ }
3588
+
3589
+ // Declarations whose names at run time are not the written one, or that
3590
+ // exist once for every call of the function around them.
3591
+ const SEVERAL_KINDS = new Set(['generated-cases', 'name-not-literal', 'suite-name-not-literal', 'declared-indirectly']);
3592
+
3593
+ /**
3594
+ * Does one declaration stand for several of the runner's tests: a table
3595
+ * of cases, a parametrized test, a title computed at run time, a
3596
+ * declaration inside a function that may be called more than once.
3597
+ */
3598
+ function standsForSeveral(unit) {
3599
+ return !!unit.several || SEVERAL_KINDS.has(unit.dynamic);
3600
+ }
3601
+
3602
+ /**
3603
+ * A count of tests as text: "3 tests", "at least 3 tests" when tests
3604
+ * that are not listed may add to it, "(1 conditional)" for the ones that
3605
+ * run only sometimes.
3606
+ */
3607
+ function testCountText(count, more, conditional) {
3608
+ if (!count) return more ? 'tests not listed' : '';
3609
+ return `${more ? 'at least ' : ''}${count} test${count === 1 ? '' : 's'}${conditional ? ` (${conditional} conditional)` : ''}`;
3610
+ }
3611
+
3612
+ /** A unit's name as people write it: `suite > suite > name`. */
3613
+ function unitLabel(unit) {
3614
+ return [...(unit.suite || []), unit.name == null ? '(computed name)' : unit.name].join(' > ');
3615
+ }
3616
+
3617
+ module.exports = {
3618
+ createTestModel, selectorText, unitLabel, shellWord, globMatches, testCountText, standsForSeveral,
3619
+ addTestCaseRows, linkFile, linkFiles, commandsFor,
3620
+ // Exposed for tests.
3621
+ scriptRunners, nodeTestOptions, configWords, tableText, namesRequirement, focusedOut,
3622
+ shellSplit, configArguments, pytestOptions, markSelects, pathGlob, nodeTestPaths, nodeRunsFile,
3623
+ mochaLoadingOptions, runnerScriptWords,
3624
+ };