jev-agent-tools 0.1.3

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 (95) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/LICENSE +21 -0
  3. package/README.md +119 -0
  4. package/docs/design.md +187 -0
  5. package/docs/tools/jev_ask.md +92 -0
  6. package/docs/tools/jev_ask_files.md +80 -0
  7. package/docs/tools/jev_check_diff.md +77 -0
  8. package/docs/tools/jev_find_files.md +59 -0
  9. package/docs/tools/jev_locate_in_file.md +51 -0
  10. package/docs/tools/jev_select_tests.md +59 -0
  11. package/package.json +85 -0
  12. package/rules/jev-ask.md +4 -0
  13. package/src/adapters/analysis-context.ts +100 -0
  14. package/src/adapters/ask-files.ts +236 -0
  15. package/src/adapters/ask-proof.ts +202 -0
  16. package/src/adapters/ask-syntax.ts +463 -0
  17. package/src/adapters/command.ts +240 -0
  18. package/src/adapters/docs.ts +222 -0
  19. package/src/adapters/files.ts +411 -0
  20. package/src/adapters/find.ts +151 -0
  21. package/src/adapters/git-base.ts +32 -0
  22. package/src/adapters/git-inventory.ts +94 -0
  23. package/src/adapters/git.ts +525 -0
  24. package/src/adapters/locate-file.ts +197 -0
  25. package/src/adapters/output-lines.ts +50 -0
  26. package/src/adapters/risk-callers.ts +525 -0
  27. package/src/adapters/runner-version.ts +102 -0
  28. package/src/adapters/syntax.ts +229 -0
  29. package/src/adapters/test-inventory.ts +168 -0
  30. package/src/adapters/usage.ts +21 -0
  31. package/src/adapters/utf8.ts +57 -0
  32. package/src/constants.ts +109 -0
  33. package/src/core/ask-closure.ts +419 -0
  34. package/src/core/ask-proof.ts +32 -0
  35. package/src/core/ask-references.ts +249 -0
  36. package/src/core/asks.ts +616 -0
  37. package/src/core/batches.ts +83 -0
  38. package/src/core/command-output.ts +249 -0
  39. package/src/core/diff.ts +226 -0
  40. package/src/core/docs.ts +399 -0
  41. package/src/core/find.ts +157 -0
  42. package/src/core/git.ts +5 -0
  43. package/src/core/import-boundaries.ts +102 -0
  44. package/src/core/imports.ts +691 -0
  45. package/src/core/integrity.ts +64 -0
  46. package/src/core/lexical.ts +130 -0
  47. package/src/core/locate.ts +213 -0
  48. package/src/core/output.ts +264 -0
  49. package/src/core/pointer.ts +51 -0
  50. package/src/core/risk-callers.ts +1270 -0
  51. package/src/core/runner-version.ts +66 -0
  52. package/src/core/sections.ts +269 -0
  53. package/src/core/state.ts +53 -0
  54. package/src/core/syntax.ts +8 -0
  55. package/src/core/test-commands.ts +430 -0
  56. package/src/core/test-coverage.ts +103 -0
  57. package/src/core/test-discovery.ts +1695 -0
  58. package/src/core/test-evidence.ts +649 -0
  59. package/src/core/test-state.ts +99 -0
  60. package/src/core/truncate.ts +14 -0
  61. package/src/core/units.ts +531 -0
  62. package/src/describe.ts +26 -0
  63. package/src/guide.ts +42 -0
  64. package/src/host.ts +22 -0
  65. package/src/index.ts +40 -0
  66. package/src/jev/client.ts +505 -0
  67. package/src/jev/pool.ts +60 -0
  68. package/src/jev/types.ts +60 -0
  69. package/src/presets/docs.ts +85 -0
  70. package/src/presets/risk.ts +263 -0
  71. package/src/presets/spec.ts +111 -0
  72. package/src/presets/witnesses.ts +313 -0
  73. package/src/render.ts +45 -0
  74. package/src/result.ts +4 -0
  75. package/src/run-end.ts +157 -0
  76. package/src/runtime.ts +16 -0
  77. package/src/session.ts +106 -0
  78. package/src/texts/ask-files.ts +2 -0
  79. package/src/texts/ask.ts +4 -0
  80. package/src/texts/check-diff.ts +24 -0
  81. package/src/texts/configuration.ts +2 -0
  82. package/src/texts/find.ts +14 -0
  83. package/src/texts/guide.ts +16 -0
  84. package/src/texts/locate.ts +10 -0
  85. package/src/texts/run-end.ts +13 -0
  86. package/src/texts/select-tests.ts +3 -0
  87. package/src/tools/ask-files.ts +263 -0
  88. package/src/tools/ask-schema.ts +116 -0
  89. package/src/tools/ask.ts +925 -0
  90. package/src/tools/check-diff.ts +510 -0
  91. package/src/tools/docs-check.ts +399 -0
  92. package/src/tools/find.ts +529 -0
  93. package/src/tools/locate.ts +369 -0
  94. package/src/tools/select-tests.ts +746 -0
  95. package/src/tools/spec-check.ts +210 -0
@@ -0,0 +1,1695 @@
1
+ import { posix } from "node:path";
2
+ import { isTestFile } from "./diff.ts";
3
+ import {
4
+ type LexicalSource,
5
+ type LexicalToken as Token,
6
+ tokenize,
7
+ } from "./lexical.ts";
8
+
9
+ export type Framework =
10
+ | "vitest"
11
+ | "jest"
12
+ | "node"
13
+ | "bun"
14
+ | "playwright"
15
+ | "go"
16
+ | "pytest"
17
+ | "ava"
18
+ | "mocha"
19
+ | "deno"
20
+ | "unknown";
21
+ export interface TestScenario {
22
+ id: string;
23
+ name: string;
24
+ line: number;
25
+ reliable: boolean;
26
+ nameParts?: readonly string[];
27
+ ancestors?: readonly string[];
28
+ range?: { start: number; end: number };
29
+ }
30
+ export interface TestEntry {
31
+ path: string;
32
+ framework: Framework;
33
+ runnerVersion?: string;
34
+ cwd: string;
35
+ config?: string;
36
+ project?: string;
37
+ options: readonly string[];
38
+ commandRefusal?: string;
39
+ nameFilterBlocked?: boolean;
40
+ nameFilterReason?: string;
41
+ invocation?: {
42
+ executable: string;
43
+ args: readonly string[];
44
+ kind: "runner" | "typecheck";
45
+ };
46
+ scenarios: readonly TestScenario[];
47
+ countKnown: boolean;
48
+ kind: "runtime" | "types";
49
+ provenance: string;
50
+ }
51
+ export interface Discovery {
52
+ entries: readonly TestEntry[];
53
+ evidence: readonly string[];
54
+ limits: readonly {
55
+ path: string;
56
+ kind:
57
+ | "incomplete"
58
+ | "unsupported"
59
+ | "unknown"
60
+ | "types"
61
+ | "local_runner_unproven"
62
+ | "interactive_script_skipped";
63
+ framework?: Framework;
64
+ }[];
65
+ }
66
+ type Literal =
67
+ | string
68
+ | boolean
69
+ | number
70
+ | null
71
+ | Literal[]
72
+ | { [key: string]: Literal };
73
+ const unknown = Symbol("computed");
74
+ type Parsed = Literal | typeof unknown;
75
+ function literal(
76
+ tokens: readonly Token[],
77
+ start: number,
78
+ ): { value: Parsed; end: number; complete: boolean } {
79
+ const t = tokens[start];
80
+ if (!t) return { value: unknown, end: start, complete: false };
81
+ if (t.string) return { value: t.value, end: start + 1, complete: true };
82
+ if (["true", "false", "null"].includes(t.value))
83
+ return {
84
+ value: t.value === "null" ? null : t.value === "true",
85
+ end: start + 1,
86
+ complete: true,
87
+ };
88
+ if (/^\d+$/.test(t.value))
89
+ return { value: Number(t.value), end: start + 1, complete: true };
90
+ if (t.value !== "{" && t.value !== "[")
91
+ return { value: unknown, end: start + 1, complete: false };
92
+ const object = t.value === "{";
93
+ const endToken = object ? "}" : "]";
94
+ const result: { [key: string]: Literal } = {};
95
+ const items: Literal[] = [];
96
+ let complete = true;
97
+ let i = start + 1;
98
+ while (i < tokens.length && tokens[i]?.value !== endToken) {
99
+ if (tokens[i]?.value === ",") {
100
+ i++;
101
+ continue;
102
+ }
103
+ const key = tokens[i]?.value ?? "";
104
+ if (object) {
105
+ if (tokens[i + 1]?.value !== ":") {
106
+ complete = false;
107
+ i = skipExpression(tokens, i, endToken);
108
+ continue;
109
+ }
110
+ i += 2;
111
+ }
112
+ const parsed = literal(tokens, i);
113
+ const delimiter = tokens[parsed.end]?.value;
114
+ if (
115
+ parsed.value !== unknown &&
116
+ (delimiter === "," || delimiter === endToken)
117
+ ) {
118
+ if (object) result[key] = parsed.value;
119
+ else items.push(parsed.value);
120
+ complete &&= parsed.complete;
121
+ i = parsed.end;
122
+ } else {
123
+ complete = false;
124
+ i = skipExpression(tokens, i, endToken);
125
+ }
126
+ }
127
+ return {
128
+ value: object ? result : items,
129
+ end: i + 1,
130
+ complete: complete && tokens[i]?.value === endToken,
131
+ };
132
+ }
133
+ function skipExpression(
134
+ tokens: readonly Token[],
135
+ start: number,
136
+ endToken: string,
137
+ ): number {
138
+ let depth = 0;
139
+ let i = start;
140
+ for (; i < tokens.length; i++) {
141
+ const v = tokens[i]?.value ?? "";
142
+ if (depth === 0 && (v === "," || v === endToken))
143
+ return v === "," ? i + 1 : i;
144
+ if (["{", "[", "("].includes(v)) depth++;
145
+ if (["}", "]", ")"].includes(v)) depth--;
146
+ }
147
+ return i;
148
+ }
149
+ function record(value: Parsed | undefined): { [key: string]: Literal } {
150
+ return value && typeof value === "object" && !Array.isArray(value)
151
+ ? value
152
+ : {};
153
+ }
154
+ function strings(value: Literal | undefined): string[] {
155
+ return typeof value === "string"
156
+ ? [value]
157
+ : Array.isArray(value)
158
+ ? value.filter((v): v is string => typeof v === "string")
159
+ : [];
160
+ }
161
+ function expand(pattern: string): string[] {
162
+ const braces = /\{([^{}]+)\}/.exec(pattern);
163
+ if (braces)
164
+ return (braces[1] ?? "")
165
+ .split(",")
166
+ .flatMap((part) =>
167
+ expand(
168
+ pattern.slice(0, braces.index) +
169
+ part +
170
+ pattern.slice(braces.index + braces[0].length),
171
+ ),
172
+ );
173
+ const group = /([?@+*]?)\(([^()]+)\)/.exec(pattern);
174
+ if (group) {
175
+ const choices = (group[2] ?? "").split("|");
176
+ if (group[1] === "?") choices.push("");
177
+ return choices.flatMap((part) =>
178
+ expand(
179
+ pattern.slice(0, group.index) +
180
+ part +
181
+ pattern.slice(group.index + group[0].length),
182
+ ),
183
+ );
184
+ }
185
+ return [pattern];
186
+ }
187
+ function matches(path: string, pattern: string): boolean {
188
+ return expand(pattern.replace(/^\.\//, "")).some((p) => {
189
+ let expression = "^";
190
+ for (let i = 0; i < p.length; i++) {
191
+ const c = p[i] ?? "";
192
+ if (c === "*" && p[i + 1] === "*") {
193
+ i++;
194
+ if (p[i + 1] === "/") {
195
+ expression += "(?:.*/)?";
196
+ i++;
197
+ } else expression += ".*";
198
+ } else if (c === "*") expression += "[^/]*";
199
+ else if (c === "?") expression += "[^/]";
200
+ else if (c === "[" && p.indexOf("]", i + 1) > i + 1) {
201
+ const end = p.indexOf("]", i + 1);
202
+ const content = p.slice(i + 1, end);
203
+ expression += `[${content.startsWith("!") ? `^${content.slice(1)}` : content}]`;
204
+ i = end;
205
+ } else expression += c.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
206
+ }
207
+ return new RegExp(`${expression}$`).test(path);
208
+ });
209
+ }
210
+ function shellWords(text: string): string[] {
211
+ const words: string[] = [];
212
+ let current = "";
213
+ let quote = "";
214
+ for (let i = 0; i < text.length; i++) {
215
+ const c = text[i] ?? "";
216
+ if (c === "\\" && quote !== "'") current += text[++i] ?? "";
217
+ else if (quote) {
218
+ if (c === quote) quote = "";
219
+ else current += c;
220
+ } else if (c === "'" || c === '"') quote = c;
221
+ else if (/\s/.test(c)) {
222
+ if (current) {
223
+ words.push(current);
224
+ current = "";
225
+ }
226
+ } else if (";&|<>".includes(c)) {
227
+ if (current) words.push(current);
228
+ words.push(c);
229
+ current = "";
230
+ } else current += c;
231
+ }
232
+ if (current) words.push(current);
233
+ return words;
234
+ }
235
+ const moduleFramework: Record<string, Framework> = {
236
+ vitest: "vitest",
237
+ "@jest/globals": "jest",
238
+ "node:test": "node",
239
+ "bun:test": "bun",
240
+ "@playwright/test": "playwright",
241
+ ava: "ava",
242
+ mocha: "mocha",
243
+ };
244
+ interface Imports {
245
+ frameworks: Set<Framework>;
246
+ tests: Set<string>;
247
+ suites: Set<string>;
248
+ }
249
+ function imports(tokens: readonly Token[]): Imports {
250
+ const result: Imports = {
251
+ frameworks: new Set(),
252
+ tests: new Set(),
253
+ suites: new Set(),
254
+ };
255
+ for (let i = 0; i < tokens.length; i++) {
256
+ const t = tokens[i];
257
+ if (!t) continue;
258
+ if (
259
+ !t.string &&
260
+ t.value === "import" &&
261
+ tokens[i + 1]?.value !== "(" &&
262
+ tokens[i + 1]?.value !== "."
263
+ ) {
264
+ let end = i + 1;
265
+ while (
266
+ end < tokens.length &&
267
+ !tokens[end]?.string &&
268
+ tokens[end]?.value !== ";"
269
+ )
270
+ end++;
271
+ const source = tokens[end];
272
+ const framework = source?.string
273
+ ? moduleFramework[source.value]
274
+ : undefined;
275
+ if (!framework) continue;
276
+ result.frameworks.add(framework);
277
+ for (let j = i + 1; j < end; j++) {
278
+ const name = tokens[j]?.value ?? "";
279
+ const local =
280
+ tokens[j + 1]?.value === "as" ? tokens[j + 2]?.value : name;
281
+ if (["test", "it", "default"].includes(name) && local)
282
+ result.tests.add(local);
283
+ if (["describe", "suite"].includes(name) && local)
284
+ result.suites.add(local);
285
+ }
286
+ if (tokens[i + 1]?.value !== "{")
287
+ result.tests.add(tokens[i + 1]?.value ?? "test");
288
+ }
289
+ if (
290
+ t.value === "require" &&
291
+ tokens[i + 1]?.value === "(" &&
292
+ tokens[i + 2]?.string &&
293
+ tokens[i + 3]?.value === ")"
294
+ ) {
295
+ const framework = moduleFramework[tokens[i + 2]?.value ?? ""];
296
+ if (framework) {
297
+ result.frameworks.add(framework);
298
+ result.tests.add("test");
299
+ result.tests.add("it");
300
+ result.suites.add("describe");
301
+ }
302
+ }
303
+ if (
304
+ t.value === "Deno" &&
305
+ tokens[i + 1]?.value === "." &&
306
+ tokens[i + 2]?.value === "test"
307
+ )
308
+ result.frameworks.add("deno");
309
+ }
310
+ return result;
311
+ }
312
+ function jsScenarios(
313
+ tokens: readonly Token[],
314
+ framework: Framework,
315
+ bindings: Imports,
316
+ ): { scenarios: TestScenario[]; countKnown: boolean } {
317
+ const tests = new Set([...bindings.tests, "test", "it"]);
318
+ const suites = new Set([...bindings.suites, "describe", "suite"]);
319
+ if (framework === "mocha") {
320
+ for (const name of ["specify", "xit", "xspecify"]) tests.add(name);
321
+ for (const name of ["context", "xcontext", "xdescribe"]) suites.add(name);
322
+ }
323
+ const result: TestScenario[] = [];
324
+ const scopes: { end: number; name: string; reliable: boolean }[] = [];
325
+ let countKnown = true;
326
+ for (let i = 0; i < tokens.length; i++) {
327
+ while (scopes.length && (scopes[scopes.length - 1]?.end ?? Infinity) < i)
328
+ scopes.pop();
329
+ const t = tokens[i];
330
+ if (!t) continue;
331
+ if (t.string || (!tests.has(t.value) && !suites.has(t.value))) continue;
332
+ if (
333
+ tokens[i - 1]?.value === "." &&
334
+ !["t", "ctx", "context", "Deno"].includes(tokens[i - 2]?.value ?? "")
335
+ )
336
+ continue;
337
+ let suite = suites.has(t.value);
338
+ let j = i + 1;
339
+ let parameterized = false;
340
+ let knownModifiers = true;
341
+ let pendingModifier = false;
342
+ while (tokens[j]?.value === ".") {
343
+ const modifier = tokens[j + 1]?.value ?? "";
344
+ pendingModifier ||= modifier === "todo" || modifier === "skip";
345
+ if (["describe", "suite"].includes(modifier)) suite = true;
346
+ if (["each", "for"].includes(modifier)) parameterized = true;
347
+ knownModifiers &&= [
348
+ "describe",
349
+ "suite",
350
+ "each",
351
+ "for",
352
+ "skipIf",
353
+ "runIf",
354
+ "if",
355
+ "only",
356
+ "skip",
357
+ "todo",
358
+ "concurrent",
359
+ "sequential",
360
+ "fails",
361
+ "serial",
362
+ "failing",
363
+ "cb",
364
+ "before",
365
+ "after",
366
+ "beforeEach",
367
+ "afterEach",
368
+ "always",
369
+ ].includes(modifier);
370
+ j += 2;
371
+ if (
372
+ ["each", "for", "skipIf", "runIf", "if"].includes(modifier) &&
373
+ tokens[j]?.value === "("
374
+ ) {
375
+ j = closing(tokens, j) + 1;
376
+ }
377
+ }
378
+ if (tokens[j]?.value !== "(") continue;
379
+ const end = closing(tokens, j);
380
+ const args: { start: number; end: number }[] = [];
381
+ let start = j + 1;
382
+ for (let k = start; k < end; k++) {
383
+ const token = tokens[k];
384
+ if (token?.string) continue;
385
+ if (["(", "[", "{"].includes(token?.value ?? "")) {
386
+ k = closing(tokens, k);
387
+ } else if (token?.value === ",") {
388
+ args.push({ start, end: k });
389
+ start = k + 1;
390
+ }
391
+ }
392
+ if (start < end) args.push({ start, end });
393
+ const title = tokens[j + 1];
394
+ const callback = args.find((arg) => {
395
+ const head = tokens[arg.start]?.value;
396
+ if (
397
+ head === "function" ||
398
+ (head === "async" && tokens[arg.start + 1]?.value === "function")
399
+ )
400
+ return true;
401
+ for (let k = arg.start; k < arg.end; k++) {
402
+ const token = tokens[k];
403
+ if (token?.string) continue;
404
+ if (["(", "[", "{"].includes(token?.value ?? ""))
405
+ k = closing(tokens, k);
406
+ else if (token?.value === "=" && tokens[k + 1]?.value === ">")
407
+ return true;
408
+ }
409
+ return false;
410
+ });
411
+ const optionsArg = title?.string ? args[1] : args[0];
412
+ const nodeOptions =
413
+ framework === "node" && tokens[optionsArg?.start ?? end]?.value === "{"
414
+ ? record(literal(tokens, optionsArg?.start ?? end).value)
415
+ : {};
416
+ const callbackFreeNode =
417
+ framework === "node" &&
418
+ !suite &&
419
+ !callback &&
420
+ (args.length === 1 ||
421
+ (args.length === 2 && tokens[args[1]?.start ?? end]?.value === "{"));
422
+ if (
423
+ callbackFreeNode &&
424
+ (pendingModifier || nodeOptions.todo || nodeOptions.skip)
425
+ )
426
+ continue;
427
+ // Unlike JS runners, Node executes bare literal declarations as passing tests.
428
+ if (
429
+ !callbackFreeNode &&
430
+ !callback &&
431
+ title?.string &&
432
+ (args.length <= 1 ||
433
+ (args.length === 2 && tokens[args[1]?.start ?? end]?.value === "{"))
434
+ )
435
+ continue;
436
+ if (!args.length) {
437
+ countKnown = false;
438
+ continue;
439
+ }
440
+ const functionStart =
441
+ callback && tokens[callback.start]?.value === "async"
442
+ ? callback.start + 1
443
+ : callback?.start;
444
+ const namedFunction =
445
+ framework === "node" &&
446
+ callback?.start === j + 1 &&
447
+ functionStart !== undefined &&
448
+ tokens[functionStart]?.value === "function"
449
+ ? tokens[
450
+ functionStart + (tokens[functionStart + 1]?.value === "*" ? 2 : 1)
451
+ ]
452
+ : undefined;
453
+ const objectTitle =
454
+ framework === "node" && title?.value === "{"
455
+ ? record(literal(tokens, j + 1).value).name
456
+ : undefined;
457
+ const staticName =
458
+ title?.string && args[0]?.end === j + 2
459
+ ? title.value
460
+ : typeof objectTitle === "string"
461
+ ? objectTitle
462
+ : namedFunction && /^[a-zA-Z_$][\w$]*$/.test(namedFunction.value)
463
+ ? namedFunction.value
464
+ : undefined;
465
+ const reliable =
466
+ staticName !== undefined &&
467
+ (!!callback || callbackFreeNode) &&
468
+ knownModifiers &&
469
+ !parameterized &&
470
+ scopes.every((s) => s.reliable);
471
+ const name = staticName ?? `<dynamic:${t.line}>`;
472
+ if (!reliable) countKnown = false;
473
+ const ancestors = scopes.map((s) => s.name);
474
+ if (suite) scopes.push({ end, name, reliable });
475
+ else {
476
+ const nativeName = [...ancestors, name].join(
477
+ framework === "playwright" ? " › " : " ",
478
+ );
479
+ const nativeAncestors =
480
+ framework === "node"
481
+ ? ancestors.map((_, index) => ancestors.slice(0, index + 1).join(" "))
482
+ : ancestors;
483
+ result.push({
484
+ id: `s${result.length + 1}`,
485
+ name: nativeName,
486
+ nameParts: [...ancestors, name],
487
+ line: t.line,
488
+ reliable,
489
+ ...(tokens[end]
490
+ ? { range: { start: t.line, end: tokens[end]?.line ?? t.line } }
491
+ : {}),
492
+ ...(ancestors.length || framework === "node"
493
+ ? { ancestors: nativeAncestors }
494
+ : {}),
495
+ });
496
+ if (framework === "node") scopes.push({ end, name, reliable });
497
+ }
498
+ }
499
+ const names = new Set<string>();
500
+ for (const scenario of result) {
501
+ if (names.has(scenario.name)) {
502
+ countKnown = false;
503
+ for (const same of result)
504
+ if (same.name === scenario.name) same.reliable = false;
505
+ }
506
+ names.add(scenario.name);
507
+ }
508
+ return { scenarios: result, countKnown };
509
+ }
510
+ function closing(tokens: readonly Token[], start: number): number {
511
+ const open = tokens[start]?.value;
512
+ const close = open === "(" ? ")" : open === "[" ? "]" : "}";
513
+ let depth = 0;
514
+ for (let i = start; i < tokens.length; i++) {
515
+ if (tokens[i]?.string) continue;
516
+ if (tokens[i]?.value === open) depth++;
517
+ if (tokens[i]?.value === close && --depth === 0) return i;
518
+ }
519
+ return tokens.length;
520
+ }
521
+ function pythonScenarios(
522
+ text: string,
523
+ config: { [key: string]: Literal },
524
+ ): { scenarios: TestScenario[]; countKnown: boolean } {
525
+ const result: TestScenario[] = [];
526
+ const classes: { name: string; indent: number }[] = [];
527
+ let decorators: string[] = [];
528
+ let decoratorStart: number | undefined;
529
+ let countKnown = true;
530
+ const functionPatterns = strings(config.python_functions).flatMap(shellWords);
531
+ const classPatterns = strings(config.python_classes).flatMap(shellWords);
532
+ const source = text.split("\n");
533
+ for (let i = 0; i < source.length; i++) {
534
+ const line = source[i] ?? "";
535
+ const tokens = tokenize(line, true);
536
+ if (!tokens.length) continue;
537
+ const indent = line.length - line.trimStart().length;
538
+ while (
539
+ classes.length &&
540
+ indent <= (classes[classes.length - 1]?.indent ?? -1)
541
+ )
542
+ classes.pop();
543
+ if (tokens[0]?.value === "@") {
544
+ decoratorStart ??= i + 1;
545
+ let decoration = line.trim();
546
+ let balance =
547
+ tokens.filter((t) => !t.string && ["(", "[", "{"].includes(t.value))
548
+ .length -
549
+ tokens.filter((t) => !t.string && [")", "]", "}"].includes(t.value))
550
+ .length;
551
+ while (balance > 0 && i + 1 < source.length) {
552
+ const next = source[++i] ?? "";
553
+ decoration += `\n${next}`;
554
+ const nextTokens = tokenize(next, true);
555
+ balance +=
556
+ nextTokens.filter(
557
+ (t) => !t.string && ["(", "[", "{"].includes(t.value),
558
+ ).length -
559
+ nextTokens.filter(
560
+ (t) => !t.string && [")", "]", "}"].includes(t.value),
561
+ ).length;
562
+ }
563
+ decorators.push(decoration);
564
+ continue;
565
+ }
566
+ if (tokens[0]?.value === "class") {
567
+ const name = tokens[1]?.value ?? "";
568
+ if (
569
+ (classPatterns.length ? classPatterns : ["Test*"]).some((p) =>
570
+ matches(name, p),
571
+ ) ||
572
+ tokens.some((t) => t.value === "TestCase")
573
+ )
574
+ classes.push({ name, indent });
575
+ }
576
+ const def = tokens[0]?.value === "async" ? 1 : 0;
577
+ if (tokens[def]?.value === "def") {
578
+ const name = tokens[def + 1]?.value ?? "";
579
+ const inClass = indent > 0;
580
+ if (
581
+ (!inClass || classes.length) &&
582
+ (functionPatterns.length ? functionPatterns : ["test_*"]).some((p) =>
583
+ matches(name, p),
584
+ )
585
+ ) {
586
+ const qualified = [...classes.map((c) => c.name), name].join("::");
587
+ const parameterDecorators = decorators
588
+ .map((text) => tokenize(text, true))
589
+ .filter((d) => d.some((t) => t.value === "parametrize"));
590
+ let parameterIds: string[] | undefined;
591
+ if (parameterDecorators.length === 1) {
592
+ const decoration = parameterDecorators[0] ?? [];
593
+ const idsIndex = decoration.findIndex(
594
+ (t, index) =>
595
+ t.value === "ids" && decoration[index + 1]?.value === "=",
596
+ );
597
+ const ids = literal(decoration, idsIndex + 2);
598
+ const call =
599
+ decoration.findIndex((t) => t.value === "parametrize") + 1;
600
+ const argument = literal(decoration, call + 1);
601
+ const values = literal(decoration, argument.end + 1);
602
+ if (
603
+ idsIndex >= 0 &&
604
+ ids.complete &&
605
+ values.complete &&
606
+ Array.isArray(ids.value) &&
607
+ Array.isArray(values.value) &&
608
+ ids.value.length === values.value.length &&
609
+ ids.value.length > 0 &&
610
+ ids.value.every(
611
+ (id) =>
612
+ typeof id === "string" &&
613
+ /^[\x20-\x7e]+$/.test(id) &&
614
+ !id.includes("[") &&
615
+ !id.includes("]") &&
616
+ !id.includes("\\"),
617
+ ) &&
618
+ new Set(ids.value).size === ids.value.length
619
+ )
620
+ parameterIds = ids.value as string[];
621
+ }
622
+ // Generated IDs, stacked decorators and collection hooks stay conservative.
623
+ const reliable = !parameterDecorators.length || !!parameterIds;
624
+ if (!reliable) countKnown = false;
625
+ let endLine = i + 1;
626
+ for (let bodyLine = i + 1; bodyLine < source.length; bodyLine++) {
627
+ const body = source[bodyLine] ?? "";
628
+ if (!body.trim() || body.trimStart().startsWith("#")) continue;
629
+ if (body.length - body.trimStart().length <= indent) break;
630
+ endLine = bodyLine + 1;
631
+ }
632
+ for (const nodeName of parameterIds
633
+ ? parameterIds.map((id) => `${qualified}[${id}]`)
634
+ : [qualified]) {
635
+ result.push({
636
+ id: nodeName,
637
+ name: nodeName,
638
+ line: i + 1,
639
+ reliable,
640
+ range: { start: decoratorStart ?? i + 1, end: endLine },
641
+ ...(classes.length
642
+ ? { ancestors: classes.map((c) => c.name) }
643
+ : {}),
644
+ });
645
+ }
646
+ }
647
+ }
648
+ decorators = [];
649
+ decoratorStart = undefined;
650
+ }
651
+ return { scenarios: result, countKnown };
652
+ }
653
+ interface Group {
654
+ framework: Framework;
655
+ cwd: string;
656
+ config?: string;
657
+ project?: string;
658
+ include: string[];
659
+ exclude: string[];
660
+ setup: string[];
661
+ options: string[];
662
+ settings: { [key: string]: Literal };
663
+ provenance: string;
664
+ incomplete: boolean;
665
+ types?: boolean;
666
+ }
667
+ function native(path: string, framework: Framework): boolean {
668
+ if (framework === "go") return /_test\.go$/.test(path);
669
+ if (framework === "pytest")
670
+ return /(?:^|\/)(?:test_[^/]+|[^/]+_test)\.py$/.test(path);
671
+ if (framework === "bun")
672
+ return /(?:\.(?:test|spec)|_(?:test|spec))\.[cm]?[jt]sx?$/.test(path);
673
+ if (framework === "jest" && /(?:^|\/)__tests__\/.*\.[cm]?[jt]sx?$/.test(path))
674
+ return true;
675
+ if (framework === "ava")
676
+ return /(?:^|\/)test(?:s)?\/.*\.[cm]?[jt]sx?$/.test(path);
677
+ return (
678
+ /\.(?:test|spec|vitest)\.[cm]?[jt]sx?$/.test(path) ||
679
+ /(?:^|\/)test\.[cm]?[jt]s$/.test(path)
680
+ );
681
+ }
682
+ function directory(path: string): string {
683
+ const dir = posix.dirname(path);
684
+ return dir === "." ? "." : dir;
685
+ }
686
+ function relative(cwd: string, path: string): string {
687
+ return posix.relative(cwd, path);
688
+ }
689
+ function under(cwd: string, path: string): boolean {
690
+ const rel = relative(cwd, path);
691
+ return rel !== ".." && !rel.startsWith("../");
692
+ }
693
+ function configFramework(path: string): Framework | undefined {
694
+ const name = posix.basename(path);
695
+ if (
696
+ !/\.(?:setup|workspace)\./.test(name) &&
697
+ /^vitest(?:\.[\w-]+)*\.(?:[cm]?[jt]s)$/.test(name)
698
+ )
699
+ return "vitest";
700
+ if (/^jest\.config\./.test(name)) return "jest";
701
+ if (/^playwright\.config\./.test(name)) return "playwright";
702
+ if (["pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini"].includes(name))
703
+ return "pytest";
704
+ if (name === "bunfig.toml") return "bun";
705
+ if (name === "deno.json" || name === "deno.jsonc") return "deno";
706
+ if (/^\.mocharc\./.test(name)) return "mocha";
707
+ if (/^ava\.config\.[cm]?js$/.test(name)) return "ava";
708
+ return undefined;
709
+ }
710
+
711
+ /** Configuration files needed before planning configured test paths. */
712
+ export function isTestConfiguration(path: string): boolean {
713
+ const name = posix.basename(path);
714
+ return (
715
+ configFramework(path) !== undefined ||
716
+ name === "package.json" ||
717
+ /^vitest\.workspace\.[cm]?[jt]s$/.test(name)
718
+ );
719
+ }
720
+ function iniSettings(
721
+ text: string,
722
+ path: string,
723
+ ): { settings: { [key: string]: Literal }; complete: boolean } | undefined {
724
+ const settings: { [key: string]: Literal } = {};
725
+ let active = posix.basename(path) === "pytest.ini";
726
+ let seen = active;
727
+ let key = "";
728
+ let complete = true;
729
+ const source = text.split("\n");
730
+ for (let lineIndex = 0; lineIndex < source.length; lineIndex++) {
731
+ const line = source[lineIndex] ?? "";
732
+ const trimmed = line.trim();
733
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";"))
734
+ continue;
735
+ if (trimmed.startsWith("[")) {
736
+ active = [
737
+ "[pytest]",
738
+ "[tool.pytest.ini_options]",
739
+ "[tool:pytest]",
740
+ "[test]",
741
+ ].includes(trimmed);
742
+ seen ||= active;
743
+ continue;
744
+ }
745
+ if (!active) continue;
746
+ const equal = line.indexOf("=");
747
+ if (equal >= 0) {
748
+ key = line.slice(0, equal).trim();
749
+ let value = line.slice(equal + 1).trim();
750
+ if (value.startsWith("[")) {
751
+ while (
752
+ !literal(tokenize(value), 0).complete &&
753
+ lineIndex + 1 < source.length
754
+ )
755
+ value += `\n${source[++lineIndex] ?? ""}`;
756
+ }
757
+ if (
758
+ value.startsWith("[") ||
759
+ value.startsWith('"') ||
760
+ value.startsWith("'")
761
+ ) {
762
+ const parsed = literal(tokenize(value), 0);
763
+ if (parsed.value !== unknown) settings[key] = parsed.value;
764
+ complete &&= parsed.complete;
765
+ } else settings[key] = value;
766
+ } else if (/^\s/.test(line) && key)
767
+ settings[key] = `${String(settings[key] ?? "")} ${trimmed}`;
768
+ else complete = false;
769
+ }
770
+ return seen ? { settings, complete } : undefined;
771
+ }
772
+ function readConfig(
773
+ text: string,
774
+ tokens = tokenize(text),
775
+ ): {
776
+ settings: { [key: string]: Literal };
777
+ complete: boolean;
778
+ } {
779
+ let start = -1;
780
+ for (let i = 0; i < tokens.length; i++) {
781
+ if (tokens[i]?.value === "default" && tokens[i - 1]?.value === "export") {
782
+ start = i + 1;
783
+ if (
784
+ tokens[start]?.value === "defineConfig" &&
785
+ tokens[start + 1]?.value === "("
786
+ )
787
+ start += 2;
788
+ break;
789
+ }
790
+ if (tokens[i]?.value === "exports" && tokens[i + 1]?.value === "=") {
791
+ start = i + 2;
792
+ break;
793
+ }
794
+ }
795
+ if (start < 0 && tokens[0]?.value === "{") start = 0;
796
+ const parsed = literal(tokens, start);
797
+ return {
798
+ settings: record(parsed.value),
799
+ complete:
800
+ parsed.complete && !!tokens[start] && tokens[start]?.value === "{",
801
+ };
802
+ }
803
+ function groupFrom(
804
+ framework: Framework,
805
+ cwd: string,
806
+ config: string,
807
+ settings: { [key: string]: Literal },
808
+ complete: boolean,
809
+ project?: string,
810
+ ): Group {
811
+ const test = ["vitest", "deno"].includes(framework)
812
+ ? record(settings.test)
813
+ : settings;
814
+ const root =
815
+ typeof settings.root === "string"
816
+ ? settings.root
817
+ : typeof test.root === "string"
818
+ ? test.root
819
+ : undefined;
820
+ const actualCwd = root ? posix.normalize(posix.join(cwd, root)) : cwd;
821
+ let include = strings(
822
+ test.include ?? test.testMatch ?? test.files ?? test.spec,
823
+ );
824
+ let exclude = strings(test.exclude ?? test.testPathIgnorePatterns);
825
+ if (framework === "deno") exclude.push(...strings(settings.exclude));
826
+ if (framework === "mocha") exclude.push(...strings(test.ignore));
827
+ if (framework === "playwright") {
828
+ const testDir = typeof test.testDir === "string" ? test.testDir : ".";
829
+ include = strings(test.testMatch).map((p) => posix.join(testDir, p));
830
+ if (!include.length && testDir !== ".")
831
+ include = [
832
+ `${testDir}/**/*.spec.{js,ts,jsx,tsx,mjs,cjs}`,
833
+ `${testDir}/**/*.test.{js,ts,jsx,tsx,mjs,cjs}`,
834
+ ];
835
+ exclude = strings(test.testIgnore);
836
+ }
837
+ if (framework === "pytest") {
838
+ const paths = strings(test.testpaths).flatMap((s) => shellWords(s));
839
+ const filenames = strings(test.python_files).flatMap((s) => shellWords(s));
840
+ if (paths.length || filenames.length)
841
+ include = (paths.length ? paths : ["."]).flatMap((p) =>
842
+ (filenames.length ? filenames : ["test_*.py", "*_test.py"]).map((f) =>
843
+ posix.join(p, "**", f),
844
+ ),
845
+ );
846
+ exclude = strings(test.norecursedirs)
847
+ .flatMap((s) => shellWords(s))
848
+ .map((p) => `**/${p}/**`);
849
+ }
850
+ const negatives = include
851
+ .filter((p) => p.startsWith("!"))
852
+ .map((p) => p.slice(1));
853
+ exclude.push(...negatives);
854
+ include = include.filter((p) => !p.startsWith("!"));
855
+ const options =
856
+ framework === "pytest"
857
+ ? typeof test.addopts === "string"
858
+ ? shellWords(test.addopts)
859
+ : strings(test.addopts)
860
+ : [];
861
+ return {
862
+ framework,
863
+ cwd: actualCwd,
864
+ config,
865
+ ...(project
866
+ ? { project }
867
+ : typeof test.name === "string"
868
+ ? { project: test.name }
869
+ : {}),
870
+ include,
871
+ exclude,
872
+ setup: strings(test.setupFiles ?? test.globalSetup),
873
+ options,
874
+ settings: test,
875
+ provenance: `config:${config}`,
876
+ incomplete: !complete,
877
+ };
878
+ }
879
+
880
+ /** Pure discovery over the caller's tracked-file snapshot; no I/O or configuration evaluation. */
881
+ export function discoverTests(
882
+ files: readonly { path: string; text: string }[],
883
+ plannedPaths?: Set<string>,
884
+ lexical?: LexicalSource,
885
+ configurationPaths?: Set<string>,
886
+ ): Discovery {
887
+ const ordered = [...files].sort((a, b) => a.path.localeCompare(b.path));
888
+ const byPath = new Map(ordered.map((f) => [posix.normalize(f.path), f]));
889
+ const groups: Group[] = [];
890
+ const invocations: {
891
+ cwd: string;
892
+ framework: Framework;
893
+ config?: string;
894
+ project?: string;
895
+ invocation: NonNullable<TestEntry["invocation"]>;
896
+ }[] = [];
897
+ const typechecks: {
898
+ cwd: string;
899
+ invocation: NonNullable<TestEntry["invocation"]>;
900
+ }[] = [];
901
+ const limits: {
902
+ path: string;
903
+ kind:
904
+ | "incomplete"
905
+ | "unsupported"
906
+ | "unknown"
907
+ | "types"
908
+ | "local_runner_unproven"
909
+ | "interactive_script_skipped";
910
+ framework?: Framework;
911
+ }[] = [];
912
+ const addLimit = (
913
+ path: string,
914
+ kind:
915
+ | "incomplete"
916
+ | "unsupported"
917
+ | "unknown"
918
+ | "types"
919
+ | "local_runner_unproven"
920
+ | "interactive_script_skipped",
921
+ framework?: Framework,
922
+ ) => {
923
+ if (
924
+ !limits.some(
925
+ (l) => l.path === path && l.kind === kind && l.framework === framework,
926
+ )
927
+ )
928
+ limits.push({ path, kind, ...(framework ? { framework } : {}) });
929
+ };
930
+ const visited = new Set<string>();
931
+ const addConfig = (
932
+ path: string,
933
+ framework: Framework,
934
+ options: string[] = [],
935
+ project?: string,
936
+ ) => {
937
+ configurationPaths?.add(path);
938
+ const key = `${path}:${framework}:${project ?? ""}:${JSON.stringify(options)}`;
939
+ if (visited.has(key)) return;
940
+ visited.add(key);
941
+ const file = byPath.get(path);
942
+ if (!file) {
943
+ addLimit(path, "incomplete", framework);
944
+ return;
945
+ }
946
+ const parsed =
947
+ framework === "pytest" || path.endsWith(".toml")
948
+ ? iniSettings(file.text, path)
949
+ : readConfig(file.text, lexical?.tokenize(file.path, file.text));
950
+ if (!parsed) return;
951
+ const group = groupFrom(
952
+ framework,
953
+ directory(path),
954
+ path,
955
+ parsed.settings,
956
+ parsed.complete,
957
+ project,
958
+ );
959
+ if (framework === "deno") {
960
+ const task = record(parsed.settings.tasks).test;
961
+ if (typeof task === "string") {
962
+ const words = shellWords(task);
963
+ const flags = words.slice(2).filter((word) => word.startsWith("-"));
964
+ const safe =
965
+ words[0] === "deno" &&
966
+ words[1] === "test" &&
967
+ flags.every((flag) =>
968
+ /^(?:-A|--allow-all|--allow-(?:read|write|net|env|run|sys|ffi)(?:=[\w@./,:*-]+)?|--no-check|--parallel|--fail-fast|--quiet)$/.test(
969
+ flag,
970
+ ),
971
+ ) &&
972
+ words
973
+ .slice(2)
974
+ .every((word) => flags.includes(word) || /^[\w./*-]+$/.test(word));
975
+ if (safe) group.options.push(...flags);
976
+ else group.incomplete = true;
977
+ }
978
+ }
979
+ group.options.push(...options);
980
+ const projects =
981
+ parsed.settings.projects ?? record(parsed.settings.test).projects;
982
+ if (Array.isArray(projects)) {
983
+ for (const item of projects) {
984
+ if (typeof item === "string") {
985
+ const reference = posix.normalize(posix.join(directory(path), item));
986
+ const found = ordered.filter(
987
+ (f) =>
988
+ matches(f.path, reference) ||
989
+ (under(reference, f.path) &&
990
+ configFramework(f.path) === framework),
991
+ );
992
+ if (!found.length) addLimit(reference, "incomplete", framework);
993
+ for (const child of found)
994
+ addConfig(child.path, framework, group.options);
995
+ } else {
996
+ const childSettings = record(item);
997
+ const child = groupFrom(
998
+ framework,
999
+ group.cwd,
1000
+ path,
1001
+ {
1002
+ ...parsed.settings,
1003
+ ...childSettings,
1004
+ ...(framework === "vitest"
1005
+ ? {
1006
+ test: {
1007
+ ...record(parsed.settings.test),
1008
+ ...record(childSettings.test),
1009
+ },
1010
+ }
1011
+ : {}),
1012
+ },
1013
+ parsed.complete,
1014
+ );
1015
+ child.options.push(...options);
1016
+ groups.push(child);
1017
+ }
1018
+ }
1019
+ // A projects list defines separate execution contexts, not an extra root run.
1020
+ if (group.incomplete) addLimit(path, "incomplete", framework);
1021
+ } else groups.push(group);
1022
+ const typecheck = record(group.settings.typecheck);
1023
+ if (strings(typecheck.include).length)
1024
+ groups.push({
1025
+ ...group,
1026
+ include: strings(typecheck.include),
1027
+ exclude: strings(typecheck.exclude),
1028
+ types: true,
1029
+ });
1030
+ if (group.incomplete) addLimit(path, "incomplete", framework);
1031
+ };
1032
+ // Configs must constrain implicit package scripts regardless of path ordering.
1033
+ for (const file of ordered) {
1034
+ const framework = configFramework(file.path);
1035
+ if (framework) addConfig(file.path, framework);
1036
+ }
1037
+ for (const file of ordered) {
1038
+ const framework = configFramework(file.path);
1039
+ if (framework) addConfig(file.path, framework);
1040
+ if (/^vitest\.workspace\.[cm]?[jt]s$/.test(posix.basename(file.path))) {
1041
+ const tokens = lexical
1042
+ ? lexical.tokenize(file.path, file.text)
1043
+ : tokenize(file.text);
1044
+ const index = tokens.findIndex(
1045
+ (t, i) => t.value === "default" && tokens[i - 1]?.value === "export",
1046
+ );
1047
+ const parsed = literal(tokens, index + 1);
1048
+ if (!parsed.complete) addLimit(file.path, "incomplete", "vitest");
1049
+ if (Array.isArray(parsed.value))
1050
+ for (const item of parsed.value) {
1051
+ if (typeof item === "string") {
1052
+ const pattern = posix.join(directory(file.path), item);
1053
+ const refs = ordered.filter(
1054
+ (f) =>
1055
+ matches(f.path, pattern) ||
1056
+ (under(pattern, f.path) &&
1057
+ configFramework(f.path) === "vitest"),
1058
+ );
1059
+ if (!refs.length) addLimit(pattern, "incomplete", "vitest");
1060
+ for (const ref of refs) addConfig(ref.path, "vitest");
1061
+ } else
1062
+ groups.push(
1063
+ groupFrom(
1064
+ "vitest",
1065
+ directory(file.path),
1066
+ file.path,
1067
+ record(item),
1068
+ parsed.complete,
1069
+ ),
1070
+ );
1071
+ }
1072
+ }
1073
+ if (posix.basename(file.path) !== "package.json") continue;
1074
+ let manifest: { [key: string]: Literal };
1075
+ try {
1076
+ manifest = record(JSON.parse(file.text));
1077
+ } catch {
1078
+ addLimit(file.path, "incomplete");
1079
+ continue;
1080
+ }
1081
+ for (const framework of ["jest", "ava", "mocha"] as const)
1082
+ if (manifest[framework])
1083
+ groups.push(
1084
+ groupFrom(
1085
+ framework,
1086
+ directory(file.path),
1087
+ file.path,
1088
+ record(manifest[framework]),
1089
+ true,
1090
+ ),
1091
+ );
1092
+ const scripts = record(manifest.scripts);
1093
+ const claimedScripts = new Set<string>();
1094
+ for (const [scriptName, script] of Object.entries(scripts).sort(
1095
+ ([a], [b]) => Number(b === "test") - Number(a === "test"),
1096
+ )) {
1097
+ if (typeof script !== "string") continue;
1098
+ const allWords = shellWords(script);
1099
+ const lastSeparator = allWords.lastIndexOf("&");
1100
+ const tail = allWords.slice(lastSeparator + 1);
1101
+ const avaLoader =
1102
+ tail[1] === "ava" &&
1103
+ /^NODE_OPTIONS=--import=[\w@./-]+$/.test(tail[0] ?? "") &&
1104
+ allWords
1105
+ .slice(0, lastSeparator + 1)
1106
+ .every((word) => ["xo", "tsc", "&"].includes(word));
1107
+ const words = avaLoader ? tail : allWords;
1108
+ const simple = !words.some(
1109
+ (w) =>
1110
+ [";", "&", "|", "<", ">"].includes(w) ||
1111
+ w.includes("$") ||
1112
+ w.includes("`"),
1113
+ );
1114
+ if (
1115
+ words[0] === "tsc" &&
1116
+ simple &&
1117
+ words.some(
1118
+ (w) => w === "-p" || w === "--project" || w.startsWith("--project="),
1119
+ )
1120
+ ) {
1121
+ typechecks.push({
1122
+ cwd: directory(file.path),
1123
+ invocation: {
1124
+ executable: "tsc",
1125
+ args: words.slice(1),
1126
+ kind: "typecheck",
1127
+ },
1128
+ });
1129
+ }
1130
+ const commands: Record<string, Framework> = {
1131
+ vitest: "vitest",
1132
+ jest: "jest",
1133
+ bun: "bun",
1134
+ playwright: "playwright",
1135
+ pytest: "pytest",
1136
+ ava: "ava",
1137
+ mocha: "mocha",
1138
+ deno: "deno",
1139
+ node: "node",
1140
+ go: "go",
1141
+ };
1142
+ const index = words.findIndex(
1143
+ (w, i) =>
1144
+ commands[w] &&
1145
+ (w !== "node" || words.slice(i + 1).includes("--test")) &&
1146
+ (!["bun", "deno", "go"].includes(w) || words[i + 1] === "test"),
1147
+ );
1148
+ if (index < 0) continue;
1149
+ const framework = commands[words[index] ?? ""];
1150
+ if (!framework) continue;
1151
+ const args = words.slice(index + 1);
1152
+ const configIndex = args.findIndex((w) => ["--config", "-c"].includes(w));
1153
+ const configFlag = args.find((w) => w.startsWith("--config="));
1154
+ const reference =
1155
+ configIndex >= 0 ? args[configIndex + 1] : configFlag?.slice(9);
1156
+ const options: string[] = [];
1157
+ const includes: string[] = [];
1158
+ let incomplete = words.some(
1159
+ (w) =>
1160
+ [";", "&", "|", "<", ">"].includes(w) ||
1161
+ w.includes("$") ||
1162
+ w.includes("`"),
1163
+ );
1164
+ if (
1165
+ ["ava", "mocha", "deno"].includes(framework) &&
1166
+ index > 0 &&
1167
+ !avaLoader
1168
+ )
1169
+ incomplete = true;
1170
+ let scriptCwd = directory(file.path);
1171
+ let unsafeOptions = false;
1172
+ let project: string | undefined;
1173
+ const interactive = args.some(
1174
+ (arg) =>
1175
+ [
1176
+ "--watch",
1177
+ "--watchAll",
1178
+ "-w",
1179
+ ...(framework === "mocha" ? [] : ["--ui"]),
1180
+ ].includes(arg) ||
1181
+ (framework === "mocha"
1182
+ ? /^(?:--watch|--watchAll)=/
1183
+ : /^(?:--watch|--watchAll|--ui)=/
1184
+ ).test(arg),
1185
+ );
1186
+ for (let i = 0; i < args.length; i++) {
1187
+ const arg = args[i] ?? "";
1188
+ if (["run", "test", "--test"].includes(arg)) continue;
1189
+ if (
1190
+ [
1191
+ "--watch",
1192
+ "--watchAll",
1193
+ "-w",
1194
+ ...(framework === "mocha" ? [] : ["--ui"]),
1195
+ ].includes(arg) ||
1196
+ (framework === "mocha"
1197
+ ? /^(?:--watch|--watchAll)=/
1198
+ : /^(?:--watch|--watchAll|--ui)=/
1199
+ ).test(arg)
1200
+ )
1201
+ continue;
1202
+ if (["--config", "-c"].includes(arg)) {
1203
+ i++;
1204
+ continue;
1205
+ }
1206
+ if (arg.startsWith("--config=")) continue;
1207
+ if (["--project", "--selectProjects"].includes(arg)) {
1208
+ project = args[++i];
1209
+ continue;
1210
+ }
1211
+ if (arg.startsWith("--project=")) {
1212
+ project = arg.slice(10);
1213
+ continue;
1214
+ }
1215
+ if (arg === "--cwd" || arg.startsWith("--cwd=")) {
1216
+ const cwd = arg === "--cwd" ? args[++i] : arg.slice(6);
1217
+ if (cwd) scriptCwd = posix.normalize(posix.join(scriptCwd, cwd));
1218
+ else {
1219
+ incomplete = true;
1220
+ unsafeOptions = true;
1221
+ }
1222
+ continue;
1223
+ }
1224
+ if (
1225
+ framework === "deno" &&
1226
+ /^(?:-A|--allow-all|--allow-(?:read|write|net|env|run|sys|ffi)(?:=.*)?)$/.test(
1227
+ arg,
1228
+ )
1229
+ ) {
1230
+ options.push(arg);
1231
+ continue;
1232
+ }
1233
+ if (
1234
+ [
1235
+ "--reporter",
1236
+ "--environment",
1237
+ "--pool",
1238
+ "--timeout",
1239
+ "--testTimeout",
1240
+ "--loader",
1241
+ "--import",
1242
+ "--require",
1243
+ "--grep",
1244
+ "--fgrep",
1245
+ "--file",
1246
+ "--spec",
1247
+ "--ui",
1248
+ "--extension",
1249
+ "--maxWorkers",
1250
+ "--minWorkers",
1251
+ "-n",
1252
+ "-r",
1253
+ "-m",
1254
+ "-k",
1255
+ ].includes(arg)
1256
+ ) {
1257
+ options.push(arg, args[++i] ?? "");
1258
+ continue;
1259
+ }
1260
+ if (arg.startsWith("-")) {
1261
+ if (
1262
+ !arg.includes("=") &&
1263
+ args[i + 1] &&
1264
+ !args[i + 1]?.startsWith("-") &&
1265
+ ![
1266
+ "--coverage",
1267
+ "--run",
1268
+ "--noEmit",
1269
+ "--passWithNoTests",
1270
+ "--bail",
1271
+ ].includes(arg)
1272
+ ) {
1273
+ unsafeOptions = true;
1274
+ incomplete = true;
1275
+ i++;
1276
+ } else options.push(arg);
1277
+ } else if (
1278
+ /\.(?:[cm]?[jt]sx?|py)$/.test(arg) ||
1279
+ arg.includes("*") ||
1280
+ (framework === "deno" && arg.endsWith("/")) ||
1281
+ byPath.has(posix.join(directory(file.path), arg))
1282
+ )
1283
+ includes.push(arg);
1284
+ else incomplete = true;
1285
+ }
1286
+ if (unsafeOptions) options.length = 0;
1287
+ if (
1288
+ reference &&
1289
+ ["ava", "mocha", "deno"].includes(framework) &&
1290
+ !byPath.has(
1291
+ posix.normalize(posix.join(directory(file.path), reference)),
1292
+ )
1293
+ )
1294
+ incomplete = true;
1295
+ const context = JSON.stringify([
1296
+ framework,
1297
+ scriptCwd,
1298
+ reference ?? "",
1299
+ project ?? "",
1300
+ ]);
1301
+ if (claimedScripts.has(context)) continue;
1302
+ claimedScripts.add(context);
1303
+ if (interactive)
1304
+ addLimit(file.path, "interactive_script_skipped", framework);
1305
+ if (reference)
1306
+ addConfig(
1307
+ posix.normalize(posix.join(directory(file.path), reference)),
1308
+ framework,
1309
+ options,
1310
+ project,
1311
+ );
1312
+ if (reference && ["ava", "mocha", "deno"].includes(framework)) {
1313
+ const configPath = posix.normalize(
1314
+ posix.join(directory(file.path), reference),
1315
+ );
1316
+ for (const group of groups.filter(
1317
+ (group) =>
1318
+ group.config === configPath && group.framework === framework,
1319
+ )) {
1320
+ group.cwd = scriptCwd;
1321
+ if (!group.include.length || framework === "mocha")
1322
+ group.include.push(...includes);
1323
+ group.incomplete ||= incomplete;
1324
+ }
1325
+ }
1326
+ if (!reference) {
1327
+ const existing = groups.filter(
1328
+ (g) => g.framework === framework && g.cwd === scriptCwd,
1329
+ );
1330
+ if (!existing.length)
1331
+ groups.push({
1332
+ framework,
1333
+ cwd: scriptCwd,
1334
+ ...(project ? { project } : {}),
1335
+ include: includes,
1336
+ exclude: [],
1337
+ setup: [],
1338
+ options,
1339
+ settings: {},
1340
+ provenance: `script:${file.path}:${scriptName}`,
1341
+ incomplete,
1342
+ });
1343
+ else
1344
+ for (const group of existing) {
1345
+ group.options.push(...options);
1346
+ if (
1347
+ ["ava", "mocha", "deno"].includes(framework) &&
1348
+ (!group.include.length || framework === "mocha")
1349
+ )
1350
+ group.include.push(...includes);
1351
+ if (["ava", "mocha", "deno"].includes(framework))
1352
+ group.incomplete ||= incomplete;
1353
+ }
1354
+ }
1355
+ if (simple && (index === 0 || avaLoader) && !incomplete && !interactive) {
1356
+ const prefix =
1357
+ framework === "node"
1358
+ ? ["--test"]
1359
+ : ["bun", "go", "deno", "playwright"].includes(framework)
1360
+ ? ["test"]
1361
+ : framework === "vitest" && args.includes("run")
1362
+ ? ["run"]
1363
+ : [];
1364
+ invocations.push({
1365
+ cwd: scriptCwd,
1366
+ framework,
1367
+ ...(reference
1368
+ ? {
1369
+ config: posix.normalize(
1370
+ posix.join(directory(file.path), reference),
1371
+ ),
1372
+ }
1373
+ : {}),
1374
+ ...(project ? { project } : {}),
1375
+ invocation: {
1376
+ executable: index === 1 ? "env" : (words[index] ?? framework),
1377
+ args: [
1378
+ ...(index === 1
1379
+ ? [words[0] ?? "", words[index] ?? framework]
1380
+ : []),
1381
+ ...prefix,
1382
+ ...options,
1383
+ ],
1384
+ kind: "runner",
1385
+ },
1386
+ });
1387
+ }
1388
+ if (incomplete) addLimit(file.path, "incomplete", framework);
1389
+ }
1390
+ }
1391
+ const entries: TestEntry[] = [];
1392
+ for (const file of ordered) {
1393
+ const typeFile = /\.(?:test|spec)-d\.[cm]?tsx?$/.test(file.path);
1394
+ const candidate =
1395
+ typeFile ||
1396
+ ["vitest", "jest", "node", "bun", "ava", "go", "pytest"].some(
1397
+ (framework) => native(file.path, framework as Framework),
1398
+ ) ||
1399
+ groups.some((group) => {
1400
+ if (!under(group.cwd, file.path)) return false;
1401
+ const local = relative(group.cwd, file.path);
1402
+ return group.include.some(
1403
+ (pattern) =>
1404
+ matches(local, pattern) ||
1405
+ (under(pattern, local) && !pattern.includes("*")),
1406
+ );
1407
+ });
1408
+ if (!candidate) continue;
1409
+ if (plannedPaths) {
1410
+ plannedPaths.add(file.path);
1411
+ continue;
1412
+ }
1413
+ const python = file.path.endsWith(".py");
1414
+ const tokens = lexical
1415
+ ? lexical.tokenize(file.path, file.text, python)
1416
+ : tokenize(file.text, python);
1417
+ const bindings = imports(tokens);
1418
+ if (file.path.endsWith("_test.go")) {
1419
+ bindings.frameworks.add("go");
1420
+ if (
1421
+ /^\/\/\s*(?:go:build|\+build)\b/m.test(file.text) ||
1422
+ /_(?:linux|windows|darwin|amd64|arm64)_test\.go$/.test(file.path)
1423
+ )
1424
+ addLimit(file.path, "incomplete", "go");
1425
+ }
1426
+ if (file.path.endsWith(".py") && native(file.path, "pytest"))
1427
+ bindings.frameworks.add("pytest");
1428
+ const applicable = groups.filter((g) => under(g.cwd, file.path));
1429
+ const matched: Group[] = [];
1430
+ const blocked = new Set<Framework>();
1431
+ for (const group of applicable) {
1432
+ const local = relative(group.cwd, file.path);
1433
+ if (group.exclude.some((p) => matches(local, p) || local.includes(p))) {
1434
+ blocked.add(group.framework);
1435
+ continue;
1436
+ }
1437
+ const explicit = group.include.length > 0;
1438
+ if (
1439
+ !explicit &&
1440
+ group.provenance.startsWith("script:") &&
1441
+ !bindings.frameworks.has(group.framework) &&
1442
+ !["pytest", "go"].includes(group.framework)
1443
+ )
1444
+ continue;
1445
+ const included = explicit
1446
+ ? group.include.some(
1447
+ (p) => matches(local, p) || (under(p, local) && !p.includes("*")),
1448
+ )
1449
+ : native(local, group.framework) || typeFile;
1450
+ if (!included) {
1451
+ blocked.add(group.framework);
1452
+ continue;
1453
+ }
1454
+ if (!explicit && group.setup.some((p) => matches(local, p))) {
1455
+ blocked.add(group.framework);
1456
+ continue;
1457
+ }
1458
+ if (typeFile && !group.types) continue;
1459
+ matched.push(group);
1460
+ }
1461
+ // A closer package/config overrides an ancestor's default convention.
1462
+ const deepest = new Map<Framework, number>();
1463
+ for (const group of applicable)
1464
+ deepest.set(
1465
+ group.framework,
1466
+ Math.max(deepest.get(group.framework) ?? 0, group.cwd.length),
1467
+ );
1468
+ const candidates = matched.filter(
1469
+ (g) => g.cwd.length === deepest.get(g.framework),
1470
+ );
1471
+ for (const framework of bindings.frameworks) {
1472
+ if (
1473
+ blocked.has(framework) ||
1474
+ candidates.some((g) => g.framework === framework) ||
1475
+ applicable.some((g) => g.framework === framework)
1476
+ )
1477
+ continue;
1478
+ if (!native(file.path, framework)) continue;
1479
+ candidates.push({
1480
+ framework,
1481
+ cwd: framework === "go" ? directory(file.path) : ".",
1482
+ include: [],
1483
+ exclude: [],
1484
+ setup: [],
1485
+ options: [],
1486
+ settings: {},
1487
+ provenance: `declaration:${framework}`,
1488
+ incomplete: false,
1489
+ });
1490
+ }
1491
+ const frameworks = new Set([
1492
+ ...candidates.map((g) => g.framework),
1493
+ ...bindings.frameworks,
1494
+ ]);
1495
+ if (frameworks.size > 1 && candidates.length) {
1496
+ addLimit(file.path, "incomplete");
1497
+ continue;
1498
+ }
1499
+ if (typeFile && !candidates.length)
1500
+ candidates.push({
1501
+ framework: bindings.frameworks.values().next().value ?? "unknown",
1502
+ cwd: ".",
1503
+ include: [],
1504
+ exclude: [],
1505
+ setup: [],
1506
+ options: [],
1507
+ settings: {},
1508
+ provenance: "type convention",
1509
+ incomplete: false,
1510
+ types: true,
1511
+ });
1512
+ for (const group of candidates) {
1513
+ if (
1514
+ group.framework === "ava" &&
1515
+ !group.include.length &&
1516
+ /(?:^|\/)(?:_[^/]*|helpers|fixtures)(?:\/|$)/.test(file.path)
1517
+ )
1518
+ continue;
1519
+ let extracted: { scenarios: TestScenario[]; countKnown: boolean };
1520
+ const exclusiveMocha =
1521
+ group.framework === "mocha" &&
1522
+ tokens.some(
1523
+ (token, index) =>
1524
+ token.value === "only" &&
1525
+ tokens[index - 1]?.value === "." &&
1526
+ tokens[index + 1]?.value === "(",
1527
+ );
1528
+ if (group.framework === "pytest")
1529
+ extracted = pythonScenarios(file.text, group.settings);
1530
+ else if (group.framework === "go") {
1531
+ const scenarios: TestScenario[] = [];
1532
+ for (let i = 0; i < tokens.length - 2; i++) {
1533
+ const name = tokens[i + 1]?.value ?? "";
1534
+ if (
1535
+ tokens[i]?.value === "func" &&
1536
+ tokens[i + 2]?.value === "(" &&
1537
+ /^(?:Test[^a-z]|Example(?:[^a-z]|$))/.test(name)
1538
+ ) {
1539
+ // Go examples are runnable only with an Output/Unordered output directive.
1540
+ const end = closing(tokens, i + 2);
1541
+ const bodyStart = tokens.findIndex(
1542
+ (t, j) => j > end && t.value === "{",
1543
+ );
1544
+ const bodyEnd =
1545
+ bodyStart < 0 ? bodyStart : closing(tokens, bodyStart);
1546
+ const body =
1547
+ bodyStart < 0
1548
+ ? ""
1549
+ : file.text.slice(
1550
+ tokens[bodyStart]?.offset ?? 0,
1551
+ tokens[bodyEnd]?.offset ?? file.text.length,
1552
+ );
1553
+ if (
1554
+ name.startsWith("Example") &&
1555
+ !/\/\/\s*(?:Unordered output|Output):/i.test(body)
1556
+ )
1557
+ continue;
1558
+ const line = tokens[i]?.line ?? 1;
1559
+ scenarios.push({
1560
+ id: name,
1561
+ name,
1562
+ line,
1563
+ reliable: true,
1564
+ ...(tokens[bodyEnd]
1565
+ ? { range: { start: line, end: tokens[bodyEnd]?.line ?? line } }
1566
+ : {}),
1567
+ });
1568
+ }
1569
+ }
1570
+ extracted = { scenarios, countKnown: true };
1571
+ } else extracted = jsScenarios(tokens, group.framework, bindings);
1572
+ if (exclusiveMocha) extracted.countKnown = false;
1573
+ const explicit = group.include.length > 0;
1574
+ if (!explicit && !extracted.scenarios.length && !typeFile) continue;
1575
+ const kind = typeFile || group.types ? "types" : "runtime";
1576
+ const entry: TestEntry = {
1577
+ path: file.path,
1578
+ framework: group.framework,
1579
+ cwd: group.cwd,
1580
+ ...(group.config ? { config: group.config } : {}),
1581
+ ...(group.project ? { project: group.project } : {}),
1582
+ options: group.options,
1583
+ scenarios: extracted.scenarios,
1584
+ countKnown:
1585
+ extracted.countKnown &&
1586
+ !group.incomplete &&
1587
+ extracted.scenarios.length > 0,
1588
+ kind,
1589
+ provenance: group.provenance,
1590
+ ...(["ava", "deno"].includes(group.framework) && group.incomplete
1591
+ ? {
1592
+ commandRefusal:
1593
+ "runner configuration or options are not statically resolved",
1594
+ }
1595
+ : {}),
1596
+ ...(group.framework === "mocha"
1597
+ ? {
1598
+ ...(applicable.some(
1599
+ (candidate) =>
1600
+ candidate.framework === "mocha" &&
1601
+ (candidate.settings.spec !== undefined ||
1602
+ candidate.settings.file !== undefined ||
1603
+ candidate.options.some((option) =>
1604
+ /^(?:--spec|--file)(?:=|$)/.test(option),
1605
+ )),
1606
+ )
1607
+ ? { commandRefusal: "Mocha additive spec is not neutralized" }
1608
+ : applicable.some(
1609
+ (candidate) =>
1610
+ candidate.framework === "mocha" &&
1611
+ (candidate.incomplete ||
1612
+ candidate.settings.extends !== undefined),
1613
+ )
1614
+ ? {
1615
+ commandRefusal:
1616
+ "Mocha configuration is not statically resolved",
1617
+ }
1618
+ : {}),
1619
+ ...(exclusiveMocha
1620
+ ? {
1621
+ nameFilterReason:
1622
+ "Mocha .only declarations restrict project execution",
1623
+ }
1624
+ : {}),
1625
+ nameFilterBlocked:
1626
+ applicable.some(
1627
+ (candidate) =>
1628
+ candidate.framework === "mocha" &&
1629
+ (["grep", "fgrep", "invert"].some(
1630
+ (key) => candidate.settings[key] !== undefined,
1631
+ ) ||
1632
+ (candidate.settings.ui !== undefined &&
1633
+ candidate.settings.ui !== "bdd")),
1634
+ ) ||
1635
+ group.options.some(
1636
+ (option, index) =>
1637
+ /^(?:--grep|--fgrep|--invert|-g|-f|-i)(?:=|$)/.test(
1638
+ option,
1639
+ ) ||
1640
+ (option === "--ui" && group.options[index + 1] !== "bdd") ||
1641
+ (option.startsWith("--ui=") && option !== "--ui=bdd"),
1642
+ ),
1643
+ }
1644
+ : {}),
1645
+ };
1646
+ const proven = invocations.find(
1647
+ (i) =>
1648
+ i.framework === group.framework &&
1649
+ under(i.cwd, file.path) &&
1650
+ (!i.config || i.config === group.config) &&
1651
+ (!i.project || i.project === group.project),
1652
+ );
1653
+ if (kind === "types") {
1654
+ const typecheck = typechecks
1655
+ .filter((t) => under(t.cwd, file.path))
1656
+ .sort((a, b) => b.cwd.length - a.cwd.length)[0];
1657
+ if (typecheck) {
1658
+ entry.invocation = typecheck.invocation;
1659
+ entry.cwd = typecheck.cwd;
1660
+ }
1661
+ } else if (proven) entry.invocation = proven.invocation;
1662
+ else if (!["ava", "mocha", "deno", "unknown"].includes(group.framework))
1663
+ addLimit(file.path, "local_runner_unproven", group.framework);
1664
+ if (
1665
+ !entries.some(
1666
+ (e) =>
1667
+ e.path === entry.path &&
1668
+ e.framework === entry.framework &&
1669
+ e.cwd === entry.cwd &&
1670
+ e.config === entry.config &&
1671
+ e.project === entry.project &&
1672
+ e.kind === entry.kind &&
1673
+ JSON.stringify(e.options) === JSON.stringify(entry.options),
1674
+ )
1675
+ )
1676
+ entries.push(entry);
1677
+ if (
1678
+ ["ava", "mocha", "deno"].includes(group.framework) &&
1679
+ group.incomplete
1680
+ )
1681
+ addLimit(file.path, "incomplete", group.framework);
1682
+ if (kind === "types") addLimit(file.path, "types", group.framework);
1683
+ }
1684
+ }
1685
+ const evidence = ordered
1686
+ .filter((f) => isTestFile(f.path) || entries.some((e) => e.path === f.path))
1687
+ .map((f) => f.path);
1688
+ for (const path of evidence)
1689
+ if (!entries.some((e) => e.path === path)) {
1690
+ addLimit(path, "incomplete");
1691
+ if (!limits.some((limit) => limit.path === path && limit.framework))
1692
+ addLimit(path, "unknown", "unknown");
1693
+ }
1694
+ return { entries, evidence, limits };
1695
+ }