lacuna-cli 0.1.1

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 (144) hide show
  1. package/README.md +451 -0
  2. package/bin/run.js +5 -0
  3. package/dist/agent/context.d.ts +25 -0
  4. package/dist/agent/context.d.ts.map +1 -0
  5. package/dist/agent/context.js +366 -0
  6. package/dist/agent/context.js.map +1 -0
  7. package/dist/agent/fix-loop.d.ts +20 -0
  8. package/dist/agent/fix-loop.d.ts.map +1 -0
  9. package/dist/agent/fix-loop.js +466 -0
  10. package/dist/agent/fix-loop.js.map +1 -0
  11. package/dist/agent/generator.d.ts +35 -0
  12. package/dist/agent/generator.d.ts.map +1 -0
  13. package/dist/agent/generator.js +220 -0
  14. package/dist/agent/generator.js.map +1 -0
  15. package/dist/agent/loop.d.ts +23 -0
  16. package/dist/agent/loop.d.ts.map +1 -0
  17. package/dist/agent/loop.js +394 -0
  18. package/dist/agent/loop.js.map +1 -0
  19. package/dist/agent/project-memory.d.ts +10 -0
  20. package/dist/agent/project-memory.d.ts.map +1 -0
  21. package/dist/agent/project-memory.js +57 -0
  22. package/dist/agent/project-memory.js.map +1 -0
  23. package/dist/agent/prompts.d.ts +44 -0
  24. package/dist/agent/prompts.d.ts.map +1 -0
  25. package/dist/agent/prompts.js +377 -0
  26. package/dist/agent/prompts.js.map +1 -0
  27. package/dist/ci/comment.d.ts +2 -0
  28. package/dist/ci/comment.d.ts.map +1 -0
  29. package/dist/ci/comment.js +97 -0
  30. package/dist/ci/comment.js.map +1 -0
  31. package/dist/ci/parse-outputs.d.ts +2 -0
  32. package/dist/ci/parse-outputs.d.ts.map +1 -0
  33. package/dist/ci/parse-outputs.js +30 -0
  34. package/dist/ci/parse-outputs.js.map +1 -0
  35. package/dist/commands/analyze.d.ts +13 -0
  36. package/dist/commands/analyze.d.ts.map +1 -0
  37. package/dist/commands/analyze.js +151 -0
  38. package/dist/commands/analyze.js.map +1 -0
  39. package/dist/commands/fix.d.ts +15 -0
  40. package/dist/commands/fix.d.ts.map +1 -0
  41. package/dist/commands/fix.js +106 -0
  42. package/dist/commands/fix.js.map +1 -0
  43. package/dist/commands/generate.d.ts +18 -0
  44. package/dist/commands/generate.d.ts.map +1 -0
  45. package/dist/commands/generate.js +129 -0
  46. package/dist/commands/generate.js.map +1 -0
  47. package/dist/commands/init.d.ts +7 -0
  48. package/dist/commands/init.d.ts.map +1 -0
  49. package/dist/commands/init.js +131 -0
  50. package/dist/commands/init.js.map +1 -0
  51. package/dist/commands/run.d.ts +10 -0
  52. package/dist/commands/run.d.ts.map +1 -0
  53. package/dist/commands/run.js +45 -0
  54. package/dist/commands/run.js.map +1 -0
  55. package/dist/lib/config.d.ts +58 -0
  56. package/dist/lib/config.d.ts.map +1 -0
  57. package/dist/lib/config.js +68 -0
  58. package/dist/lib/config.js.map +1 -0
  59. package/dist/lib/coverage/gaps.d.ts +12 -0
  60. package/dist/lib/coverage/gaps.d.ts.map +1 -0
  61. package/dist/lib/coverage/gaps.js +186 -0
  62. package/dist/lib/coverage/gaps.js.map +1 -0
  63. package/dist/lib/coverage/index.d.ts +7 -0
  64. package/dist/lib/coverage/index.d.ts.map +1 -0
  65. package/dist/lib/coverage/index.js +24 -0
  66. package/dist/lib/coverage/index.js.map +1 -0
  67. package/dist/lib/coverage/json.d.ts +3 -0
  68. package/dist/lib/coverage/json.d.ts.map +1 -0
  69. package/dist/lib/coverage/json.js +24 -0
  70. package/dist/lib/coverage/json.js.map +1 -0
  71. package/dist/lib/coverage/lcov.d.ts +3 -0
  72. package/dist/lib/coverage/lcov.d.ts.map +1 -0
  73. package/dist/lib/coverage/lcov.js +58 -0
  74. package/dist/lib/coverage/lcov.js.map +1 -0
  75. package/dist/lib/coverage/types.d.ts +27 -0
  76. package/dist/lib/coverage/types.d.ts.map +1 -0
  77. package/dist/lib/coverage/types.js +2 -0
  78. package/dist/lib/coverage/types.js.map +1 -0
  79. package/dist/lib/coverage-spinner.d.ts +6 -0
  80. package/dist/lib/coverage-spinner.d.ts.map +1 -0
  81. package/dist/lib/coverage-spinner.js +101 -0
  82. package/dist/lib/coverage-spinner.js.map +1 -0
  83. package/dist/lib/detector.d.ts +13 -0
  84. package/dist/lib/detector.d.ts.map +1 -0
  85. package/dist/lib/detector.js +106 -0
  86. package/dist/lib/detector.js.map +1 -0
  87. package/dist/lib/extract-error.d.ts +2 -0
  88. package/dist/lib/extract-error.d.ts.map +1 -0
  89. package/dist/lib/extract-error.js +116 -0
  90. package/dist/lib/extract-error.js.map +1 -0
  91. package/dist/lib/providers/anthropic.d.ts +8 -0
  92. package/dist/lib/providers/anthropic.d.ts.map +1 -0
  93. package/dist/lib/providers/anthropic.js +38 -0
  94. package/dist/lib/providers/anthropic.js.map +1 -0
  95. package/dist/lib/providers/index.d.ts +6 -0
  96. package/dist/lib/providers/index.d.ts.map +1 -0
  97. package/dist/lib/providers/index.js +27 -0
  98. package/dist/lib/providers/index.js.map +1 -0
  99. package/dist/lib/providers/openai-compatible.d.ts +11 -0
  100. package/dist/lib/providers/openai-compatible.d.ts.map +1 -0
  101. package/dist/lib/providers/openai-compatible.js +93 -0
  102. package/dist/lib/providers/openai-compatible.js.map +1 -0
  103. package/dist/lib/providers/types.d.ts +17 -0
  104. package/dist/lib/providers/types.d.ts.map +1 -0
  105. package/dist/lib/providers/types.js +97 -0
  106. package/dist/lib/providers/types.js.map +1 -0
  107. package/dist/lib/report-upload.d.ts +3 -0
  108. package/dist/lib/report-upload.d.ts.map +1 -0
  109. package/dist/lib/report-upload.js +15 -0
  110. package/dist/lib/report-upload.js.map +1 -0
  111. package/dist/lib/reporter.d.ts +51 -0
  112. package/dist/lib/reporter.d.ts.map +1 -0
  113. package/dist/lib/reporter.js +172 -0
  114. package/dist/lib/reporter.js.map +1 -0
  115. package/dist/lib/runner.d.ts +9 -0
  116. package/dist/lib/runner.d.ts.map +1 -0
  117. package/dist/lib/runner.js +50 -0
  118. package/dist/lib/runner.js.map +1 -0
  119. package/dist/lib/skeleton.d.ts +8 -0
  120. package/dist/lib/skeleton.d.ts.map +1 -0
  121. package/dist/lib/skeleton.js +122 -0
  122. package/dist/lib/skeleton.js.map +1 -0
  123. package/dist/lib/streaming-viewer.d.ts +14 -0
  124. package/dist/lib/streaming-viewer.d.ts.map +1 -0
  125. package/dist/lib/streaming-viewer.js +80 -0
  126. package/dist/lib/streaming-viewer.js.map +1 -0
  127. package/dist/lib/tips.d.ts +16 -0
  128. package/dist/lib/tips.d.ts.map +1 -0
  129. package/dist/lib/tips.js +76 -0
  130. package/dist/lib/tips.js.map +1 -0
  131. package/dist/lib/typecheck.d.ts +3 -0
  132. package/dist/lib/typecheck.d.ts.map +1 -0
  133. package/dist/lib/typecheck.js +28 -0
  134. package/dist/lib/typecheck.js.map +1 -0
  135. package/dist/lib/validate.d.ts +7 -0
  136. package/dist/lib/validate.d.ts.map +1 -0
  137. package/dist/lib/validate.js +82 -0
  138. package/dist/lib/validate.js.map +1 -0
  139. package/dist/lib/worker-display.d.ts +45 -0
  140. package/dist/lib/worker-display.d.ts.map +1 -0
  141. package/dist/lib/worker-display.js +168 -0
  142. package/dist/lib/worker-display.js.map +1 -0
  143. package/oclif.manifest.json +295 -0
  144. package/package.json +62 -0
@@ -0,0 +1,220 @@
1
+ import { createProvider } from '../lib/providers/index.js';
2
+ import { buildSystemPrompt, buildGeneratePrompt, buildFixPrompt, buildRetryPrompt } from './prompts.js';
3
+ // Thrown when the model's output was cut off before </code_output> was emitted.
4
+ // The partial code is attached so callers can include it in the retry message.
5
+ export class TruncatedOutputError extends Error {
6
+ partialCode;
7
+ constructor(partialCode) {
8
+ super('Model output was truncated before the response was complete.');
9
+ this.partialCode = partialCode;
10
+ this.name = 'TruncatedOutputError';
11
+ }
12
+ }
13
+ export class OscillationError extends Error {
14
+ constructor() {
15
+ super('Agent detected a loop — the generated code is identical to a previous attempt.');
16
+ this.name = 'OscillationError';
17
+ }
18
+ }
19
+ const GENERATE_TEMPERATURE = 0.4; // some creativity to match existing patterns
20
+ const RETRY_TEMPERATURE = 0.1; // precise and deterministic when fixing errors
21
+ // Wraps a token callback so that <thinking> content is suppressed.
22
+ // Buffers silently until <code_output> is seen, then streams from there.
23
+ // Falls back to streaming everything if <code_output> never appears (e.g. non-XML response).
24
+ function codeOnlyStream(onToken) {
25
+ let buf = '';
26
+ let streaming = false;
27
+ return (token) => {
28
+ if (streaming) {
29
+ onToken(token);
30
+ return;
31
+ }
32
+ buf += token;
33
+ const idx = buf.indexOf('<code_output>');
34
+ if (idx !== -1) {
35
+ streaming = true;
36
+ const after = buf.slice(idx + '<code_output>'.length);
37
+ if (after)
38
+ onToken(after);
39
+ buf = '';
40
+ return;
41
+ }
42
+ // If no <code_output> after 3000 chars the model skipped the XML wrapper — flush and stream
43
+ if (buf.length > 3000) {
44
+ streaming = true;
45
+ onToken(buf);
46
+ buf = '';
47
+ }
48
+ };
49
+ }
50
+ function normalizeCode(code) {
51
+ return code.replace(/\s+/g, '');
52
+ }
53
+ const TRUNCATION_RETRY_MESSAGE = 'Your previous output was cut off before the code was complete (unmatched braces or incomplete expression detected). ' +
54
+ 'Write a shorter, more focused test file. Cover the most important behaviors only — skip exhaustive edge cases if needed. ' +
55
+ 'Every function body must be closed.';
56
+ // Detect syntactically incomplete code — a strong signal that output was cut off mid-generation.
57
+ function isCodeIncomplete(code) {
58
+ if (!code.trim())
59
+ return true;
60
+ // Strip string literals to avoid false positives from braces inside strings
61
+ const stripped = code
62
+ .replace(/`(?:[^`\\]|\\.)*`/gs, '``')
63
+ .replace(/"(?:[^"\\]|\\.)*"/g, '""')
64
+ .replace(/'(?:[^'\\]|\\.)*'/g, "''");
65
+ // Unmatched opening braces (allow +1 tolerance for edge cases)
66
+ const opens = (stripped.match(/\{/g) ?? []).length;
67
+ const closes = (stripped.match(/\}/g) ?? []).length;
68
+ if (opens > closes + 1)
69
+ return true;
70
+ // Last meaningful character suggests an incomplete expression
71
+ const lastChar = code.trimEnd().slice(-1);
72
+ if (',(=+-&|?:'.includes(lastChar))
73
+ return true;
74
+ return false;
75
+ }
76
+ // Parse the structured <thinking> + <code_output> response.
77
+ // The stop sequence </code_output> is registered with the API, so the closing tag
78
+ // is normally absent from the raw response — that is a clean stop, not truncation.
79
+ // True truncation (model hit max_tokens mid-code) is detected syntactically.
80
+ function parseStructuredResponse(raw) {
81
+ const thinkingMatch = raw.match(/<thinking>([\s\S]*?)<\/thinking>/i);
82
+ const hypothesis = thinkingMatch ? thinkingMatch[1].trim() : '';
83
+ // Closed tag present (stop sequence not in effect, or model included it anyway)
84
+ const codeMatch = raw.match(/<code_output>([\s\S]*?)<\/code_output>/i);
85
+ if (codeMatch) {
86
+ const code = codeMatch[1].trim();
87
+ return { hypothesis, code, truncated: isCodeIncomplete(code) };
88
+ }
89
+ // Opening tag present, no closing tag — normal when stop sequence fires cleanly
90
+ const openIdx = raw.search(/<code_output>/i);
91
+ if (openIdx !== -1) {
92
+ const code = raw.slice(openIdx + '<code_output>'.length).trim();
93
+ return { hypothesis, code, truncated: isCodeIncomplete(code) };
94
+ }
95
+ // No XML tags — extract the last fenced code block if present.
96
+ // Gemini and other models sometimes emit prose + multiple draft blocks before
97
+ // settling on a final answer; the last block is the intended output.
98
+ const fenceMatches = [...raw.matchAll(/```(?:typescript|tsx?|javascript|jsx?|python|go)?\s*\n([\s\S]*?)```/g)];
99
+ if (fenceMatches.length > 0) {
100
+ const code = fenceMatches[fenceMatches.length - 1][1].trim();
101
+ return { hypothesis, code, truncated: isCodeIncomplete(code) };
102
+ }
103
+ // No fenced blocks at all — strip any single fence pair and use as code
104
+ let fallback = raw.trim();
105
+ fallback = fallback.replace(/^```(?:typescript|tsx?|javascript|jsx?|python|go)?\s*\n/, '');
106
+ fallback = fallback.replace(/\n```\s*$/, '');
107
+ const code = fallback.trim();
108
+ return { hypothesis, code, truncated: isCodeIncomplete(code) };
109
+ }
110
+ export class TestGenerator {
111
+ provider;
112
+ env;
113
+ rawOnToken; // unwrapped callback; filter recreated per call
114
+ maxTokens;
115
+ history = [];
116
+ lastHypothesis = '';
117
+ failedAttempts = [];
118
+ previousCodes = []; // normalized codes from all attempts, for oscillation detection
119
+ constructor(options) {
120
+ this.provider = createProvider(options.config);
121
+ this.env = options.env;
122
+ this.rawOnToken = options.onToken;
123
+ this.maxTokens = options.config.maxTokens ?? 16000;
124
+ }
125
+ // Swap the token callback between files (e.g. to attach a StreamingFileViewer per file).
126
+ // A fresh codeOnlyStream filter is created on every generate/fix/retry call anyway,
127
+ // so calling this resets streaming state automatically.
128
+ setTokenCallback(cb) {
129
+ this.rawOnToken = cb;
130
+ }
131
+ async generate(context, gap, projectMemory) {
132
+ this.lastHypothesis = '';
133
+ this.failedAttempts = [];
134
+ this.previousCodes = [];
135
+ this.history = [
136
+ {
137
+ role: 'user',
138
+ content: buildGeneratePrompt({
139
+ sourceFile: context.sourceFile,
140
+ sourceCode: context.sourceCode,
141
+ existingTestCode: context.existingTestCode,
142
+ uncoveredFunctions: gap.uncoveredFunctions,
143
+ uncoveredLines: gap.uncoveredLines,
144
+ env: this.env,
145
+ sourceImportPath: context.sourceImportPath,
146
+ mocksCode: context.mocksCode,
147
+ mocksImportPath: context.mocksImportPath,
148
+ setupFileCode: context.setupFileCode,
149
+ packageDeps: context.packageDeps,
150
+ tsconfigPaths: context.tsconfigPaths,
151
+ typeDefinitions: context.typeDefinitions,
152
+ localImportPaths: context.localImportPaths,
153
+ reactMajorVersion: context.reactMajorVersion,
154
+ projectMemory,
155
+ }),
156
+ },
157
+ ];
158
+ const response = await this.provider.generate(this.history, buildSystemPrompt(this.env), this.rawOnToken ? codeOnlyStream(this.rawOnToken) : undefined, this.maxTokens, GENERATE_TEMPERATURE);
159
+ const { hypothesis, code, truncated } = parseStructuredResponse(response);
160
+ this.lastHypothesis = hypothesis;
161
+ this.previousCodes.push(normalizeCode(code));
162
+ this.history.push({ role: 'assistant', content: response });
163
+ if (truncated)
164
+ throw new TruncatedOutputError(code);
165
+ return code;
166
+ }
167
+ async fix(args) {
168
+ this.lastHypothesis = '';
169
+ this.failedAttempts = [];
170
+ this.previousCodes = [];
171
+ this.history = [{ role: 'user', content: buildFixPrompt(args) }];
172
+ const response = await this.provider.generate(this.history, buildSystemPrompt(this.env), this.rawOnToken ? codeOnlyStream(this.rawOnToken) : undefined, this.maxTokens, GENERATE_TEMPERATURE);
173
+ const { hypothesis, code, truncated } = parseStructuredResponse(response);
174
+ this.lastHypothesis = hypothesis;
175
+ this.previousCodes.push(normalizeCode(code));
176
+ this.history.push({ role: 'assistant', content: response });
177
+ if (truncated)
178
+ throw new TruncatedOutputError(code);
179
+ return code;
180
+ }
181
+ async retry(failureOutput) {
182
+ // Record what the previous attempt planned and why it failed
183
+ this.failedAttempts.push({
184
+ attemptNumber: this.failedAttempts.length + 1,
185
+ hypothesis: this.lastHypothesis,
186
+ failureReason: failureOutput,
187
+ });
188
+ // Trim history to: original prompt + latest code + new retry message
189
+ // This keeps memory flat regardless of iteration count.
190
+ const original = this.history[0];
191
+ let latestCode;
192
+ for (let i = this.history.length - 1; i >= 0; i--) {
193
+ if (this.history[i].role === 'assistant') {
194
+ latestCode = this.history[i];
195
+ break;
196
+ }
197
+ }
198
+ this.history = latestCode && latestCode !== original
199
+ ? [original, latestCode]
200
+ : [original];
201
+ this.history.push({
202
+ role: 'user',
203
+ content: buildRetryPrompt(failureOutput, this.failedAttempts),
204
+ });
205
+ const response = await this.provider.generate(this.history, buildSystemPrompt(this.env), this.rawOnToken ? codeOnlyStream(this.rawOnToken) : undefined, this.maxTokens, RETRY_TEMPERATURE);
206
+ const { hypothesis, code, truncated } = parseStructuredResponse(response);
207
+ this.lastHypothesis = hypothesis;
208
+ this.history.push({ role: 'assistant', content: response });
209
+ if (truncated)
210
+ throw new TruncatedOutputError(code);
211
+ // Oscillation check: if this code is identical to any prior attempt, break early
212
+ const norm = normalizeCode(code);
213
+ if (this.previousCodes.includes(norm))
214
+ throw new OscillationError();
215
+ this.previousCodes.push(norm);
216
+ return code;
217
+ }
218
+ }
219
+ export { TRUNCATION_RETRY_MESSAGE };
220
+ //# sourceMappingURL=generator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"generator.js","sourceRoot":"","sources":["../../src/agent/generator.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,23 @@
1
+ import type { LacunaConfig } from '../lib/config.js';
2
+ import type { DetectedEnvironment } from '../lib/detector.js';
3
+ export interface LoopOptions {
4
+ config: LacunaConfig;
5
+ env: DetectedEnvironment;
6
+ cwd: string;
7
+ dryRun: boolean;
8
+ verbose: boolean;
9
+ targetFile?: string;
10
+ workers?: number;
11
+ fresh?: boolean;
12
+ log: (msg: string) => void;
13
+ }
14
+ export interface LoopResult {
15
+ filesProcessed: number;
16
+ testsWritten: number;
17
+ coverageBefore: number;
18
+ coverageAfter: number;
19
+ hasCoverage: boolean;
20
+ errors: string[];
21
+ }
22
+ export declare function runAgentLoop(options: LoopOptions): Promise<LoopResult>;
23
+ //# sourceMappingURL=loop.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loop.d.ts","sourceRoot":"","sources":["../../src/agent/loop.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AACpD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAA;AAiB7D,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,YAAY,CAAA;IACpB,GAAG,EAAE,mBAAmB,CAAA;IACxB,GAAG,EAAE,MAAM,CAAA;IACX,MAAM,EAAE,OAAO,CAAA;IACf,OAAO,EAAE,OAAO,CAAA;IAChB,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAC3B;AAED,MAAM,WAAW,UAAU;IACzB,cAAc,EAAE,MAAM,CAAA;IACtB,YAAY,EAAE,MAAM,CAAA;IACpB,cAAc,EAAE,MAAM,CAAA;IACtB,aAAa,EAAE,MAAM,CAAA;IACrB,WAAW,EAAE,OAAO,CAAA;IACpB,MAAM,EAAE,MAAM,EAAE,CAAA;CACjB;AA6QD,wBAAsB,YAAY,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,UAAU,CAAC,CAyJ5E"}
@@ -0,0 +1,394 @@
1
+ import { writeFile, mkdir, readFile, unlink } from 'fs/promises';
2
+ import { dirname, join } from 'path';
3
+ import chalk from 'chalk';
4
+ import { fileTestCommand } from '../lib/detector.js';
5
+ import { runCommand } from '../lib/runner.js';
6
+ import { loadCoverage, coverageAgeSeconds, extractGaps, filterTestableGaps, findUncoveredFiles } from '../lib/coverage/index.js';
7
+ import { WorkerDisplay } from '../lib/worker-display.js';
8
+ import { startCoverageSpinner } from '../lib/coverage-spinner.js';
9
+ import { buildFileContext } from './context.js';
10
+ import { TestGenerator, TruncatedOutputError, OscillationError, TRUNCATION_RETRY_MESSAGE } from './generator.js';
11
+ import { ProjectMemory } from './project-memory.js';
12
+ import { getActiveTips, createTipRotator, formatTip } from '../lib/tips.js';
13
+ import { typeCheckFile } from '../lib/typecheck.js';
14
+ import { hasTestFunctions, enrichNoTestsError, isZeroTestsOutput, parsePassCount, buildStructureBrokenMessage, buildRegressionMessage } from '../lib/validate.js';
15
+ import { extractTestFailure } from '../lib/extract-error.js';
16
+ import { StreamingFileViewer } from '../lib/streaming-viewer.js';
17
+ async function getCoverageRate(config, cwd) {
18
+ try {
19
+ const report = await loadCoverage(config, cwd);
20
+ return report.totalLineRate * 100;
21
+ }
22
+ catch {
23
+ return 0;
24
+ }
25
+ }
26
+ async function processGap(gap, options, generator, parallel, onStatus, projectMemory) {
27
+ const { config, env, cwd, dryRun, verbose, log } = options;
28
+ const shortPath = gap.filePath.replace(cwd + '/', '');
29
+ if (!onStatus) {
30
+ log(chalk.bold(`\n Processing: ${chalk.cyan(shortPath)}`));
31
+ if (gap.uncoveredFunctions.length > 0) {
32
+ log(chalk.dim(` Uncovered functions: ${gap.uncoveredFunctions.join(', ')}`));
33
+ }
34
+ }
35
+ let context;
36
+ try {
37
+ context = await buildFileContext(gap.filePath.replace(cwd + '/', ''), cwd, env, config);
38
+ }
39
+ catch {
40
+ const msg = `Could not read source file: ${gap.filePath}`;
41
+ if (!onStatus)
42
+ log(chalk.red(` ${msg}`));
43
+ onStatus?.({ phase: 'failed', file: shortPath });
44
+ return { success: false, error: msg };
45
+ }
46
+ if (!onStatus) {
47
+ log(chalk.dim(` ${context.existingTestFile ? 'Updating' : 'Creating'}: ${context.suggestedTestFile.replace(cwd + '/', '')}`));
48
+ }
49
+ // parallel: run only this test file so workers don't race on the full suite
50
+ const testCmd = parallel
51
+ ? fileTestCommand(env, context.suggestedTestFile)
52
+ : env.testCommand;
53
+ // Capture pre-existing test file so we can restore on failure
54
+ let originalTestContent = null;
55
+ if (!dryRun) {
56
+ try {
57
+ originalTestContent = await readFile(context.suggestedTestFile, 'utf-8');
58
+ }
59
+ catch { /* new file */ }
60
+ }
61
+ let generatedCode = null;
62
+ let lastError = null;
63
+ let firstError = null; // error from attempt 1, kept as anchor for regressions
64
+ let firstPassCount = 0; // passing tests on attempt 1
65
+ for (let attempt = 1; attempt <= config.maxIterations; attempt++) {
66
+ if (!onStatus) {
67
+ if (attempt > 1) {
68
+ log(chalk.yellow(`\n Retry ${attempt}/${config.maxIterations} — fixing failures...`));
69
+ }
70
+ else {
71
+ log(chalk.dim(`\n Generating tests via ${config.model}...`));
72
+ }
73
+ }
74
+ onStatus?.({
75
+ phase: attempt === 1 ? 'generating' : 'retrying',
76
+ file: shortPath,
77
+ ...(attempt > 1 ? { attempt, max: config.maxIterations } : {}),
78
+ });
79
+ let viewer;
80
+ if (verbose && !onStatus) {
81
+ viewer = new StreamingFileViewer(shortPath);
82
+ generator.setTokenCallback(t => viewer.append(t));
83
+ viewer.start();
84
+ }
85
+ try {
86
+ generatedCode = attempt === 1
87
+ ? await generator.generate(context, gap, projectMemory)
88
+ : await generator.retry(lastError ?? '');
89
+ }
90
+ catch (err) {
91
+ viewer?.stop();
92
+ generator.setTokenCallback(undefined);
93
+ if (err instanceof TruncatedOutputError) {
94
+ lastError = TRUNCATION_RETRY_MESSAGE;
95
+ if (!onStatus)
96
+ log(chalk.yellow(`\n Output truncated — retrying with shorter output request...`));
97
+ onStatus?.({ phase: 'retrying', file: shortPath, attempt, max: config.maxIterations });
98
+ continue;
99
+ }
100
+ if (err instanceof OscillationError) {
101
+ if (!onStatus)
102
+ log(chalk.red(`\n ⚠ Agent loop detected — output identical to a previous attempt. Stopping early.`));
103
+ onStatus?.({ phase: 'failed', file: shortPath });
104
+ await restoreTestFile(context.suggestedTestFile, originalTestContent);
105
+ return { success: false, error: err.message };
106
+ }
107
+ const msg = err instanceof Error ? err.message : String(err);
108
+ if (!onStatus)
109
+ log(chalk.red(`\n API error: ${msg}`));
110
+ onStatus?.({ phase: 'failed', file: shortPath });
111
+ return { success: false, error: msg };
112
+ }
113
+ viewer?.stop();
114
+ generator.setTokenCallback(undefined);
115
+ if (dryRun) {
116
+ if (!onStatus) {
117
+ log(chalk.yellow('\n [dry-run] Would write:'));
118
+ log(chalk.dim(generatedCode.split('\n').slice(0, 10).map((l) => ` ${l}`).join('\n')));
119
+ if (generatedCode.split('\n').length > 10)
120
+ log(chalk.dim(' …'));
121
+ }
122
+ onStatus?.({ phase: 'passed', file: shortPath });
123
+ return { success: true, testCode: generatedCode };
124
+ }
125
+ const MOCKS_SEPARATOR = '// ---MOCKS_FILE---';
126
+ let testCode = generatedCode;
127
+ if (generatedCode.includes(MOCKS_SEPARATOR) && config.mocksFile) {
128
+ const [newTestCode, newMocksCode] = generatedCode.split(MOCKS_SEPARATOR);
129
+ testCode = newTestCode.trim();
130
+ if (newMocksCode?.trim()) {
131
+ const absoluteMocksFile = join(cwd, config.mocksFile);
132
+ await mkdir(dirname(absoluteMocksFile), { recursive: true });
133
+ await writeFile(absoluteMocksFile, newMocksCode.trim(), 'utf-8');
134
+ if (!onStatus)
135
+ log(chalk.dim(` Updated mocks file: ${config.mocksFile}`));
136
+ }
137
+ }
138
+ // Catch empty test files before writing — no point running a file with no tests
139
+ if (!hasTestFunctions(testCode)) {
140
+ lastError =
141
+ 'ERROR: The code you wrote contains NO test functions (no it() or test() calls).\n' +
142
+ 'Do not write a file with only imports, types, describe() blocks, or helper functions.\n' +
143
+ 'Every test file must contain at least one: it(\'description\', () => { expect(...).toBe(...) })\n' +
144
+ 'Rewrite the file and include real test cases.';
145
+ if (!onStatus)
146
+ log(chalk.yellow(` Generated file has no tests — retrying...`));
147
+ onStatus?.({ phase: 'retrying', file: shortPath, attempt, max: config.maxIterations });
148
+ continue;
149
+ }
150
+ onStatus?.({ phase: 'writing', file: shortPath });
151
+ await mkdir(dirname(context.suggestedTestFile), { recursive: true });
152
+ await writeFile(context.suggestedTestFile, testCode, 'utf-8');
153
+ if (!onStatus)
154
+ log(chalk.dim(` Written. Running tests...`));
155
+ onStatus?.({ phase: 'running', file: shortPath });
156
+ const runResult = await runCommand(testCmd, cwd);
157
+ if (runResult.success) {
158
+ const typeErrors = await typeCheckFile(context.suggestedTestFile, cwd, env);
159
+ if (typeErrors) {
160
+ lastError = `Tests passed but TypeScript type errors were found in the generated file:\n${typeErrors}\n\nFix ALL type errors. Do not use 'as any' or '@ts-ignore'.`;
161
+ if (!onStatus)
162
+ log(chalk.yellow(` Type errors found — retrying...`));
163
+ onStatus?.({ phase: 'retrying', file: shortPath, attempt, max: config.maxIterations });
164
+ continue;
165
+ }
166
+ if (!onStatus)
167
+ log(chalk.green(` Tests passed.`));
168
+ onStatus?.({ phase: 'passed', file: shortPath });
169
+ return { success: true, testCode };
170
+ }
171
+ const rawRunOutput = runResult.stdout + '\n' + runResult.stderr;
172
+ const extracted = enrichNoTestsError(extractTestFailure(rawRunOutput));
173
+ const passCount = parsePassCount(rawRunOutput);
174
+ if (attempt === 1) {
175
+ firstError = extracted;
176
+ firstPassCount = passCount;
177
+ lastError = extracted;
178
+ if (!onStatus)
179
+ log(chalk.red(` Tests failed (attempt ${attempt}/${config.maxIterations})`));
180
+ }
181
+ else if (isZeroTestsOutput(rawRunOutput)) {
182
+ lastError = buildStructureBrokenMessage(firstError, extracted);
183
+ if (!onStatus)
184
+ log(chalk.red(` Fix broke file structure — 0 tests collected (attempt ${attempt}/${config.maxIterations})`));
185
+ }
186
+ else if (passCount < firstPassCount) {
187
+ lastError = buildRegressionMessage(firstError, extracted, firstPassCount, passCount);
188
+ if (!onStatus)
189
+ log(chalk.red(` Fix caused regression: ${firstPassCount} → ${passCount} passing (attempt ${attempt}/${config.maxIterations})`));
190
+ }
191
+ else {
192
+ lastError = extracted;
193
+ if (!onStatus)
194
+ log(chalk.red(` Tests failed (attempt ${attempt}/${config.maxIterations})`));
195
+ }
196
+ if (!onStatus && verbose)
197
+ log(chalk.dim(lastError.split('\n').slice(0, 20).join('\n')));
198
+ }
199
+ onStatus?.({ phase: 'failed', file: shortPath });
200
+ await restoreTestFile(context.suggestedTestFile, originalTestContent);
201
+ return {
202
+ success: false,
203
+ error: `Tests still failing after ${config.maxIterations} attempts. Last error:\n${lastError?.slice(0, 1500)}`,
204
+ };
205
+ }
206
+ async function restoreTestFile(testPath, original) {
207
+ try {
208
+ if (original !== null) {
209
+ await writeFile(testPath, original, 'utf-8');
210
+ }
211
+ else {
212
+ await unlink(testPath);
213
+ }
214
+ }
215
+ catch { /* best-effort */ }
216
+ }
217
+ async function runWorkerPool(gaps, options, workerCount, projectMemory) {
218
+ const tips = getActiveTips({
219
+ workers: workerCount,
220
+ targetFile: options.targetFile,
221
+ verbose: options.verbose,
222
+ dryRun: options.dryRun,
223
+ fresh: options.fresh,
224
+ model: options.config.model,
225
+ threshold: options.config.threshold,
226
+ mocksFile: options.config.mocksFile,
227
+ ignore: options.config.ignore,
228
+ command: 'generate',
229
+ });
230
+ const display = new WorkerDisplay(workerCount, gaps.length, tips);
231
+ const queue = [...gaps];
232
+ let filesProcessed = 0;
233
+ let testsWritten = 0;
234
+ const errors = [];
235
+ display.start();
236
+ const workers = Array.from({ length: workerCount }, async (_, wi) => {
237
+ const generator = new TestGenerator({
238
+ config: options.config,
239
+ env: options.env,
240
+ // suppress token streaming in parallel mode — display is the UI
241
+ });
242
+ while (true) {
243
+ const gap = queue.shift();
244
+ if (!gap)
245
+ break;
246
+ const onStatus = (state) => display.update(wi, state);
247
+ const result = await processGap(gap, { ...options, log: () => { }, verbose: false }, generator, true, onStatus, projectMemory);
248
+ filesProcessed++;
249
+ if (result.success)
250
+ testsWritten++;
251
+ else if (result.error)
252
+ errors.push(result.error);
253
+ }
254
+ });
255
+ await Promise.all(workers);
256
+ display.finish();
257
+ return { filesProcessed, testsWritten, errors };
258
+ }
259
+ // Coverage report is considered fresh for 10 minutes — lets `analyze` then `generate` share one run.
260
+ const COVERAGE_CACHE_TTL_S = 600;
261
+ export async function runAgentLoop(options) {
262
+ const { config, env, cwd, log } = options;
263
+ const workerCount = Math.max(1, Math.min(options.workers ?? 1, 10));
264
+ const parallel = workerCount > 1;
265
+ // ─── Single-file fast path ────────────────────────────────────────────────────
266
+ // Skip the coverage suite entirely. Build a synthetic gap that treats the whole
267
+ // file as uncovered — the AI reads the source and writes comprehensive tests.
268
+ // Uses fileTestCommand (not the full suite) to verify the generated tests pass.
269
+ if (options.targetFile) {
270
+ const abs = options.targetFile.startsWith('/')
271
+ ? options.targetFile
272
+ : join(cwd, options.targetFile);
273
+ const gap = {
274
+ filePath: abs,
275
+ uncoveredLines: [],
276
+ uncoveredFunctions: [],
277
+ };
278
+ const memory = new ProjectMemory();
279
+ await memory.initialize(cwd, env, config);
280
+ const generator = new TestGenerator({ config, env });
281
+ const result = await processGap(gap, options, generator, true, undefined, memory.toPromptSection());
282
+ return {
283
+ filesProcessed: 1,
284
+ testsWritten: result.success ? 1 : 0,
285
+ coverageBefore: 0,
286
+ coverageAfter: 0,
287
+ hasCoverage: false,
288
+ errors: result.error ? [result.error] : [],
289
+ };
290
+ }
291
+ // ─── Full suite path ──────────────────────────────────────────────────────────
292
+ const ageSeconds = await coverageAgeSeconds(config, cwd);
293
+ const useCached = !options.fresh && ageSeconds !== null && ageSeconds < COVERAGE_CACHE_TTL_S;
294
+ if (useCached) {
295
+ log(chalk.dim(` Using cached coverage report (${Math.round(ageSeconds)}s old). Pass --fresh to re-run the suite.`));
296
+ }
297
+ else {
298
+ const spinner = startCoverageSpinner(chalk.dim(' Running test suite to collect coverage...'), env.testRunner);
299
+ const coverageResult = await runCommand(env.coverageCommand, cwd, config.coverageTimeout * 1000, spinner.onLine);
300
+ spinner.stop();
301
+ if (coverageResult.timedOut) {
302
+ throw new Error(`Test suite timed out after ${config.coverageTimeout}s.\n\n` +
303
+ `This usually means a test has an open handle (unclosed server, timer, or connection).\n` +
304
+ `Try running: ${env.testCommand} --reporter=verbose\n` +
305
+ `Or increase the timeout in .lacuna.json: { "coverageTimeout": ${config.coverageTimeout * 2} }`);
306
+ }
307
+ const zeroTests = /Tests:\s+0 total|no tests found/i.test(coverageResult.stdout + coverageResult.stderr);
308
+ if (zeroTests) {
309
+ throw new Error(`Your test suites are failing before any tests run.\n\n` +
310
+ `This usually means a missing environment variable, broken import, or setup file error.\n` +
311
+ `Run: ${env.testCommand} 2>&1 | head -80\nto see the actual error.`);
312
+ }
313
+ }
314
+ let report;
315
+ try {
316
+ report = await loadCoverage(config, cwd);
317
+ }
318
+ catch {
319
+ throw new Error(`Could not read coverage report from ./${config.coverageDir}/`);
320
+ }
321
+ const coverageBefore = report.totalLineRate * 100;
322
+ const gaps = await filterTestableGaps(extractGaps(report, config.threshold), config.ignore);
323
+ const untouchedFiles = await findUncoveredFiles(report, config.sourceDir, cwd, config.ignore);
324
+ const existingPaths = new Set(gaps.map((g) => g.filePath));
325
+ for (const g of untouchedFiles) {
326
+ if (!existingPaths.has(g.filePath))
327
+ gaps.push(g);
328
+ }
329
+ if (gaps.length === 0) {
330
+ log(chalk.green(`\nAll files already meet the ${config.threshold}% threshold.`));
331
+ return { filesProcessed: 0, testsWritten: 0, coverageBefore, coverageAfter: coverageBefore, hasCoverage: true, errors: [] };
332
+ }
333
+ log(chalk.bold(`\nFound ${gaps.length} file(s) below ${config.threshold}% threshold.`));
334
+ log(chalk.dim(`Coverage before: ${coverageBefore.toFixed(1)}%`));
335
+ if (parallel) {
336
+ if (options.verbose)
337
+ log(chalk.dim(` (--verbose is not shown in parallel mode — use --workers 1 to see the live code panel)`));
338
+ log(chalk.dim(`\nWorkers: ${workerCount}\n`));
339
+ }
340
+ // Build project memory once — shared snapshot for all files in this run
341
+ const memory = new ProjectMemory();
342
+ await memory.initialize(cwd, env, config);
343
+ const memorySnapshot = memory.toPromptSection();
344
+ let filesProcessed;
345
+ let testsWritten;
346
+ let errors;
347
+ if (parallel) {
348
+ ;
349
+ ({ filesProcessed, testsWritten, errors } = await runWorkerPool(gaps, options, workerCount, memorySnapshot));
350
+ if (!options.dryRun) {
351
+ const finalSpinner = startCoverageSpinner(chalk.dim('\n Running full suite for final coverage measurement...'), env.testRunner);
352
+ await runCommand(env.coverageCommand, cwd, config.coverageTimeout * 1000, finalSpinner.onLine);
353
+ finalSpinner.stop();
354
+ }
355
+ }
356
+ else {
357
+ filesProcessed = 0;
358
+ testsWritten = 0;
359
+ errors = [];
360
+ const generator = new TestGenerator({ config, env });
361
+ const tips = getActiveTips({
362
+ workers: 1,
363
+ targetFile: options.targetFile,
364
+ verbose: options.verbose,
365
+ dryRun: options.dryRun,
366
+ fresh: options.fresh,
367
+ model: config.model,
368
+ threshold: config.threshold,
369
+ mocksFile: config.mocksFile,
370
+ ignore: config.ignore,
371
+ command: 'generate',
372
+ });
373
+ const nextTip = createTipRotator(tips);
374
+ for (const gap of gaps) {
375
+ const tip = nextTip();
376
+ if (tip)
377
+ log(formatTip(tip));
378
+ const result = await processGap(gap, options, generator, false, undefined, memory.toPromptSection());
379
+ filesProcessed++;
380
+ if (result.success) {
381
+ testsWritten++;
382
+ // Update memory so subsequent files learn from patterns in this one
383
+ if (result.testCode) {
384
+ memory.recordSuccess(gap.filePath.replace(cwd + '/', ''), result.testCode);
385
+ }
386
+ }
387
+ else if (result.error)
388
+ errors.push(result.error);
389
+ }
390
+ }
391
+ const coverageAfter = options.dryRun ? coverageBefore : await getCoverageRate(config, cwd);
392
+ return { filesProcessed, testsWritten, coverageBefore, coverageAfter, hasCoverage: true, errors };
393
+ }
394
+ //# sourceMappingURL=loop.js.map