lacuna-cli 0.1.5 → 0.1.6

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 (61) hide show
  1. package/README.md +38 -10
  2. package/dist/agent/fix-loop.d.ts +5 -0
  3. package/dist/agent/fix-loop.d.ts.map +1 -1
  4. package/dist/agent/fix-loop.js +342 -35
  5. package/dist/agent/fix-loop.js.map +1 -1
  6. package/dist/agent/generator.d.ts +4 -1
  7. package/dist/agent/generator.d.ts.map +1 -1
  8. package/dist/agent/generator.js +42 -3
  9. package/dist/agent/generator.js.map +1 -1
  10. package/dist/agent/loop.d.ts +8 -0
  11. package/dist/agent/loop.d.ts.map +1 -1
  12. package/dist/agent/loop.js +26 -9
  13. package/dist/agent/loop.js.map +1 -1
  14. package/dist/agent/prompts.d.ts +8 -0
  15. package/dist/agent/prompts.d.ts.map +1 -1
  16. package/dist/agent/prompts.js +285 -70
  17. package/dist/agent/prompts.js.map +1 -1
  18. package/dist/commands/fix.d.ts +2 -0
  19. package/dist/commands/fix.d.ts.map +1 -1
  20. package/dist/commands/fix.js +31 -2
  21. package/dist/commands/fix.js.map +1 -1
  22. package/dist/commands/init.d.ts.map +1 -1
  23. package/dist/commands/init.js +107 -21
  24. package/dist/commands/init.js.map +1 -1
  25. package/dist/lib/config.d.ts +3 -3
  26. package/dist/lib/config.d.ts.map +1 -1
  27. package/dist/lib/config.js +3 -1
  28. package/dist/lib/config.js.map +1 -1
  29. package/dist/lib/coverage/gaps.d.ts +2 -2
  30. package/dist/lib/coverage/gaps.d.ts.map +1 -1
  31. package/dist/lib/coverage/gaps.js +4 -4
  32. package/dist/lib/coverage/gaps.js.map +1 -1
  33. package/dist/lib/detector.d.ts +1 -0
  34. package/dist/lib/detector.d.ts.map +1 -1
  35. package/dist/lib/detector.js +19 -0
  36. package/dist/lib/detector.js.map +1 -1
  37. package/dist/lib/providers/anthropic.d.ts.map +1 -1
  38. package/dist/lib/providers/anthropic.js +16 -2
  39. package/dist/lib/providers/anthropic.js.map +1 -1
  40. package/dist/lib/providers/openai-compatible.d.ts +1 -1
  41. package/dist/lib/providers/openai-compatible.d.ts.map +1 -1
  42. package/dist/lib/providers/openai-compatible.js +9 -1
  43. package/dist/lib/providers/openai-compatible.js.map +1 -1
  44. package/dist/lib/skeleton.d.ts +4 -0
  45. package/dist/lib/skeleton.d.ts.map +1 -1
  46. package/dist/lib/skeleton.js +220 -0
  47. package/dist/lib/skeleton.js.map +1 -1
  48. package/dist/lib/validate.d.ts +1 -0
  49. package/dist/lib/validate.d.ts.map +1 -1
  50. package/dist/lib/validate.js +132 -0
  51. package/dist/lib/validate.js.map +1 -1
  52. package/dist/lib/worker-display.d.ts +3 -0
  53. package/dist/lib/worker-display.d.ts.map +1 -1
  54. package/dist/lib/worker-display.js +19 -4
  55. package/dist/lib/worker-display.js.map +1 -1
  56. package/package.json +1 -1
  57. package/dist/lib/report-upload.d.ts +0 -3
  58. package/dist/lib/report-upload.d.ts.map +0 -1
  59. package/dist/lib/report-upload.js +0 -15
  60. package/dist/lib/report-upload.js.map +0 -1
  61. package/oclif.manifest.json +0 -295
package/README.md CHANGED
@@ -67,10 +67,16 @@ Interactive setup wizard. Configures your model, test runner, source directory,
67
67
 
68
68
  Works from any subdirectory — lacuna finds the project root automatically.
69
69
 
70
- For **React and Next.js** projects, `lacuna init` also:
70
+ For **React** projects, `lacuna init` also:
71
71
  - Installs `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`, and `jsdom`
72
- - Creates `vitest.config.ts` with the correct `environment: 'jsdom'` and `@/` alias (read from your `tsconfig.json`)
73
- - Creates a setup file pre-loaded with global `vi.mock()` calls for `next/navigation`, `next/headers`, and `next/cache` so individual tests don't need to mock them
72
+ - Creates `vitest.config.ts` with `environment: 'jsdom'` and `restoreMocks: true` + `clearMocks: true`
73
+ - Creates a setup file with `@testing-library/jest-dom` and `beforeEach`/`afterEach` mock cleanup hooks
74
+
75
+ For **Next.js** projects, the setup is different — Next.js and plain React are not compatible:
76
+ - Installs the same testing-library packages but does **not** add `environment: 'jsdom'` to vitest config (Next.js manages its own environment)
77
+ - Adds `@/` alias (from your `tsconfig.json`) and a `server-only` stub alias to vitest config
78
+ - Creates a setup file with global `vi.mock()` calls for `next/navigation`, `next/headers`, `next/cache`, `next/image`, and `next/font` — these are Next.js-specific mocks that would crash a plain React project
79
+ - If test dependencies are found in `node_modules` but not declared in `package.json` (e.g. from a different branch), lacuna will prompt you to add them — undeclared deps break CI on a fresh checkout
74
80
 
75
81
  ```bash
76
82
  lacuna init
@@ -108,7 +114,7 @@ lacuna generate --format json --output report.json
108
114
  ```
109
115
 
110
116
  ### `lacuna fix`
111
- Finds all failing tests and repairs them using AI — without rewriting them from scratch. Sends each failing file along with its error output and source code to the model, which surgically fixes what's broken and retries until it passes.
117
+ Finds all failing tests and repairs them using AI. Sends each failing file along with its error output and source code to the model, which surgically fixes what's broken and retries until it passes. If all fix retries fail, lacuna automatically deletes the broken test and regenerates it from the source file — a clean slate rather than another round of patchwork.
112
118
 
113
119
  ```bash
114
120
  lacuna fix
@@ -117,15 +123,19 @@ lacuna fix --file src/utils/math.test.ts # fix a single test file (skips full
117
123
  lacuna fix --dry-run # preview fixes without writing
118
124
  lacuna fix --verbose # live code panel as model writes each fix
119
125
  lacuna fix --fresh # re-run the suite even if cache is recent
126
+ lacuna fix --no-regenerate-on-failure # disable the regen fallback (fix only, no delete)
127
+ lacuna fix --fix-polluters # also handle tests that pass alone but fail in suite
120
128
  ```
121
129
 
122
- Unlike `lacuna generate`, which creates new tests, `lacuna fix` operates on existing failing tests. It preserves all test logic and only changes what is necessary to make the suite pass.
130
+ Unlike `lacuna generate`, which creates new tests, `lacuna fix` operates on existing failing tests and preserves all test logic where possible.
131
+
132
+ **Regeneration fallback (default on):** When fix retries are exhausted, lacuna deletes the test file and regenerates it from scratch using the generate path. This works better than continued repair for structurally broken tests — the AI starts with a clean conversation and full source context instead of carrying forward a chain of failed hypotheses. Use `--no-regenerate-on-failure` to disable this and get fix-only behaviour.
123
133
 
124
- If all retries fail or the model oscillates (identical output detected), the original file is restored automatically. Your test suite is always left in a coherent state.
134
+ **Passing in isolation, failing in suite (`--fix-polluters`):** Some tests pass when run alone but fail in the full suite. `--fix-polluters` handles these in two phases: (1) bisect the test suite to find if another file is leaking state (e.g. an uncleaned mock or global), and fix the polluter; (2) if no polluter can be isolated (the test has an internal spy lifecycle bug), delete and regenerate the victim file directly.
125
135
 
126
- If a fix attempt breaks an import (causing 0 tests to be collected) or reduces the number of passing tests, lacuna detects the regression and tells the model exactly what the original failure was — so it doesn't waste further iterations trying to recover from the wrong problem.
136
+ If all retries fail or the model oscillates (identical output detected), the original file is restored automatically. If a fix attempt breaks an import or reduces passing tests, lacuna detects the regression and anchors the next retry to the original error.
127
137
 
128
- When `--file` is given, lacuna skips the full suite and runs only the target file — much faster for iterating on a single broken test. Without `--file`, the failing-files list is cached for 30 minutes. After a fix run, the cache is updated to contain only the files that are still failing — so re-running `lacuna fix` immediately picks up exactly where the last run left off. Once all files are fixed, the cache is cleared so the next run does a clean suite scan.
138
+ When `--file` is given, lacuna skips the full suite and runs only the target file. Without `--file`, the failing-files list is cached for 30 minutes. After a fix run, the cache is updated to contain only the still-failing files — so re-running immediately picks up where the last run left off.
129
139
 
130
140
  ### `lacuna run`
131
141
  Runs your test suite and reports coverage. No AI involved.
@@ -167,7 +177,7 @@ Created by `lacuna init`. All fields are optional with sensible defaults.
167
177
  | `testRunner` | auto-detect | `jest` \| `vitest` \| `pytest` \| `mocha` \| `go-test` |
168
178
  | `coverageFormat` | `lcov` | `lcov` \| `json-summary` |
169
179
  | `coverageDir` | `coverage` | Where your test runner writes coverage |
170
- | `sourceDir` | `src` | Root directory of source files set during `lacuna init` |
180
+ | `sourceDir` | `"src"` | Source directory to scan. Accepts a string or an array — `["src", "lib", "utils"]` scans multiple directories |
171
181
  | `threshold` | `80` | Minimum line coverage % to pass |
172
182
  | `maxIterations` | `3` | How many times to retry a failing generated test |
173
183
  | `coverageTimeout` | `300` | Seconds before the test suite is killed (prevents hanging on open handles) |
@@ -257,7 +267,25 @@ beforeEach(() => {
257
267
  lacuna generate
258
268
  ```
259
269
 
260
- Every generated test will import from `src/test/mocks.ts` instead of creating its own `vi.fn()` calls. If a test needs a mock that doesn't exist yet, Claude will add it to the mocks file and import it — keeping everything centralized.
270
+ Every generated test will import from `src/test/mocks.ts` instead of creating its own `vi.fn()` calls. If a test needs a mock that doesn't exist yet, lacuna will add it to the mocks file and import it — keeping everything centralized.
271
+
272
+ ### How lacuna reads and updates the shared mock file
273
+
274
+ Lacuna parses your mock file before every prompt and builds a **mock inventory** — a structured table of every `vi.mock()` call, its line number, and all the keys it exports:
275
+
276
+ ```
277
+ 'react-router-dom' → useNavigate, useParams
278
+ 'axios' → get, post, put, delete
279
+ ```
280
+
281
+ This inventory is injected into every prompt so the AI knows exactly which modules are already mocked and what they export. When the AI needs to add a new export (e.g. a new API client method), it updates the **existing** `vi.mock()` block with the full list of old + new exports — never appending a second block for the same module.
282
+
283
+ **Structure rules the AI follows:**
284
+ - Module already in inventory → update the existing block (never a duplicate)
285
+ - New mock variable → declare near same-domain exports, reset in existing `beforeEach`
286
+ - New module → append at end before the final `beforeEach`
287
+
288
+ For fix prompts, the mock file is **compressed** before sending (multi-line React component stub bodies are collapsed to `vi.fn()`) to reduce token cost while preserving all `vi.mock()` blocks and factory functions. For generate prompts, only the inventory and exports list are sent — the raw file is skipped entirely since the AI only needs to know what to import, not re-read every implementation.
261
289
 
262
290
  ---
263
291
 
@@ -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;AA6nBD,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, 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;
@@ -201,6 +248,16 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
201
248
  continue;
202
249
  }
203
250
  if (err instanceof OscillationError) {
251
+ if (attempt < config.maxIterations) {
252
+ // Iterations remain — give one escape-hatch attempt with fresh oscillation state
253
+ // and an explicit "completely different approach" message instead of stopping.
254
+ if (!onStatus)
255
+ log(chalk.yellow(`\n ⚠ Agent loop detected — retrying with different strategy...`));
256
+ onStatus?.({ phase: 'retrying', file: shortPath, attempt, max: config.maxIterations });
257
+ generator.resetOscillationState();
258
+ errorOutput = OSCILLATION_ESCAPE_MESSAGE;
259
+ continue;
260
+ }
204
261
  if (!onStatus)
205
262
  log(chalk.red(`\n ⚠ Agent loop detected — output identical to a previous attempt. Stopping early.`));
206
263
  onStatus?.({ phase: 'failed', file: shortPath });
@@ -255,6 +312,7 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
255
312
  }
256
313
  }
257
314
  }
315
+ testFileContent = deduplicateViMocks(testFileContent);
258
316
  // Catch empty test files before writing
259
317
  if (!hasTestFunctions(testFileContent)) {
260
318
  errorOutput =
@@ -317,13 +375,210 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
317
375
  error: `Still failing after ${config.maxIterations} attempts. Last error:\n${errorOutput.slice(0, 1500)}`,
318
376
  };
319
377
  }
378
+ // ─── Polluter detection ───────────────────────────────────────────────────────
379
+ function buildTestFileRegex(pattern) {
380
+ const filename = pattern.split('/').pop() ?? pattern;
381
+ const regexStr = filename
382
+ .replace(/\{([^}]+)\}/g, (_, g) => `(${g.split(',').map((s) => s.trim()).join('|')})`)
383
+ .replace(/\./g, '\\.')
384
+ .replace(/\*+/g, '[^/]+');
385
+ return new RegExp(regexStr + '$');
386
+ }
387
+ async function discoverTestFiles(cwd, env) {
388
+ const testRe = buildTestFileRegex(env.testFilePattern);
389
+ const files = [];
390
+ const skipDirs = new Set(['node_modules', 'dist', '.git', 'coverage', '.nyc_output', '.lacuna']);
391
+ async function walk(dir) {
392
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
393
+ for (const e of entries) {
394
+ if (e.isDirectory()) {
395
+ if (!skipDirs.has(e.name))
396
+ await walk(join(dir, e.name));
397
+ }
398
+ else if (testRe.test(e.name)) {
399
+ files.push(join(dir, e.name));
400
+ }
401
+ }
402
+ }
403
+ await walk(cwd);
404
+ return files.sort();
405
+ }
406
+ function victimInFailing(victim, failing, cwd) {
407
+ const rel = (p) => (p.startsWith(cwd + '/') ? p.slice(cwd.length + 1) : p);
408
+ const shortVictim = rel(victim);
409
+ return failing.some(f => {
410
+ const shortF = rel(f);
411
+ return shortF === shortVictim || shortVictim.endsWith(shortF) || shortF.endsWith(shortVictim);
412
+ });
413
+ }
414
+ async function victimFailsWithSubset(victim, subset, env, cwd) {
415
+ if (subset.length === 0)
416
+ return false;
417
+ const result = await runCommand(multiFileTestCommand(env, [...subset, victim]), cwd, 120_000);
418
+ if (result.success)
419
+ return false;
420
+ const failing = parseFailingTestFiles(result.stdout + '\n' + result.stderr, env.testRunner);
421
+ return victimInFailing(victim, failing, cwd);
422
+ }
423
+ async function bisectPolluter(victim, candidates, env, cwd) {
424
+ if (candidates.length === 0)
425
+ return null;
426
+ if (candidates.length === 1) {
427
+ const fails = await victimFailsWithSubset(victim, candidates, env, cwd);
428
+ return fails ? candidates[0] : null;
429
+ }
430
+ const mid = Math.floor(candidates.length / 2);
431
+ const left = candidates.slice(0, mid);
432
+ const right = candidates.slice(mid);
433
+ if (await victimFailsWithSubset(victim, left, env, cwd))
434
+ return bisectPolluter(victim, left, env, cwd);
435
+ if (await victimFailsWithSubset(victim, right, env, cwd))
436
+ return bisectPolluter(victim, right, env, cwd);
437
+ return null;
438
+ }
439
+ async function findAndFixPolluters(victimFiles, options, projectMemory) {
440
+ const { config, env, cwd, log } = options;
441
+ const allTestFiles = await discoverTestFiles(cwd, env);
442
+ log(chalk.dim(` Discovered ${allTestFiles.length} test files to search.`));
443
+ const generator = new TestGenerator({ config, env });
444
+ let pollutersFixed = 0;
445
+ let victimsRegenerated = 0;
446
+ const seenPolluters = new Set();
447
+ const unresolvedVictims = [];
448
+ for (const victim of victimFiles) {
449
+ const shortVictim = victim.replace(cwd + '/', '');
450
+ log(chalk.dim(`\n Bisecting for: ${chalk.cyan(shortVictim)}`));
451
+ const candidates = allTestFiles.filter(f => f !== victim);
452
+ // Probe: verify the pollution reproduces before spending O(log N) bisect runs.
453
+ // If it doesn't reproduce here, the pollution requires vitest's default multi-worker
454
+ // config to manifest and can't be found by this approach.
455
+ log(chalk.dim(` Probing (${candidates.length} files + victim)...`));
456
+ const reproduced = await victimFailsWithSubset(victim, candidates, env, cwd);
457
+ if (!reproduced) {
458
+ log(chalk.yellow(` Pollution did not reproduce in sequential mode — this is concurrency-based globalThis contamination.`));
459
+ log(chalk.dim(` A vi.spyOn(global, ...) spy from another file is persisting in the shared worker thread.`));
460
+ log(chalk.dim(` Fix: add restoreMocks: true and clearMocks: true to the test: {} block in vitest.config.ts`));
461
+ log(chalk.dim(` Also add beforeEach(() => vi.restoreAllMocks()) to your test setup file.`));
462
+ unresolvedVictims.push(victim);
463
+ continue;
464
+ }
465
+ const polluter = await bisectPolluter(victim, candidates, env, cwd);
466
+ if (!polluter) {
467
+ log(chalk.yellow(` Could not isolate a polluter — file may have an internal spy lifecycle bug.`));
468
+ unresolvedVictims.push(victim);
469
+ continue;
470
+ }
471
+ const shortPolluter = polluter.replace(cwd + '/', '');
472
+ log(` Found polluter: ${chalk.cyan(shortPolluter)}`);
473
+ if (seenPolluters.has(polluter)) {
474
+ log(chalk.dim(` Already processed ${shortPolluter}.`));
475
+ continue;
476
+ }
477
+ seenPolluters.add(polluter);
478
+ // Capture the victim's failure output when run after the polluter
479
+ const errorRun = await runCommand(multiFileTestCommand(env, [polluter, victim]), cwd, 60_000);
480
+ const victimError = extractTestFailure(errorRun.stdout + '\n' + errorRun.stderr);
481
+ const pollutorCode = await readFile(polluter, 'utf-8').catch(() => null);
482
+ const victimCode = await readFile(victim, 'utf-8').catch(() => null);
483
+ if (!pollutorCode || !victimCode) {
484
+ log(chalk.red(` Could not read files — skipping ${shortPolluter}`));
485
+ unresolvedVictims.push(victim);
486
+ continue;
487
+ }
488
+ log(chalk.dim(` Sending to ${config.model} for cleanup...`));
489
+ let fixed;
490
+ try {
491
+ fixed = await generator.fixPollution({
492
+ pollutorFile: shortPolluter,
493
+ pollutorCode,
494
+ victimFile: shortVictim,
495
+ victimCode,
496
+ victimError,
497
+ env,
498
+ });
499
+ }
500
+ catch (err) {
501
+ log(chalk.red(` AI error: ${err instanceof Error ? err.message : String(err)}`));
502
+ unresolvedVictims.push(victim);
503
+ continue;
504
+ }
505
+ await writeFile(polluter, fixed, 'utf-8');
506
+ const verifyRun = await runCommand(multiFileTestCommand(env, [polluter, victim]), cwd, 60_000);
507
+ const verifyFailing = parseFailingTestFiles(verifyRun.stdout + '\n' + verifyRun.stderr, env.testRunner);
508
+ const victimResolved = !victimInFailing(victim, verifyFailing, cwd);
509
+ if (victimResolved) {
510
+ log(chalk.green(` Cleanup applied: ${shortPolluter}`));
511
+ pollutersFixed++;
512
+ }
513
+ else {
514
+ log(chalk.red(` Cleanup did not resolve the victim — restoring ${shortPolluter}`));
515
+ await writeFile(polluter, pollutorCode, 'utf-8').catch(() => { });
516
+ unresolvedVictims.push(victim);
517
+ }
518
+ }
519
+ // Phase 2: regenerate victims that bisection couldn't resolve.
520
+ // These files pass alone but fail in the suite due to internal bugs
521
+ // (e.g. module-level vi.spyOn, wrong mock structure). A fresh generation
522
+ // produces properly-structured tests with spies inside beforeEach.
523
+ if (unresolvedVictims.length > 0 && options.regenerateOnFailure !== false) {
524
+ log(chalk.bold(`\n Regenerating ${unresolvedVictims.length} victim file(s) that couldn't be resolved by polluter cleanup...`));
525
+ for (const victim of unresolvedVictims) {
526
+ const shortVictim = victim.replace(cwd + '/', '');
527
+ log(chalk.dim(`\n Regenerating: ${chalk.cyan(shortVictim)}`));
528
+ const result = await regenerateFile(victim, options, undefined, projectMemory);
529
+ if (result.success) {
530
+ log(chalk.green(` Regenerated successfully.`));
531
+ victimsRegenerated++;
532
+ }
533
+ else {
534
+ log(chalk.red(` Regeneration failed: ${result.error?.slice(0, 200) ?? 'unknown error'}`));
535
+ }
536
+ }
537
+ }
538
+ return { pollutersFixed, victimsRegenerated };
539
+ }
540
+ // ─── Regeneration fallback ────────────────────────────────────────────────────
541
+ async function regenerateFile(testFilePath, options, onStatus, projectMemory) {
542
+ const absTestFile = testFilePath.startsWith('/') ? testFilePath : join(options.cwd, testFilePath);
543
+ // Find the source file so processGap gets the right starting point.
544
+ // processGap expects gap.filePath to be the SOURCE file, not the test file.
545
+ const sourceFile = await findSourceFile(absTestFile, options.cwd);
546
+ if (!sourceFile) {
547
+ return { success: false, error: `Could not find source file for ${absTestFile}` };
548
+ }
549
+ // Delete the broken test file before regenerating. If it stays on disk,
550
+ // buildFileContext reads it as existingTestCode and the generate prompt says
551
+ // "preserve all existing tests" — locking the AI into the same broken structure.
552
+ await unlink(absTestFile).catch(() => { });
553
+ const gap = { filePath: sourceFile, uncoveredLines: [], uncoveredFunctions: [] };
554
+ const generator = new TestGenerator({ config: options.config, env: options.env });
555
+ // processGap uses gap.filePath (the source file) as its display identifier, but during
556
+ // regen the worker should stay in 'regenerating' for all intermediate phases and only
557
+ // flip to passed/failed at the end. This prevents the brief flash where 'regenerating'
558
+ // gets overwritten by 'generating' (<80ms) as soon as processGap starts.
559
+ const testShortPath = absTestFile.replace(options.cwd + '/', '');
560
+ const regenOnStatus = onStatus
561
+ ? (state) => {
562
+ if (state.phase === 'passed' || state.phase === 'failed') {
563
+ onStatus('file' in state ? { ...state, file: testShortPath } : state);
564
+ }
565
+ else {
566
+ onStatus({ phase: 'regenerating', file: testShortPath });
567
+ }
568
+ }
569
+ : undefined;
570
+ const result = await processGap(gap, options, generator, true, regenOnStatus, projectMemory);
571
+ return { success: result.success, error: result.error };
572
+ }
320
573
  // ─── Worker pool ──────────────────────────────────────────────────────────────
321
574
  async function runFixWorkers(testFiles, options, workerCount, projectMemory) {
322
575
  const queue = [...testFiles];
323
576
  let filesProcessed = 0;
324
577
  let filesFixed = 0;
578
+ let filesAlreadyPassing = 0;
325
579
  const errors = [];
326
580
  const stillFailingFiles = [];
581
+ const victimFiles = [];
327
582
  const tips = getActiveTips({
328
583
  workers: workerCount,
329
584
  targetFile: options.targetFile,
@@ -344,10 +599,32 @@ async function runFixWorkers(testFiles, options, workerCount, projectMemory) {
344
599
  if (!file)
345
600
  break;
346
601
  const onStatus = (state) => display.update(wi, state);
347
- const result = await fixFile(file, { ...options, log: () => { }, verbose: false }, generator, onStatus, projectMemory);
602
+ const absFile = file.startsWith('/') ? file : join(options.cwd, file);
603
+ const workerOptions = { ...options, log: () => { }, verbose: false };
604
+ const result = await fixFile(absFile, workerOptions, generator, onStatus, projectMemory);
348
605
  filesProcessed++;
349
- if (result.success)
350
- filesFixed++;
606
+ if (result.success) {
607
+ if (result.skipped) {
608
+ filesAlreadyPassing++;
609
+ victimFiles.push(absFile);
610
+ }
611
+ else
612
+ filesFixed++;
613
+ }
614
+ else if (options.regenerateOnFailure) {
615
+ // Signal 'regenerating' first — this undoes the 'failed' done-count from fixFile
616
+ // so the regen's final phase is the single counted outcome for this file.
617
+ onStatus?.({ phase: 'regenerating', file: absFile.replace(options.cwd + '/', '') });
618
+ const regenResult = await regenerateFile(absFile, workerOptions, onStatus, projectMemory);
619
+ if (regenResult.success) {
620
+ filesFixed++;
621
+ }
622
+ else {
623
+ stillFailingFiles.push(file);
624
+ if (regenResult.error)
625
+ errors.push(regenResult.error);
626
+ }
627
+ }
351
628
  else {
352
629
  stillFailingFiles.push(file);
353
630
  if (result.error)
@@ -356,7 +633,7 @@ async function runFixWorkers(testFiles, options, workerCount, projectMemory) {
356
633
  }
357
634
  }));
358
635
  display.finish();
359
- return { filesProcessed, filesFixed, errors, stillFailingFiles };
636
+ return { filesProcessed, filesFixed, filesAlreadyPassing, errors, stillFailingFiles, victimFiles };
360
637
  }
361
638
  // ─── Main entry point ─────────────────────────────────────────────────────────
362
639
  export async function runFixLoop(options) {
@@ -374,7 +651,7 @@ export async function runFixLoop(options) {
374
651
  spinner.stop();
375
652
  if (fileResult.success) {
376
653
  log(chalk.green('\n All tests are passing — nothing to fix.'));
377
- return { filesProcessed: 0, filesFixed: 0, errors: [] };
654
+ return { filesProcessed: 0, filesFixed: 0, filesAlreadyPassing: 0, pollutersFixed: 0, victimsRegenerated: 0, errors: [] };
378
655
  }
379
656
  failingFiles = [absTarget];
380
657
  }
@@ -396,7 +673,7 @@ export async function runFixLoop(options) {
396
673
  }
397
674
  if (suiteResult.success) {
398
675
  log(chalk.green('\n All tests are passing — nothing to fix.'));
399
- return { filesProcessed: 0, filesFixed: 0, errors: [] };
676
+ return { filesProcessed: 0, filesFixed: 0, filesAlreadyPassing: 0, pollutersFixed: 0, victimsRegenerated: 0, errors: [] };
400
677
  }
401
678
  failingFiles = parseFailingTestFiles(suiteResult.stdout + suiteResult.stderr, env.testRunner);
402
679
  failingFiles = failingFiles.filter((f) => {
@@ -413,7 +690,7 @@ export async function runFixLoop(options) {
413
690
  .join('\n');
414
691
  if (lastLines)
415
692
  log(chalk.dim('\n Last output lines:\n' + lastLines.split('\n').map((l) => ` ${l}`).join('\n')));
416
- return { filesProcessed: 0, filesFixed: 0, errors: [] };
693
+ return { filesProcessed: 0, filesFixed: 0, filesAlreadyPassing: 0, pollutersFixed: 0, victimsRegenerated: 0, errors: [] };
417
694
  }
418
695
  await saveFixCache(cwd, failingFiles);
419
696
  }
@@ -429,17 +706,21 @@ export async function runFixLoop(options) {
429
706
  const memorySnapshot = memory.toPromptSection();
430
707
  let filesProcessed;
431
708
  let filesFixed;
709
+ let filesAlreadyPassing;
432
710
  let errors;
433
711
  let stillFailingFiles;
712
+ let victimFiles;
434
713
  if (parallel) {
435
714
  ;
436
- ({ filesProcessed, filesFixed, errors, stillFailingFiles } = await runFixWorkers(failingFiles, options, workerCount, memorySnapshot));
715
+ ({ filesProcessed, filesFixed, filesAlreadyPassing, errors, stillFailingFiles, victimFiles } = await runFixWorkers(failingFiles, options, workerCount, memorySnapshot));
437
716
  }
438
717
  else {
439
718
  filesProcessed = 0;
440
719
  filesFixed = 0;
720
+ filesAlreadyPassing = 0;
441
721
  errors = [];
442
722
  stillFailingFiles = [];
723
+ victimFiles = [];
443
724
  const generator = new TestGenerator({ config, env });
444
725
  const tips = getActiveTips({
445
726
  workers: 1,
@@ -460,8 +741,26 @@ export async function runFixLoop(options) {
460
741
  const absFile = file.startsWith('/') ? file : join(cwd, file);
461
742
  const result = await fixFile(absFile, options, generator, undefined, memory.toPromptSection());
462
743
  filesProcessed++;
463
- if (result.success)
464
- filesFixed++;
744
+ if (result.success) {
745
+ if (result.skipped) {
746
+ filesAlreadyPassing++;
747
+ victimFiles.push(absFile);
748
+ }
749
+ else
750
+ filesFixed++;
751
+ }
752
+ else if (options.regenerateOnFailure) {
753
+ log(chalk.yellow(` Fix exhausted — falling back to full regeneration...`));
754
+ const regenResult = await regenerateFile(absFile, options, undefined, memory.toPromptSection());
755
+ if (regenResult.success) {
756
+ filesFixed++;
757
+ }
758
+ else {
759
+ stillFailingFiles.push(file);
760
+ if (regenResult.error)
761
+ errors.push(regenResult.error);
762
+ }
763
+ }
465
764
  else {
466
765
  stillFailingFiles.push(file);
467
766
  if (result.error)
@@ -479,6 +778,14 @@ export async function runFixLoop(options) {
479
778
  else
480
779
  await clearFixCache(cwd);
481
780
  }
482
- return { filesProcessed, filesFixed, errors };
781
+ let pollutersFixed = 0;
782
+ let victimsRegenerated = 0;
783
+ if (options.fixPolluters && victimFiles.length > 0) {
784
+ log(chalk.bold(`\n Scanning for test polluters (${victimFiles.length} victim file(s) pass alone but fail in suite)...`));
785
+ const polluterResult = await findAndFixPolluters(victimFiles, options, memorySnapshot);
786
+ pollutersFixed = polluterResult.pollutersFixed;
787
+ victimsRegenerated = polluterResult.victimsRegenerated;
788
+ }
789
+ return { filesProcessed, filesFixed, filesAlreadyPassing, pollutersFixed, victimsRegenerated, errors };
483
790
  }
484
791
  //# sourceMappingURL=fix-loop.js.map