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,334 @@
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
2
|
+
import { gatherContext } from './gatherContext.js';
|
3
|
+
import { promises as fs } from 'node:fs';
|
4
|
+
vi.mock('node:fs', () => ({
|
5
|
+
promises: {
|
6
|
+
readFile: vi.fn(),
|
7
|
+
readdir: vi.fn(),
|
8
|
+
realpath: vi.fn(),
|
9
|
+
},
|
10
|
+
}));
|
11
|
+
describe('gatherContext', () => {
|
12
|
+
const originalCwd = process.cwd();
|
13
|
+
const mockCwd = '/test/workspace';
|
14
|
+
beforeEach(() => {
|
15
|
+
vi.clearAllMocks();
|
16
|
+
process.chdir = vi.fn(() => mockCwd);
|
17
|
+
vi.spyOn(process, 'cwd').mockReturnValue(mockCwd);
|
18
|
+
});
|
19
|
+
afterEach(() => {
|
20
|
+
vi.restoreAllMocks();
|
21
|
+
});
|
22
|
+
describe('Project Guidelines Priority', () => {
|
23
|
+
it('should prioritize .cursor/rules files first', async () => {
|
24
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
25
|
+
const mockReaddir = vi.mocked(fs.readdir);
|
26
|
+
const mockRealpath = vi.mocked(fs.realpath);
|
27
|
+
// Mock .cursor/rules directory scanning
|
28
|
+
mockRealpath.mockResolvedValue('/test/workspace/.cursor/rules');
|
29
|
+
mockReaddir.mockResolvedValue([
|
30
|
+
{ name: 'project.mdc', isFile: () => true, isDirectory: () => false },
|
31
|
+
{ name: 'tsx.mdc', isFile: () => true, isDirectory: () => false },
|
32
|
+
]);
|
33
|
+
mockReadFile.mockImplementation(async (path) => {
|
34
|
+
if (path.includes('.cursor/rules/project.mdc'))
|
35
|
+
return '# Project Rules\nStrict guidelines';
|
36
|
+
if (path.includes('.cursor/rules/tsx.mdc'))
|
37
|
+
return '# TSX Rules\nComponent patterns';
|
38
|
+
if (path.includes('CODE_REVIEW.md'))
|
39
|
+
return '# Code Review Guidelines';
|
40
|
+
return Promise.reject(new Error('ENOENT'));
|
41
|
+
});
|
42
|
+
const context = await gatherContext();
|
43
|
+
// Verify .cursor/rules files are included
|
44
|
+
expect(context).toContain('Project Rules');
|
45
|
+
expect(context).toContain('TSX Rules');
|
46
|
+
// Verify files are marked with their paths
|
47
|
+
expect(context).toMatch(/<!--.*\.cursor\/rules.*-->/);
|
48
|
+
});
|
49
|
+
it('should include CODE_REVIEW.md when present', async () => {
|
50
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
51
|
+
const mockReaddir = vi.mocked(fs.readdir);
|
52
|
+
const mockRealpath = vi.mocked(fs.realpath);
|
53
|
+
mockRealpath.mockRejectedValue(new Error('ENOENT'));
|
54
|
+
mockReaddir.mockResolvedValue([]);
|
55
|
+
mockReadFile.mockImplementation(async (path) => {
|
56
|
+
if (path.includes('CODE_REVIEW.md')) {
|
57
|
+
return '# Code Review Standards\n\n## Conventions\n- Use TypeScript strict mode\n- Follow ESLint rules';
|
58
|
+
}
|
59
|
+
return Promise.reject(new Error('ENOENT'));
|
60
|
+
});
|
61
|
+
const context = await gatherContext();
|
62
|
+
expect(context).toContain('Code Review Standards');
|
63
|
+
expect(context).toContain('<!-- CODE_REVIEW.md -->');
|
64
|
+
});
|
65
|
+
it('should include CONTRIBUTING.md guidelines', async () => {
|
66
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
67
|
+
const mockReaddir = vi.mocked(fs.readdir);
|
68
|
+
const mockRealpath = vi.mocked(fs.realpath);
|
69
|
+
mockRealpath.mockRejectedValue(new Error('ENOENT'));
|
70
|
+
mockReaddir.mockResolvedValue([]);
|
71
|
+
mockReadFile.mockImplementation(async (path) => {
|
72
|
+
if (path.includes('CONTRIBUTING.md')) {
|
73
|
+
return '# Contributing\n\n## Code Style\n- 2 spaces for indentation';
|
74
|
+
}
|
75
|
+
return Promise.reject(new Error('ENOENT'));
|
76
|
+
});
|
77
|
+
const context = await gatherContext();
|
78
|
+
expect(context).toContain('Contributing');
|
79
|
+
expect(context).toContain('<!-- CONTRIBUTING.md -->');
|
80
|
+
});
|
81
|
+
});
|
82
|
+
describe('Configuration Files', () => {
|
83
|
+
it('should include ESLint configuration', async () => {
|
84
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
85
|
+
const mockReaddir = vi.mocked(fs.readdir);
|
86
|
+
const mockRealpath = vi.mocked(fs.realpath);
|
87
|
+
mockRealpath.mockRejectedValue(new Error('ENOENT'));
|
88
|
+
mockReaddir.mockResolvedValue([]);
|
89
|
+
mockReadFile.mockImplementation(async (path) => {
|
90
|
+
if (path.includes('.eslintrc.json')) {
|
91
|
+
return JSON.stringify({
|
92
|
+
extends: ['eslint:recommended'],
|
93
|
+
rules: { 'no-console': 'error' }
|
94
|
+
});
|
95
|
+
}
|
96
|
+
return Promise.reject(new Error('ENOENT'));
|
97
|
+
});
|
98
|
+
const context = await gatherContext();
|
99
|
+
expect(context).toContain('eslintrc');
|
100
|
+
expect(context).toContain('no-console');
|
101
|
+
});
|
102
|
+
it('should include TypeScript configuration', async () => {
|
103
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
104
|
+
const mockReaddir = vi.mocked(fs.readdir);
|
105
|
+
const mockRealpath = vi.mocked(fs.realpath);
|
106
|
+
mockRealpath.mockRejectedValue(new Error('ENOENT'));
|
107
|
+
mockReaddir.mockResolvedValue([]);
|
108
|
+
mockReadFile.mockImplementation(async (path) => {
|
109
|
+
if (path.includes('tsconfig.json')) {
|
110
|
+
return JSON.stringify({
|
111
|
+
compilerOptions: { strict: true, noImplicitAny: true }
|
112
|
+
});
|
113
|
+
}
|
114
|
+
return Promise.reject(new Error('ENOENT'));
|
115
|
+
});
|
116
|
+
const context = await gatherContext();
|
117
|
+
expect(context).toContain('tsconfig');
|
118
|
+
expect(context).toContain('strict');
|
119
|
+
});
|
120
|
+
it('should include package.json for dependency info', async () => {
|
121
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
122
|
+
const mockReaddir = vi.mocked(fs.readdir);
|
123
|
+
const mockRealpath = vi.mocked(fs.realpath);
|
124
|
+
mockRealpath.mockRejectedValue(new Error('ENOENT'));
|
125
|
+
mockReaddir.mockResolvedValue([]);
|
126
|
+
mockReadFile.mockImplementation(async (path) => {
|
127
|
+
if (path.includes('package.json')) {
|
128
|
+
return JSON.stringify({
|
129
|
+
name: 'test-project',
|
130
|
+
dependencies: { react: '^18.0.0', typescript: '^5.0.0' }
|
131
|
+
});
|
132
|
+
}
|
133
|
+
return Promise.reject(new Error('ENOENT'));
|
134
|
+
});
|
135
|
+
const context = await gatherContext();
|
136
|
+
expect(context).toContain('package.json');
|
137
|
+
expect(context).toContain('react');
|
138
|
+
});
|
139
|
+
});
|
140
|
+
describe('Size and Safety Limits', () => {
|
141
|
+
it('should cap individual files at 8000 characters', async () => {
|
142
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
143
|
+
const mockReaddir = vi.mocked(fs.readdir);
|
144
|
+
const mockRealpath = vi.mocked(fs.realpath);
|
145
|
+
mockRealpath.mockRejectedValue(new Error('ENOENT'));
|
146
|
+
mockReaddir.mockResolvedValue([]);
|
147
|
+
const largeContent = 'x'.repeat(10000);
|
148
|
+
mockReadFile.mockImplementation(async (path) => {
|
149
|
+
if (path.includes('CODE_REVIEW.md'))
|
150
|
+
return largeContent;
|
151
|
+
return Promise.reject(new Error('ENOENT'));
|
152
|
+
});
|
153
|
+
const context = await gatherContext();
|
154
|
+
// Should be capped at 8000 + overhead for comments
|
155
|
+
const contentLength = context.length;
|
156
|
+
expect(contentLength).toBeLessThan(10000);
|
157
|
+
expect(context).toContain('CODE_REVIEW.md');
|
158
|
+
});
|
159
|
+
it('should cap total context at ~50000 characters', async () => {
|
160
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
161
|
+
const mockReaddir = vi.mocked(fs.readdir);
|
162
|
+
const mockRealpath = vi.mocked(fs.realpath);
|
163
|
+
mockRealpath.mockRejectedValue(new Error('ENOENT'));
|
164
|
+
mockReaddir.mockResolvedValue([]);
|
165
|
+
// Create many files with max content
|
166
|
+
const largeContent = 'x'.repeat(8000);
|
167
|
+
mockReadFile.mockResolvedValue(largeContent);
|
168
|
+
const context = await gatherContext();
|
169
|
+
// Should stop before processing all files
|
170
|
+
expect(context.length).toBeLessThanOrEqual(60000); // Allow some overhead
|
171
|
+
});
|
172
|
+
it('should skip node_modules and other ignored directories', async () => {
|
173
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
174
|
+
const mockReaddir = vi.mocked(fs.readdir);
|
175
|
+
const mockRealpath = vi.mocked(fs.realpath);
|
176
|
+
mockRealpath.mockResolvedValue('/test/workspace/.cursor/rules');
|
177
|
+
mockReaddir.mockResolvedValue([
|
178
|
+
{ name: 'node_modules', isDirectory: () => true, isFile: () => false },
|
179
|
+
{ name: '.git', isDirectory: () => true, isFile: () => false },
|
180
|
+
{ name: 'project.mdc', isFile: () => true, isDirectory: () => false },
|
181
|
+
]);
|
182
|
+
mockReadFile.mockImplementation(async (path) => {
|
183
|
+
if (path.includes('project.mdc'))
|
184
|
+
return '# Project Rules';
|
185
|
+
return Promise.reject(new Error('ENOENT'));
|
186
|
+
});
|
187
|
+
const context = await gatherContext();
|
188
|
+
// Should include project.mdc but not traverse node_modules or .git
|
189
|
+
expect(context).toContain('Project Rules');
|
190
|
+
expect(mockReaddir).toHaveBeenCalledTimes(1); // Only the .cursor/rules directory
|
191
|
+
});
|
192
|
+
it('should handle circular symlinks gracefully', async () => {
|
193
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
194
|
+
const mockReaddir = vi.mocked(fs.readdir);
|
195
|
+
const mockRealpath = vi.mocked(fs.realpath);
|
196
|
+
// Simulate circular symlink by returning same realpath
|
197
|
+
mockRealpath.mockResolvedValue('/test/workspace/.cursor/rules');
|
198
|
+
mockReaddir.mockResolvedValue([
|
199
|
+
{ name: 'link', isDirectory: () => true, isFile: () => false },
|
200
|
+
]);
|
201
|
+
const context = await gatherContext();
|
202
|
+
// Should not hang or error, should handle gracefully
|
203
|
+
expect(context).toBeDefined();
|
204
|
+
});
|
205
|
+
});
|
206
|
+
describe('Error Handling', () => {
|
207
|
+
it('should continue when individual files are missing', async () => {
|
208
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
209
|
+
const mockReaddir = vi.mocked(fs.readdir);
|
210
|
+
const mockRealpath = vi.mocked(fs.realpath);
|
211
|
+
mockRealpath.mockRejectedValue(new Error('ENOENT'));
|
212
|
+
mockReaddir.mockResolvedValue([]);
|
213
|
+
mockReadFile.mockImplementation(async (path) => {
|
214
|
+
if (path.includes('CODE_REVIEW.md'))
|
215
|
+
return '# Code Review';
|
216
|
+
if (path.includes('CONTRIBUTING.md'))
|
217
|
+
return '# Contributing';
|
218
|
+
// All other files throw ENOENT
|
219
|
+
return Promise.reject(new Error('ENOENT'));
|
220
|
+
});
|
221
|
+
const context = await gatherContext();
|
222
|
+
// Should still include the files that exist
|
223
|
+
expect(context).toContain('Code Review');
|
224
|
+
expect(context).toContain('Contributing');
|
225
|
+
});
|
226
|
+
it('should return empty string when no files exist', async () => {
|
227
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
228
|
+
const mockReaddir = vi.mocked(fs.readdir);
|
229
|
+
const mockRealpath = vi.mocked(fs.realpath);
|
230
|
+
mockRealpath.mockRejectedValue(new Error('ENOENT'));
|
231
|
+
mockReaddir.mockResolvedValue([]);
|
232
|
+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
233
|
+
const context = await gatherContext();
|
234
|
+
expect(context).toBe('');
|
235
|
+
});
|
236
|
+
it('should handle permission errors gracefully', async () => {
|
237
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
238
|
+
const mockReaddir = vi.mocked(fs.readdir);
|
239
|
+
const mockRealpath = vi.mocked(fs.realpath);
|
240
|
+
mockRealpath.mockRejectedValue(new Error('ENOENT'));
|
241
|
+
mockReaddir.mockResolvedValue([]);
|
242
|
+
mockReadFile.mockImplementation(async (path) => {
|
243
|
+
if (path.includes('CODE_REVIEW.md'))
|
244
|
+
return '# Code Review';
|
245
|
+
if (path.includes('.eslintrc.json')) {
|
246
|
+
const error = new Error('EACCES: permission denied');
|
247
|
+
error.code = 'EACCES';
|
248
|
+
throw error;
|
249
|
+
}
|
250
|
+
return Promise.reject(new Error('ENOENT'));
|
251
|
+
});
|
252
|
+
const context = await gatherContext();
|
253
|
+
// Should still include accessible files
|
254
|
+
expect(context).toContain('Code Review');
|
255
|
+
// Should not throw an error
|
256
|
+
expect(context).toBeDefined();
|
257
|
+
});
|
258
|
+
});
|
259
|
+
describe('File Format Support', () => {
|
260
|
+
it('should scan .cursor/rules directory for .md and .mdc files', async () => {
|
261
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
262
|
+
const mockReaddir = vi.mocked(fs.readdir);
|
263
|
+
const mockRealpath = vi.mocked(fs.realpath);
|
264
|
+
mockRealpath.mockResolvedValue('/test/workspace/.cursor/rules');
|
265
|
+
mockReaddir.mockResolvedValue([
|
266
|
+
{ name: 'project.mdc', isFile: () => true, isDirectory: () => false },
|
267
|
+
{ name: 'readme.md', isFile: () => true, isDirectory: () => false },
|
268
|
+
{ name: 'config.json', isFile: () => true, isDirectory: () => false },
|
269
|
+
]);
|
270
|
+
mockReadFile.mockImplementation(async (path) => {
|
271
|
+
if (path.includes('project.mdc'))
|
272
|
+
return '# Project';
|
273
|
+
if (path.includes('readme.md'))
|
274
|
+
return '# Readme';
|
275
|
+
if (path.includes('config.json'))
|
276
|
+
return '{}';
|
277
|
+
return Promise.reject(new Error('ENOENT'));
|
278
|
+
});
|
279
|
+
const context = await gatherContext();
|
280
|
+
expect(context).toContain('Project');
|
281
|
+
expect(context).toContain('Readme');
|
282
|
+
// Verify .json files from directory scan are not included (checking for the file marker)
|
283
|
+
expect(context).not.toMatch(/<!--.*\.cursor\/rules\/config\.json.*-->/);
|
284
|
+
// But ts config from the root candidate list should still be included
|
285
|
+
expect(context).toContain('tsconfig.json');
|
286
|
+
});
|
287
|
+
it('should support multiple ESLint config formats', async () => {
|
288
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
289
|
+
const mockReaddir = vi.mocked(fs.readdir);
|
290
|
+
const mockRealpath = vi.mocked(fs.realpath);
|
291
|
+
mockRealpath.mockRejectedValue(new Error('ENOENT'));
|
292
|
+
mockReaddir.mockResolvedValue([]);
|
293
|
+
let callCount = 0;
|
294
|
+
mockReadFile.mockImplementation(async (path) => {
|
295
|
+
if (path.includes('.eslintrc.json')) {
|
296
|
+
callCount++;
|
297
|
+
return JSON.stringify({ rules: { 'no-console': 'error' } });
|
298
|
+
}
|
299
|
+
if (path.includes('.eslintrc.js') && callCount === 0) {
|
300
|
+
// Should only read first found ESLint config
|
301
|
+
throw new Error('Should not reach here');
|
302
|
+
}
|
303
|
+
return Promise.reject(new Error('ENOENT'));
|
304
|
+
});
|
305
|
+
const context = await gatherContext();
|
306
|
+
expect(context).toContain('eslintrc');
|
307
|
+
expect(callCount).toBe(1); // Should stop after finding first config
|
308
|
+
});
|
309
|
+
});
|
310
|
+
describe('Deduplication', () => {
|
311
|
+
it('should not process the same file twice', async () => {
|
312
|
+
const mockReadFile = vi.mocked(fs.readFile);
|
313
|
+
const mockReaddir = vi.mocked(fs.readdir);
|
314
|
+
const mockRealpath = vi.mocked(fs.realpath);
|
315
|
+
mockRealpath.mockResolvedValue('/test/workspace/.cursor/rules');
|
316
|
+
mockReaddir.mockResolvedValue([
|
317
|
+
{ name: 'tsx.mdc', isFile: () => true, isDirectory: () => false },
|
318
|
+
]);
|
319
|
+
let readCount = 0;
|
320
|
+
mockReadFile.mockImplementation(async (path) => {
|
321
|
+
if (path.includes('tsx.mdc') || path.includes('.cursor/rules/tsx.mdc')) {
|
322
|
+
readCount++;
|
323
|
+
return '# TSX Rules';
|
324
|
+
}
|
325
|
+
return Promise.reject(new Error('ENOENT'));
|
326
|
+
});
|
327
|
+
const context = await gatherContext();
|
328
|
+
// tsx.mdc appears in CANDIDATE_FILES twice (explicit and in directory scan)
|
329
|
+
// Should only be read once
|
330
|
+
expect(readCount).toBe(1);
|
331
|
+
expect(context).toContain('TSX Rules');
|
332
|
+
});
|
333
|
+
});
|
334
|
+
});
|
@@ -1,14 +1,52 @@
|
|
1
|
-
import { Agent, run } from '@openai/agents';
|
1
|
+
import { Agent, run, tool } from '@openai/agents';
|
2
2
|
import { debugLog } from '../util/debug.js';
|
3
|
-
|
3
|
+
import { promises as fs } from 'node:fs';
|
4
|
+
import { join } from 'node:path';
|
5
|
+
import { z } from 'zod';
|
6
|
+
export async function invokeAgent({ prompt, maxTokens, workspaceDir }) {
|
4
7
|
const model = process.env.CODEX_MODEL || 'gpt-5-codex';
|
8
|
+
const cwd = workspaceDir || process.cwd();
|
9
|
+
// Tool: Read file from the codebase
|
10
|
+
const readFileTool = tool({
|
11
|
+
name: 'read_file',
|
12
|
+
description: 'Read the contents of a file from the codebase to examine existing patterns, utilities, or configuration. Use this to check for existing code before suggesting changes.',
|
13
|
+
parameters: z.object({
|
14
|
+
path: z.string().describe('Relative path to the file from the repository root'),
|
15
|
+
}),
|
16
|
+
execute: async (params) => {
|
17
|
+
try {
|
18
|
+
// Basic path sandboxing to keep reads under cwd
|
19
|
+
const requested = params.path.replace(/\\/g, '/');
|
20
|
+
if (requested.includes('..')) {
|
21
|
+
return 'Access denied: parent directory traversal is not allowed.';
|
22
|
+
}
|
23
|
+
const fullPath = join(cwd, requested);
|
24
|
+
if (!fullPath.startsWith(cwd)) {
|
25
|
+
return 'Access denied: path escapes workspace boundary.';
|
26
|
+
}
|
27
|
+
const content = await fs.readFile(fullPath, 'utf8');
|
28
|
+
const MAX_RETURN = 50_000; // cap very large files
|
29
|
+
const body = content.length > MAX_RETURN ? content.slice(0, MAX_RETURN) + '\n... [truncated]' : content;
|
30
|
+
await debugLog(`Agent read file: ${params.path} (${content.length} chars)`);
|
31
|
+
return `File: ${params.path}\n\n${body}`;
|
32
|
+
}
|
33
|
+
catch (error) {
|
34
|
+
const errorMsg = `Unable to read ${params.path}: ${error.message}`;
|
35
|
+
await debugLog(`Agent file read error: ${errorMsg}`);
|
36
|
+
return errorMsg;
|
37
|
+
}
|
38
|
+
},
|
39
|
+
});
|
5
40
|
const agent = new Agent({
|
6
41
|
name: 'Code Reviewer',
|
7
|
-
instructions: 'You are a precise code-review agent. Follow the output contract exactly. Use minimal verbosity.',
|
42
|
+
instructions: 'You are a precise code-review agent. Follow the output contract exactly. Use minimal verbosity. When you need to examine existing code patterns, utilities, or configurations to provide accurate recommendations, use the read_file tool.',
|
8
43
|
model,
|
44
|
+
tools: [readFileTool],
|
9
45
|
});
|
10
46
|
try {
|
11
|
-
const result = await run(agent, prompt
|
47
|
+
const result = await run(agent, prompt, {
|
48
|
+
maxTurns: 25, // Allow more turns for file reading (default is 10)
|
49
|
+
});
|
12
50
|
const out = result.finalOutput ?? '';
|
13
51
|
await debugLog(`Agent model=${model} outputLen=${out.length}`);
|
14
52
|
return out;
|
@@ -5,6 +5,7 @@ import { run } from '@openai/agents';
|
|
5
5
|
vi.mock('@openai/agents', () => ({
|
6
6
|
Agent: vi.fn().mockImplementation((config) => config),
|
7
7
|
run: vi.fn(),
|
8
|
+
tool: vi.fn().mockImplementation((config) => config),
|
8
9
|
}));
|
9
10
|
vi.mock('../util/debug', () => ({
|
10
11
|
debugLog: vi.fn(),
|
@@ -74,4 +75,34 @@ describe('invokeAgent', () => {
|
|
74
75
|
process.env.CODEX_MODEL = originalEnv;
|
75
76
|
}
|
76
77
|
});
|
78
|
+
it('should create agent with read_file tool for examining codebase', async () => {
|
79
|
+
vi.mocked(run).mockResolvedValue({
|
80
|
+
finalOutput: '# Review',
|
81
|
+
});
|
82
|
+
await invokeAgent({
|
83
|
+
prompt: 'Review this code',
|
84
|
+
workspaceDir: '/test/workspace'
|
85
|
+
});
|
86
|
+
const { Agent, tool } = await import('@openai/agents');
|
87
|
+
// Verify tool was created
|
88
|
+
expect(tool).toHaveBeenCalledWith(expect.objectContaining({
|
89
|
+
name: 'read_file',
|
90
|
+
description: expect.stringContaining('examine existing patterns'),
|
91
|
+
}));
|
92
|
+
// Verify agent was created with tools
|
93
|
+
expect(Agent).toHaveBeenCalledWith(expect.objectContaining({
|
94
|
+
tools: expect.arrayContaining([expect.anything()]),
|
95
|
+
}));
|
96
|
+
});
|
97
|
+
it('should pass workspaceDir to control file reading scope', async () => {
|
98
|
+
vi.mocked(run).mockResolvedValue({
|
99
|
+
finalOutput: '# Review',
|
100
|
+
});
|
101
|
+
const result = await invokeAgent({
|
102
|
+
prompt: 'Test',
|
103
|
+
workspaceDir: '/custom/workspace'
|
104
|
+
});
|
105
|
+
expect(result).toBe('# Review');
|
106
|
+
expect(run).toHaveBeenCalled();
|
107
|
+
});
|
77
108
|
});
|
@@ -1,26 +1,63 @@
|
|
1
|
-
import { collectDiff } from '../review/collectDiff.js';
|
2
1
|
import { gatherContext } from '../review/gatherContext.js';
|
3
2
|
import { buildPrompt } from '../review/buildPrompt.js';
|
4
3
|
import { invokeAgent } from '../review/invokeAgent.js';
|
5
4
|
import { formatOutput } from '../review/formatOutput.js';
|
6
5
|
import { debugLog } from '../util/debug.js';
|
6
|
+
import { readFileSync } from 'node:fs';
|
7
|
+
import { fileURLToPath } from 'node:url';
|
8
|
+
import { dirname, join } from 'node:path';
|
9
|
+
// Read version from package.json
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
11
|
+
const __dirname = dirname(__filename);
|
12
|
+
const packageJson = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8'));
|
13
|
+
const VERSION = packageJson.version;
|
14
|
+
/**
|
15
|
+
* Perform code review with GPT-5 Codex
|
16
|
+
*
|
17
|
+
* The calling agent provides the content (diff or code) to review.
|
18
|
+
* We handle: context gathering, expert prompt building, GPT-5 invocation, formatting.
|
19
|
+
*/
|
7
20
|
export async function performCodeReview(input, onProgress) {
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
21
|
+
// Validate input
|
22
|
+
if (!input.content || !input.content.trim()) {
|
23
|
+
throw new Error('content is required and cannot be empty. ' +
|
24
|
+
'The calling agent should provide the diff or code to review.');
|
12
25
|
}
|
13
|
-
await onProgress?.('
|
14
|
-
|
15
|
-
|
16
|
-
const
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
const
|
26
|
+
await onProgress?.('Preparing review…', 10, 100);
|
27
|
+
// Gather context with smart precedence
|
28
|
+
const shouldSkip = input.skipContextGathering === true;
|
29
|
+
const usingCustom = Boolean(input.customContext?.trim());
|
30
|
+
await onProgress?.(usingCustom
|
31
|
+
? 'Using provided project context…'
|
32
|
+
: shouldSkip
|
33
|
+
? 'Skipping context gathering…'
|
34
|
+
: 'Gathering project context…', 30, 100);
|
35
|
+
const context = usingCustom
|
36
|
+
? input.customContext
|
37
|
+
: shouldSkip
|
38
|
+
? ''
|
39
|
+
: await gatherContext(input.workspaceDir);
|
40
|
+
// Build expert prompt
|
41
|
+
await onProgress?.('Building expert prompt…', 50, 100);
|
42
|
+
const isStaticReview = input.contentType === 'code';
|
43
|
+
const prompt = buildPrompt({
|
44
|
+
diffText: input.content,
|
45
|
+
context,
|
46
|
+
focus: input.focus,
|
47
|
+
version: VERSION,
|
48
|
+
isStaticReview
|
49
|
+
});
|
50
|
+
// Invoke GPT-5 Codex
|
51
|
+
const lineCount = input.content.split('\n').length;
|
52
|
+
const reviewType = isStaticReview ? 'code' : 'diff';
|
53
|
+
await onProgress?.(`Calling GPT-5 Codex with ${reviewType} (~${lineCount} lines)…`, 70, 100);
|
54
|
+
const agentMd = await invokeAgent({
|
55
|
+
prompt,
|
56
|
+
maxTokens: input.maxTokens,
|
57
|
+
workspaceDir: input.workspaceDir || process.cwd()
|
58
|
+
});
|
23
59
|
await debugLog(`Review produced ${agentMd.length} chars`);
|
60
|
+
// Format output
|
24
61
|
await onProgress?.('Formatting output…', 90, 100);
|
25
62
|
const out = formatOutput(agentMd);
|
26
63
|
await onProgress?.('Done', 100, 100);
|