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.
Files changed (63) hide show
  1. package/README.md +94 -11
  2. package/dist/agent/context.d.ts +1 -0
  3. package/dist/agent/context.d.ts.map +1 -1
  4. package/dist/agent/context.js +141 -6
  5. package/dist/agent/context.js.map +1 -1
  6. package/dist/agent/fix-loop.d.ts +1 -0
  7. package/dist/agent/fix-loop.d.ts.map +1 -1
  8. package/dist/agent/fix-loop.js +245 -40
  9. package/dist/agent/fix-loop.js.map +1 -1
  10. package/dist/agent/generator.d.ts +7 -0
  11. package/dist/agent/generator.d.ts.map +1 -1
  12. package/dist/agent/generator.js +205 -35
  13. package/dist/agent/generator.js.map +1 -1
  14. package/dist/agent/loop.d.ts +1 -1
  15. package/dist/agent/loop.d.ts.map +1 -1
  16. package/dist/agent/loop.js +154 -11
  17. package/dist/agent/loop.js.map +1 -1
  18. package/dist/agent/prompts/index.d.ts +4 -1
  19. package/dist/agent/prompts/index.d.ts.map +1 -1
  20. package/dist/agent/prompts/index.js +182 -37
  21. package/dist/agent/prompts/index.js.map +1 -1
  22. package/dist/agent/prompts/react-native.d.ts.map +1 -1
  23. package/dist/agent/prompts/react-native.js +15 -4
  24. package/dist/agent/prompts/react-native.js.map +1 -1
  25. package/dist/agent/prompts/react.js +1 -1
  26. package/dist/agent/prompts/runners/vitest.js +1 -1
  27. package/dist/commands/analyze.d.ts.map +1 -1
  28. package/dist/commands/analyze.js +4 -0
  29. package/dist/commands/analyze.js.map +1 -1
  30. package/dist/commands/fix.d.ts +1 -0
  31. package/dist/commands/fix.d.ts.map +1 -1
  32. package/dist/commands/fix.js +24 -5
  33. package/dist/commands/fix.js.map +1 -1
  34. package/dist/commands/generate.d.ts.map +1 -1
  35. package/dist/commands/generate.js +10 -0
  36. package/dist/commands/generate.js.map +1 -1
  37. package/dist/commands/init.d.ts.map +1 -1
  38. package/dist/commands/init.js +6 -1
  39. package/dist/commands/init.js.map +1 -1
  40. package/dist/commands/run.d.ts.map +1 -1
  41. package/dist/commands/run.js +4 -0
  42. package/dist/commands/run.js.map +1 -1
  43. package/dist/lib/config.d.ts +10 -2
  44. package/dist/lib/config.d.ts.map +1 -1
  45. package/dist/lib/config.js +43 -21
  46. package/dist/lib/config.js.map +1 -1
  47. package/dist/lib/extract-error.d.ts.map +1 -1
  48. package/dist/lib/extract-error.js +8 -2
  49. package/dist/lib/extract-error.js.map +1 -1
  50. package/dist/lib/reporter.d.ts.map +1 -1
  51. package/dist/lib/reporter.js +11 -1
  52. package/dist/lib/reporter.js.map +1 -1
  53. package/dist/lib/typecheck.d.ts +1 -0
  54. package/dist/lib/typecheck.d.ts.map +1 -1
  55. package/dist/lib/typecheck.js +119 -7
  56. package/dist/lib/typecheck.js.map +1 -1
  57. package/dist/lib/validate.d.ts +37 -1
  58. package/dist/lib/validate.d.ts.map +1 -1
  59. package/dist/lib/validate.js +659 -11
  60. package/dist/lib/validate.js.map +1 -1
  61. package/lacuna.schema.json +150 -0
  62. package/package.json +14 -7
  63. package/oclif.manifest.json +0 -309
@@ -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
- // 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}`);
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.dim(' Already passingskipping.'));
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 = extractTestFailure(firstRun.stdout + '\n' + firstRun.stderr);
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(MOCKS_SEPARATOR) && config.mocksFile) {
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(' Type errors found — retrying...'));
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
- error: `Still failing after ${config.maxIterations} attempts. Last error:\n${errorOutput.slice(0, 1500)}`,
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
- log(chalk.green('\n All tests are passing nothing to fix.'));
682
- return { filesProcessed: 0, filesFixed: 0, filesAlreadyPassing: 0, pollutersFixed: 0, victimsRegenerated: 0, errors: [] };
875
+ // Tests passbut 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
- if (!options.targetFile) {
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