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.
- package/README.md +38 -10
- package/dist/agent/fix-loop.d.ts +5 -0
- package/dist/agent/fix-loop.d.ts.map +1 -1
- package/dist/agent/fix-loop.js +342 -35
- package/dist/agent/fix-loop.js.map +1 -1
- package/dist/agent/generator.d.ts +4 -1
- package/dist/agent/generator.d.ts.map +1 -1
- package/dist/agent/generator.js +42 -3
- package/dist/agent/generator.js.map +1 -1
- package/dist/agent/loop.d.ts +8 -0
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +26 -9
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/prompts.d.ts +8 -0
- package/dist/agent/prompts.d.ts.map +1 -1
- package/dist/agent/prompts.js +285 -70
- package/dist/agent/prompts.js.map +1 -1
- package/dist/commands/fix.d.ts +2 -0
- package/dist/commands/fix.d.ts.map +1 -1
- package/dist/commands/fix.js +31 -2
- package/dist/commands/fix.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +107 -21
- package/dist/commands/init.js.map +1 -1
- package/dist/lib/config.d.ts +3 -3
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +3 -1
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/coverage/gaps.d.ts +2 -2
- package/dist/lib/coverage/gaps.d.ts.map +1 -1
- package/dist/lib/coverage/gaps.js +4 -4
- package/dist/lib/coverage/gaps.js.map +1 -1
- package/dist/lib/detector.d.ts +1 -0
- package/dist/lib/detector.d.ts.map +1 -1
- package/dist/lib/detector.js +19 -0
- package/dist/lib/detector.js.map +1 -1
- package/dist/lib/providers/anthropic.d.ts.map +1 -1
- package/dist/lib/providers/anthropic.js +16 -2
- package/dist/lib/providers/anthropic.js.map +1 -1
- package/dist/lib/providers/openai-compatible.d.ts +1 -1
- package/dist/lib/providers/openai-compatible.d.ts.map +1 -1
- package/dist/lib/providers/openai-compatible.js +9 -1
- package/dist/lib/providers/openai-compatible.js.map +1 -1
- package/dist/lib/skeleton.d.ts +4 -0
- package/dist/lib/skeleton.d.ts.map +1 -1
- package/dist/lib/skeleton.js +220 -0
- package/dist/lib/skeleton.js.map +1 -1
- package/dist/lib/validate.d.ts +1 -0
- package/dist/lib/validate.d.ts.map +1 -1
- package/dist/lib/validate.js +132 -0
- package/dist/lib/validate.js.map +1 -1
- package/dist/lib/worker-display.d.ts +3 -0
- package/dist/lib/worker-display.d.ts.map +1 -1
- package/dist/lib/worker-display.js +19 -4
- package/dist/lib/worker-display.js.map +1 -1
- package/package.json +1 -1
- package/dist/lib/report-upload.d.ts +0 -3
- package/dist/lib/report-upload.d.ts.map +0 -1
- package/dist/lib/report-upload.js +0 -15
- package/dist/lib/report-upload.js.map +0 -1
- 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
|
|
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
|
|
73
|
-
- Creates a setup file
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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` |
|
|
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,
|
|
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
|
|
package/dist/agent/fix-loop.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/agent/fix-loop.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
continue;
|
|
72
|
+
failFiles.add(m[1]);
|
|
69
73
|
}
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
78
|
-
|
|
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
|
-
|
|
121
|
+
if (m && !combined.has(m[1]) && !inTrace) {
|
|
122
|
+
combined.add(m[1]);
|
|
123
|
+
inTrace = true;
|
|
124
|
+
}
|
|
82
125
|
}
|
|
83
126
|
}
|
|
84
|
-
return [...
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|