lacuna-cli 0.1.8 → 0.2.0
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 +94 -11
- package/dist/agent/context.d.ts +1 -0
- package/dist/agent/context.d.ts.map +1 -1
- package/dist/agent/context.js +141 -6
- package/dist/agent/context.js.map +1 -1
- package/dist/agent/fix-loop.d.ts +1 -0
- package/dist/agent/fix-loop.d.ts.map +1 -1
- package/dist/agent/fix-loop.js +245 -40
- package/dist/agent/fix-loop.js.map +1 -1
- package/dist/agent/generator.d.ts +7 -0
- package/dist/agent/generator.d.ts.map +1 -1
- package/dist/agent/generator.js +205 -35
- package/dist/agent/generator.js.map +1 -1
- package/dist/agent/loop.d.ts +1 -1
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +154 -11
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/prompts/index.d.ts +4 -1
- package/dist/agent/prompts/index.d.ts.map +1 -1
- package/dist/agent/prompts/index.js +182 -37
- package/dist/agent/prompts/index.js.map +1 -1
- package/dist/agent/prompts/react-native.d.ts.map +1 -1
- package/dist/agent/prompts/react-native.js +15 -4
- package/dist/agent/prompts/react-native.js.map +1 -1
- package/dist/agent/prompts/react.js +1 -1
- package/dist/agent/prompts/runners/vitest.js +1 -1
- package/dist/commands/analyze.d.ts.map +1 -1
- package/dist/commands/analyze.js +4 -0
- package/dist/commands/analyze.js.map +1 -1
- package/dist/commands/fix.d.ts +1 -0
- package/dist/commands/fix.d.ts.map +1 -1
- package/dist/commands/fix.js +24 -5
- package/dist/commands/fix.js.map +1 -1
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +10 -0
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +6 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +4 -0
- package/dist/commands/run.js.map +1 -1
- package/dist/lib/config.d.ts +10 -2
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +43 -21
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/extract-error.d.ts.map +1 -1
- package/dist/lib/extract-error.js +8 -2
- package/dist/lib/extract-error.js.map +1 -1
- package/dist/lib/reporter.d.ts.map +1 -1
- package/dist/lib/reporter.js +11 -1
- package/dist/lib/reporter.js.map +1 -1
- package/dist/lib/typecheck.d.ts +1 -0
- package/dist/lib/typecheck.d.ts.map +1 -1
- package/dist/lib/typecheck.js +119 -7
- package/dist/lib/typecheck.js.map +1 -1
- package/dist/lib/validate.d.ts +37 -1
- package/dist/lib/validate.d.ts.map +1 -1
- package/dist/lib/validate.js +659 -11
- package/dist/lib/validate.js.map +1 -1
- package/lacuna.schema.json +150 -0
- package/package.json +14 -7
- package/oclif.manifest.json +0 -309
package/dist/agent/fix-loop.js
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir, unlink, readdir } from 'fs/promises';
|
|
2
|
-
import { join, dirname, basename, extname } from 'path';
|
|
2
|
+
import { join, dirname, basename, extname, isAbsolute } from 'path';
|
|
3
3
|
import { access, stat } from 'fs/promises';
|
|
4
4
|
import chalk from 'chalk';
|
|
5
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
|
-
import { buildFixFileContext, computeRelativeImport, collectTypeDefinitions, collectLocalImportPaths, detectReactMajorVersion } from './context.js';
|
|
9
|
+
import { buildFixFileContext, computeRelativeImport, collectTypeDefinitions, collectLocalImportPaths, detectReactMajorVersion, findFileByName } from './context.js';
|
|
10
10
|
import { TestGenerator, TruncatedOutputError, OscillationError, ModelStallError, TRUNCATION_RETRY_MESSAGE, OSCILLATION_ESCAPE_MESSAGE } from './generator.js';
|
|
11
11
|
import { processGap } from './loop.js';
|
|
12
12
|
import { ProjectMemory } from './project-memory.js';
|
|
13
13
|
import { getActiveTips, createTipRotator, formatTip } from '../lib/tips.js';
|
|
14
|
-
import { typeCheckFile } from '../lib/typecheck.js';
|
|
15
|
-
import { hasTestFunctions, enrichNoTestsError, isZeroTestsOutput, parsePassCount, buildStructureBrokenMessage, buildRegressionMessage, sanitizeMocksContent, stripLeadingProse, mergeMocksContent, deduplicateViMocks } from '../lib/validate.js';
|
|
14
|
+
import { typeCheckFile, findTestFilesWithTypeErrors } from '../lib/typecheck.js';
|
|
15
|
+
import { hasTestFunctions, hasPlaceholderBodies, enrichNoTestsError, isZeroTestsOutput, parsePassCount, buildStructureBrokenMessage, buildRegressionMessage, sanitizeMocksContent, stripLeadingProse, mergeMocksContent, deduplicateViMocks, tryApplyPatch, tryApplyMocksPatch } from '../lib/validate.js';
|
|
16
16
|
import { extractTestFailure } from '../lib/extract-error.js';
|
|
17
17
|
import { StreamingFileViewer } from '../lib/streaming-viewer.js';
|
|
18
18
|
// ─── Failing-files cache ──────────────────────────────────────────────────────
|
|
19
19
|
const FIX_CACHE_TTL_S = 1800; // 30 minutes
|
|
20
|
+
// Regenerate-on-failure only attempts a from-scratch rewrite when the file has FEWER than
|
|
21
|
+
// this many passing tests. A file with a substantial passing suite is repaired, never nuked
|
|
22
|
+
// and rebuilt — regenerating it from scratch is slow and almost never reproduces the suite.
|
|
23
|
+
// (regenerateFile additionally never keeps a regen that reduces the passing count.)
|
|
24
|
+
const REGEN_MAX_BASELINE_PASS = 10;
|
|
20
25
|
function fixCachePath(cwd) {
|
|
21
26
|
return join(cwd, '.lacuna-fix-cache.json');
|
|
22
27
|
}
|
|
@@ -127,26 +132,74 @@ function parseFailingTestFiles(output, runner) {
|
|
|
127
132
|
return [...combined];
|
|
128
133
|
}
|
|
129
134
|
// ─── Find the source file that a test file is testing ────────────────────────
|
|
130
|
-
async function findSourceFile(testFilePath, cwd) {
|
|
135
|
+
async function findSourceFile(testFilePath, cwd, configSourceDirs = 'src') {
|
|
131
136
|
const ext = extname(testFilePath);
|
|
132
137
|
const base = basename(testFilePath, ext);
|
|
133
138
|
const dir = dirname(testFilePath);
|
|
134
|
-
// strip test suffix: Button.test → Button, Button.spec → Button
|
|
135
139
|
const sourceBase = base.replace(/\.(test|spec)$/, '').replace(/^test_/, '').replace(/_test$/, '');
|
|
136
|
-
// if inside __tests__ dir, source is in parent
|
|
137
|
-
const sourceDir = basename(dir) === '__tests__' ? dirname(dir) : dir;
|
|
138
140
|
const exts = [ext, '.ts', '.tsx', '.js', '.jsx'];
|
|
141
|
+
const srcDirs = Array.isArray(configSourceDirs) ? configSourceDirs : [configSourceDirs];
|
|
142
|
+
async function tryCandidates(targetDir) {
|
|
143
|
+
const resolved = isAbsolute(targetDir) ? targetDir : join(cwd, targetDir);
|
|
144
|
+
for (const e of exts) {
|
|
145
|
+
try {
|
|
146
|
+
await access(join(resolved, `${sourceBase}${e}`));
|
|
147
|
+
return join(resolved, `${sourceBase}${e}`);
|
|
148
|
+
}
|
|
149
|
+
catch { /* next */ }
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
// Attempt 1: same directory as test file, or parent of __tests__
|
|
154
|
+
const sameDir = basename(dir) === '__tests__' ? dirname(dir) : dir;
|
|
155
|
+
const attempt1 = await tryCandidates(sameDir);
|
|
156
|
+
if (attempt1)
|
|
157
|
+
return attempt1;
|
|
158
|
+
// Attempt 2: replace test directory segment with sourceDir
|
|
159
|
+
// Handles monorepo layouts like: packages/server/test/unit/adapters/Foo.test.ts
|
|
160
|
+
// → packages/server/src/adapters/Foo.ts
|
|
161
|
+
const TEST_SEGMENT_RE = /^(.*[/\\])(?:tests?|specs?)[/\\](?:(?:unit|integration|e2e|functional|features?)[/\\])?(.*)$/i;
|
|
162
|
+
const match = dir.match(TEST_SEGMENT_RE);
|
|
163
|
+
if (match) {
|
|
164
|
+
const [, prefix, suffix] = match;
|
|
165
|
+
for (const srcDir of srcDirs) {
|
|
166
|
+
// Strategy A: relative srcDir appended to the test root prefix
|
|
167
|
+
// Works when sourceDir is short ("src") and test is nested under same package root
|
|
168
|
+
const a = await tryCandidates(join(prefix, srcDir, suffix));
|
|
169
|
+
if (a)
|
|
170
|
+
return a;
|
|
171
|
+
// Strategy B: absolute resolved srcDir + relative suffix
|
|
172
|
+
// Works when sourceDir is explicit ("packages/server/src")
|
|
173
|
+
const absSrc = isAbsolute(srcDir) ? srcDir : join(cwd, srcDir);
|
|
174
|
+
const b = await tryCandidates(join(absSrc, suffix));
|
|
175
|
+
if (b)
|
|
176
|
+
return b;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Attempt 3: recursive filename search.
|
|
180
|
+
// Handles extra segments between src/ and the file (e.g. test/unit/interactors/Foo.test.ts
|
|
181
|
+
// → src/lib/interactors/Foo.ts — the "lib" is invisible to the mirror logic above).
|
|
182
|
+
// Search roots: (a) package prefix + srcDir (most targeted, e.g. packages/server/src/),
|
|
183
|
+
// then (b) absolute srcDir from config (for flat repos).
|
|
184
|
+
const searchRoots = [];
|
|
185
|
+
if (match) {
|
|
186
|
+
const [, prefix] = match;
|
|
187
|
+
for (const srcDir of srcDirs) {
|
|
188
|
+
searchRoots.push(join(prefix, srcDir));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (const srcDir of srcDirs) {
|
|
192
|
+
const abs = isAbsolute(srcDir) ? srcDir : join(cwd, srcDir);
|
|
193
|
+
if (!searchRoots.includes(abs))
|
|
194
|
+
searchRoots.push(abs);
|
|
195
|
+
}
|
|
139
196
|
for (const e of exts) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
await access(candidate);
|
|
147
|
-
return candidate;
|
|
197
|
+
const filename = `${sourceBase}${e}`;
|
|
198
|
+
for (const root of searchRoots) {
|
|
199
|
+
const found = await findFileByName(root, filename);
|
|
200
|
+
if (found)
|
|
201
|
+
return found;
|
|
148
202
|
}
|
|
149
|
-
catch { /* try next */ }
|
|
150
203
|
}
|
|
151
204
|
return null;
|
|
152
205
|
}
|
|
@@ -160,13 +213,26 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
|
|
|
160
213
|
onStatus?.({ phase: 'running', file: shortPath });
|
|
161
214
|
// Run just this test file to get focused error output
|
|
162
215
|
const firstRun = await runCommand(fileTestCommand(env, absTestPath), cwd, 60_000);
|
|
216
|
+
let typeErrorsAtStart = null;
|
|
163
217
|
if (firstRun.success) {
|
|
218
|
+
// Tests pass. In targeted (--file) or --types mode, a green file may still have
|
|
219
|
+
// TypeScript errors the runner ignores (it transpiles, doesn't type-check) — repair
|
|
220
|
+
// those rather than skip, otherwise generate's "run lacuna fix --file …" hand-off and
|
|
221
|
+
// `lacuna fix --types` are dead ends. Default full-suite mode keeps skipping so
|
|
222
|
+
// pollution-victim accounting is untouched.
|
|
223
|
+
typeErrorsAtStart = (options.targetFile || options.types) ? await typeCheckFile(absTestPath, cwd, env) : null;
|
|
224
|
+
if (!typeErrorsAtStart) {
|
|
225
|
+
if (!onStatus)
|
|
226
|
+
log(chalk.dim(' Already passing — skipping.'));
|
|
227
|
+
onStatus?.({ phase: 'passed', file: shortPath });
|
|
228
|
+
return { success: true, skipped: true };
|
|
229
|
+
}
|
|
164
230
|
if (!onStatus)
|
|
165
|
-
log(chalk.
|
|
166
|
-
onStatus?.({ phase: 'passed', file: shortPath });
|
|
167
|
-
return { success: true, skipped: true };
|
|
231
|
+
log(chalk.yellow(' Tests pass but type errors found — repairing types.'));
|
|
168
232
|
}
|
|
169
|
-
let errorOutput =
|
|
233
|
+
let errorOutput = typeErrorsAtStart
|
|
234
|
+
? `Tests pass but the test file has TypeScript type errors:\n${typeErrorsAtStart}\n\nFix ALL type errors without changing test behavior. Do not use 'as any' or '@ts-ignore'.`
|
|
235
|
+
: extractTestFailure(firstRun.stdout + '\n' + firstRun.stderr);
|
|
170
236
|
const initialErrorOutput = errorOutput;
|
|
171
237
|
const baselinePassCount = parsePassCount(firstRun.stdout + '\n' + firstRun.stderr);
|
|
172
238
|
// Read existing test file
|
|
@@ -182,7 +248,7 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
|
|
|
182
248
|
return { success: false, error: msg };
|
|
183
249
|
}
|
|
184
250
|
// Find and read the source file being tested
|
|
185
|
-
const sourceFilePath = await findSourceFile(testFilePath, cwd);
|
|
251
|
+
const sourceFilePath = await findSourceFile(testFilePath, cwd, config.sourceDir);
|
|
186
252
|
let sourceCode = null;
|
|
187
253
|
if (sourceFilePath) {
|
|
188
254
|
sourceCode = await readFile(sourceFilePath, 'utf-8').catch(() => null);
|
|
@@ -245,6 +311,7 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
|
|
|
245
311
|
localImportPaths,
|
|
246
312
|
reactMajorVersion,
|
|
247
313
|
projectMemory,
|
|
314
|
+
existingTestLineCount: testCode.split('\n').length,
|
|
248
315
|
})
|
|
249
316
|
: await generator.retry(errorOutput);
|
|
250
317
|
}
|
|
@@ -304,6 +371,29 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
|
|
|
304
371
|
onStatus?.({ phase: 'passed', file: shortPath });
|
|
305
372
|
return { success: true };
|
|
306
373
|
}
|
|
374
|
+
// Patch mode: apply surgical edits against the current file on disk
|
|
375
|
+
if (generator.isPatch) {
|
|
376
|
+
const currentContent = await readFile(absTestPath, 'utf-8').catch(() => null) ?? testCode;
|
|
377
|
+
const patched = tryApplyPatch(currentContent, fixed);
|
|
378
|
+
if (patched !== null) {
|
|
379
|
+
fixed = patched;
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
// Anchor(s) not found — do NOT write raw patch markers to disk
|
|
383
|
+
errorOutput =
|
|
384
|
+
'PATCH APPLICATION FAILED: one or more anchor strings in your patch were not found in the test file.\n' +
|
|
385
|
+
'Anchors must be copied character-for-character (including quote style) from the CURRENT TEST FILE shown above.\n' +
|
|
386
|
+
'Checklist:\n' +
|
|
387
|
+
' • REPLACE_TEST / DELETE_TEST anchor = exact it/test name already in the file\n' +
|
|
388
|
+
' • ADD_AFTER_DESCRIBE anchor = exact describe() name already in the file\n' +
|
|
389
|
+
' • For a brand-new test, use ADD_AFTER_DESCRIBE with the enclosing describe name\n' +
|
|
390
|
+
'Re-read the test file, find the exact anchor names, and rewrite your patch.';
|
|
391
|
+
if (!onStatus)
|
|
392
|
+
log(chalk.yellow(` Patch anchors not found — retrying...`));
|
|
393
|
+
onStatus?.({ phase: 'retrying', file: shortPath, attempt, max: config.maxIterations });
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
307
397
|
// Strip thinking/prose that leaked before the first real code line.
|
|
308
398
|
const { code: cleanFixed, stripped: bleedText } = stripLeadingProse(fixed);
|
|
309
399
|
if (bleedText !== null) {
|
|
@@ -313,8 +403,37 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
|
|
|
313
403
|
}
|
|
314
404
|
// Split out mocks file if AI returned one
|
|
315
405
|
const MOCKS_SEPARATOR = '// ---MOCKS_FILE---';
|
|
406
|
+
const MOCKS_PATCH_SEPARATOR = '// ---MOCKS_PATCH---';
|
|
316
407
|
let testFileContent = fixed;
|
|
317
|
-
if (fixed.includes(
|
|
408
|
+
if (fixed.includes(MOCKS_PATCH_SEPARATOR) && config.mocksFile) {
|
|
409
|
+
// Surgical patch mode: model only emits the changed sections
|
|
410
|
+
const [newTestCode, patchContent] = fixed.split(MOCKS_PATCH_SEPARATOR);
|
|
411
|
+
testFileContent = newTestCode.trim();
|
|
412
|
+
if (patchContent?.trim()) {
|
|
413
|
+
const absoluteMocksFile = join(cwd, config.mocksFile);
|
|
414
|
+
let existing = '';
|
|
415
|
+
try {
|
|
416
|
+
existing = await readFile(absoluteMocksFile, 'utf-8');
|
|
417
|
+
}
|
|
418
|
+
catch { /* new file — patch can't apply */ }
|
|
419
|
+
if (existing) {
|
|
420
|
+
const applied = tryApplyMocksPatch(existing, patchContent.trim());
|
|
421
|
+
if (applied) {
|
|
422
|
+
if (applied.failedOps.length > 0) {
|
|
423
|
+
const anchors = applied.failedOps.map(op => `"${op.oldText.slice(0, 60).replace(/\n/g, '↵')}"`).join(', ');
|
|
424
|
+
errorOutput = `MOCKS PATCH FAILED: the following REPLACE anchor(s) were not found in the mock file:\n${anchors}\nAnchors must be copied character-for-character from the SHARED MOCK FILE shown above. Re-read it and rewrite your ---MOCKS_PATCH--- block.`;
|
|
425
|
+
if (!onStatus)
|
|
426
|
+
log(chalk.yellow(` ⚠ Mock patch anchors not found — retrying...`));
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
await writeFile(absoluteMocksFile, applied.result, 'utf-8');
|
|
430
|
+
if (!onStatus)
|
|
431
|
+
log(chalk.dim(` Patched mocks file: ${config.mocksFile}`));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
else if (fixed.includes(MOCKS_SEPARATOR) && config.mocksFile) {
|
|
318
437
|
const [newTestCode, newMocksCode] = fixed.split(MOCKS_SEPARATOR);
|
|
319
438
|
testFileContent = newTestCode.trim();
|
|
320
439
|
if (newMocksCode?.trim()) {
|
|
@@ -356,14 +475,39 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
|
|
|
356
475
|
onStatus?.({ phase: 'running', file: shortPath });
|
|
357
476
|
const result = await runCommand(fileTestCommand(env, absTestPath), cwd, 60_000);
|
|
358
477
|
if (result.success) {
|
|
478
|
+
if (hasPlaceholderBodies(testFileContent)) {
|
|
479
|
+
errorOutput =
|
|
480
|
+
'ERROR: One or more test bodies contain placeholder comments (e.g. `// body`, `// TODO`) with no real assertions.\n' +
|
|
481
|
+
'Every test must have complete, working expectations:\n' +
|
|
482
|
+
' it(\'description\', async () => {\n' +
|
|
483
|
+
' const result = await subject.doThing(...);\n' +
|
|
484
|
+
' expect(result).toEqual(expectedValue);\n' +
|
|
485
|
+
' })\n' +
|
|
486
|
+
'Replace every `// body` placeholder with real arrange-act-assert code.';
|
|
487
|
+
if (!onStatus)
|
|
488
|
+
log(chalk.yellow(' Placeholder test bodies detected — retrying...'));
|
|
489
|
+
onStatus?.({ phase: 'retrying', file: shortPath, attempt, max: config.maxIterations });
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
359
492
|
const typeErrors = await typeCheckFile(absTestPath, cwd, env);
|
|
360
|
-
if (typeErrors) {
|
|
493
|
+
if (typeErrors && typeErrorsAtStart !== null) {
|
|
494
|
+
// Type-cleanup mode: the file's tests already passed at start (generate→fix handoff or
|
|
495
|
+
// --types), so type errors ARE the goal — keep retrying to clear them.
|
|
361
496
|
errorOutput = `Tests passed but TypeScript type errors were found:\n${typeErrors}\n\nFix ALL type errors. Do not use 'as any' or '@ts-ignore'.`;
|
|
362
497
|
if (!onStatus)
|
|
363
|
-
log(chalk.yellow('
|
|
498
|
+
log(chalk.yellow(' Tests pass but type errors found — retrying...'));
|
|
364
499
|
onStatus?.({ phase: 'retrying', file: shortPath, attempt, max: config.maxIterations });
|
|
365
500
|
continue;
|
|
366
501
|
}
|
|
502
|
+
if (typeErrors) {
|
|
503
|
+
// Test-repair mode: the failing test(s) now PASS — that IS the fix. Residual type
|
|
504
|
+
// errors (often just implicit-any in mocks) must not make us burn retries or revert to
|
|
505
|
+
// the broken original. Keep the passing fix; surface the types as a follow-up.
|
|
506
|
+
if (!onStatus)
|
|
507
|
+
log(chalk.yellow(' ⚠ Tests now pass — keeping the fix. Type errors remain; run `lacuna fix --types` to clean them up.'));
|
|
508
|
+
onStatus?.({ phase: 'passed', file: shortPath });
|
|
509
|
+
return { success: true };
|
|
510
|
+
}
|
|
367
511
|
if (!onStatus)
|
|
368
512
|
log(chalk.green(' Fixed.'));
|
|
369
513
|
onStatus?.({ phase: 'passed', file: shortPath });
|
|
@@ -376,7 +520,7 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
|
|
|
376
520
|
// enrichNoTestsError adds guidance for genuinely missing test functions;
|
|
377
521
|
// in the structure-broken path the issue is always a broken import, so use
|
|
378
522
|
// rawExtracted there so the actual module error isn't buried in boilerplate.
|
|
379
|
-
const extracted = enrichNoTestsError(rawExtracted);
|
|
523
|
+
const extracted = enrichNoTestsError(rawExtracted, rawRunOutput);
|
|
380
524
|
if (structureBroken) {
|
|
381
525
|
errorOutput = buildStructureBrokenMessage(initialErrorOutput, rawExtracted);
|
|
382
526
|
if (!onStatus)
|
|
@@ -395,12 +539,17 @@ async function fixFile(testFilePath, options, generator, onStatus, projectMemory
|
|
|
395
539
|
if (!onStatus && verbose)
|
|
396
540
|
log(chalk.dim(errorOutput.split('\n').slice(0, 20).join('\n')));
|
|
397
541
|
}
|
|
398
|
-
// Restore original test file — don't leave broken AI code on disk
|
|
542
|
+
// Restore original test file — don't leave broken AI code on disk.
|
|
543
|
+
// For a type-only repair this puts back the passing (but type-erroring) file, which is
|
|
544
|
+
// strictly better than a regenerated guess — so the caller must NOT regenerate it.
|
|
399
545
|
await writeFile(absTestPath, testCode, 'utf-8').catch(() => { });
|
|
400
546
|
onStatus?.({ phase: 'failed', file: shortPath });
|
|
547
|
+
const typeOnly = firstRun.success;
|
|
401
548
|
return {
|
|
402
549
|
success: false,
|
|
403
|
-
|
|
550
|
+
typeOnly,
|
|
551
|
+
baselinePassCount,
|
|
552
|
+
error: `${typeOnly ? 'Type errors remain' : 'Still failing'} after ${config.maxIterations} attempts. Last error:\n${errorOutput.slice(0, 1500)}`,
|
|
404
553
|
};
|
|
405
554
|
}
|
|
406
555
|
// ─── Polluter detection ───────────────────────────────────────────────────────
|
|
@@ -566,14 +715,21 @@ async function findAndFixPolluters(victimFiles, options, projectMemory) {
|
|
|
566
715
|
return { pollutersFixed, victimsRegenerated };
|
|
567
716
|
}
|
|
568
717
|
// ─── Regeneration fallback ────────────────────────────────────────────────────
|
|
569
|
-
async function regenerateFile(testFilePath, options, onStatus, projectMemory) {
|
|
718
|
+
async function regenerateFile(testFilePath, options, onStatus, projectMemory, baselinePassCount = 0) {
|
|
570
719
|
const absTestFile = testFilePath.startsWith('/') ? testFilePath : join(options.cwd, testFilePath);
|
|
571
720
|
// Find the source file so processGap gets the right starting point.
|
|
572
721
|
// processGap expects gap.filePath to be the SOURCE file, not the test file.
|
|
573
|
-
const sourceFile = await findSourceFile(absTestFile, options.cwd);
|
|
722
|
+
const sourceFile = await findSourceFile(absTestFile, options.cwd, options.config.sourceDir);
|
|
574
723
|
if (!sourceFile) {
|
|
575
724
|
return { success: false, error: `Could not find source file for ${absTestFile}` };
|
|
576
725
|
}
|
|
726
|
+
// Back up the current content so a failed regeneration never leaves the file deleted or
|
|
727
|
+
// filled with a broken last attempt — on failure we restore exactly what was here.
|
|
728
|
+
let originalContent = null;
|
|
729
|
+
try {
|
|
730
|
+
originalContent = await readFile(absTestFile, 'utf-8');
|
|
731
|
+
}
|
|
732
|
+
catch { /* already gone */ }
|
|
577
733
|
// Delete the broken test file before regenerating. If it stays on disk,
|
|
578
734
|
// buildFileContext reads it as existingTestCode and the generate prompt says
|
|
579
735
|
// "preserve all existing tests" — locking the AI into the same broken structure.
|
|
@@ -595,7 +751,23 @@ async function regenerateFile(testFilePath, options, onStatus, projectMemory) {
|
|
|
595
751
|
}
|
|
596
752
|
}
|
|
597
753
|
: undefined;
|
|
598
|
-
const result = await processGap(gap, options, generator, true, regenOnStatus, projectMemory);
|
|
754
|
+
const result = await processGap(gap, options, generator, true, regenOnStatus, projectMemory, absTestFile);
|
|
755
|
+
if (result.success) {
|
|
756
|
+
// Never-regress: a "green" regen with fewer tests than the original is still a net loss
|
|
757
|
+
// (e.g. 50 passing replacing 477). Re-run the regenerated file and keep it only if it has
|
|
758
|
+
// at least as many passing tests as the original — otherwise restore the original.
|
|
759
|
+
const regenRun = await runCommand(fileTestCommand(options.env, absTestFile), options.cwd, 60_000);
|
|
760
|
+
const regenPass = parsePassCount(regenRun.stdout + '\n' + regenRun.stderr);
|
|
761
|
+
if (regenPass < baselinePassCount && originalContent !== null) {
|
|
762
|
+
await writeFile(absTestFile, originalContent, 'utf-8').catch(() => { });
|
|
763
|
+
return { success: false, error: `Regeneration produced fewer passing tests (${regenPass}) than the original (${baselinePassCount}) — restored the original.` };
|
|
764
|
+
}
|
|
765
|
+
return { success: true };
|
|
766
|
+
}
|
|
767
|
+
// Regeneration failed — restore the original so we never leave the workspace worse than we
|
|
768
|
+
// found it (deleted, or holding a truncated/garbage attempt).
|
|
769
|
+
if (originalContent !== null)
|
|
770
|
+
await writeFile(absTestFile, originalContent, 'utf-8').catch(() => { });
|
|
599
771
|
return { success: result.success, error: result.error };
|
|
600
772
|
}
|
|
601
773
|
// ─── Worker pool ──────────────────────────────────────────────────────────────
|
|
@@ -639,11 +811,16 @@ async function runFixWorkers(testFiles, options, workerCount, projectMemory) {
|
|
|
639
811
|
else
|
|
640
812
|
filesFixed++;
|
|
641
813
|
}
|
|
642
|
-
else if (options.regenerateOnFailure) {
|
|
814
|
+
else if (options.regenerateOnFailure && !options.types && !result.typeOnly && (result.baselinePassCount ?? Infinity) < REGEN_MAX_BASELINE_PASS) {
|
|
815
|
+
// Regenerate from scratch only for mostly-broken files (few passing tests) — that's
|
|
816
|
+
// where a fresh take rescues stuck tests. A file with a substantial passing suite is
|
|
817
|
+
// left restored by fixFile, never nuked. Skip too for type-only/--types repairs, and
|
|
818
|
+
// when the baseline is unknown (?? Infinity ⇒ don't risk it). regenerateFile itself
|
|
819
|
+
// also discards any regen that lowers the passing count.
|
|
643
820
|
// Signal 'regenerating' first — this undoes the 'failed' done-count from fixFile
|
|
644
821
|
// so the regen's final phase is the single counted outcome for this file.
|
|
645
822
|
onStatus?.({ phase: 'regenerating', file: absFile.replace(options.cwd + '/', '') });
|
|
646
|
-
const regenResult = await regenerateFile(absFile, workerOptions, onStatus, projectMemory);
|
|
823
|
+
const regenResult = await regenerateFile(absFile, workerOptions, onStatus, projectMemory, result.baselinePassCount ?? 0);
|
|
647
824
|
if (regenResult.success) {
|
|
648
825
|
filesFixed++;
|
|
649
826
|
}
|
|
@@ -669,7 +846,24 @@ export async function runFixLoop(options) {
|
|
|
669
846
|
const workerCount = Math.max(1, Math.min(options.workers ?? 1, 10));
|
|
670
847
|
const parallel = workerCount > 1;
|
|
671
848
|
let failingFiles;
|
|
672
|
-
if (options.targetFile) {
|
|
849
|
+
if (options.types && !options.targetFile) {
|
|
850
|
+
// Types mode: select by type errors rather than test failures. One project-wide tsc
|
|
851
|
+
// finds every test file that fails type-checking — including files whose tests pass,
|
|
852
|
+
// which the normal failure-driven selection never sees.
|
|
853
|
+
if (env.language !== 'typescript') {
|
|
854
|
+
log(chalk.yellow('\n --types only applies to TypeScript projects — nothing to do.'));
|
|
855
|
+
return { filesProcessed: 0, filesFixed: 0, filesAlreadyPassing: 0, pollutersFixed: 0, victimsRegenerated: 0, errors: [] };
|
|
856
|
+
}
|
|
857
|
+
const spinner = startCoverageSpinner(chalk.dim(' Type-checking project to find test files with type errors...'), env.testRunner);
|
|
858
|
+
const allTestFiles = await discoverTestFiles(cwd, env);
|
|
859
|
+
failingFiles = await findTestFilesWithTypeErrors(allTestFiles, cwd, env);
|
|
860
|
+
spinner.stop();
|
|
861
|
+
if (failingFiles.length === 0) {
|
|
862
|
+
log(chalk.green('\n All test files are type-clean — nothing to fix.'));
|
|
863
|
+
return { filesProcessed: 0, filesFixed: 0, filesAlreadyPassing: 0, pollutersFixed: 0, victimsRegenerated: 0, errors: [] };
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
else if (options.targetFile) {
|
|
673
867
|
// Single-file mode: skip the full suite run, go straight to the target file
|
|
674
868
|
const absTarget = options.targetFile.startsWith('/')
|
|
675
869
|
? options.targetFile
|
|
@@ -678,8 +872,15 @@ export async function runFixLoop(options) {
|
|
|
678
872
|
const fileResult = await runCommand(fileTestCommand(env, absTarget), cwd, 60_000, spinner.onLine);
|
|
679
873
|
spinner.stop();
|
|
680
874
|
if (fileResult.success) {
|
|
681
|
-
|
|
682
|
-
|
|
875
|
+
// Tests pass — but the runner only transpiles, it doesn't type-check. A green file
|
|
876
|
+
// can still have TypeScript errors (the exact case `generate` hands off here). Only
|
|
877
|
+
// declare victory if the file is also type-clean; otherwise fall through and repair.
|
|
878
|
+
const typeErrors = await typeCheckFile(absTarget, cwd, env);
|
|
879
|
+
if (!typeErrors) {
|
|
880
|
+
log(chalk.green('\n All tests are passing — nothing to fix.'));
|
|
881
|
+
return { filesProcessed: 0, filesFixed: 0, filesAlreadyPassing: 0, pollutersFixed: 0, victimsRegenerated: 0, errors: [] };
|
|
882
|
+
}
|
|
883
|
+
log(chalk.yellow('\n Tests pass but TypeScript type errors remain — repairing types.'));
|
|
683
884
|
}
|
|
684
885
|
failingFiles = [absTarget];
|
|
685
886
|
}
|
|
@@ -723,7 +924,7 @@ export async function runFixLoop(options) {
|
|
|
723
924
|
await saveFixCache(cwd, failingFiles);
|
|
724
925
|
}
|
|
725
926
|
}
|
|
726
|
-
log(chalk.bold(`\n Found ${failingFiles.length} failing test file(s).`));
|
|
927
|
+
log(chalk.bold(`\n Found ${failingFiles.length} ${options.types ? 'test file(s) with type errors' : 'failing test file(s)'}.`));
|
|
727
928
|
if (parallel) {
|
|
728
929
|
if (options.verbose)
|
|
729
930
|
log(chalk.dim(` (--verbose is not shown in parallel mode — use --workers 1 to see the live code panel)`));
|
|
@@ -777,9 +978,11 @@ export async function runFixLoop(options) {
|
|
|
777
978
|
else
|
|
778
979
|
filesFixed++;
|
|
779
980
|
}
|
|
780
|
-
else if (options.regenerateOnFailure) {
|
|
981
|
+
else if (options.regenerateOnFailure && !options.types && !result.typeOnly && (result.baselinePassCount ?? Infinity) < REGEN_MAX_BASELINE_PASS) {
|
|
982
|
+
// Regenerate only for mostly-broken files (few passing tests) — see runFixWorkers.
|
|
983
|
+
// A substantial passing suite is left restored by fixFile, never nuked + rebuilt.
|
|
781
984
|
log(chalk.yellow(` Fix exhausted — falling back to full regeneration...`));
|
|
782
|
-
const regenResult = await regenerateFile(absFile, options, undefined, memory.toPromptSection());
|
|
985
|
+
const regenResult = await regenerateFile(absFile, options, undefined, memory.toPromptSection(), result.baselinePassCount ?? 0);
|
|
783
986
|
if (regenResult.success) {
|
|
784
987
|
filesFixed++;
|
|
785
988
|
}
|
|
@@ -800,7 +1003,9 @@ export async function runFixLoop(options) {
|
|
|
800
1003
|
// This means the next `lacuna fix` run skips the full suite and picks up exactly
|
|
801
1004
|
// where we left off. If everything was fixed, delete the cache so the next run
|
|
802
1005
|
// does a clean suite scan to confirm.
|
|
803
|
-
|
|
1006
|
+
// Skip in --types mode: it selects by type errors, not the suite-failure axis the
|
|
1007
|
+
// cache represents, so it must not overwrite the failing-files cache.
|
|
1008
|
+
if (!options.targetFile && !options.types) {
|
|
804
1009
|
if (stillFailingFiles.length > 0)
|
|
805
1010
|
await saveFixCache(cwd, stillFailingFiles);
|
|
806
1011
|
else
|