@kakarot-ci/core 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/LICENSE +42 -0
- package/README.md +2 -0
- package/dist/github/client.d.ts +56 -0
- package/dist/github/client.d.ts.map +1 -0
- package/dist/index.cjs +1439 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1382 -0
- package/dist/index.js.map +7 -0
- package/dist/llm/factory.d.ts +10 -0
- package/dist/llm/factory.d.ts.map +1 -0
- package/dist/llm/parser.d.ts +16 -0
- package/dist/llm/parser.d.ts.map +1 -0
- package/dist/llm/prompts/test-fix.d.ts +7 -0
- package/dist/llm/prompts/test-fix.d.ts.map +1 -0
- package/dist/llm/prompts/test-generation.d.ts +7 -0
- package/dist/llm/prompts/test-generation.d.ts.map +1 -0
- package/dist/llm/providers/anthropic.d.ts +10 -0
- package/dist/llm/providers/anthropic.d.ts.map +1 -0
- package/dist/llm/providers/base.d.ts +15 -0
- package/dist/llm/providers/base.d.ts.map +1 -0
- package/dist/llm/providers/google.d.ts +10 -0
- package/dist/llm/providers/google.d.ts.map +1 -0
- package/dist/llm/providers/openai.d.ts +10 -0
- package/dist/llm/providers/openai.d.ts.map +1 -0
- package/dist/llm/test-generator.d.ts +19 -0
- package/dist/llm/test-generator.d.ts.map +1 -0
- package/dist/types/config.d.ts +62 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/diff.d.ts +34 -0
- package/dist/types/diff.d.ts.map +1 -0
- package/dist/types/github.d.ts +70 -0
- package/dist/types/github.d.ts.map +1 -0
- package/dist/types/llm.d.ts +60 -0
- package/dist/types/llm.d.ts.map +1 -0
- package/dist/utils/ast-analyzer.d.ts +8 -0
- package/dist/utils/ast-analyzer.d.ts.map +1 -0
- package/dist/utils/config-loader.d.ts +6 -0
- package/dist/utils/config-loader.d.ts.map +1 -0
- package/dist/utils/diff-parser.d.ts +24 -0
- package/dist/utils/diff-parser.d.ts.map +1 -0
- package/dist/utils/logger.d.ts +9 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/test-target-extractor.d.ts +9 -0
- package/dist/utils/test-target-extractor.d.ts.map +1 -0
- package/package.json +70 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1439 @@
|
|
|
1
|
+
const require = (await import('node:module')).createRequire(import.meta.url);
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __export = (target, all) => {
|
|
10
|
+
for (var name in all)
|
|
11
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
12
|
+
};
|
|
13
|
+
var __copyProps = (to, from, except, desc) => {
|
|
14
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
15
|
+
for (let key of __getOwnPropNames(from))
|
|
16
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
17
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
18
|
+
}
|
|
19
|
+
return to;
|
|
20
|
+
};
|
|
21
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
22
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
23
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
24
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
25
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
26
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
27
|
+
mod
|
|
28
|
+
));
|
|
29
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
30
|
+
|
|
31
|
+
// src/index.ts
|
|
32
|
+
var src_exports = {};
|
|
33
|
+
__export(src_exports, {
|
|
34
|
+
GitHubClient: () => GitHubClient,
|
|
35
|
+
KakarotConfigSchema: () => KakarotConfigSchema,
|
|
36
|
+
TestGenerator: () => TestGenerator,
|
|
37
|
+
analyzeFile: () => analyzeFile,
|
|
38
|
+
buildTestFixPrompt: () => buildTestFixPrompt,
|
|
39
|
+
buildTestGenerationPrompt: () => buildTestGenerationPrompt,
|
|
40
|
+
createLLMProvider: () => createLLMProvider,
|
|
41
|
+
debug: () => debug,
|
|
42
|
+
error: () => error,
|
|
43
|
+
extractTestTargets: () => extractTestTargets,
|
|
44
|
+
getChangedRanges: () => getChangedRanges,
|
|
45
|
+
info: () => info,
|
|
46
|
+
initLogger: () => initLogger,
|
|
47
|
+
loadConfig: () => loadConfig,
|
|
48
|
+
parsePullRequestFiles: () => parsePullRequestFiles,
|
|
49
|
+
parseTestCode: () => parseTestCode,
|
|
50
|
+
progress: () => progress,
|
|
51
|
+
success: () => success,
|
|
52
|
+
validateTestCodeStructure: () => validateTestCodeStructure,
|
|
53
|
+
warn: () => warn
|
|
54
|
+
});
|
|
55
|
+
module.exports = __toCommonJS(src_exports);
|
|
56
|
+
|
|
57
|
+
// src/types/config.ts
|
|
58
|
+
var import_zod = require("zod");
|
|
59
|
+
var KakarotConfigSchema = import_zod.z.object({
|
|
60
|
+
apiKey: import_zod.z.string(),
|
|
61
|
+
githubToken: import_zod.z.string().optional(),
|
|
62
|
+
provider: import_zod.z.enum(["openai", "anthropic", "google"]).optional(),
|
|
63
|
+
model: import_zod.z.string().optional(),
|
|
64
|
+
maxTokens: import_zod.z.number().int().min(1).max(1e5).optional(),
|
|
65
|
+
temperature: import_zod.z.number().min(0).max(2).optional(),
|
|
66
|
+
fixTemperature: import_zod.z.number().min(0).max(2).optional(),
|
|
67
|
+
maxFixAttempts: import_zod.z.number().int().min(0).max(5).default(3),
|
|
68
|
+
testLocation: import_zod.z.enum(["separate", "co-located"]).default("separate"),
|
|
69
|
+
testDirectory: import_zod.z.string().default("__tests__"),
|
|
70
|
+
testFilePattern: import_zod.z.string().default("*.test.ts"),
|
|
71
|
+
includePatterns: import_zod.z.array(import_zod.z.string()).default(["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]),
|
|
72
|
+
excludePatterns: import_zod.z.array(import_zod.z.string()).default(["**/*.test.ts", "**/*.spec.ts", "**/*.test.js", "**/*.spec.js", "**/node_modules/**"]),
|
|
73
|
+
maxTestsPerPR: import_zod.z.number().int().min(1).default(50),
|
|
74
|
+
enableAutoCommit: import_zod.z.boolean().default(true),
|
|
75
|
+
commitStrategy: import_zod.z.enum(["direct", "branch-pr"]).default("direct"),
|
|
76
|
+
enablePRComments: import_zod.z.boolean().default(true),
|
|
77
|
+
debug: import_zod.z.boolean().default(false)
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// src/utils/config-loader.ts
|
|
81
|
+
var import_fs = require("fs");
|
|
82
|
+
var import_path = require("path");
|
|
83
|
+
|
|
84
|
+
// src/utils/logger.ts
|
|
85
|
+
var debugMode = false;
|
|
86
|
+
var jsonMode = false;
|
|
87
|
+
function initLogger(config) {
|
|
88
|
+
debugMode = config.debug ?? process.env.KAKAROT_DEBUG === "true";
|
|
89
|
+
jsonMode = process.env.KAKAROT_OUTPUT === "json";
|
|
90
|
+
}
|
|
91
|
+
function info(message, ...args) {
|
|
92
|
+
if (jsonMode) {
|
|
93
|
+
console.log(JSON.stringify({ level: "info", message, ...args }));
|
|
94
|
+
} else {
|
|
95
|
+
console.log(`[kakarot-ci] ${message}`, ...args);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function debug(message, ...args) {
|
|
99
|
+
if (debugMode) {
|
|
100
|
+
if (jsonMode) {
|
|
101
|
+
console.debug(JSON.stringify({ level: "debug", message, ...args }));
|
|
102
|
+
} else {
|
|
103
|
+
console.debug(`[kakarot-ci:debug] ${message}`, ...args);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function warn(message, ...args) {
|
|
108
|
+
if (jsonMode) {
|
|
109
|
+
console.warn(JSON.stringify({ level: "warn", message, ...args }));
|
|
110
|
+
} else {
|
|
111
|
+
console.warn(`[kakarot-ci] \u26A0 ${message}`, ...args);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function error(message, ...args) {
|
|
115
|
+
if (jsonMode) {
|
|
116
|
+
console.error(JSON.stringify({ level: "error", message, ...args }));
|
|
117
|
+
} else {
|
|
118
|
+
console.error(`[kakarot-ci] \u2717 ${message}`, ...args);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function success(message, ...args) {
|
|
122
|
+
if (jsonMode) {
|
|
123
|
+
console.log(JSON.stringify({ level: "success", message, ...args }));
|
|
124
|
+
} else {
|
|
125
|
+
console.log(`[kakarot-ci] \u2713 ${message}`, ...args);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function progress(step, total, message, ...args) {
|
|
129
|
+
if (jsonMode) {
|
|
130
|
+
console.log(JSON.stringify({ level: "info", step, total, message, ...args }));
|
|
131
|
+
} else {
|
|
132
|
+
console.log(`[kakarot-ci] Step ${step}/${total}: ${message}`, ...args);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/utils/config-loader.ts
|
|
137
|
+
function findProjectRoot(startPath) {
|
|
138
|
+
const start = startPath ?? process.cwd();
|
|
139
|
+
let current = start;
|
|
140
|
+
let previous = null;
|
|
141
|
+
while (current !== previous) {
|
|
142
|
+
if ((0, import_fs.existsSync)((0, import_path.join)(current, "package.json"))) {
|
|
143
|
+
return current;
|
|
144
|
+
}
|
|
145
|
+
previous = current;
|
|
146
|
+
current = (0, import_path.dirname)(current);
|
|
147
|
+
}
|
|
148
|
+
return start;
|
|
149
|
+
}
|
|
150
|
+
async function loadTypeScriptConfig(root) {
|
|
151
|
+
const configPath = (0, import_path.join)(root, "kakarot.config.ts");
|
|
152
|
+
if (!(0, import_fs.existsSync)(configPath)) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const configModule = await import(configPath);
|
|
157
|
+
return configModule.default || configModule.config || null;
|
|
158
|
+
} catch (err) {
|
|
159
|
+
error(`Failed to load kakarot.config.ts: ${err instanceof Error ? err.message : String(err)}`);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async function loadJavaScriptConfig(root) {
|
|
164
|
+
const configPath = (0, import_path.join)(root, ".kakarot-ci.config.js");
|
|
165
|
+
if (!(0, import_fs.existsSync)(configPath)) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const configModule = await import(configPath);
|
|
170
|
+
return configModule.default || configModule.config || null;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
error(`Failed to load .kakarot-ci.config.js: ${err instanceof Error ? err.message : String(err)}`);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function loadJsonConfig(root) {
|
|
177
|
+
const configPath = (0, import_path.join)(root, ".kakarot-ci.config.json");
|
|
178
|
+
if (!(0, import_fs.existsSync)(configPath)) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const content = (0, import_fs.readFileSync)(configPath, "utf-8");
|
|
183
|
+
return JSON.parse(content);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
error(`Failed to load .kakarot-ci.config.json: ${err instanceof Error ? err.message : String(err)}`);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function loadPackageJsonConfig(root) {
|
|
190
|
+
const packagePath = (0, import_path.join)(root, "package.json");
|
|
191
|
+
if (!(0, import_fs.existsSync)(packagePath)) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const content = (0, import_fs.readFileSync)(packagePath, "utf-8");
|
|
196
|
+
const pkg = JSON.parse(content);
|
|
197
|
+
return pkg.kakarotCi || null;
|
|
198
|
+
} catch (err) {
|
|
199
|
+
error(`Failed to load package.json: ${err instanceof Error ? err.message : String(err)}`);
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function mergeEnvConfig(config) {
|
|
204
|
+
const merged = { ...config };
|
|
205
|
+
if (!merged.apiKey && process.env.KAKAROT_API_KEY) {
|
|
206
|
+
merged.apiKey = process.env.KAKAROT_API_KEY;
|
|
207
|
+
}
|
|
208
|
+
if (!merged.githubToken && process.env.GITHUB_TOKEN) {
|
|
209
|
+
merged.githubToken = process.env.GITHUB_TOKEN;
|
|
210
|
+
}
|
|
211
|
+
return merged;
|
|
212
|
+
}
|
|
213
|
+
async function loadConfig() {
|
|
214
|
+
const projectRoot = findProjectRoot();
|
|
215
|
+
let config = null;
|
|
216
|
+
config = await loadTypeScriptConfig(projectRoot);
|
|
217
|
+
if (config) {
|
|
218
|
+
return KakarotConfigSchema.parse(mergeEnvConfig(config));
|
|
219
|
+
}
|
|
220
|
+
config = await loadJavaScriptConfig(projectRoot);
|
|
221
|
+
if (config) {
|
|
222
|
+
return KakarotConfigSchema.parse(mergeEnvConfig(config));
|
|
223
|
+
}
|
|
224
|
+
config = loadJsonConfig(projectRoot);
|
|
225
|
+
if (config) {
|
|
226
|
+
return KakarotConfigSchema.parse(mergeEnvConfig(config));
|
|
227
|
+
}
|
|
228
|
+
config = loadPackageJsonConfig(projectRoot);
|
|
229
|
+
if (config) {
|
|
230
|
+
return KakarotConfigSchema.parse(mergeEnvConfig(config));
|
|
231
|
+
}
|
|
232
|
+
const envConfig = mergeEnvConfig({});
|
|
233
|
+
try {
|
|
234
|
+
return KakarotConfigSchema.parse(envConfig);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
error(
|
|
237
|
+
"Missing required apiKey. Provide it via:\n - Config file (kakarot.config.ts, .kakarot-ci.config.js/json, or package.json)\n - Environment variable: KAKAROT_API_KEY"
|
|
238
|
+
);
|
|
239
|
+
throw err;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// src/github/client.ts
|
|
244
|
+
var import_rest = require("@octokit/rest");
|
|
245
|
+
var GitHubClient = class {
|
|
246
|
+
// 1 second
|
|
247
|
+
constructor(options) {
|
|
248
|
+
this.maxRetries = 3;
|
|
249
|
+
this.retryDelay = 1e3;
|
|
250
|
+
this.owner = options.owner;
|
|
251
|
+
this.repo = options.repo;
|
|
252
|
+
this.octokit = new import_rest.Octokit({
|
|
253
|
+
auth: options.token,
|
|
254
|
+
request: {
|
|
255
|
+
retries: this.maxRetries,
|
|
256
|
+
retryAfter: this.retryDelay / 1e3
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Retry wrapper with exponential backoff
|
|
262
|
+
*/
|
|
263
|
+
async withRetry(fn, operation, retries = this.maxRetries) {
|
|
264
|
+
try {
|
|
265
|
+
return await fn();
|
|
266
|
+
} catch (err) {
|
|
267
|
+
if (retries <= 0) {
|
|
268
|
+
error(`${operation} failed after ${this.maxRetries} retries: ${err instanceof Error ? err.message : String(err)}`);
|
|
269
|
+
throw err;
|
|
270
|
+
}
|
|
271
|
+
const isRateLimit = err instanceof Error && err.message.includes("rate limit");
|
|
272
|
+
const isServerError = err instanceof Error && (err.message.includes("500") || err.message.includes("502") || err.message.includes("503") || err.message.includes("504"));
|
|
273
|
+
if (isRateLimit || isServerError) {
|
|
274
|
+
const delay = this.retryDelay * Math.pow(2, this.maxRetries - retries);
|
|
275
|
+
warn(`${operation} failed, retrying in ${delay}ms... (${retries} retries left)`);
|
|
276
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
277
|
+
return this.withRetry(fn, operation, retries - 1);
|
|
278
|
+
}
|
|
279
|
+
throw err;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Get pull request details
|
|
284
|
+
*/
|
|
285
|
+
async getPullRequest(prNumber) {
|
|
286
|
+
return this.withRetry(async () => {
|
|
287
|
+
debug(`Fetching PR #${prNumber}`);
|
|
288
|
+
const response = await this.octokit.rest.pulls.get({
|
|
289
|
+
owner: this.owner,
|
|
290
|
+
repo: this.repo,
|
|
291
|
+
pull_number: prNumber
|
|
292
|
+
});
|
|
293
|
+
return response.data;
|
|
294
|
+
}, `getPullRequest(${prNumber})`);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* List all files changed in a pull request with patches
|
|
298
|
+
*/
|
|
299
|
+
async listPullRequestFiles(prNumber) {
|
|
300
|
+
return this.withRetry(async () => {
|
|
301
|
+
debug(`Fetching files for PR #${prNumber}`);
|
|
302
|
+
const response = await this.octokit.rest.pulls.listFiles({
|
|
303
|
+
owner: this.owner,
|
|
304
|
+
repo: this.repo,
|
|
305
|
+
pull_number: prNumber
|
|
306
|
+
});
|
|
307
|
+
return response.data.map((file) => ({
|
|
308
|
+
filename: file.filename,
|
|
309
|
+
status: file.status,
|
|
310
|
+
additions: file.additions,
|
|
311
|
+
deletions: file.deletions,
|
|
312
|
+
changes: file.changes,
|
|
313
|
+
patch: file.patch || void 0,
|
|
314
|
+
previous_filename: file.previous_filename || void 0
|
|
315
|
+
}));
|
|
316
|
+
}, `listPullRequestFiles(${prNumber})`);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Get file contents from a specific ref (branch, commit, etc.)
|
|
320
|
+
*/
|
|
321
|
+
async getFileContents(ref, path) {
|
|
322
|
+
return this.withRetry(async () => {
|
|
323
|
+
debug(`Fetching file contents: ${path}@${ref}`);
|
|
324
|
+
const response = await this.octokit.rest.repos.getContent({
|
|
325
|
+
owner: this.owner,
|
|
326
|
+
repo: this.repo,
|
|
327
|
+
path,
|
|
328
|
+
ref
|
|
329
|
+
});
|
|
330
|
+
if (Array.isArray(response.data)) {
|
|
331
|
+
throw new Error(`Expected file but got directory: ${path}`);
|
|
332
|
+
}
|
|
333
|
+
const data = response.data;
|
|
334
|
+
let content;
|
|
335
|
+
if (data.encoding === "base64") {
|
|
336
|
+
content = Buffer.from(data.content, "base64").toString("utf-8");
|
|
337
|
+
} else {
|
|
338
|
+
content = data.content;
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
content,
|
|
342
|
+
encoding: data.encoding,
|
|
343
|
+
sha: data.sha,
|
|
344
|
+
size: data.size
|
|
345
|
+
};
|
|
346
|
+
}, `getFileContents(${ref}, ${path})`);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Commit multiple files in a single commit using Git tree API
|
|
350
|
+
*/
|
|
351
|
+
async commitFiles(options) {
|
|
352
|
+
return this.withRetry(async () => {
|
|
353
|
+
debug(`Committing ${options.files.length} file(s) to branch ${options.branch}`);
|
|
354
|
+
const baseCommit = await this.octokit.rest.repos.getCommit({
|
|
355
|
+
owner: this.owner,
|
|
356
|
+
repo: this.repo,
|
|
357
|
+
ref: options.baseSha
|
|
358
|
+
});
|
|
359
|
+
const baseTreeSha = baseCommit.data.commit.tree.sha;
|
|
360
|
+
const blobPromises = options.files.map(async (file) => {
|
|
361
|
+
const blobResponse = await this.octokit.rest.git.createBlob({
|
|
362
|
+
owner: this.owner,
|
|
363
|
+
repo: this.repo,
|
|
364
|
+
content: Buffer.from(file.content, "utf-8").toString("base64"),
|
|
365
|
+
encoding: "base64"
|
|
366
|
+
});
|
|
367
|
+
return {
|
|
368
|
+
path: file.path,
|
|
369
|
+
sha: blobResponse.data.sha,
|
|
370
|
+
mode: "100644",
|
|
371
|
+
type: "blob"
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
const treeItems = await Promise.all(blobPromises);
|
|
375
|
+
const treeResponse = await this.octokit.rest.git.createTree({
|
|
376
|
+
owner: this.owner,
|
|
377
|
+
repo: this.repo,
|
|
378
|
+
base_tree: baseTreeSha,
|
|
379
|
+
tree: treeItems
|
|
380
|
+
});
|
|
381
|
+
const commitResponse = await this.octokit.rest.git.createCommit({
|
|
382
|
+
owner: this.owner,
|
|
383
|
+
repo: this.repo,
|
|
384
|
+
message: options.message,
|
|
385
|
+
tree: treeResponse.data.sha,
|
|
386
|
+
parents: [options.baseSha]
|
|
387
|
+
});
|
|
388
|
+
await this.octokit.rest.git.updateRef({
|
|
389
|
+
owner: this.owner,
|
|
390
|
+
repo: this.repo,
|
|
391
|
+
ref: `heads/${options.branch}`,
|
|
392
|
+
sha: commitResponse.data.sha
|
|
393
|
+
});
|
|
394
|
+
return commitResponse.data.sha;
|
|
395
|
+
}, `commitFiles(${options.files.length} files)`);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Create a new branch from a base ref
|
|
399
|
+
*/
|
|
400
|
+
async createBranch(branchName, baseRef) {
|
|
401
|
+
return this.withRetry(async () => {
|
|
402
|
+
debug(`Creating branch ${branchName} from ${baseRef}`);
|
|
403
|
+
const baseRefResponse = await this.octokit.rest.git.getRef({
|
|
404
|
+
owner: this.owner,
|
|
405
|
+
repo: this.repo,
|
|
406
|
+
ref: baseRef.startsWith("refs/") ? baseRef : `heads/${baseRef}`
|
|
407
|
+
});
|
|
408
|
+
const baseSha = baseRefResponse.data.object.sha;
|
|
409
|
+
await this.octokit.rest.git.createRef({
|
|
410
|
+
owner: this.owner,
|
|
411
|
+
repo: this.repo,
|
|
412
|
+
ref: `refs/heads/${branchName}`,
|
|
413
|
+
sha: baseSha
|
|
414
|
+
});
|
|
415
|
+
return baseSha;
|
|
416
|
+
}, `createBranch(${branchName})`);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Create a pull request
|
|
420
|
+
*/
|
|
421
|
+
async createPullRequest(title, body, head, base) {
|
|
422
|
+
return this.withRetry(async () => {
|
|
423
|
+
debug(`Creating PR: ${head} -> ${base}`);
|
|
424
|
+
const response = await this.octokit.rest.pulls.create({
|
|
425
|
+
owner: this.owner,
|
|
426
|
+
repo: this.repo,
|
|
427
|
+
title,
|
|
428
|
+
body,
|
|
429
|
+
head,
|
|
430
|
+
base
|
|
431
|
+
});
|
|
432
|
+
return response.data;
|
|
433
|
+
}, `createPullRequest(${head} -> ${base})`);
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Post a comment on a pull request
|
|
437
|
+
*/
|
|
438
|
+
async commentPR(prNumber, body) {
|
|
439
|
+
await this.withRetry(async () => {
|
|
440
|
+
debug(`Posting comment on PR #${prNumber}`);
|
|
441
|
+
await this.octokit.rest.issues.createComment({
|
|
442
|
+
owner: this.owner,
|
|
443
|
+
repo: this.repo,
|
|
444
|
+
issue_number: prNumber,
|
|
445
|
+
body
|
|
446
|
+
});
|
|
447
|
+
}, `commentPR(${prNumber})`);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Check if a file exists in the repository
|
|
451
|
+
*/
|
|
452
|
+
async fileExists(ref, path) {
|
|
453
|
+
return this.withRetry(async () => {
|
|
454
|
+
try {
|
|
455
|
+
await this.octokit.rest.repos.getContent({
|
|
456
|
+
owner: this.owner,
|
|
457
|
+
repo: this.repo,
|
|
458
|
+
path,
|
|
459
|
+
ref
|
|
460
|
+
});
|
|
461
|
+
return true;
|
|
462
|
+
} catch (err) {
|
|
463
|
+
if (err instanceof Error && err.message.includes("404")) {
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
throw err;
|
|
467
|
+
}
|
|
468
|
+
}, `fileExists(${ref}, ${path})`);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Get the current rate limit status
|
|
472
|
+
*/
|
|
473
|
+
async getRateLimit() {
|
|
474
|
+
const response = await this.octokit.rest.rateLimit.get();
|
|
475
|
+
return {
|
|
476
|
+
remaining: response.data.rate.remaining,
|
|
477
|
+
reset: response.data.rate.reset
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
// src/utils/diff-parser.ts
|
|
483
|
+
function parseUnifiedDiff(patch) {
|
|
484
|
+
const hunks = [];
|
|
485
|
+
const lines = patch.split("\n");
|
|
486
|
+
let i = 0;
|
|
487
|
+
while (i < lines.length) {
|
|
488
|
+
const line = lines[i];
|
|
489
|
+
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
490
|
+
if (hunkMatch) {
|
|
491
|
+
const oldStart = parseInt(hunkMatch[1], 10);
|
|
492
|
+
const oldLines = parseInt(hunkMatch[2] || "1", 10);
|
|
493
|
+
const newStart = parseInt(hunkMatch[3], 10);
|
|
494
|
+
const newLines = parseInt(hunkMatch[4] || "1", 10);
|
|
495
|
+
const hunkLines = [];
|
|
496
|
+
i++;
|
|
497
|
+
while (i < lines.length && !lines[i].startsWith("@@")) {
|
|
498
|
+
hunkLines.push(lines[i]);
|
|
499
|
+
i++;
|
|
500
|
+
}
|
|
501
|
+
hunks.push({
|
|
502
|
+
oldStart,
|
|
503
|
+
oldLines,
|
|
504
|
+
newStart,
|
|
505
|
+
newLines,
|
|
506
|
+
lines: hunkLines
|
|
507
|
+
});
|
|
508
|
+
} else {
|
|
509
|
+
i++;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return hunks;
|
|
513
|
+
}
|
|
514
|
+
function hunksToChangedRanges(hunks) {
|
|
515
|
+
const ranges = [];
|
|
516
|
+
for (const hunk of hunks) {
|
|
517
|
+
let oldLine = hunk.oldStart;
|
|
518
|
+
let newLine = hunk.newStart;
|
|
519
|
+
for (const line of hunk.lines) {
|
|
520
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
521
|
+
ranges.push({
|
|
522
|
+
start: newLine,
|
|
523
|
+
end: newLine,
|
|
524
|
+
type: "addition"
|
|
525
|
+
});
|
|
526
|
+
newLine++;
|
|
527
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
528
|
+
ranges.push({
|
|
529
|
+
start: oldLine,
|
|
530
|
+
end: oldLine,
|
|
531
|
+
type: "deletion"
|
|
532
|
+
});
|
|
533
|
+
oldLine++;
|
|
534
|
+
} else if (!line.startsWith("\\")) {
|
|
535
|
+
oldLine++;
|
|
536
|
+
newLine++;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return mergeRanges(ranges);
|
|
541
|
+
}
|
|
542
|
+
function mergeRanges(ranges) {
|
|
543
|
+
if (ranges.length === 0)
|
|
544
|
+
return [];
|
|
545
|
+
const sorted = [...ranges].sort((a, b) => a.start - b.start);
|
|
546
|
+
const merged = [];
|
|
547
|
+
let current = sorted[0];
|
|
548
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
549
|
+
const next = sorted[i];
|
|
550
|
+
if (next.start <= current.end + 2 && next.type === current.type) {
|
|
551
|
+
current = {
|
|
552
|
+
start: current.start,
|
|
553
|
+
end: Math.max(current.end, next.end),
|
|
554
|
+
type: current.type
|
|
555
|
+
};
|
|
556
|
+
} else {
|
|
557
|
+
merged.push(current);
|
|
558
|
+
current = next;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
merged.push(current);
|
|
562
|
+
return merged;
|
|
563
|
+
}
|
|
564
|
+
function parsePullRequestFiles(files) {
|
|
565
|
+
const diffs = [];
|
|
566
|
+
for (const file of files) {
|
|
567
|
+
if (!file.filename.match(/\.(ts|tsx|js|jsx)$/)) {
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
if (!file.patch) {
|
|
571
|
+
diffs.push({
|
|
572
|
+
filename: file.filename,
|
|
573
|
+
status: file.status,
|
|
574
|
+
hunks: [],
|
|
575
|
+
additions: file.additions,
|
|
576
|
+
deletions: file.deletions
|
|
577
|
+
});
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
const hunks = parseUnifiedDiff(file.patch);
|
|
581
|
+
diffs.push({
|
|
582
|
+
filename: file.filename,
|
|
583
|
+
status: file.status,
|
|
584
|
+
hunks,
|
|
585
|
+
additions: file.additions,
|
|
586
|
+
deletions: file.deletions
|
|
587
|
+
});
|
|
588
|
+
debug(`Parsed ${hunks.length} hunk(s) for ${file.filename}`);
|
|
589
|
+
}
|
|
590
|
+
return diffs;
|
|
591
|
+
}
|
|
592
|
+
function getChangedRanges(diff, fileContent) {
|
|
593
|
+
if (diff.status === "added") {
|
|
594
|
+
if (!fileContent) {
|
|
595
|
+
throw new Error("fileContent is required for added files to determine line count");
|
|
596
|
+
}
|
|
597
|
+
const lineCount = fileContent.split("\n").length;
|
|
598
|
+
return [{ start: 1, end: lineCount, type: "addition" }];
|
|
599
|
+
}
|
|
600
|
+
if (diff.status === "removed") {
|
|
601
|
+
return [];
|
|
602
|
+
}
|
|
603
|
+
return hunksToChangedRanges(diff.hunks);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// src/utils/ast-analyzer.ts
|
|
607
|
+
var ts = __toESM(require("typescript"), 1);
|
|
608
|
+
function extractFunctions(sourceFile) {
|
|
609
|
+
const functions = [];
|
|
610
|
+
function visit(node) {
|
|
611
|
+
if (ts.isFunctionDeclaration(node)) {
|
|
612
|
+
const isExported = node.modifiers?.some(
|
|
613
|
+
(m) => m.kind === ts.SyntaxKind.ExportKeyword || m.kind === ts.SyntaxKind.DefaultKeyword
|
|
614
|
+
);
|
|
615
|
+
if (node.name) {
|
|
616
|
+
functions.push({
|
|
617
|
+
name: node.name.text,
|
|
618
|
+
type: "function",
|
|
619
|
+
start: node.getStart(sourceFile),
|
|
620
|
+
end: node.getEnd(),
|
|
621
|
+
node
|
|
622
|
+
});
|
|
623
|
+
} else if (isExported) {
|
|
624
|
+
functions.push({
|
|
625
|
+
name: "default",
|
|
626
|
+
type: "function",
|
|
627
|
+
start: node.getStart(sourceFile),
|
|
628
|
+
end: node.getEnd(),
|
|
629
|
+
node
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (ts.isExportAssignment(node) && node.isExportEquals === false && ts.isFunctionExpression(node.expression)) {
|
|
634
|
+
const func = node.expression;
|
|
635
|
+
const name = func.name ? func.name.text : "default";
|
|
636
|
+
functions.push({
|
|
637
|
+
name,
|
|
638
|
+
type: "function",
|
|
639
|
+
start: node.getStart(sourceFile),
|
|
640
|
+
end: node.getEnd(),
|
|
641
|
+
node
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
if (ts.isMethodDeclaration(node) && node.name && ts.isIdentifier(node.name)) {
|
|
645
|
+
functions.push({
|
|
646
|
+
name: node.name.text,
|
|
647
|
+
type: "class-method",
|
|
648
|
+
start: node.getStart(sourceFile),
|
|
649
|
+
end: node.getEnd(),
|
|
650
|
+
node
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
if (ts.isVariableStatement(node)) {
|
|
654
|
+
for (const declaration of node.declarationList.declarations) {
|
|
655
|
+
if (declaration.initializer) {
|
|
656
|
+
if (ts.isArrowFunction(declaration.initializer)) {
|
|
657
|
+
if (ts.isIdentifier(declaration.name)) {
|
|
658
|
+
functions.push({
|
|
659
|
+
name: declaration.name.text,
|
|
660
|
+
type: "arrow-function",
|
|
661
|
+
start: declaration.getStart(sourceFile),
|
|
662
|
+
end: declaration.getEnd(),
|
|
663
|
+
node: declaration
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
} else if (ts.isFunctionExpression(declaration.initializer)) {
|
|
667
|
+
const funcExpr = declaration.initializer;
|
|
668
|
+
const name = funcExpr.name ? funcExpr.name.text : ts.isIdentifier(declaration.name) ? declaration.name.text : "anonymous";
|
|
669
|
+
if (name !== "anonymous") {
|
|
670
|
+
functions.push({
|
|
671
|
+
name,
|
|
672
|
+
type: "function",
|
|
673
|
+
start: declaration.getStart(sourceFile),
|
|
674
|
+
end: declaration.getEnd(),
|
|
675
|
+
node: declaration
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name)) {
|
|
683
|
+
if (ts.isFunctionExpression(node.initializer) || ts.isArrowFunction(node.initializer)) {
|
|
684
|
+
functions.push({
|
|
685
|
+
name: node.name.text,
|
|
686
|
+
type: "method",
|
|
687
|
+
start: node.getStart(sourceFile),
|
|
688
|
+
end: node.getEnd(),
|
|
689
|
+
node
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
ts.forEachChild(node, visit);
|
|
694
|
+
}
|
|
695
|
+
visit(sourceFile);
|
|
696
|
+
return functions;
|
|
697
|
+
}
|
|
698
|
+
function getLineNumber(source, position) {
|
|
699
|
+
return source.substring(0, position).split("\n").length;
|
|
700
|
+
}
|
|
701
|
+
function functionOverlapsChanges(func, changedRanges, source) {
|
|
702
|
+
const funcStartLine = getLineNumber(source, func.start);
|
|
703
|
+
const funcEndLine = getLineNumber(source, func.end);
|
|
704
|
+
const additionRanges = changedRanges.filter((r) => r.type === "addition");
|
|
705
|
+
for (const range of additionRanges) {
|
|
706
|
+
if (range.start >= funcStartLine && range.start <= funcEndLine || range.end >= funcStartLine && range.end <= funcEndLine || range.start <= funcStartLine && range.end >= funcEndLine) {
|
|
707
|
+
return true;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return false;
|
|
711
|
+
}
|
|
712
|
+
function extractCodeSnippet(source, func) {
|
|
713
|
+
return source.substring(func.start, func.end);
|
|
714
|
+
}
|
|
715
|
+
function extractContext(source, func, allFunctions) {
|
|
716
|
+
const funcStartLine = getLineNumber(source, func.start);
|
|
717
|
+
const funcEndLine = getLineNumber(source, func.end);
|
|
718
|
+
const previousFunc = allFunctions.filter((f) => getLineNumber(source, f.end) < funcStartLine).sort((a, b) => getLineNumber(source, b.end) - getLineNumber(source, a.end))[0];
|
|
719
|
+
const contextStart = previousFunc ? getLineNumber(source, previousFunc.start) : Math.max(1, funcStartLine - 10);
|
|
720
|
+
const lines = source.split("\n");
|
|
721
|
+
const contextLines = lines.slice(contextStart - 1, funcEndLine + 5);
|
|
722
|
+
return contextLines.join("\n");
|
|
723
|
+
}
|
|
724
|
+
async function detectTestFile(filePath, ref, githubClient, testDirectory) {
|
|
725
|
+
const dir = filePath.substring(0, filePath.lastIndexOf("/"));
|
|
726
|
+
const baseName = filePath.substring(filePath.lastIndexOf("/") + 1).replace(/\.(ts|tsx|js|jsx)$/, "");
|
|
727
|
+
let ext;
|
|
728
|
+
if (filePath.endsWith(".tsx"))
|
|
729
|
+
ext = "tsx";
|
|
730
|
+
else if (filePath.endsWith(".jsx"))
|
|
731
|
+
ext = "jsx";
|
|
732
|
+
else if (filePath.endsWith(".ts"))
|
|
733
|
+
ext = "ts";
|
|
734
|
+
else
|
|
735
|
+
ext = "js";
|
|
736
|
+
const testPatterns = ext === "tsx" ? [`.test.tsx`, `.spec.tsx`, `.test.ts`, `.spec.ts`] : ext === "jsx" ? [`.test.jsx`, `.spec.jsx`, `.test.js`, `.spec.js`] : ext === "ts" ? [`.test.ts`, `.spec.ts`] : [`.test.js`, `.spec.js`];
|
|
737
|
+
const locations = [
|
|
738
|
+
// Co-located in same directory
|
|
739
|
+
...testPatterns.map((pattern) => `${dir}/${baseName}${pattern}`),
|
|
740
|
+
// Co-located __tests__ directory
|
|
741
|
+
...testPatterns.map((pattern) => `${dir}/__tests__/${baseName}${pattern}`),
|
|
742
|
+
// Test directory at root
|
|
743
|
+
...testPatterns.map((pattern) => `${testDirectory}/${baseName}${pattern}`),
|
|
744
|
+
// Nested test directory matching source structure
|
|
745
|
+
...testPatterns.map((pattern) => `${testDirectory}${dir}/${baseName}${pattern}`),
|
|
746
|
+
// __tests__ at root
|
|
747
|
+
...testPatterns.map((pattern) => `__tests__/${baseName}${pattern}`)
|
|
748
|
+
];
|
|
749
|
+
for (const testPath of locations) {
|
|
750
|
+
const exists = await githubClient.fileExists(ref, testPath);
|
|
751
|
+
if (exists) {
|
|
752
|
+
return testPath;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return void 0;
|
|
756
|
+
}
|
|
757
|
+
async function analyzeFile(filePath, content, changedRanges, ref, githubClient, testDirectory) {
|
|
758
|
+
const sourceFile = ts.createSourceFile(
|
|
759
|
+
filePath,
|
|
760
|
+
content,
|
|
761
|
+
ts.ScriptTarget.Latest,
|
|
762
|
+
true
|
|
763
|
+
);
|
|
764
|
+
const functions = extractFunctions(sourceFile);
|
|
765
|
+
const existingTestFile = await detectTestFile(filePath, ref, githubClient, testDirectory);
|
|
766
|
+
const targets = [];
|
|
767
|
+
for (const func of functions) {
|
|
768
|
+
if (functionOverlapsChanges(func, changedRanges, content)) {
|
|
769
|
+
const startLine = getLineNumber(content, func.start);
|
|
770
|
+
const endLine = getLineNumber(content, func.end);
|
|
771
|
+
targets.push({
|
|
772
|
+
filePath,
|
|
773
|
+
functionName: func.name,
|
|
774
|
+
functionType: func.type,
|
|
775
|
+
startLine,
|
|
776
|
+
endLine,
|
|
777
|
+
code: extractCodeSnippet(content, func),
|
|
778
|
+
context: extractContext(content, func, functions),
|
|
779
|
+
existingTestFile,
|
|
780
|
+
changedRanges: changedRanges.filter(
|
|
781
|
+
(r) => r.start >= startLine && r.end <= endLine
|
|
782
|
+
)
|
|
783
|
+
});
|
|
784
|
+
debug(`Found test target: ${func.name} (${func.type}) in ${filePath}${existingTestFile ? ` - existing test: ${existingTestFile}` : ""}`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return targets;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// src/utils/test-target-extractor.ts
|
|
791
|
+
async function extractTestTargets(files, githubClient, prHeadRef, config) {
|
|
792
|
+
info(`Analyzing ${files.length} file(s) for test targets`);
|
|
793
|
+
const diffs = parsePullRequestFiles(files);
|
|
794
|
+
const filteredDiffs = diffs.filter((diff) => {
|
|
795
|
+
const matchesInclude = config.includePatterns.some((pattern) => {
|
|
796
|
+
const regex = new RegExp(pattern.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*"));
|
|
797
|
+
return regex.test(diff.filename);
|
|
798
|
+
});
|
|
799
|
+
if (!matchesInclude)
|
|
800
|
+
return false;
|
|
801
|
+
const matchesExclude = config.excludePatterns.some((pattern) => {
|
|
802
|
+
const regex = new RegExp(pattern.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*"));
|
|
803
|
+
return regex.test(diff.filename);
|
|
804
|
+
});
|
|
805
|
+
return !matchesExclude;
|
|
806
|
+
});
|
|
807
|
+
debug(`Filtered to ${filteredDiffs.length} file(s) after pattern matching`);
|
|
808
|
+
const targets = [];
|
|
809
|
+
for (const diff of filteredDiffs) {
|
|
810
|
+
if (diff.status === "removed") {
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
try {
|
|
814
|
+
const fileContents = await githubClient.getFileContents(prHeadRef, diff.filename);
|
|
815
|
+
const changedRanges = getChangedRanges(diff, fileContents.content);
|
|
816
|
+
if (changedRanges.length === 0) {
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
const ranges = changedRanges.map((r) => ({
|
|
820
|
+
start: r.start,
|
|
821
|
+
end: r.end,
|
|
822
|
+
type: r.type
|
|
823
|
+
}));
|
|
824
|
+
const fileTargets = await analyzeFile(
|
|
825
|
+
diff.filename,
|
|
826
|
+
fileContents.content,
|
|
827
|
+
ranges,
|
|
828
|
+
prHeadRef,
|
|
829
|
+
githubClient,
|
|
830
|
+
config.testDirectory
|
|
831
|
+
);
|
|
832
|
+
targets.push(...fileTargets);
|
|
833
|
+
if (fileTargets.length > 0) {
|
|
834
|
+
info(`Found ${fileTargets.length} test target(s) in ${diff.filename}`);
|
|
835
|
+
}
|
|
836
|
+
} catch (error2) {
|
|
837
|
+
debug(`Failed to analyze ${diff.filename}: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
info(`Extracted ${targets.length} total test target(s)`);
|
|
841
|
+
return targets;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// src/llm/providers/base.ts
|
|
845
|
+
var BaseLLMProvider = class {
|
|
846
|
+
constructor(apiKey, model, defaultOptions) {
|
|
847
|
+
this.apiKey = apiKey;
|
|
848
|
+
this.model = model;
|
|
849
|
+
this.defaultOptions = {
|
|
850
|
+
temperature: defaultOptions?.temperature ?? 0.2,
|
|
851
|
+
maxTokens: defaultOptions?.maxTokens ?? 4e3,
|
|
852
|
+
stopSequences: defaultOptions?.stopSequences ?? []
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
mergeOptions(options) {
|
|
856
|
+
return {
|
|
857
|
+
temperature: options?.temperature ?? this.defaultOptions.temperature,
|
|
858
|
+
maxTokens: options?.maxTokens ?? this.defaultOptions.maxTokens,
|
|
859
|
+
stopSequences: options?.stopSequences ?? this.defaultOptions.stopSequences
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
validateApiKey() {
|
|
863
|
+
if (!this.apiKey || this.apiKey.trim().length === 0) {
|
|
864
|
+
error("LLM API key is required but not provided");
|
|
865
|
+
throw new Error("LLM API key is required");
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
logUsage(usage, operation) {
|
|
869
|
+
if (usage) {
|
|
870
|
+
debug(
|
|
871
|
+
`${operation} usage: ${usage.totalTokens ?? "unknown"} tokens (prompt: ${usage.promptTokens ?? "unknown"}, completion: ${usage.completionTokens ?? "unknown"})`
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
// src/llm/providers/openai.ts
|
|
878
|
+
var OpenAIProvider = class extends BaseLLMProvider {
|
|
879
|
+
constructor() {
|
|
880
|
+
super(...arguments);
|
|
881
|
+
this.baseUrl = "https://api.openai.com/v1";
|
|
882
|
+
}
|
|
883
|
+
async generate(messages, options) {
|
|
884
|
+
this.validateApiKey();
|
|
885
|
+
const mergedOptions = this.mergeOptions(options);
|
|
886
|
+
const requestBody = {
|
|
887
|
+
model: this.model,
|
|
888
|
+
messages: messages.map((msg) => ({
|
|
889
|
+
role: msg.role,
|
|
890
|
+
content: msg.content
|
|
891
|
+
})),
|
|
892
|
+
temperature: mergedOptions.temperature,
|
|
893
|
+
max_tokens: mergedOptions.maxTokens,
|
|
894
|
+
...mergedOptions.stopSequences.length > 0 && { stop: mergedOptions.stopSequences }
|
|
895
|
+
};
|
|
896
|
+
debug(`Calling OpenAI API with model: ${this.model}`);
|
|
897
|
+
try {
|
|
898
|
+
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
|
899
|
+
method: "POST",
|
|
900
|
+
headers: {
|
|
901
|
+
"Content-Type": "application/json",
|
|
902
|
+
Authorization: `Bearer ${this.apiKey}`
|
|
903
|
+
},
|
|
904
|
+
body: JSON.stringify(requestBody)
|
|
905
|
+
});
|
|
906
|
+
if (!response.ok) {
|
|
907
|
+
const errorText = await response.text();
|
|
908
|
+
error(`OpenAI API error: ${response.status} ${response.statusText} - ${errorText}`);
|
|
909
|
+
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`);
|
|
910
|
+
}
|
|
911
|
+
const data = await response.json();
|
|
912
|
+
if (!data.choices || data.choices.length === 0) {
|
|
913
|
+
error("OpenAI API returned no choices");
|
|
914
|
+
throw new Error("OpenAI API returned no choices");
|
|
915
|
+
}
|
|
916
|
+
const content = data.choices[0]?.message?.content ?? "";
|
|
917
|
+
const usage = data.usage ? {
|
|
918
|
+
promptTokens: data.usage.prompt_tokens,
|
|
919
|
+
completionTokens: data.usage.completion_tokens,
|
|
920
|
+
totalTokens: data.usage.total_tokens
|
|
921
|
+
} : void 0;
|
|
922
|
+
this.logUsage(usage, "OpenAI");
|
|
923
|
+
return {
|
|
924
|
+
content,
|
|
925
|
+
usage
|
|
926
|
+
};
|
|
927
|
+
} catch (err) {
|
|
928
|
+
if (err instanceof Error) {
|
|
929
|
+
error(`OpenAI API request failed: ${err.message}`);
|
|
930
|
+
throw err;
|
|
931
|
+
}
|
|
932
|
+
throw new Error("Unknown error calling OpenAI API");
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
// src/llm/providers/anthropic.ts
|
|
938
|
+
var AnthropicProvider = class extends BaseLLMProvider {
|
|
939
|
+
constructor() {
|
|
940
|
+
super(...arguments);
|
|
941
|
+
this.baseUrl = "https://api.anthropic.com/v1";
|
|
942
|
+
}
|
|
943
|
+
async generate(messages, options) {
|
|
944
|
+
this.validateApiKey();
|
|
945
|
+
const mergedOptions = this.mergeOptions(options);
|
|
946
|
+
const systemMessage = messages.find((m) => m.role === "system")?.content ?? "";
|
|
947
|
+
const conversationMessages = messages.filter((m) => m.role !== "system");
|
|
948
|
+
const requestBody = {
|
|
949
|
+
model: this.model,
|
|
950
|
+
max_tokens: mergedOptions.maxTokens,
|
|
951
|
+
temperature: mergedOptions.temperature,
|
|
952
|
+
messages: conversationMessages.map((msg) => ({
|
|
953
|
+
role: msg.role === "assistant" ? "assistant" : "user",
|
|
954
|
+
content: msg.content
|
|
955
|
+
})),
|
|
956
|
+
...systemMessage && { system: systemMessage },
|
|
957
|
+
...mergedOptions.stopSequences.length > 0 && { stop_sequences: mergedOptions.stopSequences }
|
|
958
|
+
};
|
|
959
|
+
debug(`Calling Anthropic API with model: ${this.model}`);
|
|
960
|
+
try {
|
|
961
|
+
const response = await fetch(`${this.baseUrl}/messages`, {
|
|
962
|
+
method: "POST",
|
|
963
|
+
headers: {
|
|
964
|
+
"Content-Type": "application/json",
|
|
965
|
+
"x-api-key": this.apiKey,
|
|
966
|
+
"anthropic-version": "2023-06-01"
|
|
967
|
+
},
|
|
968
|
+
body: JSON.stringify(requestBody)
|
|
969
|
+
});
|
|
970
|
+
if (!response.ok) {
|
|
971
|
+
const errorText = await response.text();
|
|
972
|
+
error(`Anthropic API error: ${response.status} ${response.statusText} - ${errorText}`);
|
|
973
|
+
throw new Error(`Anthropic API error: ${response.status} ${response.statusText}`);
|
|
974
|
+
}
|
|
975
|
+
const data = await response.json();
|
|
976
|
+
if (!data.content || data.content.length === 0) {
|
|
977
|
+
error("Anthropic API returned no content");
|
|
978
|
+
throw new Error("Anthropic API returned no content");
|
|
979
|
+
}
|
|
980
|
+
const content = data.content.map((c) => c.text).join("\n");
|
|
981
|
+
const usage = data.usage ? {
|
|
982
|
+
promptTokens: data.usage.input_tokens,
|
|
983
|
+
completionTokens: data.usage.output_tokens,
|
|
984
|
+
totalTokens: data.usage.input_tokens + data.usage.output_tokens
|
|
985
|
+
} : void 0;
|
|
986
|
+
this.logUsage(usage, "Anthropic");
|
|
987
|
+
return {
|
|
988
|
+
content,
|
|
989
|
+
usage
|
|
990
|
+
};
|
|
991
|
+
} catch (err) {
|
|
992
|
+
if (err instanceof Error) {
|
|
993
|
+
error(`Anthropic API request failed: ${err.message}`);
|
|
994
|
+
throw err;
|
|
995
|
+
}
|
|
996
|
+
throw new Error("Unknown error calling Anthropic API");
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
// src/llm/providers/google.ts
|
|
1002
|
+
var GoogleProvider = class extends BaseLLMProvider {
|
|
1003
|
+
constructor() {
|
|
1004
|
+
super(...arguments);
|
|
1005
|
+
this.baseUrl = "https://generativelanguage.googleapis.com/v1beta";
|
|
1006
|
+
}
|
|
1007
|
+
async generate(messages, options) {
|
|
1008
|
+
this.validateApiKey();
|
|
1009
|
+
const mergedOptions = this.mergeOptions(options);
|
|
1010
|
+
const systemInstruction = messages.find((m) => m.role === "system")?.content;
|
|
1011
|
+
const conversationMessages = messages.filter((m) => m.role !== "system");
|
|
1012
|
+
const contents = conversationMessages.map((msg) => ({
|
|
1013
|
+
role: msg.role === "assistant" ? "model" : "user",
|
|
1014
|
+
parts: [{ text: msg.content }]
|
|
1015
|
+
}));
|
|
1016
|
+
const generationConfig = {
|
|
1017
|
+
temperature: mergedOptions.temperature,
|
|
1018
|
+
maxOutputTokens: mergedOptions.maxTokens,
|
|
1019
|
+
...mergedOptions.stopSequences.length > 0 && { stopSequences: mergedOptions.stopSequences }
|
|
1020
|
+
};
|
|
1021
|
+
const requestBody = {
|
|
1022
|
+
contents,
|
|
1023
|
+
generationConfig,
|
|
1024
|
+
...systemInstruction && { systemInstruction: { parts: [{ text: systemInstruction }] } }
|
|
1025
|
+
};
|
|
1026
|
+
debug(`Calling Google API with model: ${this.model}`);
|
|
1027
|
+
try {
|
|
1028
|
+
const response = await fetch(`${this.baseUrl}/${this.model}:generateContent?key=${this.apiKey}`, {
|
|
1029
|
+
method: "POST",
|
|
1030
|
+
headers: {
|
|
1031
|
+
"Content-Type": "application/json"
|
|
1032
|
+
},
|
|
1033
|
+
body: JSON.stringify(requestBody)
|
|
1034
|
+
});
|
|
1035
|
+
if (!response.ok) {
|
|
1036
|
+
const errorText = await response.text();
|
|
1037
|
+
error(`Google API error: ${response.status} ${response.statusText} - ${errorText}`);
|
|
1038
|
+
throw new Error(`Google API error: ${response.status} ${response.statusText}`);
|
|
1039
|
+
}
|
|
1040
|
+
const data = await response.json();
|
|
1041
|
+
if (!data.candidates || data.candidates.length === 0) {
|
|
1042
|
+
error("Google API returned no candidates");
|
|
1043
|
+
throw new Error("Google API returned no candidates");
|
|
1044
|
+
}
|
|
1045
|
+
const content = data.candidates[0]?.content?.parts?.map((p) => p.text).join("\n") ?? "";
|
|
1046
|
+
const usage = data.usageMetadata ? {
|
|
1047
|
+
promptTokens: data.usageMetadata.promptTokenCount,
|
|
1048
|
+
completionTokens: data.usageMetadata.candidatesTokenCount,
|
|
1049
|
+
totalTokens: data.usageMetadata.totalTokenCount
|
|
1050
|
+
} : void 0;
|
|
1051
|
+
this.logUsage(usage, "Google");
|
|
1052
|
+
return {
|
|
1053
|
+
content,
|
|
1054
|
+
usage
|
|
1055
|
+
};
|
|
1056
|
+
} catch (err) {
|
|
1057
|
+
if (err instanceof Error) {
|
|
1058
|
+
error(`Google API request failed: ${err.message}`);
|
|
1059
|
+
throw err;
|
|
1060
|
+
}
|
|
1061
|
+
throw new Error("Unknown error calling Google API");
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
// src/llm/factory.ts
|
|
1067
|
+
function createLLMProvider(config) {
|
|
1068
|
+
const provider = config.provider ?? "openai";
|
|
1069
|
+
const model = config.model ?? getDefaultModel(provider);
|
|
1070
|
+
const defaultOptions = config.maxTokens ? { maxTokens: config.maxTokens } : void 0;
|
|
1071
|
+
switch (provider) {
|
|
1072
|
+
case "openai":
|
|
1073
|
+
return new OpenAIProvider(config.apiKey, model, defaultOptions);
|
|
1074
|
+
case "anthropic":
|
|
1075
|
+
return new AnthropicProvider(config.apiKey, model, defaultOptions);
|
|
1076
|
+
case "google":
|
|
1077
|
+
return new GoogleProvider(config.apiKey, model, defaultOptions);
|
|
1078
|
+
default:
|
|
1079
|
+
error(`Unknown LLM provider: ${provider}`);
|
|
1080
|
+
throw new Error(`Unknown LLM provider: ${provider}`);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
function getDefaultModel(provider) {
|
|
1084
|
+
switch (provider) {
|
|
1085
|
+
case "openai":
|
|
1086
|
+
return "gpt-4-turbo-preview";
|
|
1087
|
+
case "anthropic":
|
|
1088
|
+
return "claude-3-5-sonnet-20241022";
|
|
1089
|
+
case "google":
|
|
1090
|
+
return "gemini-1.5-pro";
|
|
1091
|
+
default:
|
|
1092
|
+
return "gpt-4-turbo-preview";
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// src/llm/prompts/test-generation.ts
|
|
1097
|
+
function buildTestGenerationPrompt(context) {
|
|
1098
|
+
const { target, framework, existingTestFile, relatedFunctions } = context;
|
|
1099
|
+
const systemPrompt = buildSystemPrompt(framework);
|
|
1100
|
+
const userPrompt = buildUserPrompt(target, framework, existingTestFile, relatedFunctions);
|
|
1101
|
+
return [
|
|
1102
|
+
{ role: "system", content: systemPrompt },
|
|
1103
|
+
{ role: "user", content: userPrompt }
|
|
1104
|
+
];
|
|
1105
|
+
}
|
|
1106
|
+
function buildSystemPrompt(framework) {
|
|
1107
|
+
const frameworkName = framework === "jest" ? "Jest" : "Vitest";
|
|
1108
|
+
const importStatement = framework === "jest" ? "import { describe, it, expect } from 'jest';" : "import { describe, it, expect } from 'vitest';";
|
|
1109
|
+
return `You are an expert ${frameworkName} test writer. Your task is to generate comprehensive unit tests for TypeScript/JavaScript functions.
|
|
1110
|
+
|
|
1111
|
+
Requirements:
|
|
1112
|
+
1. Generate complete, runnable ${frameworkName} test code
|
|
1113
|
+
2. Use ${frameworkName} syntax and best practices
|
|
1114
|
+
3. Test edge cases, error conditions, and normal operation
|
|
1115
|
+
4. Use descriptive test names that explain what is being tested
|
|
1116
|
+
5. Include proper setup/teardown if needed
|
|
1117
|
+
6. Mock external dependencies appropriately
|
|
1118
|
+
7. Test both success and failure scenarios
|
|
1119
|
+
8. Follow the existing test file structure if one exists
|
|
1120
|
+
|
|
1121
|
+
Output format:
|
|
1122
|
+
- Return ONLY the test code, no explanations or markdown code blocks
|
|
1123
|
+
- The code should be ready to run in a ${frameworkName} environment
|
|
1124
|
+
- Include necessary imports at the top
|
|
1125
|
+
- Use proper TypeScript types if the source code uses TypeScript
|
|
1126
|
+
|
|
1127
|
+
${frameworkName} example structure:
|
|
1128
|
+
${importStatement}
|
|
1129
|
+
|
|
1130
|
+
describe('FunctionName', () => {
|
|
1131
|
+
it('should handle normal case', () => {
|
|
1132
|
+
// test implementation
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
it('should handle edge case', () => {
|
|
1136
|
+
// test implementation
|
|
1137
|
+
});
|
|
1138
|
+
});`;
|
|
1139
|
+
}
|
|
1140
|
+
function buildUserPrompt(target, framework, existingTestFile, relatedFunctions) {
|
|
1141
|
+
let prompt = `Generate ${framework} unit tests for the following function:
|
|
1142
|
+
|
|
1143
|
+
`;
|
|
1144
|
+
prompt += `File: ${target.filePath}
|
|
1145
|
+
`;
|
|
1146
|
+
prompt += `Function: ${target.functionName}
|
|
1147
|
+
`;
|
|
1148
|
+
prompt += `Type: ${target.functionType}
|
|
1149
|
+
|
|
1150
|
+
`;
|
|
1151
|
+
prompt += `Function code:
|
|
1152
|
+
\`\`\`typescript
|
|
1153
|
+
${target.code}
|
|
1154
|
+
\`\`\`
|
|
1155
|
+
|
|
1156
|
+
`;
|
|
1157
|
+
if (target.context) {
|
|
1158
|
+
prompt += `Context (surrounding code):
|
|
1159
|
+
\`\`\`typescript
|
|
1160
|
+
${target.context}
|
|
1161
|
+
\`\`\`
|
|
1162
|
+
|
|
1163
|
+
`;
|
|
1164
|
+
}
|
|
1165
|
+
if (relatedFunctions && relatedFunctions.length > 0) {
|
|
1166
|
+
prompt += `Related functions (for context):
|
|
1167
|
+
`;
|
|
1168
|
+
relatedFunctions.forEach((fn) => {
|
|
1169
|
+
prompt += `
|
|
1170
|
+
${fn.name}:
|
|
1171
|
+
\`\`\`typescript
|
|
1172
|
+
${fn.code}
|
|
1173
|
+
\`\`\`
|
|
1174
|
+
`;
|
|
1175
|
+
});
|
|
1176
|
+
prompt += "\n";
|
|
1177
|
+
}
|
|
1178
|
+
if (existingTestFile) {
|
|
1179
|
+
prompt += `Existing test file structure (follow this pattern):
|
|
1180
|
+
\`\`\`typescript
|
|
1181
|
+
${existingTestFile}
|
|
1182
|
+
\`\`\`
|
|
1183
|
+
|
|
1184
|
+
`;
|
|
1185
|
+
prompt += `Note: Add new tests to this file, maintaining the existing structure and style.
|
|
1186
|
+
|
|
1187
|
+
`;
|
|
1188
|
+
}
|
|
1189
|
+
prompt += `Generate comprehensive unit tests for ${target.functionName}. Include:
|
|
1190
|
+
`;
|
|
1191
|
+
prompt += `- Tests for normal operation with various inputs
|
|
1192
|
+
`;
|
|
1193
|
+
prompt += `- Tests for edge cases (null, undefined, empty arrays, etc.)
|
|
1194
|
+
`;
|
|
1195
|
+
prompt += `- Tests for error conditions if applicable
|
|
1196
|
+
`;
|
|
1197
|
+
prompt += `- Tests for boundary conditions
|
|
1198
|
+
`;
|
|
1199
|
+
prompt += `- Proper mocking of dependencies if needed
|
|
1200
|
+
|
|
1201
|
+
`;
|
|
1202
|
+
prompt += `Return ONLY the test code, no explanations or markdown formatting.`;
|
|
1203
|
+
return prompt;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// src/llm/prompts/test-fix.ts
|
|
1207
|
+
function buildTestFixPrompt(context) {
|
|
1208
|
+
const { testCode, errorMessage, testOutput, originalCode, framework, attempt, maxAttempts } = context;
|
|
1209
|
+
const systemPrompt = buildSystemPrompt2(framework, attempt, maxAttempts);
|
|
1210
|
+
const userPrompt = buildUserPrompt2(testCode, errorMessage, testOutput, originalCode, framework, attempt);
|
|
1211
|
+
return [
|
|
1212
|
+
{ role: "system", content: systemPrompt },
|
|
1213
|
+
{ role: "user", content: userPrompt }
|
|
1214
|
+
];
|
|
1215
|
+
}
|
|
1216
|
+
function buildSystemPrompt2(framework, attempt, maxAttempts) {
|
|
1217
|
+
const frameworkName = framework === "jest" ? "Jest" : "Vitest";
|
|
1218
|
+
return `You are an expert ${frameworkName} test debugger. Your task is to fix failing unit tests.
|
|
1219
|
+
|
|
1220
|
+
Context:
|
|
1221
|
+
- This is fix attempt ${attempt} of ${maxAttempts}
|
|
1222
|
+
- The test code failed to run or produced incorrect results
|
|
1223
|
+
- You need to analyze the error and fix the test code
|
|
1224
|
+
|
|
1225
|
+
Requirements:
|
|
1226
|
+
1. Fix the test code to make it pass
|
|
1227
|
+
2. Maintain the original test intent
|
|
1228
|
+
3. Use proper ${frameworkName} syntax
|
|
1229
|
+
4. Ensure all imports and dependencies are correct
|
|
1230
|
+
5. Fix any syntax errors, type errors, or logical errors
|
|
1231
|
+
6. If the original code being tested has issues, note that but focus on fixing the test
|
|
1232
|
+
|
|
1233
|
+
Output format:
|
|
1234
|
+
- Return ONLY the fixed test code, no explanations or markdown code blocks
|
|
1235
|
+
- The code should be complete and runnable
|
|
1236
|
+
- Include all necessary imports`;
|
|
1237
|
+
}
|
|
1238
|
+
function buildUserPrompt2(testCode, errorMessage, testOutput, originalCode, framework, attempt) {
|
|
1239
|
+
let prompt = `The following ${framework} test is failing. Fix it:
|
|
1240
|
+
|
|
1241
|
+
`;
|
|
1242
|
+
prompt += `Original function code:
|
|
1243
|
+
\`\`\`typescript
|
|
1244
|
+
${originalCode}
|
|
1245
|
+
\`\`\`
|
|
1246
|
+
|
|
1247
|
+
`;
|
|
1248
|
+
prompt += `Failing test code:
|
|
1249
|
+
\`\`\`typescript
|
|
1250
|
+
${testCode}
|
|
1251
|
+
\`\`\`
|
|
1252
|
+
|
|
1253
|
+
`;
|
|
1254
|
+
prompt += `Error message:
|
|
1255
|
+
\`\`\`
|
|
1256
|
+
${errorMessage}
|
|
1257
|
+
\`\`\`
|
|
1258
|
+
|
|
1259
|
+
`;
|
|
1260
|
+
if (testOutput) {
|
|
1261
|
+
prompt += `Test output:
|
|
1262
|
+
\`\`\`
|
|
1263
|
+
${testOutput}
|
|
1264
|
+
\`\`\`
|
|
1265
|
+
|
|
1266
|
+
`;
|
|
1267
|
+
}
|
|
1268
|
+
if (attempt > 1) {
|
|
1269
|
+
prompt += `Note: This is fix attempt ${attempt}. Previous attempts failed. Please analyze the error more carefully.
|
|
1270
|
+
|
|
1271
|
+
`;
|
|
1272
|
+
}
|
|
1273
|
+
prompt += `Fix the test code to resolve the error. Return ONLY the corrected test code, no explanations.`;
|
|
1274
|
+
return prompt;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// src/llm/parser.ts
|
|
1278
|
+
function parseTestCode(response) {
|
|
1279
|
+
let code = response.trim();
|
|
1280
|
+
const codeBlockRegex = /^```(?:typescript|ts|javascript|js)?\s*\n([\s\S]*?)\n```$/;
|
|
1281
|
+
const match = code.match(codeBlockRegex);
|
|
1282
|
+
if (match) {
|
|
1283
|
+
code = match[1].trim();
|
|
1284
|
+
} else {
|
|
1285
|
+
const inlineCodeRegex = /```([\s\S]*?)```/g;
|
|
1286
|
+
const inlineMatches = Array.from(code.matchAll(inlineCodeRegex));
|
|
1287
|
+
if (inlineMatches.length > 0) {
|
|
1288
|
+
code = inlineMatches.reduce((largest, match2) => {
|
|
1289
|
+
return match2[1].length > largest.length ? match2[1] : largest;
|
|
1290
|
+
}, "");
|
|
1291
|
+
code = code.trim();
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
const explanationPatterns = [
|
|
1295
|
+
/^Here'?s?\s+(?:the\s+)?(?:test\s+)?code:?\s*/i,
|
|
1296
|
+
/^Test\s+code:?\s*/i,
|
|
1297
|
+
/^Generated\s+test:?\s*/i,
|
|
1298
|
+
/^Here\s+is\s+the\s+test:?\s*/i
|
|
1299
|
+
];
|
|
1300
|
+
for (const pattern of explanationPatterns) {
|
|
1301
|
+
if (pattern.test(code)) {
|
|
1302
|
+
code = code.replace(pattern, "").trim();
|
|
1303
|
+
const codeBlockMatch = code.match(/```[\s\S]*?```/);
|
|
1304
|
+
if (codeBlockMatch) {
|
|
1305
|
+
code = codeBlockMatch[0];
|
|
1306
|
+
code = code.replace(/^```(?:typescript|ts|javascript|js)?\s*\n?/, "").replace(/\n?```$/, "").trim();
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
code = code.replace(/^```[\w]*\n?/, "").replace(/\n?```$/, "").trim();
|
|
1311
|
+
if (!code) {
|
|
1312
|
+
warn("Failed to extract test code from LLM response");
|
|
1313
|
+
return response;
|
|
1314
|
+
}
|
|
1315
|
+
return code;
|
|
1316
|
+
}
|
|
1317
|
+
function validateTestCodeStructure(code, framework) {
|
|
1318
|
+
const errors = [];
|
|
1319
|
+
if (!code.includes("describe") && !code.includes("it(") && !code.includes("test(")) {
|
|
1320
|
+
errors.push("Missing test structure (describe/it/test)");
|
|
1321
|
+
}
|
|
1322
|
+
if (framework === "jest") {
|
|
1323
|
+
if (!code.includes("from 'jest'") && !code.includes('from "jest"') && !code.includes("require(")) {
|
|
1324
|
+
if (!code.includes("describe") && !code.includes("it") && !code.includes("test")) {
|
|
1325
|
+
errors.push("Missing Jest test functions");
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
} else if (framework === "vitest") {
|
|
1329
|
+
if (!code.includes("from 'vitest'") && !code.includes('from "vitest"')) {
|
|
1330
|
+
errors.push("Missing Vitest import");
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
if (code.trim().length < 20) {
|
|
1334
|
+
errors.push("Test code appears too short or empty");
|
|
1335
|
+
}
|
|
1336
|
+
if (!code.match(/(describe|it|test)\s*\(/)) {
|
|
1337
|
+
errors.push("Missing test function calls (describe/it/test)");
|
|
1338
|
+
}
|
|
1339
|
+
return {
|
|
1340
|
+
valid: errors.length === 0,
|
|
1341
|
+
errors
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// src/llm/test-generator.ts
|
|
1346
|
+
var TestGenerator = class {
|
|
1347
|
+
constructor(config) {
|
|
1348
|
+
this.provider = createLLMProvider(config);
|
|
1349
|
+
this.config = {
|
|
1350
|
+
maxFixAttempts: config.maxFixAttempts,
|
|
1351
|
+
temperature: config.temperature,
|
|
1352
|
+
fixTemperature: config.fixTemperature
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Generate test code for a test target
|
|
1357
|
+
*/
|
|
1358
|
+
async generateTest(context) {
|
|
1359
|
+
const { target, framework } = context;
|
|
1360
|
+
info(`Generating ${framework} tests for ${target.functionName} in ${target.filePath}`);
|
|
1361
|
+
try {
|
|
1362
|
+
const messages = buildTestGenerationPrompt(context);
|
|
1363
|
+
debug(`Sending test generation request to LLM for ${target.functionName}`);
|
|
1364
|
+
const response = await this.provider.generate(messages, {
|
|
1365
|
+
temperature: this.config.temperature ?? 0.2,
|
|
1366
|
+
// Lower temperature for more consistent test generation
|
|
1367
|
+
maxTokens: 4e3
|
|
1368
|
+
});
|
|
1369
|
+
const testCode = parseTestCode(response.content);
|
|
1370
|
+
const validation = validateTestCodeStructure(testCode, framework);
|
|
1371
|
+
if (!validation.valid) {
|
|
1372
|
+
warn(`Test code validation warnings for ${target.functionName}: ${validation.errors.join(", ")}`);
|
|
1373
|
+
}
|
|
1374
|
+
debug(`Successfully generated test code for ${target.functionName}`);
|
|
1375
|
+
return {
|
|
1376
|
+
testCode,
|
|
1377
|
+
explanation: response.content !== testCode ? "Code extracted from LLM response" : void 0,
|
|
1378
|
+
usage: response.usage
|
|
1379
|
+
};
|
|
1380
|
+
} catch (err) {
|
|
1381
|
+
error(`Failed to generate test for ${target.functionName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1382
|
+
throw err;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Fix a failing test by generating a corrected version
|
|
1387
|
+
*/
|
|
1388
|
+
async fixTest(context) {
|
|
1389
|
+
const { framework, attempt } = context;
|
|
1390
|
+
info(`Fixing test (attempt ${attempt}/${this.config.maxFixAttempts})`);
|
|
1391
|
+
try {
|
|
1392
|
+
const messages = buildTestFixPrompt(context);
|
|
1393
|
+
debug(`Sending test fix request to LLM (attempt ${attempt})`);
|
|
1394
|
+
const response = await this.provider.generate(messages, {
|
|
1395
|
+
temperature: this.config.fixTemperature ?? 0.1,
|
|
1396
|
+
// Very low temperature for fix attempts
|
|
1397
|
+
maxTokens: 4e3
|
|
1398
|
+
});
|
|
1399
|
+
const fixedCode = parseTestCode(response.content);
|
|
1400
|
+
const validation = validateTestCodeStructure(fixedCode, framework);
|
|
1401
|
+
if (!validation.valid) {
|
|
1402
|
+
warn(`Fixed test code validation warnings: ${validation.errors.join(", ")}`);
|
|
1403
|
+
}
|
|
1404
|
+
debug(`Successfully generated fixed test code (attempt ${attempt})`);
|
|
1405
|
+
return {
|
|
1406
|
+
testCode: fixedCode,
|
|
1407
|
+
explanation: `Fixed test code (attempt ${attempt})`,
|
|
1408
|
+
usage: response.usage
|
|
1409
|
+
};
|
|
1410
|
+
} catch (err) {
|
|
1411
|
+
error(`Failed to fix test (attempt ${attempt}): ${err instanceof Error ? err.message : String(err)}`);
|
|
1412
|
+
throw err;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
};
|
|
1416
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1417
|
+
0 && (module.exports = {
|
|
1418
|
+
GitHubClient,
|
|
1419
|
+
KakarotConfigSchema,
|
|
1420
|
+
TestGenerator,
|
|
1421
|
+
analyzeFile,
|
|
1422
|
+
buildTestFixPrompt,
|
|
1423
|
+
buildTestGenerationPrompt,
|
|
1424
|
+
createLLMProvider,
|
|
1425
|
+
debug,
|
|
1426
|
+
error,
|
|
1427
|
+
extractTestTargets,
|
|
1428
|
+
getChangedRanges,
|
|
1429
|
+
info,
|
|
1430
|
+
initLogger,
|
|
1431
|
+
loadConfig,
|
|
1432
|
+
parsePullRequestFiles,
|
|
1433
|
+
parseTestCode,
|
|
1434
|
+
progress,
|
|
1435
|
+
success,
|
|
1436
|
+
validateTestCodeStructure,
|
|
1437
|
+
warn
|
|
1438
|
+
});
|
|
1439
|
+
//# sourceMappingURL=index.cjs.map
|