codex-review-mcp 1.4.0 β 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mcp-server.js +68 -15
- package/dist/review/buildPrompt.js +6 -2
- package/dist/review/buildPrompt.test.js +113 -0
- package/dist/review/collectDiff.test.js +490 -0
- package/dist/review/formatOutput.test.js +159 -0
- package/dist/review/gatherContext.js +2 -2
- package/dist/review/gatherContext.test.js +334 -0
- package/dist/review/invokeAgent.js +42 -4
- package/dist/review/invokeAgent.test.js +31 -0
- package/dist/tools/performCodeReview.js +52 -15
- package/dist/tools/performCodeReview.test.js +196 -76
- package/package.json +2 -1
- package/dist/review/collectDiff.js +0 -192
@@ -0,0 +1,490 @@
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
2
|
+
import { collectDiff } from './collectDiff.js';
|
3
|
+
import { execFile } from 'node:child_process';
|
4
|
+
import { promises as fs } from 'node:fs';
|
5
|
+
vi.mock('node:child_process');
|
6
|
+
vi.mock('node:fs');
|
7
|
+
describe('collectDiff', () => {
|
8
|
+
const mockExec = vi.mocked(execFile);
|
9
|
+
const mockStat = vi.mocked(fs.stat);
|
10
|
+
beforeEach(() => {
|
11
|
+
vi.clearAllMocks();
|
12
|
+
});
|
13
|
+
afterEach(() => {
|
14
|
+
vi.restoreAllMocks();
|
15
|
+
});
|
16
|
+
describe('Auto Mode - Uncommitted Changes', () => {
|
17
|
+
it('should detect and review uncommitted changes', async () => {
|
18
|
+
// Mock git root detection
|
19
|
+
mockStat.mockResolvedValueOnce({});
|
20
|
+
// Mock git status showing changes
|
21
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
22
|
+
const cb = callback || opts;
|
23
|
+
if (args?.includes('status')) {
|
24
|
+
cb(null, { stdout: 'M src/test.ts\n', stderr: '' });
|
25
|
+
}
|
26
|
+
else if (args?.includes('rev-parse') && args?.includes('HEAD')) {
|
27
|
+
cb(null, { stdout: 'abc123\n', stderr: '' });
|
28
|
+
}
|
29
|
+
else if (args?.includes('diff')) {
|
30
|
+
cb(null, {
|
31
|
+
stdout: `diff --git a/test.ts b/test.ts
|
32
|
+
+++ b/test.ts
|
33
|
+
+added line`,
|
34
|
+
stderr: ''
|
35
|
+
});
|
36
|
+
}
|
37
|
+
return {};
|
38
|
+
});
|
39
|
+
const input = { target: 'auto' };
|
40
|
+
const result = await collectDiff(input, '/test/repo');
|
41
|
+
expect(result).toContain('added line');
|
42
|
+
expect(result).toContain('diff --git');
|
43
|
+
});
|
44
|
+
it('should handle repositories with no HEAD commit yet', async () => {
|
45
|
+
mockStat.mockResolvedValueOnce({});
|
46
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
47
|
+
const cb = callback || opts;
|
48
|
+
if (args?.includes('status')) {
|
49
|
+
cb(null, { stdout: 'A new-file.ts\n', stderr: '' });
|
50
|
+
}
|
51
|
+
else if (args?.includes('rev-parse') && args?.includes('HEAD')) {
|
52
|
+
cb(new Error('fatal: ambiguous argument'), null);
|
53
|
+
}
|
54
|
+
else if (args?.includes('--staged')) {
|
55
|
+
cb(null, {
|
56
|
+
stdout: `diff --git a/new-file.ts b/new-file.ts
|
57
|
+
+++ b/new-file.ts
|
58
|
+
+new file content`,
|
59
|
+
stderr: ''
|
60
|
+
});
|
61
|
+
}
|
62
|
+
return {};
|
63
|
+
});
|
64
|
+
const input = { target: 'auto' };
|
65
|
+
const result = await collectDiff(input, '/test/repo');
|
66
|
+
expect(result).toContain('new file content');
|
67
|
+
});
|
68
|
+
it('should include untracked files in auto mode', async () => {
|
69
|
+
mockStat.mockResolvedValueOnce({});
|
70
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
71
|
+
const cb = callback || opts;
|
72
|
+
if (args?.includes('status')) {
|
73
|
+
cb(null, { stdout: 'M tracked.ts\n', stderr: '' });
|
74
|
+
}
|
75
|
+
else if (args?.includes('rev-parse') && args?.includes('HEAD')) {
|
76
|
+
cb(null, { stdout: 'abc123\n', stderr: '' });
|
77
|
+
}
|
78
|
+
else if (args?.includes('ls-files') && args?.includes('--others')) {
|
79
|
+
cb(null, { stdout: 'untracked.ts\n', stderr: '' });
|
80
|
+
}
|
81
|
+
else if (args?.includes('diff') && args?.includes('/dev/null')) {
|
82
|
+
// Untracked file diff
|
83
|
+
const error = new Error('');
|
84
|
+
error.stdout = `diff --git /dev/null b/untracked.ts
|
85
|
+
+++ b/untracked.ts
|
86
|
+
+untracked content`;
|
87
|
+
cb(error, null);
|
88
|
+
}
|
89
|
+
else if (args?.includes('diff') && !args?.includes('/dev/null')) {
|
90
|
+
cb(null, {
|
91
|
+
stdout: `diff --git a/tracked.ts b/tracked.ts
|
92
|
+
+++ b/tracked.ts
|
93
|
+
+tracked change`,
|
94
|
+
stderr: ''
|
95
|
+
});
|
96
|
+
}
|
97
|
+
return {};
|
98
|
+
});
|
99
|
+
const input = { target: 'auto' };
|
100
|
+
const result = await collectDiff(input, '/test/repo');
|
101
|
+
expect(result).toContain('tracked change');
|
102
|
+
expect(result).toContain('untracked content');
|
103
|
+
});
|
104
|
+
});
|
105
|
+
describe('Auto Mode - Branch vs Default', () => {
|
106
|
+
it('should compare current branch to default branch when no uncommitted changes', async () => {
|
107
|
+
mockStat.mockResolvedValueOnce({});
|
108
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
109
|
+
const cb = callback || opts;
|
110
|
+
if (args?.includes('status')) {
|
111
|
+
cb(null, { stdout: '', stderr: '' }); // No uncommitted changes
|
112
|
+
}
|
113
|
+
else if (args?.includes('remote show origin')) {
|
114
|
+
cb(null, { stdout: ' HEAD branch: main\n', stderr: '' });
|
115
|
+
}
|
116
|
+
else if (args?.includes('rev-parse') && args?.includes('--abbrev-ref')) {
|
117
|
+
cb(null, { stdout: 'feature-branch\n', stderr: '' });
|
118
|
+
}
|
119
|
+
else if (args?.includes('diff') && args?.includes('main...HEAD')) {
|
120
|
+
cb(null, {
|
121
|
+
stdout: `diff --git a/feature.ts b/feature.ts
|
122
|
+
+++ b/feature.ts
|
123
|
+
+feature code`,
|
124
|
+
stderr: ''
|
125
|
+
});
|
126
|
+
}
|
127
|
+
return {};
|
128
|
+
});
|
129
|
+
const input = { target: 'auto' };
|
130
|
+
const result = await collectDiff(input, '/test/repo');
|
131
|
+
expect(result).toContain('feature code');
|
132
|
+
});
|
133
|
+
it('should return empty when on default branch with clean tree', async () => {
|
134
|
+
mockStat.mockResolvedValueOnce({});
|
135
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
136
|
+
const cb = callback || opts;
|
137
|
+
if (args?.includes('status')) {
|
138
|
+
cb(null, { stdout: '', stderr: '' });
|
139
|
+
}
|
140
|
+
else if (args?.includes('remote show origin')) {
|
141
|
+
cb(null, { stdout: ' HEAD branch: main\n', stderr: '' });
|
142
|
+
}
|
143
|
+
else if (args?.includes('rev-parse') && args?.includes('--abbrev-ref')) {
|
144
|
+
cb(null, { stdout: 'main\n', stderr: '' });
|
145
|
+
}
|
146
|
+
return {};
|
147
|
+
});
|
148
|
+
const input = { target: 'auto' };
|
149
|
+
const result = await collectDiff(input, '/test/repo');
|
150
|
+
expect(result).toBe('');
|
151
|
+
});
|
152
|
+
it('should fallback to master if main does not exist', async () => {
|
153
|
+
mockStat.mockResolvedValueOnce({});
|
154
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
155
|
+
const cb = callback || opts;
|
156
|
+
if (args?.includes('status')) {
|
157
|
+
cb(null, { stdout: '', stderr: '' });
|
158
|
+
}
|
159
|
+
else if (args?.includes('remote show origin')) {
|
160
|
+
cb(new Error('No remote'), null);
|
161
|
+
}
|
162
|
+
else if (args?.includes('--verify main')) {
|
163
|
+
cb(new Error('Not found'), null);
|
164
|
+
}
|
165
|
+
else if (args?.includes('--verify master')) {
|
166
|
+
cb(null, { stdout: 'def456\n', stderr: '' });
|
167
|
+
}
|
168
|
+
else if (args?.includes('rev-parse') && args?.includes('--abbrev-ref')) {
|
169
|
+
cb(null, { stdout: 'feature\n', stderr: '' });
|
170
|
+
}
|
171
|
+
else if (args?.includes('diff') && args?.includes('master...HEAD')) {
|
172
|
+
cb(null, {
|
173
|
+
stdout: `diff --git a/file.ts b/file.ts
|
174
|
+
+content`,
|
175
|
+
stderr: ''
|
176
|
+
});
|
177
|
+
}
|
178
|
+
return {};
|
179
|
+
});
|
180
|
+
const input = { target: 'auto' };
|
181
|
+
const result = await collectDiff(input, '/test/repo');
|
182
|
+
expect(result).toContain('content');
|
183
|
+
});
|
184
|
+
it('should use HEAD~1 as last resort baseline', async () => {
|
185
|
+
mockStat.mockResolvedValueOnce({});
|
186
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
187
|
+
const cb = callback || opts;
|
188
|
+
if (args?.includes('status')) {
|
189
|
+
cb(null, { stdout: '', stderr: '' });
|
190
|
+
}
|
191
|
+
else if (args?.includes('remote show origin')) {
|
192
|
+
cb(new Error('No remote'), null);
|
193
|
+
}
|
194
|
+
else if (args?.includes('--verify main')) {
|
195
|
+
cb(new Error('Not found'), null);
|
196
|
+
}
|
197
|
+
else if (args?.includes('--verify master')) {
|
198
|
+
cb(new Error('Not found'), null);
|
199
|
+
}
|
200
|
+
else if (args?.includes('--verify HEAD~1')) {
|
201
|
+
cb(null, { stdout: 'ghi789\n', stderr: '' });
|
202
|
+
}
|
203
|
+
else if (args?.includes('rev-parse') && args?.includes('--abbrev-ref')) {
|
204
|
+
cb(null, { stdout: 'orphan-branch\n', stderr: '' });
|
205
|
+
}
|
206
|
+
else if (args?.includes('diff') && args?.includes('HEAD~1...HEAD')) {
|
207
|
+
cb(null, {
|
208
|
+
stdout: `diff --git a/file.ts b/file.ts
|
209
|
+
+last commit change`,
|
210
|
+
stderr: ''
|
211
|
+
});
|
212
|
+
}
|
213
|
+
return {};
|
214
|
+
});
|
215
|
+
const input = { target: 'auto' };
|
216
|
+
const result = await collectDiff(input, '/test/repo');
|
217
|
+
expect(result).toContain('last commit change');
|
218
|
+
});
|
219
|
+
});
|
220
|
+
describe('Explicit Target Modes', () => {
|
221
|
+
it('should review staged changes when target is staged', async () => {
|
222
|
+
mockStat.mockResolvedValueOnce({});
|
223
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
224
|
+
const cb = callback || opts;
|
225
|
+
if (args?.includes('--staged')) {
|
226
|
+
cb(null, {
|
227
|
+
stdout: `diff --git a/staged.ts b/staged.ts
|
228
|
+
+staged content`,
|
229
|
+
stderr: ''
|
230
|
+
});
|
231
|
+
}
|
232
|
+
return {};
|
233
|
+
});
|
234
|
+
const input = { target: 'staged' };
|
235
|
+
const result = await collectDiff(input, '/test/repo');
|
236
|
+
expect(result).toContain('staged content');
|
237
|
+
});
|
238
|
+
it('should review HEAD changes when target is head', async () => {
|
239
|
+
mockStat.mockResolvedValueOnce({});
|
240
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
241
|
+
const cb = callback || opts;
|
242
|
+
if (args?.includes('diff') && args?.includes('HEAD')) {
|
243
|
+
cb(null, {
|
244
|
+
stdout: `diff --git a/head.ts b/head.ts
|
245
|
+
+head changes`,
|
246
|
+
stderr: ''
|
247
|
+
});
|
248
|
+
}
|
249
|
+
return {};
|
250
|
+
});
|
251
|
+
const input = { target: 'head' };
|
252
|
+
const result = await collectDiff(input, '/test/repo');
|
253
|
+
expect(result).toContain('head changes');
|
254
|
+
});
|
255
|
+
it('should compare custom refs when target is range', async () => {
|
256
|
+
mockStat.mockResolvedValueOnce({});
|
257
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
258
|
+
const cb = callback || opts;
|
259
|
+
if (args?.includes('develop...feature')) {
|
260
|
+
cb(null, {
|
261
|
+
stdout: `diff --git a/range.ts b/range.ts
|
262
|
+
+range diff`,
|
263
|
+
stderr: ''
|
264
|
+
});
|
265
|
+
}
|
266
|
+
return {};
|
267
|
+
});
|
268
|
+
const input = {
|
269
|
+
target: 'range',
|
270
|
+
baseRef: 'develop',
|
271
|
+
headRef: 'feature'
|
272
|
+
};
|
273
|
+
const result = await collectDiff(input, '/test/repo');
|
274
|
+
expect(result).toContain('range diff');
|
275
|
+
});
|
276
|
+
it('should throw error when range mode missing refs', async () => {
|
277
|
+
const input = { target: 'range' };
|
278
|
+
await expect(collectDiff(input, '/test/repo')).rejects.toThrow('range target requires baseRef and headRef');
|
279
|
+
});
|
280
|
+
});
|
281
|
+
describe('Path Filtering', () => {
|
282
|
+
it('should filter diff to specified paths', async () => {
|
283
|
+
mockStat.mockResolvedValueOnce({});
|
284
|
+
let capturedArgs = [];
|
285
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
286
|
+
capturedArgs = args || [];
|
287
|
+
const cb = callback || opts;
|
288
|
+
if (args?.includes('status')) {
|
289
|
+
cb(null, { stdout: 'M src/test.ts\n', stderr: '' });
|
290
|
+
}
|
291
|
+
else if (args?.includes('rev-parse')) {
|
292
|
+
cb(null, { stdout: 'abc123\n', stderr: '' });
|
293
|
+
}
|
294
|
+
else if (args?.includes('diff')) {
|
295
|
+
cb(null, {
|
296
|
+
stdout: `diff --git a/src/test.ts b/src/test.ts
|
297
|
+
+filtered content`,
|
298
|
+
stderr: ''
|
299
|
+
});
|
300
|
+
}
|
301
|
+
return {};
|
302
|
+
});
|
303
|
+
const input = {
|
304
|
+
target: 'auto',
|
305
|
+
paths: ['src/test.ts', 'src/utils.ts']
|
306
|
+
};
|
307
|
+
const result = await collectDiff(input, '/test/repo');
|
308
|
+
expect(result).toContain('filtered content');
|
309
|
+
// Verify paths were passed to git diff
|
310
|
+
expect(capturedArgs.some(arg => arg === 'src/test.ts')).toBe(true);
|
311
|
+
});
|
312
|
+
it('should filter out ignored patterns (dist, build, lock files)', async () => {
|
313
|
+
mockStat.mockResolvedValueOnce({});
|
314
|
+
let capturedArgs = [];
|
315
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
316
|
+
capturedArgs = args || [];
|
317
|
+
const cb = callback || opts;
|
318
|
+
if (args?.includes('status')) {
|
319
|
+
cb(null, { stdout: 'M src/test.ts\n', stderr: '' });
|
320
|
+
}
|
321
|
+
else if (args?.includes('rev-parse')) {
|
322
|
+
cb(null, { stdout: 'abc123\n', stderr: '' });
|
323
|
+
}
|
324
|
+
else if (args?.includes('diff')) {
|
325
|
+
cb(null, { stdout: '', stderr: '' });
|
326
|
+
}
|
327
|
+
return {};
|
328
|
+
});
|
329
|
+
const input = {
|
330
|
+
target: 'auto',
|
331
|
+
paths: ['dist/bundle.js', 'package-lock.json', 'src/test.ts']
|
332
|
+
};
|
333
|
+
await collectDiff(input, '/test/repo');
|
334
|
+
// Should only include src/test.ts, not dist or lock files
|
335
|
+
expect(capturedArgs.includes('dist/bundle.js')).toBe(false);
|
336
|
+
expect(capturedArgs.includes('package-lock.json')).toBe(false);
|
337
|
+
expect(capturedArgs.some(arg => arg === 'src/test.ts')).toBe(true);
|
338
|
+
});
|
339
|
+
});
|
340
|
+
describe('Binary Files and Size Limits', () => {
|
341
|
+
it('should filter out binary file diff lines', async () => {
|
342
|
+
mockStat.mockResolvedValueOnce({});
|
343
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
344
|
+
const cb = callback || opts;
|
345
|
+
if (args?.includes('status')) {
|
346
|
+
cb(null, { stdout: 'M image.png\n', stderr: '' });
|
347
|
+
}
|
348
|
+
else if (args?.includes('rev-parse')) {
|
349
|
+
cb(null, { stdout: 'abc123\n', stderr: '' });
|
350
|
+
}
|
351
|
+
else if (args?.includes('diff')) {
|
352
|
+
cb(null, {
|
353
|
+
stdout: `Binary files a/image.png and b/image.png differ
|
354
|
+
diff --git a/code.ts b/code.ts
|
355
|
+
+code change`,
|
356
|
+
stderr: ''
|
357
|
+
});
|
358
|
+
}
|
359
|
+
return {};
|
360
|
+
});
|
361
|
+
const input = { target: 'auto' };
|
362
|
+
const result = await collectDiff(input, '/test/repo');
|
363
|
+
expect(result).not.toContain('Binary files');
|
364
|
+
expect(result).toContain('code change');
|
365
|
+
});
|
366
|
+
it('should cap diff at ~300k characters', async () => {
|
367
|
+
mockStat.mockResolvedValueOnce({});
|
368
|
+
const hugeDiff = 'x'.repeat(400000);
|
369
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
370
|
+
const cb = callback || opts;
|
371
|
+
if (args?.includes('status')) {
|
372
|
+
cb(null, { stdout: 'M huge.ts\n', stderr: '' });
|
373
|
+
}
|
374
|
+
else if (args?.includes('rev-parse')) {
|
375
|
+
cb(null, { stdout: 'abc123\n', stderr: '' });
|
376
|
+
}
|
377
|
+
else if (args?.includes('diff')) {
|
378
|
+
cb(null, { stdout: hugeDiff, stderr: '' });
|
379
|
+
}
|
380
|
+
return {};
|
381
|
+
});
|
382
|
+
const input = { target: 'auto' };
|
383
|
+
const result = await collectDiff(input, '/test/repo');
|
384
|
+
expect(result.length).toBeLessThanOrEqual(300100); // Cap + truncation message
|
385
|
+
expect(result).toContain('<!-- diff truncated -->');
|
386
|
+
});
|
387
|
+
});
|
388
|
+
describe('Repository Detection', () => {
|
389
|
+
it('should find git repo by walking up directory tree', async () => {
|
390
|
+
// First call fails (not in /test/repo)
|
391
|
+
// Second call fails (not in /test)
|
392
|
+
// Third call succeeds (found .git in /)
|
393
|
+
mockStat
|
394
|
+
.mockRejectedValueOnce(new Error('ENOENT'))
|
395
|
+
.mockRejectedValueOnce(new Error('ENOENT'))
|
396
|
+
.mockResolvedValueOnce({});
|
397
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
398
|
+
const cb = callback || opts;
|
399
|
+
if (args?.includes('status')) {
|
400
|
+
cb(null, { stdout: 'M test.ts\n', stderr: '' });
|
401
|
+
}
|
402
|
+
else if (args?.includes('rev-parse')) {
|
403
|
+
cb(null, { stdout: 'abc123\n', stderr: '' });
|
404
|
+
}
|
405
|
+
else if (args?.includes('diff')) {
|
406
|
+
cb(null, { stdout: 'diff content', stderr: '' });
|
407
|
+
}
|
408
|
+
return {};
|
409
|
+
});
|
410
|
+
const input = { target: 'auto' };
|
411
|
+
const result = await collectDiff(input, '/test/repo/deep/path');
|
412
|
+
expect(result).toContain('diff content');
|
413
|
+
});
|
414
|
+
it('should use workspaceDir when explicitly provided', async () => {
|
415
|
+
mockStat.mockResolvedValueOnce({});
|
416
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
417
|
+
const cb = callback || opts;
|
418
|
+
// Verify cwd is set to workspaceDir
|
419
|
+
expect(opts && typeof opts === 'object' && 'cwd' in opts ? opts.cwd : undefined).toBe('/explicit/workspace');
|
420
|
+
if (args?.includes('status')) {
|
421
|
+
cb(null, { stdout: '', stderr: '' });
|
422
|
+
}
|
423
|
+
else if (args?.includes('remote show origin')) {
|
424
|
+
cb(null, { stdout: ' HEAD branch: main\n', stderr: '' });
|
425
|
+
}
|
426
|
+
else if (args?.includes('rev-parse')) {
|
427
|
+
cb(null, { stdout: 'main\n', stderr: '' });
|
428
|
+
}
|
429
|
+
return {};
|
430
|
+
});
|
431
|
+
const input = { target: 'auto' };
|
432
|
+
await collectDiff(input, '/explicit/workspace');
|
433
|
+
});
|
434
|
+
it('should throw error when workspaceDir provided but no git repo found', async () => {
|
435
|
+
mockStat.mockRejectedValue(new Error('ENOENT'));
|
436
|
+
const input = { target: 'auto' };
|
437
|
+
await expect(collectDiff(input, '/not/a/repo')).rejects.toThrow('Could not locate a Git repository');
|
438
|
+
});
|
439
|
+
it('should respect environment variables for repo root', async () => {
|
440
|
+
const originalEnv = { ...process.env };
|
441
|
+
process.env.CODEX_REPO_ROOT = '/env/workspace';
|
442
|
+
mockStat.mockResolvedValueOnce({});
|
443
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
444
|
+
const cb = callback || opts;
|
445
|
+
expect(opts && typeof opts === 'object' && 'cwd' in opts ? opts.cwd : undefined).toBe('/env/workspace');
|
446
|
+
if (args?.includes('status')) {
|
447
|
+
cb(null, { stdout: '', stderr: '' });
|
448
|
+
}
|
449
|
+
else if (args?.includes('remote show origin')) {
|
450
|
+
cb(null, { stdout: ' HEAD branch: main\n', stderr: '' });
|
451
|
+
}
|
452
|
+
else if (args?.includes('rev-parse')) {
|
453
|
+
cb(null, { stdout: 'main\n', stderr: '' });
|
454
|
+
}
|
455
|
+
return {};
|
456
|
+
});
|
457
|
+
const input = { target: 'auto' };
|
458
|
+
await collectDiff(input);
|
459
|
+
process.env = originalEnv;
|
460
|
+
});
|
461
|
+
});
|
462
|
+
describe('Error Handling', () => {
|
463
|
+
it('should provide helpful error message when git command fails', async () => {
|
464
|
+
mockStat.mockResolvedValueOnce({});
|
465
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
466
|
+
const cb = callback || opts;
|
467
|
+
const error = new Error('git error');
|
468
|
+
error.stderr = 'fatal: not a git repository';
|
469
|
+
cb(error, null);
|
470
|
+
return {};
|
471
|
+
});
|
472
|
+
const input = { target: 'auto' };
|
473
|
+
await expect(collectDiff(input, '/test/repo')).rejects.toThrow('Failed to collect git diff');
|
474
|
+
});
|
475
|
+
it('should handle git command timeout gracefully', async () => {
|
476
|
+
mockStat.mockResolvedValueOnce({});
|
477
|
+
mockExec.mockImplementation((file, args, opts, callback) => {
|
478
|
+
// Verify maxBuffer is set
|
479
|
+
expect(opts && typeof opts === 'object' && 'maxBuffer' in opts ? opts.maxBuffer : undefined).toBe(10 * 1024 * 1024); // 10MB
|
480
|
+
const cb = callback || opts;
|
481
|
+
const error = new Error('timeout');
|
482
|
+
error.code = 'ETIMEDOUT';
|
483
|
+
cb(error, null);
|
484
|
+
return {};
|
485
|
+
});
|
486
|
+
const input = { target: 'auto' };
|
487
|
+
await expect(collectDiff(input, '/test/repo')).rejects.toThrow();
|
488
|
+
});
|
489
|
+
});
|
490
|
+
});
|
@@ -0,0 +1,159 @@
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
2
|
+
import { formatOutput } from './formatOutput.js';
|
3
|
+
describe('formatOutput', () => {
|
4
|
+
describe('Valid Agent Output', () => {
|
5
|
+
it('should return agent markdown as-is when valid', () => {
|
6
|
+
const agentOutput = '# Code Review\n\n## Issues\n\n- Issue 1\n- Issue 2';
|
7
|
+
const result = formatOutput(agentOutput);
|
8
|
+
expect(result).toBe(agentOutput);
|
9
|
+
});
|
10
|
+
it('should preserve formatting and special characters', () => {
|
11
|
+
const agentOutput = `# Review
|
12
|
+
|
13
|
+
## Summary
|
14
|
+
- β
Good: Type safety
|
15
|
+
- β οΈ Warning: Performance issue
|
16
|
+
- β Critical: Security vulnerability
|
17
|
+
|
18
|
+
\`\`\`typescript
|
19
|
+
function fix() {
|
20
|
+
return true;
|
21
|
+
}
|
22
|
+
\`\`\`
|
23
|
+
|
24
|
+
**Bold** and *italic* text.`;
|
25
|
+
const result = formatOutput(agentOutput);
|
26
|
+
expect(result).toBe(agentOutput);
|
27
|
+
expect(result).toContain('β
');
|
28
|
+
expect(result).toContain('```typescript');
|
29
|
+
expect(result).toContain('**Bold**');
|
30
|
+
});
|
31
|
+
it('should handle multi-line markdown with code blocks', () => {
|
32
|
+
const agentOutput = `# Code Review
|
33
|
+
|
34
|
+
## Issues Found
|
35
|
+
|
36
|
+
| Severity | File | Issue |
|
37
|
+
|----------|------|-------|
|
38
|
+
| High | test.ts | Security issue |
|
39
|
+
|
40
|
+
\`\`\`diff
|
41
|
+
- const bad = eval(userInput);
|
42
|
+
+ const good = JSON.parse(userInput);
|
43
|
+
\`\`\``;
|
44
|
+
const result = formatOutput(agentOutput);
|
45
|
+
expect(result).toBe(agentOutput);
|
46
|
+
expect(result).toContain('```diff');
|
47
|
+
expect(result).toContain('| Severity |');
|
48
|
+
});
|
49
|
+
it('should handle output with only whitespace around content', () => {
|
50
|
+
const agentOutput = '\n\n# Review\n\nContent here\n\n';
|
51
|
+
const result = formatOutput(agentOutput);
|
52
|
+
expect(result).toBe(agentOutput);
|
53
|
+
});
|
54
|
+
});
|
55
|
+
describe('Empty or Invalid Output', () => {
|
56
|
+
it('should return fallback message when output is empty string', () => {
|
57
|
+
const result = formatOutput('');
|
58
|
+
expect(result).toContain('# Code Review');
|
59
|
+
expect(result).toContain('No issues detected or empty output from agent');
|
60
|
+
});
|
61
|
+
it('should return fallback message when output is only whitespace', () => {
|
62
|
+
const result = formatOutput(' \n\n \t ');
|
63
|
+
expect(result).toContain('# Code Review');
|
64
|
+
expect(result).toContain('No issues detected or empty output from agent');
|
65
|
+
});
|
66
|
+
it('should return fallback message when output is null/undefined', () => {
|
67
|
+
const result = formatOutput('');
|
68
|
+
expect(result).toContain('# Code Review');
|
69
|
+
expect(result).toContain('No issues detected');
|
70
|
+
});
|
71
|
+
it('should return valid markdown format for fallback', () => {
|
72
|
+
const result = formatOutput('');
|
73
|
+
// Should be valid markdown
|
74
|
+
expect(result).toMatch(/^# Code Review\n\nNo issues detected/);
|
75
|
+
// Should not have trailing whitespace issues
|
76
|
+
const lines = result.split('\n');
|
77
|
+
expect(lines[0]).toBe('# Code Review');
|
78
|
+
expect(lines[1]).toBe('');
|
79
|
+
expect(lines[2]).toContain('No issues detected');
|
80
|
+
});
|
81
|
+
});
|
82
|
+
describe('Edge Cases', () => {
|
83
|
+
it('should handle extremely long output', () => {
|
84
|
+
const longOutput = '# Review\n\n' + 'x'.repeat(100000);
|
85
|
+
const result = formatOutput(longOutput);
|
86
|
+
expect(result).toBe(longOutput);
|
87
|
+
expect(result.length).toBeGreaterThan(100000);
|
88
|
+
});
|
89
|
+
it('should handle output with unicode characters', () => {
|
90
|
+
const unicode = '# Review π\n\n## ζ₯ζ¬θͺ\n\n- Emoji: π β¨ π―\n- Chinese: δΈζ\n- Arabic: Ψ§ΩΨΉΨ±Ψ¨ΩΨ©';
|
91
|
+
const result = formatOutput(unicode);
|
92
|
+
expect(result).toBe(unicode);
|
93
|
+
expect(result).toContain('π');
|
94
|
+
expect(result).toContain('ζ₯ζ¬θͺ');
|
95
|
+
});
|
96
|
+
it('should handle output with HTML-like content', () => {
|
97
|
+
const htmlContent = `# Review
|
98
|
+
|
99
|
+
<div>This looks like HTML</div>
|
100
|
+
|
101
|
+
<script>alert('test')</script>`;
|
102
|
+
const result = formatOutput(htmlContent);
|
103
|
+
// Should preserve as-is (agent output is trusted)
|
104
|
+
expect(result).toBe(htmlContent);
|
105
|
+
expect(result).toContain('<div>');
|
106
|
+
});
|
107
|
+
it('should handle output with markdown escapes', () => {
|
108
|
+
const escaped = '# Review\n\n\\# Not a heading\n\n\\*Not italic\\*';
|
109
|
+
const result = formatOutput(escaped);
|
110
|
+
expect(result).toBe(escaped);
|
111
|
+
expect(result).toContain('\\#');
|
112
|
+
});
|
113
|
+
});
|
114
|
+
describe('Real-World Agent Output Patterns', () => {
|
115
|
+
it('should handle typical GPT-5 Codex review format', () => {
|
116
|
+
const typicalOutput = `# Code Review: Feature Implementation
|
117
|
+
|
118
|
+
## Quick Summary
|
119
|
+
- Added new user authentication flow
|
120
|
+
- Updated API endpoints
|
121
|
+
- Improved error handling
|
122
|
+
|
123
|
+
## Issues
|
124
|
+
|
125
|
+
| Severity | Location | Category | Issue | Fix |
|
126
|
+
|----------|----------|----------|-------|-----|
|
127
|
+
| Medium | auth.ts:45 | Security | Plain text password | Use bcrypt |
|
128
|
+
| Low | api.ts:23 | Style | Missing JSDoc | Add documentation |
|
129
|
+
|
130
|
+
## Detailed Fixes
|
131
|
+
|
132
|
+
### 1. Password Hashing
|
133
|
+
|
134
|
+
\`\`\`typescript
|
135
|
+
// Before
|
136
|
+
const user = { password: req.body.password };
|
137
|
+
|
138
|
+
// After
|
139
|
+
const user = {
|
140
|
+
password: await bcrypt.hash(req.body.password, 10)
|
141
|
+
};
|
142
|
+
\`\`\`
|
143
|
+
|
144
|
+
## Positive Notes
|
145
|
+
- Good use of TypeScript types
|
146
|
+
- Proper error boundaries
|
147
|
+
|
148
|
+
## Next Steps
|
149
|
+
1. Review security practices
|
150
|
+
2. Add unit tests`;
|
151
|
+
const result = formatOutput(typicalOutput);
|
152
|
+
expect(result).toBe(typicalOutput);
|
153
|
+
expect(result).toContain('Quick Summary');
|
154
|
+
expect(result).toContain('| Severity |');
|
155
|
+
expect(result).toContain('```typescript');
|
156
|
+
expect(result).toContain('Positive Notes');
|
157
|
+
});
|
158
|
+
});
|
159
|
+
});
|
@@ -82,10 +82,10 @@ async function scanDirectory(dirPath, extensions, visited = new Set()) {
|
|
82
82
|
}
|
83
83
|
return files;
|
84
84
|
}
|
85
|
-
export async function gatherContext() {
|
85
|
+
export async function gatherContext(baseDir) {
|
86
86
|
const chunks = [];
|
87
87
|
const processedPaths = new Set();
|
88
|
-
const cwd = process.cwd();
|
88
|
+
const cwd = baseDir || process.cwd();
|
89
89
|
for (const pattern of CANDIDATE_FILES) {
|
90
90
|
// Check global size guard before processing each pattern
|
91
91
|
if (chunks.join('').length > 50_000)
|