lacuna-cli 0.1.5 → 0.1.7

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 (107) hide show
  1. package/README.md +83 -21
  2. package/dist/agent/context.d.ts +2 -0
  3. package/dist/agent/context.d.ts.map +1 -1
  4. package/dist/agent/context.js +314 -1
  5. package/dist/agent/context.js.map +1 -1
  6. package/dist/agent/fix-loop.d.ts +5 -0
  7. package/dist/agent/fix-loop.d.ts.map +1 -1
  8. package/dist/agent/fix-loop.js +376 -41
  9. package/dist/agent/fix-loop.js.map +1 -1
  10. package/dist/agent/generator.d.ts +8 -1
  11. package/dist/agent/generator.d.ts.map +1 -1
  12. package/dist/agent/generator.js +89 -11
  13. package/dist/agent/generator.js.map +1 -1
  14. package/dist/agent/loop.d.ts +8 -0
  15. package/dist/agent/loop.d.ts.map +1 -1
  16. package/dist/agent/loop.js +108 -46
  17. package/dist/agent/loop.js.map +1 -1
  18. package/dist/agent/{prompts.d.ts → prompts/index.d.ts} +12 -2
  19. package/dist/agent/prompts/index.d.ts.map +1 -0
  20. package/dist/agent/prompts/index.js +668 -0
  21. package/dist/agent/prompts/index.js.map +1 -0
  22. package/dist/agent/prompts/nextjs.d.ts +12 -0
  23. package/dist/agent/prompts/nextjs.d.ts.map +1 -0
  24. package/dist/agent/prompts/nextjs.js +138 -0
  25. package/dist/agent/prompts/nextjs.js.map +1 -0
  26. package/dist/agent/prompts/react-native.d.ts +4 -0
  27. package/dist/agent/prompts/react-native.d.ts.map +1 -0
  28. package/dist/agent/prompts/react-native.js +82 -0
  29. package/dist/agent/prompts/react-native.js.map +1 -0
  30. package/dist/agent/prompts/react.d.ts +2 -0
  31. package/dist/agent/prompts/react.d.ts.map +1 -0
  32. package/dist/agent/prompts/react.js +48 -0
  33. package/dist/agent/prompts/react.js.map +1 -0
  34. package/dist/agent/prompts/runners/js-common.d.ts +2 -0
  35. package/dist/agent/prompts/runners/js-common.d.ts.map +1 -0
  36. package/dist/agent/prompts/runners/js-common.js +13 -0
  37. package/dist/agent/prompts/runners/js-common.js.map +1 -0
  38. package/dist/agent/prompts/runners/typescript.d.ts +2 -0
  39. package/dist/agent/prompts/runners/typescript.d.ts.map +1 -0
  40. package/dist/agent/prompts/runners/typescript.js +12 -0
  41. package/dist/agent/prompts/runners/typescript.js.map +1 -0
  42. package/dist/agent/prompts/runners/vitest.d.ts +2 -0
  43. package/dist/agent/prompts/runners/vitest.d.ts.map +1 -0
  44. package/dist/agent/prompts/runners/vitest.js +23 -0
  45. package/dist/agent/prompts/runners/vitest.js.map +1 -0
  46. package/dist/agent/prompts/vue.d.ts +3 -0
  47. package/dist/agent/prompts/vue.d.ts.map +1 -0
  48. package/dist/agent/prompts/vue.js +29 -0
  49. package/dist/agent/prompts/vue.js.map +1 -0
  50. package/dist/commands/analyze.d.ts.map +1 -1
  51. package/dist/commands/analyze.js +43 -32
  52. package/dist/commands/analyze.js.map +1 -1
  53. package/dist/commands/fix.d.ts +2 -0
  54. package/dist/commands/fix.d.ts.map +1 -1
  55. package/dist/commands/fix.js +32 -3
  56. package/dist/commands/fix.js.map +1 -1
  57. package/dist/commands/init.d.ts.map +1 -1
  58. package/dist/commands/init.js +208 -32
  59. package/dist/commands/init.js.map +1 -1
  60. package/dist/lib/config.d.ts +3 -3
  61. package/dist/lib/config.d.ts.map +1 -1
  62. package/dist/lib/config.js +3 -1
  63. package/dist/lib/config.js.map +1 -1
  64. package/dist/lib/coverage/gaps.d.ts +2 -2
  65. package/dist/lib/coverage/gaps.d.ts.map +1 -1
  66. package/dist/lib/coverage/gaps.js +35 -5
  67. package/dist/lib/coverage/gaps.js.map +1 -1
  68. package/dist/lib/coverage/index.d.ts +1 -1
  69. package/dist/lib/coverage/index.d.ts.map +1 -1
  70. package/dist/lib/coverage/index.js +1 -1
  71. package/dist/lib/coverage/index.js.map +1 -1
  72. package/dist/lib/detector.d.ts +1 -0
  73. package/dist/lib/detector.d.ts.map +1 -1
  74. package/dist/lib/detector.js +41 -8
  75. package/dist/lib/detector.js.map +1 -1
  76. package/dist/lib/providers/anthropic.d.ts.map +1 -1
  77. package/dist/lib/providers/anthropic.js +46 -3
  78. package/dist/lib/providers/anthropic.js.map +1 -1
  79. package/dist/lib/providers/openai-compatible.d.ts +1 -1
  80. package/dist/lib/providers/openai-compatible.d.ts.map +1 -1
  81. package/dist/lib/providers/openai-compatible.js +43 -2
  82. package/dist/lib/providers/openai-compatible.js.map +1 -1
  83. package/dist/lib/providers/types.d.ts +4 -0
  84. package/dist/lib/providers/types.d.ts.map +1 -1
  85. package/dist/lib/providers/types.js +10 -0
  86. package/dist/lib/providers/types.js.map +1 -1
  87. package/dist/lib/skeleton.d.ts +4 -0
  88. package/dist/lib/skeleton.d.ts.map +1 -1
  89. package/dist/lib/skeleton.js +220 -0
  90. package/dist/lib/skeleton.js.map +1 -1
  91. package/dist/lib/validate.d.ts +1 -0
  92. package/dist/lib/validate.d.ts.map +1 -1
  93. package/dist/lib/validate.js +179 -15
  94. package/dist/lib/validate.js.map +1 -1
  95. package/dist/lib/worker-display.d.ts +10 -0
  96. package/dist/lib/worker-display.d.ts.map +1 -1
  97. package/dist/lib/worker-display.js +53 -5
  98. package/dist/lib/worker-display.js.map +1 -1
  99. package/oclif.manifest.json +16 -2
  100. package/package.json +1 -1
  101. package/dist/agent/prompts.d.ts.map +0 -1
  102. package/dist/agent/prompts.js +0 -632
  103. package/dist/agent/prompts.js.map +0 -1
  104. package/dist/lib/report-upload.d.ts +0 -3
  105. package/dist/lib/report-upload.d.ts.map +0 -1
  106. package/dist/lib/report-upload.js +0 -15
  107. package/dist/lib/report-upload.js.map +0 -1
@@ -9,11 +9,16 @@ export interface FixOptions {
9
9
  targetFile?: string;
10
10
  workers?: number;
11
11
  fresh?: boolean;
12
+ regenerateOnFailure?: boolean;
13
+ fixPolluters?: boolean;
12
14
  log: (msg: string) => void;
13
15
  }
14
16
  export interface FixResult {
15
17
  filesProcessed: number;
16
18
  filesFixed: number;
19
+ filesAlreadyPassing: number;
20
+ pollutersFixed: number;
21
+ victimsRegenerated: number;
17
22
  errors: string[];
18
23
  }
19
24
  export declare function runFixLoop(options: FixOptions): Promise<FixResult>;
@@ -1 +1 @@
1
- {"version":3,"file":"fix-loop.d.ts","sourceRoot":"","sources":["../../src/agent/fix-loop.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AACpD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAA;AAe7D,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,YAAY,CAAA;IACpB,GAAG,EAAE,mBAAmB,CAAA;IACxB,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,OAAO,CAAA;IACf,OAAO,EAAE,OAAO,CAAA;IAChB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAC3B;AAoCD,MAAM,WAAW,SAAS;IACxB,cAAc,EAAE,MAAM,CAAA;IACtB,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,MAAM,EAAE,CAAA;CACjB;AA6UD,wBAAsB,UAAU,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAgIxE"}
1
+ {"version":3,"file":"fix-loop.d.ts","sourceRoot":"","sources":["../../src/agent/fix-loop.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AACpD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAA;AAiB7D,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,YAAY,CAAA;IACpB,GAAG,EAAE,mBAAmB,CAAA;IACxB,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,OAAO,CAAA;IACf,OAAO,EAAE,OAAO,CAAA;IAChB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,mBAAmB,CAAC,EAAE,OAAO,CAAA;IAC7B,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAC3B;AAoCD,MAAM,WAAW,SAAS;IACxB,cAAc,EAAE,MAAM,CAAA;IACtB,UAAU,EAAE,MAAM,CAAA;IAClB,mBAAmB,EAAE,MAAM,CAAA;IAC3B,cAAc,EAAE,MAAM,CAAA;IACtB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,MAAM,EAAE,MAAM,EAAE,CAAA;CACjB;AAypBD,wBAAsB,UAAU,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAwJxE"}
@@ -1,17 +1,18 @@
1
- import { readFile, writeFile, mkdir, unlink } from 'fs/promises';
1
+ import { readFile, writeFile, mkdir, unlink, readdir } from 'fs/promises';
2
2
  import { join, dirname, basename, extname } from 'path';
3
3
  import { access, stat } from 'fs/promises';
4
4
  import chalk from 'chalk';
5
- import { fileTestCommand } from '../lib/detector.js';
5
+ import { fileTestCommand, multiFileTestCommand } from '../lib/detector.js';
6
6
  import { runCommand } from '../lib/runner.js';
7
7
  import { startCoverageSpinner } from '../lib/coverage-spinner.js';
8
8
  import { WorkerDisplay } from '../lib/worker-display.js';
9
9
  import { buildFixFileContext, computeRelativeImport, collectTypeDefinitions, collectLocalImportPaths, detectReactMajorVersion } from './context.js';
10
- import { TestGenerator, TruncatedOutputError, OscillationError, TRUNCATION_RETRY_MESSAGE } from './generator.js';
10
+ import { TestGenerator, TruncatedOutputError, OscillationError, ModelStallError, TRUNCATION_RETRY_MESSAGE, OSCILLATION_ESCAPE_MESSAGE } from './generator.js';
11
+ import { processGap } from './loop.js';
11
12
  import { ProjectMemory } from './project-memory.js';
12
13
  import { getActiveTips, createTipRotator, formatTip } from '../lib/tips.js';
13
14
  import { typeCheckFile } from '../lib/typecheck.js';
14
- import { hasTestFunctions, enrichNoTestsError, isZeroTestsOutput, parsePassCount, buildStructureBrokenMessage, buildRegressionMessage, sanitizeMocksContent, stripLeadingProse, mergeMocksContent } from '../lib/validate.js';
15
+ import { hasTestFunctions, enrichNoTestsError, isZeroTestsOutput, parsePassCount, buildStructureBrokenMessage, buildRegressionMessage, sanitizeMocksContent, stripLeadingProse, mergeMocksContent, deduplicateViMocks } from '../lib/validate.js';
15
16
  import { extractTestFailure } from '../lib/extract-error.js';
16
17
  import { StreamingFileViewer } from '../lib/streaming-viewer.js';
17
18
  // ─── Failing-files cache ──────────────────────────────────────────────────────
@@ -46,42 +47,84 @@ async function clearFixCache(cwd) {
46
47
  catch { /* already gone — fine */ }
47
48
  }
48
49
  // ─── Parse failing test files from runner output ──────────────────────────────
49
- const TEST_FILE_RE = /[\w./\\@-]+\.(?:test|spec)\.(?:tsx|mts|ts|jsx|js)/;
50
+ const TEST_FILE_RE = /[\w./\\@\[\]()-]+\.(?:test|spec)\.(?:tsx|mts|ts|jsx|js)/;
51
+ function stripAnsi(s) {
52
+ // Strip all CSI sequences (ESC [ ... letter), OSC sequences, carriage returns
53
+ return s.replace(/\x1B\[[0-9;]*[A-Za-z]/g, '').replace(/\x1B\][^\x1B]*/g, '').replace(/\r/g, '');
54
+ }
50
55
  function parseFailingTestFiles(output, runner) {
51
- const files = new Set();
52
56
  const lines = output.split('\n');
57
+ // Two separate sets — cross/tick pattern (per-file summary) vs FAIL pattern (per-test details)
58
+ const crossFiles = new Set();
59
+ const failFiles = new Set();
53
60
  for (const line of lines) {
54
- const clean = line.replace(/\x1B\[[0-9;]*m/g, '').trim();
55
- // Vitest: cross character followed by file path (covers multiple cross symbols across versions)
61
+ const clean = stripAnsi(line).trim();
56
62
  if (runner === 'vitest' || runner === 'unknown') {
57
63
  const m = clean.match(new RegExp(`^[×✗✕✖✘❌]\\s+(${TEST_FILE_RE.source})`));
58
64
  if (m) {
59
- files.add(m[1]);
65
+ crossFiles.add(m[1]);
60
66
  continue;
61
67
  }
62
68
  }
63
- // "FAIL <path>" — used by Jest and some Vitest reporters/configurations
64
69
  if (runner === 'jest' || runner === 'vitest' || runner === 'unknown') {
65
70
  const m = clean.match(new RegExp(`^FAIL\\s+(${TEST_FILE_RE.source})`));
66
71
  if (m) {
67
- files.add(m[1]);
68
- continue;
72
+ failFiles.add(m[1]);
69
73
  }
70
74
  }
71
75
  }
72
- // Fallback: if no files matched via primary patterns, extract test file paths from
73
- // stack traces. A path in a stack trace always belongs to a file that ran and failed.
74
- // Over-inclusive is fine fixFile re-runs each file first and skips it if already passing.
75
- if (files.size === 0) {
76
+ // Parse the expected failing file count from the runner summary line
77
+ let expectedCount = null;
78
+ for (const line of lines) {
79
+ const clean = stripAnsi(line).trim();
80
+ const mv = clean.match(/Test Files\s+(\d+)\s+failed/);
81
+ if (mv) {
82
+ expectedCount = parseInt(mv[1], 10);
83
+ break;
84
+ }
85
+ const mj = clean.match(/Test Suites:\s+(\d+)\s+failed/);
86
+ if (mj) {
87
+ expectedCount = parseInt(mj[1], 10);
88
+ break;
89
+ }
90
+ }
91
+ const combined = new Set([...crossFiles, ...failFiles]);
92
+ if (expectedCount !== null && combined.size > expectedCount) {
93
+ // Over-detected: prune false positives by preferring files confirmed by both patterns,
94
+ // then FAIL-only (strong signal — comes from the detailed failures section),
95
+ // then cross-only last (more likely to include false positives).
96
+ const pruned = new Set();
97
+ for (const f of crossFiles) {
98
+ if (failFiles.has(f))
99
+ pruned.add(f);
100
+ }
101
+ for (const f of failFiles) {
102
+ if (pruned.size < expectedCount)
103
+ pruned.add(f);
104
+ }
105
+ for (const f of crossFiles) {
106
+ if (pruned.size < expectedCount)
107
+ pruned.add(f);
108
+ }
109
+ return [...pruned];
110
+ }
111
+ // Supplement with stack traces only when primary patterns under-detected
112
+ const needsSupplement = expectedCount !== null ? combined.size < expectedCount : combined.size === 0;
113
+ if (needsSupplement) {
114
+ let inTrace = false;
76
115
  for (const line of lines) {
77
- const clean = line.replace(/\x1B\[[0-9;]*m/g, '').trim();
78
- // stack trace: at ... (src/foo.test.tsx:42:5) or at src/foo.test.tsx:42
116
+ const clean = stripAnsi(line).trim();
117
+ if (!clean || clean.startsWith('●') || clean.startsWith('FAIL') || /^[×✗✕✖✘❌]/.test(clean)) {
118
+ inTrace = false;
119
+ }
79
120
  const m = clean.match(new RegExp(`\\(?(${TEST_FILE_RE.source}):\\d+`));
80
- if (m)
81
- files.add(m[1]);
121
+ if (m && !combined.has(m[1]) && !inTrace) {
122
+ combined.add(m[1]);
123
+ inTrace = true;
124
+ }
82
125
  }
83
126
  }
84
- return [...files];
127
+ return [...combined];
85
128
  }
86
129
  // ─── Find the source file that a test file is testing ────────────────────────
87
130
  async function findSourceFile(testFilePath, cwd) {
@@ -94,7 +137,11 @@ async function findSourceFile(testFilePath, cwd) {
94
137
  const sourceDir = basename(dir) === '__tests__' ? dirname(dir) : dir;
95
138
  const exts = [ext, '.ts', '.tsx', '.js', '.jsx'];
96
139
  for (const e of exts) {
97
- const candidate = join(cwd, sourceDir, `${sourceBase}${e}`);
140
+ // If sourceDir is absolute (testFilePath was absolute), use it directly.
141
+ // join(cwd, absoluteDir) doubles the path: /project/home/project/app/... which never exists.
142
+ const candidate = sourceDir.startsWith('/') || sourceDir.startsWith('\\')
143
+ ? join(sourceDir, `${sourceBase}${e}`)
144
+ : join(cwd, sourceDir, `${sourceBase}${e}`);
98
145
  try {
99
146
  await access(candidate);
100
147
  return candidate;
@@ -115,9 +162,9 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
115
162
  const firstRun = await runCommand(fileTestCommand(env, absTestPath), cwd, 60_000);
116
163
  if (firstRun.success) {
117
164
  if (!onStatus)
118
- log(chalk.green(' Already passing — skipping.'));
165
+ log(chalk.dim(' Already passing — skipping.'));
119
166
  onStatus?.({ phase: 'passed', file: shortPath });
120
- return { success: true };
167
+ return { success: true, skipped: true };
121
168
  }
122
169
  let errorOutput = extractTestFailure(firstRun.stdout + '\n' + firstRun.stderr);
123
170
  const initialErrorOutput = errorOutput;
@@ -153,15 +200,25 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
153
200
  ]);
154
201
  // Build mocks/setup context relative to the actual test file path
155
202
  const ctx = await buildFixFileContext(absTestPath, cwd, config).catch(() => null);
156
- if (!onStatus)
157
- log(chalk.dim(` Sending to ${config.model} for repair...`));
158
- onStatus?.({ phase: 'generating', file: shortPath });
203
+ let stallRetries = 0;
204
+ const MAX_STALL_RETRIES = 2;
159
205
  for (let attempt = 1; attempt <= config.maxIterations; attempt++) {
160
206
  if (attempt > 1) {
161
207
  if (!onStatus)
162
208
  log(chalk.yellow(`\n Retry ${attempt}/${config.maxIterations}...`));
163
- onStatus?.({ phase: 'retrying', file: shortPath, attempt, max: config.maxIterations });
164
209
  }
210
+ // Show waiting phase before the model call; transition to generating/retrying on first token
211
+ onStatus?.({ phase: 'waiting', file: shortPath, since: Date.now() });
212
+ const currentAttempt = attempt;
213
+ generator.setFirstTokenCallback(() => {
214
+ onStatus?.({
215
+ phase: currentAttempt === 1 ? 'generating' : 'retrying',
216
+ file: shortPath,
217
+ ...(currentAttempt > 1 ? { attempt: currentAttempt, max: config.maxIterations } : {}),
218
+ });
219
+ });
220
+ if (!onStatus)
221
+ log(chalk.dim(` ⌛ Waiting for model response...`));
165
222
  let viewer;
166
223
  if (verbose && !onStatus) {
167
224
  viewer = new StreamingFileViewer(shortPath);
@@ -178,6 +235,7 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
178
235
  sourceCode,
179
236
  sourceImportPath,
180
237
  errorOutput,
238
+ env,
181
239
  mocksCode: ctx?.mocksCode ?? null,
182
240
  mocksImportPath: ctx?.mocksImportPath ?? null,
183
241
  setupFileCode: ctx?.setupFileCode ?? null,
@@ -193,6 +251,18 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
193
251
  catch (err) {
194
252
  viewer?.stop();
195
253
  generator.setTokenCallback(undefined);
254
+ generator.setFirstTokenCallback(undefined);
255
+ if (err instanceof ModelStallError) {
256
+ if (stallRetries < MAX_STALL_RETRIES) {
257
+ stallRetries++;
258
+ if (!onStatus)
259
+ log(chalk.yellow(`\n ⌛ Model stalled — reconnecting (${stallRetries}/${MAX_STALL_RETRIES})...`));
260
+ onStatus?.({ phase: 'waiting', file: shortPath, since: Date.now() });
261
+ await new Promise(r => setTimeout(r, 3000));
262
+ attempt--; // don't consume an AI iteration for a connection stall
263
+ continue;
264
+ }
265
+ }
196
266
  if (err instanceof TruncatedOutputError) {
197
267
  errorOutput = TRUNCATION_RETRY_MESSAGE;
198
268
  if (!onStatus)
@@ -201,6 +271,16 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
201
271
  continue;
202
272
  }
203
273
  if (err instanceof OscillationError) {
274
+ if (attempt < config.maxIterations) {
275
+ // Iterations remain — give one escape-hatch attempt with fresh oscillation state
276
+ // and an explicit "completely different approach" message instead of stopping.
277
+ if (!onStatus)
278
+ log(chalk.yellow(`\n ⚠ Agent loop detected — retrying with different strategy...`));
279
+ onStatus?.({ phase: 'retrying', file: shortPath, attempt, max: config.maxIterations });
280
+ generator.resetOscillationState();
281
+ errorOutput = OSCILLATION_ESCAPE_MESSAGE;
282
+ continue;
283
+ }
204
284
  if (!onStatus)
205
285
  log(chalk.red(`\n ⚠ Agent loop detected — output identical to a previous attempt. Stopping early.`));
206
286
  onStatus?.({ phase: 'failed', file: shortPath });
@@ -215,6 +295,7 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
215
295
  }
216
296
  viewer?.stop();
217
297
  generator.setTokenCallback(undefined);
298
+ generator.setFirstTokenCallback(undefined);
218
299
  if (dryRun) {
219
300
  if (!onStatus) {
220
301
  log(chalk.yellow('\n [dry-run] Would write:'));
@@ -255,6 +336,7 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
255
336
  }
256
337
  }
257
338
  }
339
+ testFileContent = deduplicateViMocks(testFileContent);
258
340
  // Catch empty test files before writing
259
341
  if (!hasTestFunctions(testFileContent)) {
260
342
  errorOutput =
@@ -288,11 +370,15 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
288
370
  return { success: true };
289
371
  }
290
372
  const rawRunOutput = result.stdout + '\n' + result.stderr;
291
- const extracted = enrichNoTestsError(extractTestFailure(rawRunOutput));
373
+ const rawExtracted = extractTestFailure(rawRunOutput);
292
374
  const structureBroken = isZeroTestsOutput(rawRunOutput);
293
375
  const currentPassCount = structureBroken ? 0 : parsePassCount(rawRunOutput);
376
+ // enrichNoTestsError adds guidance for genuinely missing test functions;
377
+ // in the structure-broken path the issue is always a broken import, so use
378
+ // rawExtracted there so the actual module error isn't buried in boilerplate.
379
+ const extracted = enrichNoTestsError(rawExtracted);
294
380
  if (structureBroken) {
295
- errorOutput = buildStructureBrokenMessage(initialErrorOutput, extracted);
381
+ errorOutput = buildStructureBrokenMessage(initialErrorOutput, rawExtracted);
296
382
  if (!onStatus)
297
383
  log(chalk.red(` Fix broke file structure — 0 tests collected (attempt ${attempt}/${config.maxIterations})`));
298
384
  }
@@ -317,13 +403,210 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
317
403
  error: `Still failing after ${config.maxIterations} attempts. Last error:\n${errorOutput.slice(0, 1500)}`,
318
404
  };
319
405
  }
406
+ // ─── Polluter detection ───────────────────────────────────────────────────────
407
+ function buildTestFileRegex(pattern) {
408
+ const filename = pattern.split('/').pop() ?? pattern;
409
+ const regexStr = filename
410
+ .replace(/\{([^}]+)\}/g, (_, g) => `(${g.split(',').map((s) => s.trim()).join('|')})`)
411
+ .replace(/\./g, '\\.')
412
+ .replace(/\*+/g, '[^/]+');
413
+ return new RegExp(regexStr + '$');
414
+ }
415
+ async function discoverTestFiles(cwd, env) {
416
+ const testRe = buildTestFileRegex(env.testFilePattern);
417
+ const files = [];
418
+ const skipDirs = new Set(['node_modules', 'dist', '.git', 'coverage', '.nyc_output', '.lacuna']);
419
+ async function walk(dir) {
420
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
421
+ for (const e of entries) {
422
+ if (e.isDirectory()) {
423
+ if (!skipDirs.has(e.name))
424
+ await walk(join(dir, e.name));
425
+ }
426
+ else if (testRe.test(e.name)) {
427
+ files.push(join(dir, e.name));
428
+ }
429
+ }
430
+ }
431
+ await walk(cwd);
432
+ return files.sort();
433
+ }
434
+ function victimInFailing(victim, failing, cwd) {
435
+ const rel = (p) => (p.startsWith(cwd + '/') ? p.slice(cwd.length + 1) : p);
436
+ const shortVictim = rel(victim);
437
+ return failing.some(f => {
438
+ const shortF = rel(f);
439
+ return shortF === shortVictim || shortVictim.endsWith(shortF) || shortF.endsWith(shortVictim);
440
+ });
441
+ }
442
+ async function victimFailsWithSubset(victim, subset, env, cwd) {
443
+ if (subset.length === 0)
444
+ return false;
445
+ const result = await runCommand(multiFileTestCommand(env, [...subset, victim]), cwd, 120_000);
446
+ if (result.success)
447
+ return false;
448
+ const failing = parseFailingTestFiles(result.stdout + '\n' + result.stderr, env.testRunner);
449
+ return victimInFailing(victim, failing, cwd);
450
+ }
451
+ async function bisectPolluter(victim, candidates, env, cwd) {
452
+ if (candidates.length === 0)
453
+ return null;
454
+ if (candidates.length === 1) {
455
+ const fails = await victimFailsWithSubset(victim, candidates, env, cwd);
456
+ return fails ? candidates[0] : null;
457
+ }
458
+ const mid = Math.floor(candidates.length / 2);
459
+ const left = candidates.slice(0, mid);
460
+ const right = candidates.slice(mid);
461
+ if (await victimFailsWithSubset(victim, left, env, cwd))
462
+ return bisectPolluter(victim, left, env, cwd);
463
+ if (await victimFailsWithSubset(victim, right, env, cwd))
464
+ return bisectPolluter(victim, right, env, cwd);
465
+ return null;
466
+ }
467
+ async function findAndFixPolluters(victimFiles, options, projectMemory) {
468
+ const { config, env, cwd, log } = options;
469
+ const allTestFiles = await discoverTestFiles(cwd, env);
470
+ log(chalk.dim(` Discovered ${allTestFiles.length} test files to search.`));
471
+ const generator = new TestGenerator({ config, env });
472
+ let pollutersFixed = 0;
473
+ let victimsRegenerated = 0;
474
+ const seenPolluters = new Set();
475
+ const unresolvedVictims = [];
476
+ for (const victim of victimFiles) {
477
+ const shortVictim = victim.replace(cwd + '/', '');
478
+ log(chalk.dim(`\n Bisecting for: ${chalk.cyan(shortVictim)}`));
479
+ const candidates = allTestFiles.filter(f => f !== victim);
480
+ // Probe: verify the pollution reproduces before spending O(log N) bisect runs.
481
+ // If it doesn't reproduce here, the pollution requires vitest's default multi-worker
482
+ // config to manifest and can't be found by this approach.
483
+ log(chalk.dim(` Probing (${candidates.length} files + victim)...`));
484
+ const reproduced = await victimFailsWithSubset(victim, candidates, env, cwd);
485
+ if (!reproduced) {
486
+ log(chalk.yellow(` Pollution did not reproduce in sequential mode — this is concurrency-based globalThis contamination.`));
487
+ log(chalk.dim(` A vi.spyOn(global, ...) spy from another file is persisting in the shared worker thread.`));
488
+ log(chalk.dim(` Fix: add restoreMocks: true and clearMocks: true to the test: {} block in vitest.config.ts`));
489
+ log(chalk.dim(` Also add beforeEach(() => vi.restoreAllMocks()) to your test setup file.`));
490
+ unresolvedVictims.push(victim);
491
+ continue;
492
+ }
493
+ const polluter = await bisectPolluter(victim, candidates, env, cwd);
494
+ if (!polluter) {
495
+ log(chalk.yellow(` Could not isolate a polluter — file may have an internal spy lifecycle bug.`));
496
+ unresolvedVictims.push(victim);
497
+ continue;
498
+ }
499
+ const shortPolluter = polluter.replace(cwd + '/', '');
500
+ log(` Found polluter: ${chalk.cyan(shortPolluter)}`);
501
+ if (seenPolluters.has(polluter)) {
502
+ log(chalk.dim(` Already processed ${shortPolluter}.`));
503
+ continue;
504
+ }
505
+ seenPolluters.add(polluter);
506
+ // Capture the victim's failure output when run after the polluter
507
+ const errorRun = await runCommand(multiFileTestCommand(env, [polluter, victim]), cwd, 60_000);
508
+ const victimError = extractTestFailure(errorRun.stdout + '\n' + errorRun.stderr);
509
+ const pollutorCode = await readFile(polluter, 'utf-8').catch(() => null);
510
+ const victimCode = await readFile(victim, 'utf-8').catch(() => null);
511
+ if (!pollutorCode || !victimCode) {
512
+ log(chalk.red(` Could not read files — skipping ${shortPolluter}`));
513
+ unresolvedVictims.push(victim);
514
+ continue;
515
+ }
516
+ log(chalk.dim(` Sending to ${config.model} for cleanup...`));
517
+ let fixed;
518
+ try {
519
+ fixed = await generator.fixPollution({
520
+ pollutorFile: shortPolluter,
521
+ pollutorCode,
522
+ victimFile: shortVictim,
523
+ victimCode,
524
+ victimError,
525
+ env,
526
+ });
527
+ }
528
+ catch (err) {
529
+ log(chalk.red(` AI error: ${err instanceof Error ? err.message : String(err)}`));
530
+ unresolvedVictims.push(victim);
531
+ continue;
532
+ }
533
+ await writeFile(polluter, fixed, 'utf-8');
534
+ const verifyRun = await runCommand(multiFileTestCommand(env, [polluter, victim]), cwd, 60_000);
535
+ const verifyFailing = parseFailingTestFiles(verifyRun.stdout + '\n' + verifyRun.stderr, env.testRunner);
536
+ const victimResolved = !victimInFailing(victim, verifyFailing, cwd);
537
+ if (victimResolved) {
538
+ log(chalk.green(` Cleanup applied: ${shortPolluter}`));
539
+ pollutersFixed++;
540
+ }
541
+ else {
542
+ log(chalk.red(` Cleanup did not resolve the victim — restoring ${shortPolluter}`));
543
+ await writeFile(polluter, pollutorCode, 'utf-8').catch(() => { });
544
+ unresolvedVictims.push(victim);
545
+ }
546
+ }
547
+ // Phase 2: regenerate victims that bisection couldn't resolve.
548
+ // These files pass alone but fail in the suite due to internal bugs
549
+ // (e.g. module-level vi.spyOn, wrong mock structure). A fresh generation
550
+ // produces properly-structured tests with spies inside beforeEach.
551
+ if (unresolvedVictims.length > 0 && options.regenerateOnFailure !== false) {
552
+ log(chalk.bold(`\n Regenerating ${unresolvedVictims.length} victim file(s) that couldn't be resolved by polluter cleanup...`));
553
+ for (const victim of unresolvedVictims) {
554
+ const shortVictim = victim.replace(cwd + '/', '');
555
+ log(chalk.dim(`\n Regenerating: ${chalk.cyan(shortVictim)}`));
556
+ const result = await regenerateFile(victim, options, undefined, projectMemory);
557
+ if (result.success) {
558
+ log(chalk.green(` Regenerated successfully.`));
559
+ victimsRegenerated++;
560
+ }
561
+ else {
562
+ log(chalk.red(` Regeneration failed: ${result.error?.slice(0, 200) ?? 'unknown error'}`));
563
+ }
564
+ }
565
+ }
566
+ return { pollutersFixed, victimsRegenerated };
567
+ }
568
+ // ─── Regeneration fallback ────────────────────────────────────────────────────
569
+ async function regenerateFile(testFilePath, options, onStatus, projectMemory) {
570
+ const absTestFile = testFilePath.startsWith('/') ? testFilePath : join(options.cwd, testFilePath);
571
+ // Find the source file so processGap gets the right starting point.
572
+ // processGap expects gap.filePath to be the SOURCE file, not the test file.
573
+ const sourceFile = await findSourceFile(absTestFile, options.cwd);
574
+ if (!sourceFile) {
575
+ return { success: false, error: `Could not find source file for ${absTestFile}` };
576
+ }
577
+ // Delete the broken test file before regenerating. If it stays on disk,
578
+ // buildFileContext reads it as existingTestCode and the generate prompt says
579
+ // "preserve all existing tests" — locking the AI into the same broken structure.
580
+ await unlink(absTestFile).catch(() => { });
581
+ const gap = { filePath: sourceFile, uncoveredLines: [], uncoveredFunctions: [] };
582
+ const generator = new TestGenerator({ config: options.config, env: options.env });
583
+ // processGap uses gap.filePath (the source file) as its display identifier, but during
584
+ // regen the worker should stay in 'regenerating' for all intermediate phases and only
585
+ // flip to passed/failed at the end. This prevents the brief flash where 'regenerating'
586
+ // gets overwritten by 'generating' (<80ms) as soon as processGap starts.
587
+ const testShortPath = absTestFile.replace(options.cwd + '/', '');
588
+ const regenOnStatus = onStatus
589
+ ? (state) => {
590
+ if (state.phase === 'passed' || state.phase === 'failed') {
591
+ onStatus('file' in state ? { ...state, file: testShortPath } : state);
592
+ }
593
+ else {
594
+ onStatus({ phase: 'regenerating', file: testShortPath });
595
+ }
596
+ }
597
+ : undefined;
598
+ const result = await processGap(gap, options, generator, true, regenOnStatus, projectMemory);
599
+ return { success: result.success, error: result.error };
600
+ }
320
601
  // ─── Worker pool ──────────────────────────────────────────────────────────────
321
602
  async function runFixWorkers(testFiles, options, workerCount, projectMemory) {
322
603
  const queue = [...testFiles];
323
604
  let filesProcessed = 0;
324
605
  let filesFixed = 0;
606
+ let filesAlreadyPassing = 0;
325
607
  const errors = [];
326
608
  const stillFailingFiles = [];
609
+ const victimFiles = [];
327
610
  const tips = getActiveTips({
328
611
  workers: workerCount,
329
612
  targetFile: options.targetFile,
@@ -344,10 +627,32 @@ async function runFixWorkers(testFiles, options, workerCount, projectMemory) {
344
627
  if (!file)
345
628
  break;
346
629
  const onStatus = (state) => display.update(wi, state);
347
- const result = await fixFile(file, { ...options, log: () => { }, verbose: false }, generator, onStatus, projectMemory);
630
+ const absFile = file.startsWith('/') ? file : join(options.cwd, file);
631
+ const workerOptions = { ...options, log: () => { }, verbose: false };
632
+ const result = await fixFile(absFile, workerOptions, generator, onStatus, projectMemory);
348
633
  filesProcessed++;
349
- if (result.success)
350
- filesFixed++;
634
+ if (result.success) {
635
+ if (result.skipped) {
636
+ filesAlreadyPassing++;
637
+ victimFiles.push(absFile);
638
+ }
639
+ else
640
+ filesFixed++;
641
+ }
642
+ else if (options.regenerateOnFailure) {
643
+ // Signal 'regenerating' first — this undoes the 'failed' done-count from fixFile
644
+ // so the regen's final phase is the single counted outcome for this file.
645
+ onStatus?.({ phase: 'regenerating', file: absFile.replace(options.cwd + '/', '') });
646
+ const regenResult = await regenerateFile(absFile, workerOptions, onStatus, projectMemory);
647
+ if (regenResult.success) {
648
+ filesFixed++;
649
+ }
650
+ else {
651
+ stillFailingFiles.push(file);
652
+ if (regenResult.error)
653
+ errors.push(regenResult.error);
654
+ }
655
+ }
351
656
  else {
352
657
  stillFailingFiles.push(file);
353
658
  if (result.error)
@@ -356,7 +661,7 @@ async function runFixWorkers(testFiles, options, workerCount, projectMemory) {
356
661
  }
357
662
  }));
358
663
  display.finish();
359
- return { filesProcessed, filesFixed, errors, stillFailingFiles };
664
+ return { filesProcessed, filesFixed, filesAlreadyPassing, errors, stillFailingFiles, victimFiles };
360
665
  }
361
666
  // ─── Main entry point ─────────────────────────────────────────────────────────
362
667
  export async function runFixLoop(options) {
@@ -374,7 +679,7 @@ export async function runFixLoop(options) {
374
679
  spinner.stop();
375
680
  if (fileResult.success) {
376
681
  log(chalk.green('\n All tests are passing — nothing to fix.'));
377
- return { filesProcessed: 0, filesFixed: 0, errors: [] };
682
+ return { filesProcessed: 0, filesFixed: 0, filesAlreadyPassing: 0, pollutersFixed: 0, victimsRegenerated: 0, errors: [] };
378
683
  }
379
684
  failingFiles = [absTarget];
380
685
  }
@@ -396,7 +701,7 @@ export async function runFixLoop(options) {
396
701
  }
397
702
  if (suiteResult.success) {
398
703
  log(chalk.green('\n All tests are passing — nothing to fix.'));
399
- return { filesProcessed: 0, filesFixed: 0, errors: [] };
704
+ return { filesProcessed: 0, filesFixed: 0, filesAlreadyPassing: 0, pollutersFixed: 0, victimsRegenerated: 0, errors: [] };
400
705
  }
401
706
  failingFiles = parseFailingTestFiles(suiteResult.stdout + suiteResult.stderr, env.testRunner);
402
707
  failingFiles = failingFiles.filter((f) => {
@@ -413,7 +718,7 @@ export async function runFixLoop(options) {
413
718
  .join('\n');
414
719
  if (lastLines)
415
720
  log(chalk.dim('\n Last output lines:\n' + lastLines.split('\n').map((l) => ` ${l}`).join('\n')));
416
- return { filesProcessed: 0, filesFixed: 0, errors: [] };
721
+ return { filesProcessed: 0, filesFixed: 0, filesAlreadyPassing: 0, pollutersFixed: 0, victimsRegenerated: 0, errors: [] };
417
722
  }
418
723
  await saveFixCache(cwd, failingFiles);
419
724
  }
@@ -429,17 +734,21 @@ export async function runFixLoop(options) {
429
734
  const memorySnapshot = memory.toPromptSection();
430
735
  let filesProcessed;
431
736
  let filesFixed;
737
+ let filesAlreadyPassing;
432
738
  let errors;
433
739
  let stillFailingFiles;
740
+ let victimFiles;
434
741
  if (parallel) {
435
742
  ;
436
- ({ filesProcessed, filesFixed, errors, stillFailingFiles } = await runFixWorkers(failingFiles, options, workerCount, memorySnapshot));
743
+ ({ filesProcessed, filesFixed, filesAlreadyPassing, errors, stillFailingFiles, victimFiles } = await runFixWorkers(failingFiles, options, workerCount, memorySnapshot));
437
744
  }
438
745
  else {
439
746
  filesProcessed = 0;
440
747
  filesFixed = 0;
748
+ filesAlreadyPassing = 0;
441
749
  errors = [];
442
750
  stillFailingFiles = [];
751
+ victimFiles = [];
443
752
  const generator = new TestGenerator({ config, env });
444
753
  const tips = getActiveTips({
445
754
  workers: 1,
@@ -460,8 +769,26 @@ export async function runFixLoop(options) {
460
769
  const absFile = file.startsWith('/') ? file : join(cwd, file);
461
770
  const result = await fixFile(absFile, options, generator, undefined, memory.toPromptSection());
462
771
  filesProcessed++;
463
- if (result.success)
464
- filesFixed++;
772
+ if (result.success) {
773
+ if (result.skipped) {
774
+ filesAlreadyPassing++;
775
+ victimFiles.push(absFile);
776
+ }
777
+ else
778
+ filesFixed++;
779
+ }
780
+ else if (options.regenerateOnFailure) {
781
+ log(chalk.yellow(` Fix exhausted — falling back to full regeneration...`));
782
+ const regenResult = await regenerateFile(absFile, options, undefined, memory.toPromptSection());
783
+ if (regenResult.success) {
784
+ filesFixed++;
785
+ }
786
+ else {
787
+ stillFailingFiles.push(file);
788
+ if (regenResult.error)
789
+ errors.push(regenResult.error);
790
+ }
791
+ }
465
792
  else {
466
793
  stillFailingFiles.push(file);
467
794
  if (result.error)
@@ -479,6 +806,14 @@ export async function runFixLoop(options) {
479
806
  else
480
807
  await clearFixCache(cwd);
481
808
  }
482
- return { filesProcessed, filesFixed, errors };
809
+ let pollutersFixed = 0;
810
+ let victimsRegenerated = 0;
811
+ if (options.fixPolluters && victimFiles.length > 0) {
812
+ log(chalk.bold(`\n Scanning for test polluters (${victimFiles.length} victim file(s) pass alone but fail in suite)...`));
813
+ const polluterResult = await findAndFixPolluters(victimFiles, options, memorySnapshot);
814
+ pollutersFixed = polluterResult.pollutersFixed;
815
+ victimsRegenerated = polluterResult.victimsRegenerated;
816
+ }
817
+ return { filesProcessed, filesFixed, filesAlreadyPassing, pollutersFixed, victimsRegenerated, errors };
483
818
  }
484
819
  //# sourceMappingURL=fix-loop.js.map