@nathapp/nax 0.18.2 → 0.18.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 (41) hide show
  1. package/.gitlab-ci.yml +11 -5
  2. package/bun.lock +1 -1
  3. package/bunfig.toml +2 -1
  4. package/docker-compose.test.yml +17 -0
  5. package/docs/ROADMAP.md +100 -13
  6. package/docs/specs/verification-architecture-v2.md +343 -0
  7. package/nax/config.json +7 -7
  8. package/nax/features/v0.18.3-execution-reliability/prd.json +80 -0
  9. package/nax/features/v0.18.3-execution-reliability/progress.txt +3 -0
  10. package/package.json +2 -2
  11. package/src/config/defaults.ts +1 -0
  12. package/src/config/schema.ts +1 -0
  13. package/src/config/schemas.ts +24 -1
  14. package/src/config/types.ts +16 -3
  15. package/src/context/builder.ts +11 -0
  16. package/src/context/elements.ts +38 -1
  17. package/src/execution/escalation/tier-escalation.ts +28 -3
  18. package/src/execution/post-verify-rectification.ts +4 -2
  19. package/src/execution/post-verify.ts +73 -9
  20. package/src/execution/progress.ts +2 -0
  21. package/src/pipeline/stages/review.ts +5 -3
  22. package/src/pipeline/stages/routing.ts +11 -6
  23. package/src/pipeline/stages/verify.ts +41 -7
  24. package/src/prd/index.ts +16 -1
  25. package/src/prd/types.ts +33 -0
  26. package/src/routing/strategies/llm.ts +5 -0
  27. package/src/verification/gate.ts +2 -1
  28. package/src/verification/smart-runner.ts +68 -0
  29. package/src/verification/types.ts +2 -0
  30. package/test/context/prior-failures.test.ts +462 -0
  31. package/test/execution/post-verify-bug026.test.ts +443 -0
  32. package/test/execution/post-verify.test.ts +32 -0
  33. package/test/execution/structured-failure.test.ts +414 -0
  34. package/test/integration/logger.test.ts +1 -1
  35. package/test/integration/review-plugin-integration.test.ts +2 -1
  36. package/test/integration/story-id-in-events.test.ts +1 -1
  37. package/test/unit/config/smart-runner-flag.test.ts +36 -12
  38. package/test/unit/pipeline/verify-smart-runner.test.ts +3 -0
  39. package/test/unit/prd-get-next-story.test.ts +28 -0
  40. package/test/unit/routing.test.ts +102 -0
  41. package/test/unit/smart-test-runner.test.ts +512 -0
@@ -23,6 +23,8 @@ import {
23
23
  buildBatchPrompt,
24
24
  buildRoutingPrompt,
25
25
  clearCache,
26
+ clearCacheForStory,
27
+ getCacheSize,
26
28
  llmStrategy as llmStrategyFull,
27
29
  parseRoutingResponse,
28
30
  routeBatch,
@@ -935,3 +937,103 @@ describe("Adaptive Routing Strategy", () => {
935
937
  });
936
938
  });
937
939
  });
940
+
941
+ // ============================================================================
942
+ // LLM Cache Clearing Tests (BUG-028 fix)
943
+ // ============================================================================
944
+
945
+ describe("LLM Cache Clearing on Tier Escalation", () => {
946
+ beforeEach(() => {
947
+ // Clear cache before each test
948
+ clearCache();
949
+ });
950
+
951
+ test("cache hit returns cached decision", () => {
952
+ const story: UserStory = {
953
+ id: "US-cache-001",
954
+ title: "Test story",
955
+ description: "Test story for cache",
956
+ acceptanceCriteria: ["AC1"],
957
+ tags: [],
958
+ dependencies: [],
959
+ status: "pending",
960
+ passes: false,
961
+ escalations: [],
962
+ attempts: 0,
963
+ };
964
+
965
+ const originalDecision: RoutingDecision = {
966
+ complexity: "simple",
967
+ modelTier: "fast",
968
+ testStrategy: "test-after",
969
+ reasoning: "Original decision",
970
+ };
971
+
972
+ const configWithoutLlm = { ...DEFAULT_CONFIG, routing: { ...DEFAULT_CONFIG.routing, llm: undefined } };
973
+ const context: RoutingContext = { config: configWithoutLlm };
974
+
975
+ // Simulate cached decision
976
+ const cachedDecisions = new Map<string, RoutingDecision>();
977
+ cachedDecisions.set(story.id, originalDecision);
978
+
979
+ // Verify initial cache state
980
+ expect(getCacheSize()).toBe(0);
981
+
982
+ // Note: We're testing the behavior through the exported functions
983
+ // In a real scenario, the LLM strategy would populate the cache
984
+ // For this test, we verify the cache clearing mechanism works
985
+ });
986
+
987
+ test("clearCacheForStory removes cache entry", () => {
988
+ const storyId = "US-cache-002";
989
+
990
+ // Clear cache first
991
+ clearCache();
992
+ expect(getCacheSize()).toBe(0);
993
+
994
+ // Clear non-existent entry should not throw
995
+ clearCacheForStory(storyId);
996
+ expect(getCacheSize()).toBe(0);
997
+ });
998
+
999
+ test("clearCacheForStory after tier escalation forces re-routing", () => {
1000
+ const storyId = "US-cache-003";
1001
+
1002
+ // Clear all caches
1003
+ clearCache();
1004
+ expect(getCacheSize()).toBe(0);
1005
+
1006
+ // Simulate clearing for escalation
1007
+ clearCacheForStory(storyId);
1008
+
1009
+ // Cache should still be empty
1010
+ expect(getCacheSize()).toBe(0);
1011
+ });
1012
+
1013
+ test("clearing one story does not affect other cached stories", () => {
1014
+ clearCache();
1015
+
1016
+ const story1Id = "US-escalate-1";
1017
+ const story2Id = "US-escalate-2";
1018
+
1019
+ // Verify we can clear individual stories
1020
+ clearCacheForStory(story1Id);
1021
+ clearCacheForStory(story2Id);
1022
+
1023
+ expect(getCacheSize()).toBe(0);
1024
+ });
1025
+
1026
+ test("clearCacheForStory is idempotent", () => {
1027
+ const storyId = "US-idempotent";
1028
+
1029
+ clearCache();
1030
+ expect(getCacheSize()).toBe(0);
1031
+
1032
+ // Clear multiple times should be safe
1033
+ clearCacheForStory(storyId);
1034
+ clearCacheForStory(storyId);
1035
+ clearCacheForStory(storyId);
1036
+
1037
+ expect(getCacheSize()).toBe(0);
1038
+ });
1039
+ });
@@ -0,0 +1,512 @@
1
+ /**
2
+ * STR-007: Smart Test Runner — Config Coercion + 3-Pass Discovery Tests
3
+ *
4
+ * Tests:
5
+ * - Config coercion: boolean → SmartTestRunnerConfig object
6
+ * - Pass 1: path convention mapping (existing behavior)
7
+ * - Pass 2: import-grep fallback
8
+ * - Pass 3: full-suite fallback (empty return from mapSourceToTests + importGrepFallback)
9
+ * - Custom testFilePatterns
10
+ */
11
+
12
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
13
+ import { NaxConfigSchema } from "../../src/config/schemas";
14
+ import { importGrepFallback, mapSourceToTests } from "../../src/verification/smart-runner";
15
+ import type { SmartTestRunnerConfig } from "../../src/config/types";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Config coercion: boolean → SmartTestRunnerConfig
19
+ // ---------------------------------------------------------------------------
20
+
21
+ describe("SmartTestRunner config coercion", () => {
22
+ function parseExecution(smartTestRunner: unknown) {
23
+ const minimalConfig = {
24
+ version: 1,
25
+ models: {
26
+ fast: { provider: "anthropic", model: "haiku" },
27
+ balanced: { provider: "anthropic", model: "sonnet" },
28
+ powerful: { provider: "anthropic", model: "opus" },
29
+ },
30
+ autoMode: {
31
+ enabled: true,
32
+ defaultAgent: "claude",
33
+ fallbackOrder: [],
34
+ complexityRouting: { simple: "fast", medium: "balanced", complex: "powerful", expert: "powerful" },
35
+ escalation: { enabled: true, tierOrder: [{ tier: "fast", attempts: 1 }] },
36
+ },
37
+ routing: { strategy: "keyword" },
38
+ execution: {
39
+ maxIterations: 10,
40
+ iterationDelayMs: 0,
41
+ costLimit: 1,
42
+ sessionTimeoutSeconds: 60,
43
+ maxStoriesPerFeature: 10,
44
+ rectification: {
45
+ enabled: true,
46
+ maxRetries: 1,
47
+ fullSuiteTimeoutSeconds: 30,
48
+ maxFailureSummaryChars: 500,
49
+ abortOnIncreasingFailures: true,
50
+ },
51
+ regressionGate: { enabled: true, timeoutSeconds: 30 },
52
+ contextProviderTokenBudget: 100,
53
+ smartTestRunner,
54
+ },
55
+ quality: {
56
+ requireTypecheck: false,
57
+ requireLint: false,
58
+ requireTests: false,
59
+ commands: {},
60
+ forceExit: false,
61
+ detectOpenHandles: false,
62
+ detectOpenHandlesRetries: 0,
63
+ gracePeriodMs: 500,
64
+ drainTimeoutMs: 0,
65
+ shell: "/bin/sh",
66
+ stripEnvVars: [],
67
+ environmentalEscalationDivisor: 2,
68
+ },
69
+ tdd: { maxRetries: 0, autoVerifyIsolation: false, autoApproveVerifier: false },
70
+ constitution: { enabled: false, path: "constitution.md", maxTokens: 100 },
71
+ analyze: { llmEnhanced: false, model: "balanced", fallbackToKeywords: true, maxCodebaseSummaryTokens: 100 },
72
+ review: { enabled: false, checks: [], commands: {} },
73
+ plan: { model: "balanced", outputPath: "spec.md" },
74
+ acceptance: { enabled: false, maxRetries: 0, generateTests: false, testPath: "acceptance.test.ts" },
75
+ context: {
76
+ testCoverage: {
77
+ enabled: false,
78
+ detail: "names-only",
79
+ maxTokens: 50,
80
+ testPattern: "**/*.test.ts",
81
+ scopeToStory: false,
82
+ },
83
+ autoDetect: { enabled: false, maxFiles: 1, traceImports: false },
84
+ },
85
+ };
86
+ return NaxConfigSchema.safeParse(minimalConfig);
87
+ }
88
+
89
+ test("boolean true coerces to enabled object with defaults", () => {
90
+ const result = parseExecution(true);
91
+ expect(result.success).toBe(true);
92
+ if (result.success) {
93
+ expect(result.data.execution.smartTestRunner).toEqual({
94
+ enabled: true,
95
+ testFilePatterns: ["test/**/*.test.ts"],
96
+ fallback: "import-grep",
97
+ });
98
+ }
99
+ });
100
+
101
+ test("boolean false coerces to disabled object with defaults", () => {
102
+ const result = parseExecution(false);
103
+ expect(result.success).toBe(true);
104
+ if (result.success) {
105
+ expect(result.data.execution.smartTestRunner).toEqual({
106
+ enabled: false,
107
+ testFilePatterns: ["test/**/*.test.ts"],
108
+ fallback: "import-grep",
109
+ });
110
+ }
111
+ });
112
+
113
+ test("omitted field defaults to enabled object", () => {
114
+ const result = parseExecution(undefined);
115
+ expect(result.success).toBe(true);
116
+ if (result.success) {
117
+ expect(result.data.execution.smartTestRunner).toEqual({
118
+ enabled: true,
119
+ testFilePatterns: ["test/**/*.test.ts"],
120
+ fallback: "import-grep",
121
+ });
122
+ }
123
+ });
124
+
125
+ test("object with enabled: true is preserved as-is", () => {
126
+ const result = parseExecution({
127
+ enabled: true,
128
+ testFilePatterns: ["test/custom/**/*.test.ts"],
129
+ fallback: "import-grep",
130
+ });
131
+ expect(result.success).toBe(true);
132
+ if (result.success) {
133
+ expect(result.data.execution.smartTestRunner).toEqual({
134
+ enabled: true,
135
+ testFilePatterns: ["test/custom/**/*.test.ts"],
136
+ fallback: "import-grep",
137
+ });
138
+ }
139
+ });
140
+
141
+ test("object with fallback: full-suite is accepted", () => {
142
+ const result = parseExecution({
143
+ enabled: true,
144
+ testFilePatterns: ["test/**/*.test.ts"],
145
+ fallback: "full-suite",
146
+ });
147
+ expect(result.success).toBe(true);
148
+ if (result.success) {
149
+ const cfg = result.data.execution.smartTestRunner as SmartTestRunnerConfig;
150
+ expect(cfg.fallback).toBe("full-suite");
151
+ }
152
+ });
153
+
154
+ test("custom testFilePatterns are preserved", () => {
155
+ const patterns = ["test/unit/**/*.test.ts", "test/integration/**/*.test.ts"];
156
+ const result = parseExecution({
157
+ enabled: true,
158
+ testFilePatterns: patterns,
159
+ fallback: "import-grep",
160
+ });
161
+ expect(result.success).toBe(true);
162
+ if (result.success) {
163
+ const cfg = result.data.execution.smartTestRunner as SmartTestRunnerConfig;
164
+ expect(cfg.testFilePatterns).toEqual(patterns);
165
+ }
166
+ });
167
+ });
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Pass 1: path convention mapping
171
+ // ---------------------------------------------------------------------------
172
+
173
+ describe("Pass 1: mapSourceToTests (path convention)", () => {
174
+ let originalFile: typeof Bun.file;
175
+
176
+ beforeEach(() => {
177
+ originalFile = Bun.file;
178
+ });
179
+
180
+ afterEach(() => {
181
+ // biome-ignore lint/suspicious/noExplicitAny: restoring original
182
+ (Bun as any).file = originalFile;
183
+ });
184
+
185
+ function mockFileExists(existingPaths: string[]) {
186
+ // biome-ignore lint/suspicious/noExplicitAny: mocking
187
+ (Bun as any).file = (path: string) => ({
188
+ exists: () => Promise.resolve(existingPaths.includes(path)),
189
+ });
190
+ }
191
+
192
+ test("maps src/foo/bar.ts to test/unit/foo/bar.test.ts", async () => {
193
+ mockFileExists(["/repo/test/unit/foo/bar.test.ts"]);
194
+ const result = await mapSourceToTests(["src/foo/bar.ts"], "/repo");
195
+ expect(result).toEqual(["/repo/test/unit/foo/bar.test.ts"]);
196
+ });
197
+
198
+ test("also checks test/integration/ path", async () => {
199
+ mockFileExists(["/repo/test/integration/foo/bar.test.ts"]);
200
+ const result = await mapSourceToTests(["src/foo/bar.ts"], "/repo");
201
+ expect(result).toEqual(["/repo/test/integration/foo/bar.test.ts"]);
202
+ });
203
+
204
+ test("returns empty array when no test files exist (triggers Pass 2 at caller)", async () => {
205
+ mockFileExists([]);
206
+ const result = await mapSourceToTests(["src/routing/strategies/llm.ts"], "/repo");
207
+ expect(result).toEqual([]);
208
+ });
209
+
210
+ test("returns empty array for empty sourceFiles", async () => {
211
+ mockFileExists([]);
212
+ const result = await mapSourceToTests([], "/repo");
213
+ expect(result).toEqual([]);
214
+ });
215
+ });
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Pass 2: import-grep fallback
219
+ // ---------------------------------------------------------------------------
220
+
221
+ describe("Pass 2: importGrepFallback", () => {
222
+ let originalFile: typeof Bun.file;
223
+ let originalGlob: typeof Bun.Glob;
224
+
225
+ beforeEach(() => {
226
+ originalFile = Bun.file;
227
+ originalGlob = Bun.Glob;
228
+ });
229
+
230
+ afterEach(() => {
231
+ // biome-ignore lint/suspicious/noExplicitAny: restoring originals
232
+ (Bun as any).file = originalFile;
233
+ // biome-ignore lint/suspicious/noExplicitAny: restoring originals
234
+ (Bun as any).Glob = originalGlob;
235
+ });
236
+
237
+ function mockGlob(files: string[]) {
238
+ // biome-ignore lint/suspicious/noExplicitAny: mocking Glob
239
+ (Bun as any).Glob = class {
240
+ scan(_workdir: string): AsyncIterable<string> {
241
+ return {
242
+ [Symbol.asyncIterator]() {
243
+ let i = 0;
244
+ return {
245
+ async next() {
246
+ if (i < files.length) return { value: files[i++], done: false };
247
+ return { value: undefined as unknown as string, done: true };
248
+ },
249
+ };
250
+ },
251
+ };
252
+ }
253
+ };
254
+ }
255
+
256
+ function mockFileContent(contentMap: Record<string, string>) {
257
+ // biome-ignore lint/suspicious/noExplicitAny: mocking
258
+ (Bun as any).file = (path: string) => ({
259
+ exists: () => Promise.resolve(path in contentMap),
260
+ text: () => Promise.resolve(contentMap[path] ?? ""),
261
+ });
262
+ }
263
+
264
+ test("returns empty array when sourceFiles is empty", async () => {
265
+ const result = await importGrepFallback([], "/repo", ["test/**/*.test.ts"]);
266
+ expect(result).toEqual([]);
267
+ });
268
+
269
+ test("returns empty array when testFilePatterns is empty", async () => {
270
+ const result = await importGrepFallback(["src/foo/bar.ts"], "/repo", []);
271
+ expect(result).toEqual([]);
272
+ });
273
+
274
+ test("matches test file that imports the source by basename path", async () => {
275
+ mockGlob(["test/unit/routing.test.ts"]);
276
+ mockFileContent({
277
+ "/repo/test/unit/routing.test.ts": `import { route } from "../../src/routing/strategies/llm";`,
278
+ });
279
+
280
+ const result = await importGrepFallback(
281
+ ["src/routing/strategies/llm.ts"],
282
+ "/repo",
283
+ ["test/**/*.test.ts"],
284
+ );
285
+
286
+ expect(result).toEqual(["/repo/test/unit/routing.test.ts"]);
287
+ });
288
+
289
+ test("matches test file that imports by full path segment", async () => {
290
+ mockGlob(["test/unit/routing.test.ts"]);
291
+ mockFileContent({
292
+ "/repo/test/unit/routing.test.ts": `import something from "../../../routing/strategies/llm";`,
293
+ });
294
+
295
+ const result = await importGrepFallback(
296
+ ["src/routing/strategies/llm.ts"],
297
+ "/repo",
298
+ ["test/**/*.test.ts"],
299
+ );
300
+
301
+ expect(result).toEqual(["/repo/test/unit/routing.test.ts"]);
302
+ });
303
+
304
+ test("does not match test file with no import reference", async () => {
305
+ mockGlob(["test/unit/other.test.ts"]);
306
+ mockFileContent({
307
+ "/repo/test/unit/other.test.ts": `import { something } from "../../src/other/module";`,
308
+ });
309
+
310
+ const result = await importGrepFallback(
311
+ ["src/routing/strategies/llm.ts"],
312
+ "/repo",
313
+ ["test/**/*.test.ts"],
314
+ );
315
+
316
+ expect(result).toEqual([]);
317
+ });
318
+
319
+ test("returns multiple matching test files", async () => {
320
+ mockGlob(["test/unit/a.test.ts", "test/unit/b.test.ts"]);
321
+ mockFileContent({
322
+ "/repo/test/unit/a.test.ts": `import { fn } from "../src/utils/helper";`,
323
+ "/repo/test/unit/b.test.ts": `import { fn } from "../../src/utils/helper";`,
324
+ });
325
+
326
+ const result = await importGrepFallback(
327
+ ["src/utils/helper.ts"],
328
+ "/repo",
329
+ ["test/**/*.test.ts"],
330
+ );
331
+
332
+ expect(result).toContain("/repo/test/unit/a.test.ts");
333
+ expect(result).toContain("/repo/test/unit/b.test.ts");
334
+ expect(result).toHaveLength(2);
335
+ });
336
+
337
+ test("skips test files that cannot be read", async () => {
338
+ mockGlob(["test/unit/broken.test.ts", "test/unit/ok.test.ts"]);
339
+ // biome-ignore lint/suspicious/noExplicitAny: mocking
340
+ (Bun as any).file = (path: string) => ({
341
+ exists: () => Promise.resolve(true),
342
+ text: () => {
343
+ if (path.includes("broken")) throw new Error("read error");
344
+ return Promise.resolve(`import { fn } from "../src/utils/helper";`);
345
+ },
346
+ });
347
+
348
+ const result = await importGrepFallback(
349
+ ["src/utils/helper.ts"],
350
+ "/repo",
351
+ ["test/**/*.test.ts"],
352
+ );
353
+
354
+ // broken.test.ts is skipped, ok.test.ts matches
355
+ expect(result).toEqual(["/repo/test/unit/ok.test.ts"]);
356
+ });
357
+
358
+ test("does not add the same file twice if multiple terms match", async () => {
359
+ mockGlob(["test/unit/routing.test.ts"]);
360
+ // Content contains both "/llm" and "routing/strategies/llm"
361
+ mockFileContent({
362
+ "/repo/test/unit/routing.test.ts": `import { classify } from "../../src/routing/strategies/llm";`,
363
+ });
364
+
365
+ const result = await importGrepFallback(
366
+ ["src/routing/strategies/llm.ts"],
367
+ "/repo",
368
+ ["test/**/*.test.ts"],
369
+ );
370
+
371
+ expect(result).toHaveLength(1);
372
+ });
373
+ });
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // Pass 3: full-suite fallback (empty return triggers full-suite at caller)
377
+ // ---------------------------------------------------------------------------
378
+
379
+ describe("Pass 3: full-suite fallback (empty return from both passes)", () => {
380
+ let originalFile: typeof Bun.file;
381
+ let originalGlob: typeof Bun.Glob;
382
+
383
+ beforeEach(() => {
384
+ originalFile = Bun.file;
385
+ originalGlob = Bun.Glob;
386
+ });
387
+
388
+ afterEach(() => {
389
+ // biome-ignore lint/suspicious/noExplicitAny: restoring originals
390
+ (Bun as any).file = originalFile;
391
+ // biome-ignore lint/suspicious/noExplicitAny: restoring originals
392
+ (Bun as any).Glob = originalGlob;
393
+ });
394
+
395
+ test("importGrepFallback returns empty array when no test files match any pattern", async () => {
396
+ // biome-ignore lint/suspicious/noExplicitAny: mocking Glob
397
+ (Bun as any).Glob = class {
398
+ scan(_workdir: string): AsyncIterable<string> {
399
+ return {
400
+ [Symbol.asyncIterator]() {
401
+ return { async next() { return { value: undefined as unknown as string, done: true }; } };
402
+ },
403
+ };
404
+ }
405
+ };
406
+
407
+ const result = await importGrepFallback(
408
+ ["src/foo/bar.ts"],
409
+ "/repo",
410
+ ["test/**/*.test.ts"],
411
+ );
412
+
413
+ expect(result).toEqual([]);
414
+ });
415
+
416
+ test("importGrepFallback returns empty array when no scanned test files import the module", async () => {
417
+ // biome-ignore lint/suspicious/noExplicitAny: mocking Glob
418
+ (Bun as any).Glob = class {
419
+ scan(_workdir: string): AsyncIterable<string> {
420
+ let done = false;
421
+ return {
422
+ [Symbol.asyncIterator]() {
423
+ return {
424
+ async next() {
425
+ if (!done) { done = true; return { value: "test/unit/unrelated.test.ts", done: false }; }
426
+ return { value: undefined as unknown as string, done: true };
427
+ },
428
+ };
429
+ },
430
+ };
431
+ }
432
+ };
433
+ // biome-ignore lint/suspicious/noExplicitAny: mocking
434
+ (Bun as any).file = (_path: string) => ({
435
+ text: () => Promise.resolve(`import { x } from "../src/completely/different";`),
436
+ });
437
+
438
+ const result = await importGrepFallback(
439
+ ["src/foo/bar.ts"],
440
+ "/repo",
441
+ ["test/**/*.test.ts"],
442
+ );
443
+
444
+ expect(result).toEqual([]);
445
+ });
446
+ });
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // Custom testFilePatterns
450
+ // ---------------------------------------------------------------------------
451
+
452
+ describe("Custom testFilePatterns", () => {
453
+ let originalGlob: typeof Bun.Glob;
454
+
455
+ beforeEach(() => {
456
+ originalGlob = Bun.Glob;
457
+ });
458
+
459
+ afterEach(() => {
460
+ // biome-ignore lint/suspicious/noExplicitAny: restoring original
461
+ (Bun as any).Glob = originalGlob;
462
+ });
463
+
464
+ test("passes custom testFilePatterns to Bun.Glob", async () => {
465
+ const capturedPatterns: string[] = [];
466
+ // biome-ignore lint/suspicious/noExplicitAny: mocking Glob
467
+ (Bun as any).Glob = class {
468
+ constructor(pattern: string) {
469
+ capturedPatterns.push(pattern);
470
+ }
471
+ scan(_workdir: string): AsyncIterable<string> {
472
+ return {
473
+ [Symbol.asyncIterator]() {
474
+ return { async next() { return { value: undefined as unknown as string, done: true }; } };
475
+ },
476
+ };
477
+ }
478
+ };
479
+
480
+ await importGrepFallback(
481
+ ["src/foo/bar.ts"],
482
+ "/repo",
483
+ ["test/unit/**/*.spec.ts", "test/integration/**/*.spec.ts"],
484
+ );
485
+
486
+ expect(capturedPatterns).toContain("test/unit/**/*.spec.ts");
487
+ expect(capturedPatterns).toContain("test/integration/**/*.spec.ts");
488
+ });
489
+
490
+ test("uses each pattern independently", async () => {
491
+ let scanCount = 0;
492
+ // biome-ignore lint/suspicious/noExplicitAny: mocking Glob
493
+ (Bun as any).Glob = class {
494
+ scan(_workdir: string): AsyncIterable<string> {
495
+ scanCount++;
496
+ return {
497
+ [Symbol.asyncIterator]() {
498
+ return { async next() { return { value: undefined as unknown as string, done: true }; } };
499
+ },
500
+ };
501
+ }
502
+ };
503
+
504
+ await importGrepFallback(
505
+ ["src/foo/bar.ts"],
506
+ "/repo",
507
+ ["test/unit/**/*.test.ts", "test/integration/**/*.test.ts", "test/e2e/**/*.test.ts"],
508
+ );
509
+
510
+ expect(scanCount).toBe(3);
511
+ });
512
+ });