qai-cli 3.0.0 → 3.1.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/package.json +1 -1
- package/src/generate.js +495 -0
- package/src/index.js +147 -2
- package/src/providers/anthropic.js +35 -0
- package/src/providers/base.js +128 -0
- package/src/providers/gemini.js +17 -0
- package/src/providers/ollama.js +45 -0
- package/src/providers/openai.js +27 -0
- package/src/review.js +370 -0
package/package.json
CHANGED
package/src/generate.js
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test Generation Engine
|
|
3
|
+
*
|
|
4
|
+
* Two modes:
|
|
5
|
+
* 1. URL crawl: Navigate a site, record interactions, generate Playwright E2E specs
|
|
6
|
+
* 2. Code analysis: Read source files, generate unit/integration tests
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* qai generate https://mysite.com # E2E tests from URL
|
|
10
|
+
* qai generate src/billing.ts # Unit tests from source
|
|
11
|
+
* qai generate src/ --pattern "*.service*" # Batch generate
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { getProvider } = require('./providers');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Generate tests from a URL (E2E) or source file (unit)
|
|
20
|
+
*
|
|
21
|
+
* @param {Object} options
|
|
22
|
+
* @param {string} options.target - URL or file/directory path
|
|
23
|
+
* @param {string} [options.outDir] - Output directory for generated tests (default: ./tests/generated)
|
|
24
|
+
* @param {string} [options.framework] - Test framework (playwright, jest, vitest)
|
|
25
|
+
* @param {string} [options.pattern] - Glob pattern for batch file mode
|
|
26
|
+
* @param {string} [options.baseUrl] - Base URL for generated E2E tests
|
|
27
|
+
* @param {boolean} [options.dryRun] - Print tests to stdout instead of writing files
|
|
28
|
+
* @returns {Promise<GenerateResult>}
|
|
29
|
+
*/
|
|
30
|
+
async function generateTests(options = {}) {
|
|
31
|
+
const {
|
|
32
|
+
target,
|
|
33
|
+
outDir = './tests/generated',
|
|
34
|
+
framework = 'playwright',
|
|
35
|
+
dryRun = false,
|
|
36
|
+
} = options;
|
|
37
|
+
|
|
38
|
+
if (!target) {
|
|
39
|
+
throw new Error('Target is required (URL or file path)');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Determine mode: URL or file
|
|
43
|
+
const isUrl = target.startsWith('http://') || target.startsWith('https://');
|
|
44
|
+
|
|
45
|
+
if (isUrl) {
|
|
46
|
+
return generateE2ETests({ ...options, url: target, outDir, framework, dryRun });
|
|
47
|
+
} else {
|
|
48
|
+
return generateUnitTests({ ...options, filePath: target, outDir, framework, dryRun });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generate E2E tests by crawling a URL
|
|
54
|
+
*/
|
|
55
|
+
async function generateE2ETests(options) {
|
|
56
|
+
const { url, outDir, framework, dryRun } = options;
|
|
57
|
+
|
|
58
|
+
console.log('[1/3] Crawling site...');
|
|
59
|
+
const siteData = await crawlSite(url);
|
|
60
|
+
|
|
61
|
+
console.log(
|
|
62
|
+
` Found ${siteData.pages.length} pages, ${siteData.interactions.length} interactive elements`,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
console.log('[2/3] Generating tests with AI...');
|
|
66
|
+
const provider = getProvider();
|
|
67
|
+
const prompt = buildE2EPrompt(siteData, framework);
|
|
68
|
+
const result = await provider.generateTests(prompt);
|
|
69
|
+
|
|
70
|
+
console.log('[3/3] Writing test files...');
|
|
71
|
+
const files = parseGeneratedFiles(result);
|
|
72
|
+
|
|
73
|
+
if (dryRun) {
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
console.log(`\n--- ${file.name} ---`);
|
|
76
|
+
console.log(file.content);
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
writeTestFiles(files, outDir);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
mode: 'e2e',
|
|
84
|
+
url,
|
|
85
|
+
pagesFound: siteData.pages.length,
|
|
86
|
+
testsGenerated: files.length,
|
|
87
|
+
files: files.map((f) => f.name),
|
|
88
|
+
outDir,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Generate unit tests from source file(s)
|
|
94
|
+
*/
|
|
95
|
+
async function generateUnitTests(options) {
|
|
96
|
+
const { filePath, outDir, framework, dryRun, pattern } = options;
|
|
97
|
+
|
|
98
|
+
console.log('[1/3] Reading source files...');
|
|
99
|
+
const sources = readSourceFiles(filePath, pattern);
|
|
100
|
+
console.log(` Found ${sources.length} source files`);
|
|
101
|
+
|
|
102
|
+
if (sources.length === 0) {
|
|
103
|
+
throw new Error(`No source files found at: ${filePath}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const allFiles = [];
|
|
107
|
+
|
|
108
|
+
for (const source of sources) {
|
|
109
|
+
console.log(`[2/3] Generating tests for ${source.relativePath}...`);
|
|
110
|
+
const provider = getProvider();
|
|
111
|
+
const prompt = buildUnitTestPrompt(source, framework);
|
|
112
|
+
const result = await provider.generateTests(prompt);
|
|
113
|
+
const files = parseGeneratedFiles(result);
|
|
114
|
+
|
|
115
|
+
allFiles.push(...files);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
console.log('[3/3] Writing test files...');
|
|
119
|
+
if (dryRun) {
|
|
120
|
+
for (const file of allFiles) {
|
|
121
|
+
console.log(`\n--- ${file.name} ---`);
|
|
122
|
+
console.log(file.content);
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
writeTestFiles(allFiles, outDir);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
mode: 'unit',
|
|
130
|
+
sourcesAnalyzed: sources.length,
|
|
131
|
+
testsGenerated: allFiles.length,
|
|
132
|
+
files: allFiles.map((f) => f.name),
|
|
133
|
+
outDir,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Crawl a site and gather page data using Playwright
|
|
139
|
+
*/
|
|
140
|
+
async function crawlSite(url) {
|
|
141
|
+
let playwright;
|
|
142
|
+
try {
|
|
143
|
+
playwright = require('playwright');
|
|
144
|
+
} catch {
|
|
145
|
+
throw new Error(
|
|
146
|
+
'Playwright is required for E2E test generation. Install it: npm install playwright',
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const browser = await playwright.chromium.launch({ headless: true });
|
|
151
|
+
const context = await browser.newContext();
|
|
152
|
+
const page = await context.newPage();
|
|
153
|
+
|
|
154
|
+
const pages = [];
|
|
155
|
+
const interactions = [];
|
|
156
|
+
const visited = new Set();
|
|
157
|
+
const baseUrl = new URL(url);
|
|
158
|
+
const toVisit = [url];
|
|
159
|
+
|
|
160
|
+
// Crawl up to 10 pages
|
|
161
|
+
while (toVisit.length > 0 && visited.size < 10) {
|
|
162
|
+
const currentUrl = toVisit.shift();
|
|
163
|
+
if (visited.has(currentUrl)) continue;
|
|
164
|
+
visited.add(currentUrl);
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
await page.goto(currentUrl, { waitUntil: 'networkidle', timeout: 15000 });
|
|
168
|
+
await page.waitForTimeout(1000);
|
|
169
|
+
|
|
170
|
+
const title = await page.title();
|
|
171
|
+
const pageUrl = page.url();
|
|
172
|
+
|
|
173
|
+
// Gather interactive elements
|
|
174
|
+
/* eslint-disable no-undef */
|
|
175
|
+
const elements = await page.evaluate(() => {
|
|
176
|
+
const result = [];
|
|
177
|
+
|
|
178
|
+
// Buttons
|
|
179
|
+
document.querySelectorAll('button, [role="button"]').forEach((el) => {
|
|
180
|
+
result.push({
|
|
181
|
+
type: 'button',
|
|
182
|
+
text: el.textContent.trim().slice(0, 100),
|
|
183
|
+
selector: getSelector(el),
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Links
|
|
188
|
+
document.querySelectorAll('a[href]').forEach((el) => {
|
|
189
|
+
result.push({
|
|
190
|
+
type: 'link',
|
|
191
|
+
text: el.textContent.trim().slice(0, 100),
|
|
192
|
+
href: el.href,
|
|
193
|
+
selector: getSelector(el),
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Forms
|
|
198
|
+
document.querySelectorAll('form').forEach((form) => {
|
|
199
|
+
const inputs = [];
|
|
200
|
+
form.querySelectorAll('input, textarea, select').forEach((input) => {
|
|
201
|
+
inputs.push({
|
|
202
|
+
type: input.type || input.tagName.toLowerCase(),
|
|
203
|
+
name: input.name,
|
|
204
|
+
placeholder: input.placeholder,
|
|
205
|
+
required: input.required,
|
|
206
|
+
selector: getSelector(input),
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
result.push({
|
|
210
|
+
type: 'form',
|
|
211
|
+
action: form.action,
|
|
212
|
+
method: form.method,
|
|
213
|
+
inputs,
|
|
214
|
+
selector: getSelector(form),
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// Navigation elements
|
|
219
|
+
document.querySelectorAll('nav a, [role="navigation"] a').forEach((el) => {
|
|
220
|
+
result.push({
|
|
221
|
+
type: 'nav-link',
|
|
222
|
+
text: el.textContent.trim().slice(0, 100),
|
|
223
|
+
href: el.href,
|
|
224
|
+
selector: getSelector(el),
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
function getSelector(el) {
|
|
229
|
+
if (el.id) return `#${el.id}`;
|
|
230
|
+
if (el.getAttribute('data-testid')) {
|
|
231
|
+
return `[data-testid="${el.getAttribute('data-testid')}"]`;
|
|
232
|
+
}
|
|
233
|
+
if (el.getAttribute('aria-label')) {
|
|
234
|
+
return `[aria-label="${el.getAttribute('aria-label')}"]`;
|
|
235
|
+
}
|
|
236
|
+
const text = el.textContent.trim().slice(0, 30);
|
|
237
|
+
if (text && el.tagName) {
|
|
238
|
+
return `${el.tagName.toLowerCase()}:has-text("${text}")`;
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return result;
|
|
244
|
+
});
|
|
245
|
+
/* eslint-enable no-undef */
|
|
246
|
+
|
|
247
|
+
pages.push({ url: pageUrl, title, elementCount: elements.length });
|
|
248
|
+
interactions.push(...elements.map((e) => ({ ...e, page: pageUrl })));
|
|
249
|
+
|
|
250
|
+
// Find same-origin links to crawl
|
|
251
|
+
const links = elements
|
|
252
|
+
.filter((e) => e.type === 'link' || e.type === 'nav-link')
|
|
253
|
+
.filter((e) => {
|
|
254
|
+
try {
|
|
255
|
+
const linkUrl = new URL(e.href);
|
|
256
|
+
return linkUrl.origin === baseUrl.origin;
|
|
257
|
+
} catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
.map((e) => e.href);
|
|
262
|
+
|
|
263
|
+
for (const link of links) {
|
|
264
|
+
if (!visited.has(link)) {
|
|
265
|
+
toVisit.push(link);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
} catch {
|
|
269
|
+
// Skip pages that fail to load
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
await browser.close();
|
|
274
|
+
|
|
275
|
+
return { url, pages, interactions };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Read source file(s) for unit test generation
|
|
280
|
+
*/
|
|
281
|
+
function readSourceFiles(filePath, pattern) {
|
|
282
|
+
const sources = [];
|
|
283
|
+
const absPath = path.resolve(filePath);
|
|
284
|
+
|
|
285
|
+
if (fs.existsSync(absPath) && fs.statSync(absPath).isFile()) {
|
|
286
|
+
// Single file
|
|
287
|
+
sources.push({
|
|
288
|
+
relativePath: filePath,
|
|
289
|
+
content: fs.readFileSync(absPath, 'utf-8'),
|
|
290
|
+
ext: path.extname(filePath),
|
|
291
|
+
});
|
|
292
|
+
} else if (fs.existsSync(absPath) && fs.statSync(absPath).isDirectory()) {
|
|
293
|
+
// Directory - find source files
|
|
294
|
+
const exts = ['.js', '.ts', '.jsx', '.tsx', '.mjs'];
|
|
295
|
+
const skipDirs = ['node_modules', '.next', 'dist', '.git', '__tests__', 'test', 'tests'];
|
|
296
|
+
|
|
297
|
+
const walk = (dir) => {
|
|
298
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
299
|
+
for (const entry of entries) {
|
|
300
|
+
const fullPath = path.join(dir, entry.name);
|
|
301
|
+
if (entry.isDirectory()) {
|
|
302
|
+
if (!skipDirs.includes(entry.name)) walk(fullPath);
|
|
303
|
+
} else if (entry.isFile()) {
|
|
304
|
+
const ext = path.extname(entry.name);
|
|
305
|
+
if (!exts.includes(ext)) continue;
|
|
306
|
+
// Skip test files
|
|
307
|
+
if (entry.name.includes('.test.') || entry.name.includes('.spec.')) continue;
|
|
308
|
+
// Apply pattern filter if specified
|
|
309
|
+
if (pattern && !entry.name.match(new RegExp(pattern.replace(/\*/g, '.*')))) continue;
|
|
310
|
+
|
|
311
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
312
|
+
// Skip very large or very small files
|
|
313
|
+
if (content.length > 30000 || content.length < 50) continue;
|
|
314
|
+
|
|
315
|
+
sources.push({
|
|
316
|
+
relativePath: path.relative(process.cwd(), fullPath),
|
|
317
|
+
content,
|
|
318
|
+
ext,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
walk(absPath);
|
|
325
|
+
// Cap at 10 files
|
|
326
|
+
sources.splice(10);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return sources;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Build prompt for E2E test generation
|
|
334
|
+
*/
|
|
335
|
+
function buildE2EPrompt(siteData, framework) {
|
|
336
|
+
const frameworkGuide = E2E_FRAMEWORKS[framework] || E2E_FRAMEWORKS.playwright;
|
|
337
|
+
|
|
338
|
+
return `You are a senior QA automation engineer. Generate comprehensive E2E tests for this website.
|
|
339
|
+
|
|
340
|
+
## Site Information
|
|
341
|
+
- URL: ${siteData.url}
|
|
342
|
+
- Pages found: ${siteData.pages.length}
|
|
343
|
+
|
|
344
|
+
## Pages
|
|
345
|
+
${siteData.pages.map((p) => `- ${p.url} (${p.title}) - ${p.elementCount} elements`).join('\n')}
|
|
346
|
+
|
|
347
|
+
## Interactive Elements
|
|
348
|
+
${JSON.stringify(siteData.interactions.slice(0, 100), null, 2)}
|
|
349
|
+
|
|
350
|
+
## Framework
|
|
351
|
+
${frameworkGuide}
|
|
352
|
+
|
|
353
|
+
## Instructions
|
|
354
|
+
Generate test files that cover:
|
|
355
|
+
1. Page navigation (all discovered pages load correctly)
|
|
356
|
+
2. Interactive elements (buttons click, forms submit)
|
|
357
|
+
3. Navigation flow (links work, nav elements route correctly)
|
|
358
|
+
4. Form validation (required fields, error states)
|
|
359
|
+
5. Responsive behavior (test at mobile and desktop viewports)
|
|
360
|
+
|
|
361
|
+
Output format - return ONLY a JSON array of files:
|
|
362
|
+
[
|
|
363
|
+
{
|
|
364
|
+
"name": "homepage.spec.ts",
|
|
365
|
+
"content": "// full test file content here"
|
|
366
|
+
}
|
|
367
|
+
]
|
|
368
|
+
|
|
369
|
+
Write real, runnable tests. Use descriptive test names. Add meaningful assertions.
|
|
370
|
+
Do NOT generate placeholder or skeleton tests.
|
|
371
|
+
Respond with ONLY the JSON array, no markdown code blocks.`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Build prompt for unit test generation
|
|
376
|
+
*/
|
|
377
|
+
function buildUnitTestPrompt(source, framework) {
|
|
378
|
+
const frameworkGuide = UNIT_FRAMEWORKS[framework] || UNIT_FRAMEWORKS.jest;
|
|
379
|
+
|
|
380
|
+
return `You are a senior QA automation engineer. Generate comprehensive unit tests for this source file.
|
|
381
|
+
|
|
382
|
+
## Source File: ${source.relativePath}
|
|
383
|
+
\`\`\`${source.ext.replace('.', '')}
|
|
384
|
+
${source.content}
|
|
385
|
+
\`\`\`
|
|
386
|
+
|
|
387
|
+
## Framework
|
|
388
|
+
${frameworkGuide}
|
|
389
|
+
|
|
390
|
+
## Instructions
|
|
391
|
+
Generate thorough unit tests that cover:
|
|
392
|
+
1. All exported functions/classes
|
|
393
|
+
2. Happy path for each function
|
|
394
|
+
3. Edge cases (null, undefined, empty, boundary values)
|
|
395
|
+
4. Error cases (invalid input, thrown errors)
|
|
396
|
+
5. Any async behavior (resolved/rejected promises)
|
|
397
|
+
|
|
398
|
+
Output format - return ONLY a JSON array of files:
|
|
399
|
+
[
|
|
400
|
+
{
|
|
401
|
+
"name": "${getTestFileName(source.relativePath, framework)}",
|
|
402
|
+
"content": "// full test file content here"
|
|
403
|
+
}
|
|
404
|
+
]
|
|
405
|
+
|
|
406
|
+
Write real, runnable tests with meaningful assertions.
|
|
407
|
+
Mock external dependencies where appropriate.
|
|
408
|
+
Do NOT generate placeholder or skeleton tests.
|
|
409
|
+
Respond with ONLY the JSON array, no markdown code blocks.`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Parse LLM response into file objects
|
|
414
|
+
*/
|
|
415
|
+
function parseGeneratedFiles(response) {
|
|
416
|
+
try {
|
|
417
|
+
let text = typeof response === 'string' ? response : response.raw || '';
|
|
418
|
+
|
|
419
|
+
// Remove markdown code blocks
|
|
420
|
+
if (text.startsWith('```')) {
|
|
421
|
+
text = text.replace(/```json?\n?/g, '').replace(/```\n?$/g, '');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const parsed = JSON.parse(text);
|
|
425
|
+
if (Array.isArray(parsed)) return parsed;
|
|
426
|
+
if (parsed.files) return parsed.files;
|
|
427
|
+
return [parsed];
|
|
428
|
+
} catch {
|
|
429
|
+
// If we can't parse JSON, treat entire response as a single test file
|
|
430
|
+
const text = typeof response === 'string' ? response : response.raw || '';
|
|
431
|
+
return [{ name: 'generated.spec.ts', content: text }];
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Write test files to disk
|
|
437
|
+
*/
|
|
438
|
+
function writeTestFiles(files, outDir) {
|
|
439
|
+
const absOutDir = path.resolve(outDir);
|
|
440
|
+
if (!fs.existsSync(absOutDir)) {
|
|
441
|
+
fs.mkdirSync(absOutDir, { recursive: true });
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
for (const file of files) {
|
|
445
|
+
const filePath = path.join(absOutDir, file.name);
|
|
446
|
+
fs.writeFileSync(filePath, file.content);
|
|
447
|
+
console.log(` Written: ${filePath}`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Get test file name from source file path
|
|
453
|
+
*/
|
|
454
|
+
function getTestFileName(sourcePath, _framework) {
|
|
455
|
+
const ext = path.extname(sourcePath);
|
|
456
|
+
const base = path.basename(sourcePath, ext);
|
|
457
|
+
const testExt = ext === '.ts' || ext === '.tsx' ? '.test.ts' : '.test.js';
|
|
458
|
+
return `${base}${testExt}`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const E2E_FRAMEWORKS = {
|
|
462
|
+
playwright: `Use @playwright/test:
|
|
463
|
+
- import { test, expect } from '@playwright/test'
|
|
464
|
+
- Use page.goto(), page.click(), page.fill(), page.locator()
|
|
465
|
+
- Use expect(page).toHaveTitle(), expect(locator).toBeVisible()
|
|
466
|
+
- Use test.describe() for grouping
|
|
467
|
+
- Use page.setViewportSize() for responsive tests`,
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const UNIT_FRAMEWORKS = {
|
|
471
|
+
jest: `Use Jest:
|
|
472
|
+
- describe/it/expect syntax
|
|
473
|
+
- jest.fn() for mocks
|
|
474
|
+
- beforeEach/afterEach for setup
|
|
475
|
+
- Use .toEqual, .toBe, .toThrow, .toBeNull etc.`,
|
|
476
|
+
vitest: `Use Vitest:
|
|
477
|
+
- import { describe, it, expect, vi } from 'vitest'
|
|
478
|
+
- vi.fn() for mocks
|
|
479
|
+
- Same assertion API as Jest`,
|
|
480
|
+
playwright: `Use @playwright/test:
|
|
481
|
+
- import { test, expect } from '@playwright/test'
|
|
482
|
+
- Same assertion API but for component/integration tests`,
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
module.exports = {
|
|
486
|
+
generateTests,
|
|
487
|
+
generateE2ETests,
|
|
488
|
+
generateUnitTests,
|
|
489
|
+
crawlSite,
|
|
490
|
+
readSourceFiles,
|
|
491
|
+
buildE2EPrompt,
|
|
492
|
+
buildUnitTestPrompt,
|
|
493
|
+
parseGeneratedFiles,
|
|
494
|
+
writeTestFiles,
|
|
495
|
+
};
|
package/src/index.js
CHANGED
|
@@ -3,6 +3,153 @@
|
|
|
3
3
|
const fs = require('fs').promises;
|
|
4
4
|
const { capturePage } = require('./capture');
|
|
5
5
|
const { getProvider } = require('./providers');
|
|
6
|
+
const { reviewPR, formatReviewMarkdown } = require('./review');
|
|
7
|
+
const { generateTests } = require('./generate');
|
|
8
|
+
|
|
9
|
+
// Route to the right command
|
|
10
|
+
const command = process.argv[2];
|
|
11
|
+
|
|
12
|
+
if (command === 'review') {
|
|
13
|
+
runReview().catch((err) => {
|
|
14
|
+
console.error('\nError:', err.message);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
});
|
|
17
|
+
} else if (command === 'generate') {
|
|
18
|
+
runGenerate().catch((err) => {
|
|
19
|
+
console.error('\nError:', err.message);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
});
|
|
22
|
+
} else {
|
|
23
|
+
main();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Run PR review command
|
|
28
|
+
* Usage: qai review [PR_NUMBER] [--base main] [--focus security] [--json]
|
|
29
|
+
*/
|
|
30
|
+
async function runReview() {
|
|
31
|
+
const args = process.argv.slice(3);
|
|
32
|
+
const options = {};
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < args.length; i++) {
|
|
35
|
+
if (args[i] === '--base' && args[i + 1]) {
|
|
36
|
+
options.base = args[++i];
|
|
37
|
+
} else if (args[i] === '--focus' && args[i + 1]) {
|
|
38
|
+
options.focus = args[++i];
|
|
39
|
+
} else if (args[i] === '--json') {
|
|
40
|
+
options.json = true;
|
|
41
|
+
} else if (/^\d+$/.test(args[i])) {
|
|
42
|
+
options.pr = parseInt(args[i], 10);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.log('='.repeat(60));
|
|
47
|
+
console.log('qai review');
|
|
48
|
+
console.log('='.repeat(60));
|
|
49
|
+
if (options.pr) {
|
|
50
|
+
console.log(`PR: #${options.pr}`);
|
|
51
|
+
} else {
|
|
52
|
+
console.log(`Comparing: HEAD vs ${options.base || 'main'}`);
|
|
53
|
+
}
|
|
54
|
+
console.log(`Focus: ${options.focus || 'all'}`);
|
|
55
|
+
console.log('='.repeat(60));
|
|
56
|
+
|
|
57
|
+
const startTime = Date.now();
|
|
58
|
+
const report = await reviewPR(options);
|
|
59
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
60
|
+
|
|
61
|
+
if (options.json) {
|
|
62
|
+
console.log(JSON.stringify(report, null, 2));
|
|
63
|
+
} else {
|
|
64
|
+
const markdown = formatReviewMarkdown(report);
|
|
65
|
+
await fs.writeFile('review-report.md', markdown);
|
|
66
|
+
console.log('\nSaved: review-report.md');
|
|
67
|
+
|
|
68
|
+
// Print summary
|
|
69
|
+
console.log('\n' + '='.repeat(60));
|
|
70
|
+
console.log('Review Summary');
|
|
71
|
+
console.log('='.repeat(60));
|
|
72
|
+
console.log(`Score: ${report.score !== null ? report.score + '/100' : 'N/A'}`);
|
|
73
|
+
console.log(`Issues: ${report.issues?.length || 0}`);
|
|
74
|
+
if (report.issues?.length > 0) {
|
|
75
|
+
const critical = report.issues.filter((i) => i.severity === 'critical').length;
|
|
76
|
+
const high = report.issues.filter((i) => i.severity === 'high').length;
|
|
77
|
+
const medium = report.issues.filter((i) => i.severity === 'medium').length;
|
|
78
|
+
const low = report.issues.filter((i) => i.severity === 'low').length;
|
|
79
|
+
if (critical) console.log(` Critical: ${critical}`);
|
|
80
|
+
if (high) console.log(` High: ${high}`);
|
|
81
|
+
if (medium) console.log(` Medium: ${medium}`);
|
|
82
|
+
if (low) console.log(` Low: ${low}`);
|
|
83
|
+
}
|
|
84
|
+
console.log(`Duration: ${duration}s`);
|
|
85
|
+
console.log('='.repeat(60));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Exit with error if critical issues found
|
|
89
|
+
const criticals = report.issues?.filter((i) => i.severity === 'critical').length || 0;
|
|
90
|
+
if (criticals > 0) {
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Run test generation command
|
|
97
|
+
* Usage: qai generate <url|file> [--out dir] [--framework playwright|jest|vitest] [--dry-run]
|
|
98
|
+
*/
|
|
99
|
+
async function runGenerate() {
|
|
100
|
+
const args = process.argv.slice(3);
|
|
101
|
+
const options = {};
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < args.length; i++) {
|
|
104
|
+
if (args[i] === '--out' && args[i + 1]) {
|
|
105
|
+
options.outDir = args[++i];
|
|
106
|
+
} else if (args[i] === '--framework' && args[i + 1]) {
|
|
107
|
+
options.framework = args[++i];
|
|
108
|
+
} else if (args[i] === '--pattern' && args[i + 1]) {
|
|
109
|
+
options.pattern = args[++i];
|
|
110
|
+
} else if (args[i] === '--dry-run') {
|
|
111
|
+
options.dryRun = true;
|
|
112
|
+
} else if (args[i] === '--json') {
|
|
113
|
+
options.json = true;
|
|
114
|
+
} else if (!args[i].startsWith('--')) {
|
|
115
|
+
options.target = args[i];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!options.target) {
|
|
120
|
+
console.error(
|
|
121
|
+
'Usage: qai generate <url|file> [--out dir] [--framework playwright|jest|vitest] [--dry-run]',
|
|
122
|
+
);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log('='.repeat(60));
|
|
127
|
+
console.log('qai generate');
|
|
128
|
+
console.log('='.repeat(60));
|
|
129
|
+
console.log(`Target: ${options.target}`);
|
|
130
|
+
console.log(`Framework: ${options.framework || 'auto'}`);
|
|
131
|
+
console.log(
|
|
132
|
+
`Output: ${options.dryRun ? 'stdout (dry run)' : options.outDir || './tests/generated'}`,
|
|
133
|
+
);
|
|
134
|
+
console.log('='.repeat(60));
|
|
135
|
+
|
|
136
|
+
const startTime = Date.now();
|
|
137
|
+
const result = await generateTests(options);
|
|
138
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
139
|
+
|
|
140
|
+
if (options.json) {
|
|
141
|
+
console.log(JSON.stringify(result, null, 2));
|
|
142
|
+
} else {
|
|
143
|
+
console.log('\n' + '='.repeat(60));
|
|
144
|
+
console.log('Generation Summary');
|
|
145
|
+
console.log('='.repeat(60));
|
|
146
|
+
console.log(`Mode: ${result.mode}`);
|
|
147
|
+
console.log(`Tests generated: ${result.testsGenerated}`);
|
|
148
|
+
console.log(`Files: ${result.files.join(', ')}`);
|
|
149
|
+
console.log(`Duration: ${duration}s`);
|
|
150
|
+
console.log('='.repeat(60));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
6
153
|
|
|
7
154
|
async function main() {
|
|
8
155
|
const startTime = Date.now();
|
|
@@ -200,5 +347,3 @@ function generateMarkdownReport(report) {
|
|
|
200
347
|
|
|
201
348
|
return lines.join('\n');
|
|
202
349
|
}
|
|
203
|
-
|
|
204
|
-
main();
|
|
@@ -54,6 +54,41 @@ class AnthropicProvider extends BaseProvider {
|
|
|
54
54
|
|
|
55
55
|
return this.parseResponse(responseText);
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
async generateTests(prompt) {
|
|
59
|
+
const response = await this.client.messages.create({
|
|
60
|
+
model: this.model,
|
|
61
|
+
max_tokens: 8192,
|
|
62
|
+
messages: [{ role: 'user', content: prompt }],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return response.content
|
|
66
|
+
.filter((block) => block.type === 'text')
|
|
67
|
+
.map((block) => block.text)
|
|
68
|
+
.join('\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async reviewCode(diff, context, options = {}) {
|
|
72
|
+
const prompt = this.buildReviewPrompt(diff, context, options);
|
|
73
|
+
|
|
74
|
+
const response = await this.client.messages.create({
|
|
75
|
+
model: this.model,
|
|
76
|
+
max_tokens: 8192,
|
|
77
|
+
messages: [
|
|
78
|
+
{
|
|
79
|
+
role: 'user',
|
|
80
|
+
content: prompt,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const responseText = response.content
|
|
86
|
+
.filter((block) => block.type === 'text')
|
|
87
|
+
.map((block) => block.text)
|
|
88
|
+
.join('\n');
|
|
89
|
+
|
|
90
|
+
return this.parseResponse(responseText);
|
|
91
|
+
}
|
|
57
92
|
}
|
|
58
93
|
|
|
59
94
|
module.exports = AnthropicProvider;
|
package/src/providers/base.js
CHANGED
|
@@ -18,6 +18,28 @@ class BaseProvider {
|
|
|
18
18
|
throw new Error('analyze() must be implemented by subclass');
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Review code changes (PR diff + context)
|
|
23
|
+
* @param {string} diff - Unified diff
|
|
24
|
+
* @param {Object} context - Codebase context (files, deps, dependents)
|
|
25
|
+
* @param {Object} options - Review options
|
|
26
|
+
* @returns {Promise<Object>} Review report
|
|
27
|
+
*/
|
|
28
|
+
// eslint-disable-next-line no-unused-vars
|
|
29
|
+
async reviewCode(diff, context, options = {}) {
|
|
30
|
+
throw new Error('reviewCode() must be implemented by subclass');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generate tests from a prompt
|
|
35
|
+
* @param {string} prompt - The generation prompt
|
|
36
|
+
* @returns {Promise<string>} Raw LLM response (JSON array of files)
|
|
37
|
+
*/
|
|
38
|
+
// eslint-disable-next-line no-unused-vars
|
|
39
|
+
async generateTests(prompt) {
|
|
40
|
+
throw new Error('generateTests() must be implemented by subclass');
|
|
41
|
+
}
|
|
42
|
+
|
|
21
43
|
/**
|
|
22
44
|
* Build the analysis prompt with focus-specific guidance
|
|
23
45
|
*/
|
|
@@ -55,6 +77,8 @@ ${
|
|
|
55
77
|
## Screenshots Provided
|
|
56
78
|
${captureData.screenshots.map((s) => `- ${s.viewport}: ${s.width}x${s.height}`).join('\n')}
|
|
57
79
|
|
|
80
|
+
${ariaSection}${domSection}
|
|
81
|
+
|
|
58
82
|
## Focus Area: ${focus}
|
|
59
83
|
${focusGuidance}
|
|
60
84
|
|
|
@@ -103,8 +127,112 @@ Respond with ONLY the JSON, no markdown code blocks.`;
|
|
|
103
127
|
};
|
|
104
128
|
}
|
|
105
129
|
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the code review prompt
|
|
133
|
+
*/
|
|
134
|
+
buildReviewPrompt(diff, context, options = {}) {
|
|
135
|
+
const { focus = 'all' } = options;
|
|
136
|
+
|
|
137
|
+
const focusGuidance = REVIEW_FOCUS[focus] || REVIEW_FOCUS.all;
|
|
138
|
+
|
|
139
|
+
// Build context section
|
|
140
|
+
let contextSection = '';
|
|
141
|
+
if (context.summary) {
|
|
142
|
+
contextSection += `\n## Change Summary\n${context.summary}\n`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Include dependency info
|
|
146
|
+
if (Object.keys(context.dependencies).length > 0) {
|
|
147
|
+
contextSection += '\n## Dependencies\n';
|
|
148
|
+
for (const [file, deps] of Object.entries(context.dependencies)) {
|
|
149
|
+
contextSection += `- \`${file}\` imports: ${deps.map((d) => `\`${d}\``).join(', ')}\n`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (Object.keys(context.dependents).length > 0) {
|
|
154
|
+
contextSection += '\n## Dependents (files affected by these changes)\n';
|
|
155
|
+
for (const [file, deps] of Object.entries(context.dependents)) {
|
|
156
|
+
contextSection += `- \`${file}\` is used by: ${deps
|
|
157
|
+
.slice(0, 5)
|
|
158
|
+
.map((d) => `\`${d}\``)
|
|
159
|
+
.join(', ')}${deps.length > 5 ? ` (+${deps.length - 5} more)` : ''}\n`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (Object.keys(context.tests).length > 0) {
|
|
164
|
+
contextSection += '\n## Related Tests\n';
|
|
165
|
+
for (const [file, test] of Object.entries(context.tests)) {
|
|
166
|
+
contextSection += `- \`${file}\` has test: \`${test}\`\n`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Include relevant file contents (trimmed)
|
|
171
|
+
let fileContents = '';
|
|
172
|
+
const contextFiles = Object.entries(context.files || {});
|
|
173
|
+
if (contextFiles.length > 0) {
|
|
174
|
+
fileContents = '\n## Full File Contents (for context)\n';
|
|
175
|
+
for (const [filePath, content] of contextFiles) {
|
|
176
|
+
fileContents += `\n### \`${filePath}\`\n\`\`\`\n${content}\n\`\`\`\n`;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return `You are a senior software engineer doing a thorough code review. You have deep expertise in finding real bugs, security issues, and breaking changes. You are NOT a linter. Skip style nits.
|
|
181
|
+
|
|
182
|
+
## Focus: ${focus}
|
|
183
|
+
${focusGuidance}
|
|
184
|
+
|
|
185
|
+
${contextSection}
|
|
186
|
+
${fileContents}
|
|
187
|
+
|
|
188
|
+
## Diff to Review
|
|
189
|
+
\`\`\`diff
|
|
190
|
+
${diff}
|
|
191
|
+
\`\`\`
|
|
192
|
+
|
|
193
|
+
## Instructions
|
|
194
|
+
- Focus on **real bugs**, security holes, breaking changes, edge cases, and logic errors
|
|
195
|
+
- Reference specific files and line numbers
|
|
196
|
+
- Skip style/formatting issues (that's what linters are for)
|
|
197
|
+
- If code looks good, say so. Don't invent problems.
|
|
198
|
+
- Be direct and specific. No filler.
|
|
199
|
+
|
|
200
|
+
Respond with ONLY this JSON (no code blocks):
|
|
201
|
+
{
|
|
202
|
+
"summary": "2-3 sentence overview of the changes and their quality",
|
|
203
|
+
"issues": [
|
|
204
|
+
{
|
|
205
|
+
"severity": "critical|high|medium|low",
|
|
206
|
+
"category": "bug|security|breaking-change|performance|error-handling|logic|race-condition|type-safety",
|
|
207
|
+
"title": "Short description",
|
|
208
|
+
"description": "What's wrong and why it matters",
|
|
209
|
+
"file": "path/to/file.js",
|
|
210
|
+
"line": 42,
|
|
211
|
+
"suggestion": "Code or explanation of how to fix"
|
|
212
|
+
}
|
|
213
|
+
],
|
|
214
|
+
"score": 0-100,
|
|
215
|
+
"recommendations": ["General suggestions for improvement"]
|
|
216
|
+
}`;
|
|
217
|
+
}
|
|
106
218
|
}
|
|
107
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Review focus areas
|
|
222
|
+
*/
|
|
223
|
+
const REVIEW_FOCUS = {
|
|
224
|
+
all: 'Review for bugs, security issues, breaking changes, performance problems, error handling gaps, and logic errors.',
|
|
225
|
+
security:
|
|
226
|
+
'Focus on security vulnerabilities: injection, auth bypass, data exposure, SSRF, path traversal, ' +
|
|
227
|
+
'insecure crypto, missing input validation, secrets in code.',
|
|
228
|
+
performance:
|
|
229
|
+
'Focus on performance: N+1 queries, unnecessary re-renders, missing memoization, ' +
|
|
230
|
+
'blocking operations, memory leaks, large bundle impact.',
|
|
231
|
+
bugs:
|
|
232
|
+
'Focus on correctness: logic errors, off-by-one, null/undefined access, race conditions, ' +
|
|
233
|
+
'unhandled promise rejections, incorrect error handling.',
|
|
234
|
+
};
|
|
235
|
+
|
|
108
236
|
/**
|
|
109
237
|
* Focus-specific prompt guidance
|
|
110
238
|
*/
|
package/src/providers/gemini.js
CHANGED
|
@@ -35,6 +35,23 @@ class GeminiProvider extends BaseProvider {
|
|
|
35
35
|
const response = await result.response;
|
|
36
36
|
const responseText = response.text();
|
|
37
37
|
|
|
38
|
+
return this.parseResponse(responseText);
|
|
39
|
+
}
|
|
40
|
+
async generateTests(prompt) {
|
|
41
|
+
const model = this.genAI.getGenerativeModel({ model: this.model });
|
|
42
|
+
const result = await model.generateContent([{ text: prompt }]);
|
|
43
|
+
const response = await result.response;
|
|
44
|
+
return response.text();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async reviewCode(diff, context, options = {}) {
|
|
48
|
+
const prompt = this.buildReviewPrompt(diff, context, options);
|
|
49
|
+
const model = this.genAI.getGenerativeModel({ model: this.model });
|
|
50
|
+
|
|
51
|
+
const result = await model.generateContent([{ text: prompt }]);
|
|
52
|
+
const response = await result.response;
|
|
53
|
+
const responseText = response.text();
|
|
54
|
+
|
|
38
55
|
return this.parseResponse(responseText);
|
|
39
56
|
}
|
|
40
57
|
}
|
package/src/providers/ollama.js
CHANGED
|
@@ -41,6 +41,51 @@ class OllamaProvider extends BaseProvider {
|
|
|
41
41
|
throw new Error(`Ollama request failed: ${response.status} ${response.statusText}`);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
const data = await response.json();
|
|
45
|
+
return this.parseResponse(data.response || '');
|
|
46
|
+
}
|
|
47
|
+
async generateTests(prompt) {
|
|
48
|
+
const response = await fetch(`${this.baseUrl}/api/generate`, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
model: this.model,
|
|
53
|
+
prompt,
|
|
54
|
+
stream: false,
|
|
55
|
+
options: { temperature: 0.1 },
|
|
56
|
+
}),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
throw new Error(`Ollama request failed: ${response.status} ${response.statusText}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const data = await response.json();
|
|
64
|
+
return data.response || '';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async reviewCode(diff, context, options = {}) {
|
|
68
|
+
const prompt = this.buildReviewPrompt(diff, context, options);
|
|
69
|
+
|
|
70
|
+
const response = await fetch(`${this.baseUrl}/api/generate`, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: {
|
|
73
|
+
'Content-Type': 'application/json',
|
|
74
|
+
},
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
model: this.model,
|
|
77
|
+
prompt,
|
|
78
|
+
stream: false,
|
|
79
|
+
options: {
|
|
80
|
+
temperature: 0.1,
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw new Error(`Ollama request failed: ${response.status} ${response.statusText}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
44
89
|
const data = await response.json();
|
|
45
90
|
return this.parseResponse(data.response || '');
|
|
46
91
|
}
|
package/src/providers/openai.js
CHANGED
|
@@ -46,6 +46,33 @@ class OpenAIProvider extends BaseProvider {
|
|
|
46
46
|
],
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
const responseText = response.choices[0]?.message?.content || '';
|
|
50
|
+
return this.parseResponse(responseText);
|
|
51
|
+
}
|
|
52
|
+
async generateTests(prompt) {
|
|
53
|
+
const response = await this.client.chat.completions.create({
|
|
54
|
+
model: this.model,
|
|
55
|
+
max_tokens: 8192,
|
|
56
|
+
messages: [{ role: 'user', content: prompt }],
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return response.choices[0]?.message?.content || '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async reviewCode(diff, context, options = {}) {
|
|
63
|
+
const prompt = this.buildReviewPrompt(diff, context, options);
|
|
64
|
+
|
|
65
|
+
const response = await this.client.chat.completions.create({
|
|
66
|
+
model: this.model,
|
|
67
|
+
max_tokens: 8192,
|
|
68
|
+
messages: [
|
|
69
|
+
{
|
|
70
|
+
role: 'user',
|
|
71
|
+
content: prompt,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
|
|
49
76
|
const responseText = response.choices[0]?.message?.content || '';
|
|
50
77
|
return this.parseResponse(responseText);
|
|
51
78
|
}
|
package/src/review.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR Code Review Engine
|
|
3
|
+
*
|
|
4
|
+
* Fetches PR diffs, gathers codebase context, and sends to LLM for deep review.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* const { reviewPR } = require('./review');
|
|
8
|
+
* const report = await reviewPR({ pr: 42, base: 'main' });
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { execSync } = require('child_process');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { getProvider } = require('./providers');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Review a PR or branch diff
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} options
|
|
20
|
+
* @param {number} [options.pr] - PR number to review
|
|
21
|
+
* @param {string} [options.base] - Base branch for diff (default: main)
|
|
22
|
+
* @param {string} [options.cwd] - Working directory (default: process.cwd())
|
|
23
|
+
* @param {string} [options.focus] - Focus area (security, performance, bugs, all)
|
|
24
|
+
* @param {boolean} [options.json] - Output JSON instead of markdown
|
|
25
|
+
* @returns {Promise<ReviewReport>}
|
|
26
|
+
*/
|
|
27
|
+
async function reviewPR(options = {}) {
|
|
28
|
+
const { pr, base = 'main', cwd = process.cwd(), focus = 'all' } = options;
|
|
29
|
+
|
|
30
|
+
// Step 1: Get the diff
|
|
31
|
+
console.log('[1/4] Fetching diff...');
|
|
32
|
+
const diff = getDiff({ pr, base, cwd });
|
|
33
|
+
|
|
34
|
+
if (!diff.trim()) {
|
|
35
|
+
return {
|
|
36
|
+
summary: 'No changes found.',
|
|
37
|
+
issues: [],
|
|
38
|
+
score: 100,
|
|
39
|
+
recommendations: [],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Step 2: Parse changed files
|
|
44
|
+
console.log('[2/4] Analyzing changed files...');
|
|
45
|
+
const changedFiles = parseChangedFiles(diff);
|
|
46
|
+
console.log(` ${changedFiles.length} files changed`);
|
|
47
|
+
|
|
48
|
+
// Step 3: Gather context for each changed file
|
|
49
|
+
console.log('[3/4] Gathering codebase context...');
|
|
50
|
+
const context = gatherContext(changedFiles, cwd);
|
|
51
|
+
|
|
52
|
+
// Step 4: Send to LLM for review
|
|
53
|
+
console.log('[4/4] Reviewing with AI...');
|
|
54
|
+
const provider = getProvider();
|
|
55
|
+
const report = await provider.reviewCode(diff, context, { focus });
|
|
56
|
+
|
|
57
|
+
return report;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get diff from PR number or branch comparison
|
|
62
|
+
*/
|
|
63
|
+
function getDiff({ pr, base, cwd }) {
|
|
64
|
+
try {
|
|
65
|
+
if (pr) {
|
|
66
|
+
// Fetch PR diff via gh CLI
|
|
67
|
+
return execSync(`gh pr diff ${pr} --color=never`, {
|
|
68
|
+
cwd,
|
|
69
|
+
encoding: 'utf-8',
|
|
70
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
71
|
+
});
|
|
72
|
+
} else {
|
|
73
|
+
// Diff current branch against base
|
|
74
|
+
return execSync(`git diff ${base}...HEAD`, {
|
|
75
|
+
cwd,
|
|
76
|
+
encoding: 'utf-8',
|
|
77
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw new Error(`Failed to get diff: ${error.message}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Parse a unified diff into structured file changes
|
|
87
|
+
*/
|
|
88
|
+
function parseChangedFiles(diff) {
|
|
89
|
+
const files = [];
|
|
90
|
+
const fileDiffs = diff.split(/^diff --git /m).filter(Boolean);
|
|
91
|
+
|
|
92
|
+
for (const fileDiff of fileDiffs) {
|
|
93
|
+
const headerMatch = fileDiff.match(/a\/(.+?) b\/(.+)/);
|
|
94
|
+
if (!headerMatch) continue;
|
|
95
|
+
|
|
96
|
+
const filePath = headerMatch[2];
|
|
97
|
+
const isNew = fileDiff.includes('new file mode');
|
|
98
|
+
const isDeleted = fileDiff.includes('deleted file mode');
|
|
99
|
+
|
|
100
|
+
// Extract hunks
|
|
101
|
+
const hunks = [];
|
|
102
|
+
const hunkRegex = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)/gm;
|
|
103
|
+
let match;
|
|
104
|
+
while ((match = hunkRegex.exec(fileDiff)) !== null) {
|
|
105
|
+
hunks.push({
|
|
106
|
+
oldStart: parseInt(match[1], 10),
|
|
107
|
+
newStart: parseInt(match[2], 10),
|
|
108
|
+
header: match[3].trim(),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Count additions and deletions
|
|
113
|
+
const lines = fileDiff.split('\n');
|
|
114
|
+
const additions = lines.filter((l) => l.startsWith('+') && !l.startsWith('+++')).length;
|
|
115
|
+
const deletions = lines.filter((l) => l.startsWith('-') && !l.startsWith('---')).length;
|
|
116
|
+
|
|
117
|
+
files.push({
|
|
118
|
+
path: filePath,
|
|
119
|
+
isNew,
|
|
120
|
+
isDeleted,
|
|
121
|
+
hunks,
|
|
122
|
+
additions,
|
|
123
|
+
deletions,
|
|
124
|
+
diff: fileDiff,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return files;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Gather relevant context for changed files
|
|
133
|
+
*
|
|
134
|
+
* For each changed file, we collect:
|
|
135
|
+
* - The full current file content (for understanding structure)
|
|
136
|
+
* - Import/require dependencies
|
|
137
|
+
* - Files that import/require the changed file
|
|
138
|
+
* - Related test files
|
|
139
|
+
*/
|
|
140
|
+
function gatherContext(changedFiles, cwd) {
|
|
141
|
+
const context = {
|
|
142
|
+
files: {},
|
|
143
|
+
dependencies: {},
|
|
144
|
+
dependents: {},
|
|
145
|
+
tests: {},
|
|
146
|
+
summary: '',
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const MAX_CONTEXT_CHARS = 200000; // ~50K tokens
|
|
150
|
+
let totalChars = 0;
|
|
151
|
+
|
|
152
|
+
for (const file of changedFiles) {
|
|
153
|
+
if (file.isDeleted) continue;
|
|
154
|
+
if (totalChars > MAX_CONTEXT_CHARS) break;
|
|
155
|
+
|
|
156
|
+
const fullPath = path.join(cwd, file.path);
|
|
157
|
+
|
|
158
|
+
// Read full file content
|
|
159
|
+
try {
|
|
160
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
161
|
+
// Skip huge files
|
|
162
|
+
if (content.length > 50000) {
|
|
163
|
+
context.files[file.path] =
|
|
164
|
+
`[File too large: ${content.length} chars, showing first 5000]\n${content.slice(0, 5000)}`;
|
|
165
|
+
} else {
|
|
166
|
+
context.files[file.path] = content;
|
|
167
|
+
}
|
|
168
|
+
totalChars += Math.min(content.length, 50000);
|
|
169
|
+
} catch {
|
|
170
|
+
// File might not exist locally (renamed, etc.)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Find imports in this file
|
|
174
|
+
const deps = findImports(fullPath, cwd);
|
|
175
|
+
if (deps.length > 0) {
|
|
176
|
+
context.dependencies[file.path] = deps;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Find files that depend on this file
|
|
180
|
+
const dependents = findDependents(file.path, cwd);
|
|
181
|
+
if (dependents.length > 0) {
|
|
182
|
+
context.dependents[file.path] = dependents;
|
|
183
|
+
// Include first few dependent file contents for context
|
|
184
|
+
for (const dep of dependents.slice(0, 3)) {
|
|
185
|
+
if (totalChars > MAX_CONTEXT_CHARS) break;
|
|
186
|
+
try {
|
|
187
|
+
const depPath = path.join(cwd, dep);
|
|
188
|
+
const depContent = fs.readFileSync(depPath, 'utf-8');
|
|
189
|
+
if (!context.files[dep] && depContent.length < 20000) {
|
|
190
|
+
context.files[dep] = depContent;
|
|
191
|
+
totalChars += depContent.length;
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// Skip unreadable files
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Find related test files
|
|
200
|
+
const testFile = findTestFile(file.path, cwd);
|
|
201
|
+
if (testFile) {
|
|
202
|
+
context.tests[file.path] = testFile;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Build summary
|
|
207
|
+
const fileList = changedFiles.map((f) => {
|
|
208
|
+
const status = f.isNew ? '(new)' : f.isDeleted ? '(deleted)' : '';
|
|
209
|
+
return ` ${f.path} ${status} +${f.additions} -${f.deletions}`;
|
|
210
|
+
});
|
|
211
|
+
context.summary = `${changedFiles.length} files changed:\n${fileList.join('\n')}`;
|
|
212
|
+
|
|
213
|
+
return context;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Find imports/requires in a file
|
|
218
|
+
*/
|
|
219
|
+
function findImports(filePath, _cwd) {
|
|
220
|
+
try {
|
|
221
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
222
|
+
const imports = [];
|
|
223
|
+
|
|
224
|
+
// ES imports
|
|
225
|
+
const esImportRegex = /import\s+.*?\s+from\s+['"](.+?)['"]/g;
|
|
226
|
+
let match;
|
|
227
|
+
while ((match = esImportRegex.exec(content)) !== null) {
|
|
228
|
+
imports.push(match[1]);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// CommonJS requires
|
|
232
|
+
const cjsRegex = /require\(['"](.+?)['"]\)/g;
|
|
233
|
+
while ((match = cjsRegex.exec(content)) !== null) {
|
|
234
|
+
imports.push(match[1]);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Filter to local imports only (starting with . or /)
|
|
238
|
+
return imports.filter((i) => i.startsWith('.') || i.startsWith('/'));
|
|
239
|
+
} catch {
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Find files that import/require a given file
|
|
246
|
+
*/
|
|
247
|
+
function findDependents(filePath, cwd) {
|
|
248
|
+
const basename = path.basename(filePath, path.extname(filePath));
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
// Use grep to find files that reference this module
|
|
252
|
+
const result = execSync(
|
|
253
|
+
'grep -rl --include="*.js" --include="*.ts" --include="*.jsx" --include="*.tsx" ' +
|
|
254
|
+
'--exclude-dir=node_modules --exclude-dir=.next --exclude-dir=.git --exclude-dir=dist ' +
|
|
255
|
+
`"${basename}" . 2>/dev/null | head -20`,
|
|
256
|
+
{ cwd, encoding: 'utf-8', timeout: 10000 },
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
return result
|
|
260
|
+
.trim()
|
|
261
|
+
.split('\n')
|
|
262
|
+
.filter(Boolean)
|
|
263
|
+
.map((f) => f.replace(/^\.\//, ''))
|
|
264
|
+
.filter((f) => f !== filePath); // Exclude self
|
|
265
|
+
} catch {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Find the test file for a given source file
|
|
272
|
+
*/
|
|
273
|
+
function findTestFile(filePath, cwd) {
|
|
274
|
+
const ext = path.extname(filePath);
|
|
275
|
+
const base = path.basename(filePath, ext);
|
|
276
|
+
const dir = path.dirname(filePath);
|
|
277
|
+
|
|
278
|
+
// Common test file patterns
|
|
279
|
+
const patterns = [
|
|
280
|
+
path.join(dir, `${base}.test${ext}`),
|
|
281
|
+
path.join(dir, `${base}.spec${ext}`),
|
|
282
|
+
path.join(dir, '__tests__', `${base}${ext}`),
|
|
283
|
+
path.join(dir, '__tests__', `${base}.test${ext}`),
|
|
284
|
+
path.join('test', `${base}.test${ext}`),
|
|
285
|
+
path.join('tests', `${base}.test${ext}`),
|
|
286
|
+
path.join('test', `${base}.spec${ext}`),
|
|
287
|
+
path.join('tests', `${base}.spec${ext}`),
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
for (const pattern of patterns) {
|
|
291
|
+
const fullPath = path.join(cwd, pattern);
|
|
292
|
+
if (fs.existsSync(fullPath)) {
|
|
293
|
+
return pattern;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Format review report as markdown
|
|
302
|
+
*/
|
|
303
|
+
function formatReviewMarkdown(report) {
|
|
304
|
+
const lines = [];
|
|
305
|
+
|
|
306
|
+
lines.push('# Code Review Report');
|
|
307
|
+
lines.push('');
|
|
308
|
+
lines.push(`**Score:** ${report.score}/100`);
|
|
309
|
+
lines.push('');
|
|
310
|
+
lines.push('## Summary');
|
|
311
|
+
lines.push('');
|
|
312
|
+
lines.push(report.summary || 'No summary provided.');
|
|
313
|
+
lines.push('');
|
|
314
|
+
|
|
315
|
+
if (report.issues && report.issues.length > 0) {
|
|
316
|
+
lines.push('## Issues');
|
|
317
|
+
lines.push('');
|
|
318
|
+
|
|
319
|
+
for (const issue of report.issues) {
|
|
320
|
+
const emoji =
|
|
321
|
+
{ critical: '\u{1F534}', high: '\u{1F7E0}', medium: '\u{1F7E1}', low: '\u{1F7E2}' }[
|
|
322
|
+
issue.severity
|
|
323
|
+
] || '\u26AA';
|
|
324
|
+
|
|
325
|
+
lines.push(`### ${emoji} ${issue.title}`);
|
|
326
|
+
lines.push('');
|
|
327
|
+
lines.push(
|
|
328
|
+
`**Severity:** ${issue.severity} | **File:** \`${issue.file || 'N/A'}\`${issue.line ? ` | **Line:** ${issue.line}` : ''}`,
|
|
329
|
+
);
|
|
330
|
+
lines.push('');
|
|
331
|
+
lines.push(issue.description);
|
|
332
|
+
lines.push('');
|
|
333
|
+
if (issue.suggestion) {
|
|
334
|
+
lines.push('**Suggestion:**');
|
|
335
|
+
lines.push(issue.suggestion);
|
|
336
|
+
lines.push('');
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
lines.push('## Issues');
|
|
341
|
+
lines.push('');
|
|
342
|
+
lines.push('No issues found. Code looks good!');
|
|
343
|
+
lines.push('');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (report.recommendations && report.recommendations.length > 0) {
|
|
347
|
+
lines.push('## Recommendations');
|
|
348
|
+
lines.push('');
|
|
349
|
+
for (const rec of report.recommendations) {
|
|
350
|
+
lines.push(`- ${rec}`);
|
|
351
|
+
}
|
|
352
|
+
lines.push('');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
lines.push('---');
|
|
356
|
+
lines.push('*Generated by [qai](https://github.com/tyler-james-bridges/qaie)*');
|
|
357
|
+
|
|
358
|
+
return lines.join('\n');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
module.exports = {
|
|
362
|
+
reviewPR,
|
|
363
|
+
getDiff,
|
|
364
|
+
parseChangedFiles,
|
|
365
|
+
gatherContext,
|
|
366
|
+
findImports,
|
|
367
|
+
findDependents,
|
|
368
|
+
findTestFile,
|
|
369
|
+
formatReviewMarkdown,
|
|
370
|
+
};
|