codex-review-mcp 2.7.0 → 2.9.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/dist/mcp-server.js
CHANGED
|
@@ -73,6 +73,7 @@ server.registerTool('perform_code_review', {
|
|
|
73
73
|
// REVIEW OPTIONS
|
|
74
74
|
focus: z.string().optional().describe('Specific areas to focus on (e.g., "security and performance")'),
|
|
75
75
|
maxTokens: z.number().optional().describe('Maximum tokens for review response'),
|
|
76
|
+
force: z.boolean().optional().describe('Force review even if diff is large (>500 lines). Use this to bypass chunking suggestions.'),
|
|
76
77
|
},
|
|
77
78
|
}, async (input, extra) => {
|
|
78
79
|
try {
|
|
@@ -84,6 +85,7 @@ server.registerTool('perform_code_review', {
|
|
|
84
85
|
skipContextGathering: input.skipContextGathering,
|
|
85
86
|
focus: input.focus,
|
|
86
87
|
maxTokens: input.maxTokens,
|
|
88
|
+
force: input.force,
|
|
87
89
|
};
|
|
88
90
|
const onProgress = async (message, progress, total) => {
|
|
89
91
|
// Only send progress notifications if we have a valid string token (like toolu_xxx)
|
|
@@ -44,6 +44,7 @@ export function buildPrompt({ diffText, context, focus, version, isStaticReview
|
|
|
44
44
|
'- setTimeout/setInterval without clear cleanup or justification',
|
|
45
45
|
'- Direct DOM manipulation (document.querySelector, etc.) instead of React refs',
|
|
46
46
|
'- Inline styles with hardcoded colors/sizes instead of theme values',
|
|
47
|
+
'- ⚠️ Z-INDEX CHANGES (HIGH RISK): Treat all z-index modifications with extreme caution. MUST analyze the entire z-index hierarchy, verify no breaking changes in stacking context, and check for existing z-index patterns in codebase before recommending changes.',
|
|
47
48
|
'',
|
|
48
49
|
`\n---\n${reviewType}${isStaticReview ? ' - Code Files' : ' (unified=0)'}:\n`,
|
|
49
50
|
diffText,
|
|
@@ -83,140 +83,6 @@ async function scanDirectory(dirPath, extensions, visited = new Set()) {
|
|
|
83
83
|
}
|
|
84
84
|
return files;
|
|
85
85
|
}
|
|
86
|
-
/**
|
|
87
|
-
* Parse diff to extract changed files
|
|
88
|
-
*/
|
|
89
|
-
function parseChangedFiles(diff) {
|
|
90
|
-
const files = [];
|
|
91
|
-
const lines = diff.split('\n');
|
|
92
|
-
for (const line of lines) {
|
|
93
|
-
// Match "diff --git a/path/to/file b/path/to/file"
|
|
94
|
-
if (line.startsWith('diff --git')) {
|
|
95
|
-
const match = line.match(/b\/(.+)$/);
|
|
96
|
-
if (match && match[1]) {
|
|
97
|
-
// Unescape Git-escaped paths (e.g., "My\ Button.tsx" → "My Button.tsx")
|
|
98
|
-
const filePath = match[1].replace(/\\(.)/g, '$1');
|
|
99
|
-
files.push(filePath);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
// Also match "+++ b/path/to/file"
|
|
103
|
-
else if (line.startsWith('+++') && line.includes('b/')) {
|
|
104
|
-
const match = line.match(/\+\+\+ b\/(.+)$/);
|
|
105
|
-
if (match && match[1]) {
|
|
106
|
-
// Unescape Git-escaped paths
|
|
107
|
-
const filePath = match[1].replace(/\\(.)/g, '$1');
|
|
108
|
-
files.push(filePath);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return [...new Set(files)]; // Dedupe
|
|
113
|
-
}
|
|
114
|
-
/**
|
|
115
|
-
* Detect new exported components/functions per file
|
|
116
|
-
*/
|
|
117
|
-
function detectNewExports(diff) {
|
|
118
|
-
const exportsByFile = {};
|
|
119
|
-
let currentFile = null;
|
|
120
|
-
const lines = diff.split('\n');
|
|
121
|
-
for (const line of lines) {
|
|
122
|
-
// Track which file we're in
|
|
123
|
-
if (line.startsWith('+++ b/')) {
|
|
124
|
-
const match = line.match(/\+\+\+ b\/(.+)$/);
|
|
125
|
-
// Unescape Git-escaped paths to match parseChangedFiles behavior
|
|
126
|
-
currentFile = match && match[1] ? match[1].replace(/\\(.)/g, '$1') : null;
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
// Only look at added lines in a known file
|
|
130
|
-
if (!line.startsWith('+') || !currentFile)
|
|
131
|
-
continue;
|
|
132
|
-
// Match: export function ComponentName
|
|
133
|
-
// Match: export default function Component
|
|
134
|
-
// Match: export async function getData
|
|
135
|
-
// Match: export const ComponentName
|
|
136
|
-
// Match: export class ComponentName
|
|
137
|
-
const match = line.match(/^\+\s*export\s+(?:default\s+)?(?:async\s+)?(?:function|const|class)\s+([A-Za-z_]\w*)/);
|
|
138
|
-
if (match && match[1]) {
|
|
139
|
-
if (!exportsByFile[currentFile]) {
|
|
140
|
-
exportsByFile[currentFile] = [];
|
|
141
|
-
}
|
|
142
|
-
if (!exportsByFile[currentFile].includes(match[1])) {
|
|
143
|
-
exportsByFile[currentFile].push(match[1]);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
return exportsByFile;
|
|
148
|
-
}
|
|
149
|
-
/**
|
|
150
|
-
* Check if test file exists for given source file
|
|
151
|
-
*/
|
|
152
|
-
async function checkForTestFile(sourcePath, baseDir) {
|
|
153
|
-
const testPatterns = [
|
|
154
|
-
// Same directory patterns
|
|
155
|
-
sourcePath.replace(/\.tsx?$/, '.test.ts'),
|
|
156
|
-
sourcePath.replace(/\.tsx?$/, '.test.tsx'),
|
|
157
|
-
sourcePath.replace(/\.tsx?$/, '.spec.ts'),
|
|
158
|
-
sourcePath.replace(/\.tsx?$/, '.spec.tsx'),
|
|
159
|
-
// __tests__ directory patterns
|
|
160
|
-
sourcePath.replace(/^src\//, 'src/__tests__/').replace(/\.tsx?$/, '.test.ts'),
|
|
161
|
-
sourcePath.replace(/^src\//, 'src/__tests__/').replace(/\.tsx?$/, '.test.tsx'),
|
|
162
|
-
sourcePath.replace(/^src\//, 'src/__tests__/').replace(/\.tsx?$/, '.spec.ts'),
|
|
163
|
-
sourcePath.replace(/^src\//, 'src/__tests__/').replace(/\.tsx?$/, '.spec.tsx'),
|
|
164
|
-
];
|
|
165
|
-
for (const pattern of testPatterns) {
|
|
166
|
-
const testPath = join(baseDir, pattern);
|
|
167
|
-
const exists = await readIfExists(testPath);
|
|
168
|
-
if (exists !== null) {
|
|
169
|
-
return pattern;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
return null;
|
|
173
|
-
}
|
|
174
|
-
/**
|
|
175
|
-
* Gather smart context based on the diff
|
|
176
|
-
*/
|
|
177
|
-
export async function gatherSmartContext(diff, baseDir) {
|
|
178
|
-
const cwd = baseDir || process.cwd();
|
|
179
|
-
const chunks = [];
|
|
180
|
-
let totalSize = 0;
|
|
181
|
-
const MAX_SMART_CONTEXT_SIZE = 20_000; // 20KB budget for smart context
|
|
182
|
-
// 1. Extract changed files from diff
|
|
183
|
-
const changedFiles = parseChangedFiles(diff);
|
|
184
|
-
// 2. Read full content of changed files (up to 3 files to stay within budget)
|
|
185
|
-
for (const file of changedFiles.slice(0, 3)) {
|
|
186
|
-
if (totalSize >= MAX_SMART_CONTEXT_SIZE)
|
|
187
|
-
break;
|
|
188
|
-
const fullPath = join(cwd, file);
|
|
189
|
-
const content = await readIfExists(fullPath);
|
|
190
|
-
if (content) {
|
|
191
|
-
const capped = content.slice(0, 8000); // Cap each file at 8KB
|
|
192
|
-
chunks.push(`\n<!-- FULL CONTEXT: ${file} -->\n${capped}`);
|
|
193
|
-
totalSize += capped.length;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
// 3. Check for test coverage when new exports are detected
|
|
197
|
-
const exportsByFile = detectNewExports(diff);
|
|
198
|
-
const filesWithExports = Object.keys(exportsByFile);
|
|
199
|
-
if (filesWithExports.length > 0) {
|
|
200
|
-
for (const file of filesWithExports) {
|
|
201
|
-
// Only check TypeScript/React files
|
|
202
|
-
if (!/\.(ts|tsx)$/.test(file))
|
|
203
|
-
continue;
|
|
204
|
-
const exportsForFile = exportsByFile[file];
|
|
205
|
-
const testFile = await checkForTestFile(file, cwd);
|
|
206
|
-
if (!testFile) {
|
|
207
|
-
// Match test file extension to source extension
|
|
208
|
-
const suggestedTest = file.endsWith('.tsx')
|
|
209
|
-
? file.replace(/\.tsx$/, '.test.tsx')
|
|
210
|
-
: file.replace(/\.ts$/, '.test.ts');
|
|
211
|
-
chunks.push(`\n<!-- TEST COVERAGE: ${file} -->\n⚠️ No test file found for ${file} which exports: ${exportsForFile.join(', ')}\nConsider adding: ${suggestedTest}`);
|
|
212
|
-
}
|
|
213
|
-
else {
|
|
214
|
-
chunks.push(`\n<!-- TEST COVERAGE: ${file} -->\n✓ Test file exists: ${testFile}`);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
return chunks.join('\n');
|
|
219
|
-
}
|
|
220
86
|
export async function gatherContext(baseDir) {
|
|
221
87
|
const chunks = [];
|
|
222
88
|
const processedPaths = new Set();
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { gatherContext
|
|
2
|
+
import { gatherContext } from './gatherContext.js';
|
|
3
3
|
import { promises as fs } from 'node:fs';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
4
|
vi.mock('node:fs', () => ({
|
|
6
5
|
promises: {
|
|
7
6
|
readFile: vi.fn(),
|
|
@@ -354,357 +353,3 @@ describe('gatherContext', () => {
|
|
|
354
353
|
});
|
|
355
354
|
});
|
|
356
355
|
});
|
|
357
|
-
describe('gatherSmartContext', () => {
|
|
358
|
-
const mockCwd = '/test/workspace';
|
|
359
|
-
beforeEach(() => {
|
|
360
|
-
vi.clearAllMocks();
|
|
361
|
-
vi.spyOn(process, 'cwd').mockReturnValue(mockCwd);
|
|
362
|
-
});
|
|
363
|
-
afterEach(() => {
|
|
364
|
-
vi.restoreAllMocks();
|
|
365
|
-
});
|
|
366
|
-
describe('File Parsing', () => {
|
|
367
|
-
it('should extract changed files from diff', async () => {
|
|
368
|
-
const mockReadFile = vi.mocked(fs.readFile);
|
|
369
|
-
const diff = `diff --git a/src/components/UserCard.tsx b/src/components/UserCard.tsx
|
|
370
|
-
index 1234567..abcdefg 100644
|
|
371
|
-
--- a/src/components/UserCard.tsx
|
|
372
|
-
+++ b/src/components/UserCard.tsx
|
|
373
|
-
@@ -1,3 +1,4 @@
|
|
374
|
-
+import { useState } from 'react';
|
|
375
|
-
export function UserCard() {`;
|
|
376
|
-
mockReadFile.mockResolvedValue('// Full file content\nexport function UserCard() {}');
|
|
377
|
-
const result = await gatherSmartContext(diff, mockCwd);
|
|
378
|
-
// Should read the full file
|
|
379
|
-
expect(mockReadFile).toHaveBeenCalledWith(join(mockCwd, 'src/components/UserCard.tsx'), 'utf8');
|
|
380
|
-
expect(result).toContain('FULL CONTEXT: src/components/UserCard.tsx');
|
|
381
|
-
expect(result).toContain('export function UserCard()');
|
|
382
|
-
});
|
|
383
|
-
it('should handle multiple changed files', async () => {
|
|
384
|
-
const mockReadFile = vi.mocked(fs.readFile);
|
|
385
|
-
const diff = `diff --git a/src/UserCard.tsx b/src/UserCard.tsx
|
|
386
|
-
+++ b/src/UserCard.tsx
|
|
387
|
-
diff --git a/src/UserList.tsx b/src/UserList.tsx
|
|
388
|
-
+++ b/src/UserList.tsx
|
|
389
|
-
diff --git a/src/utils.ts b/src/utils.ts
|
|
390
|
-
+++ b/src/utils.ts`;
|
|
391
|
-
mockReadFile.mockImplementation(async (path) => {
|
|
392
|
-
if (path.includes('UserCard'))
|
|
393
|
-
return 'UserCard content';
|
|
394
|
-
if (path.includes('UserList'))
|
|
395
|
-
return 'UserList content';
|
|
396
|
-
if (path.includes('utils'))
|
|
397
|
-
return 'utils content';
|
|
398
|
-
return Promise.reject(new Error('ENOENT'));
|
|
399
|
-
});
|
|
400
|
-
const result = await gatherSmartContext(diff, mockCwd);
|
|
401
|
-
// Should read up to 3 files
|
|
402
|
-
expect(result).toContain('UserCard content');
|
|
403
|
-
expect(result).toContain('UserList content');
|
|
404
|
-
expect(result).toContain('utils content');
|
|
405
|
-
});
|
|
406
|
-
it('should cap at 3 files to stay within budget', async () => {
|
|
407
|
-
const mockReadFile = vi.mocked(fs.readFile);
|
|
408
|
-
const diff = `diff --git a/file1.tsx b/file1.tsx
|
|
409
|
-
+++ b/file1.tsx
|
|
410
|
-
diff --git a/file2.tsx b/file2.tsx
|
|
411
|
-
+++ b/file2.tsx
|
|
412
|
-
diff --git a/file3.tsx b/file3.tsx
|
|
413
|
-
+++ b/file3.tsx
|
|
414
|
-
diff --git a/file4.tsx b/file4.tsx
|
|
415
|
-
+++ b/file4.tsx`;
|
|
416
|
-
mockReadFile.mockResolvedValue('file content');
|
|
417
|
-
const result = await gatherSmartContext(diff, mockCwd);
|
|
418
|
-
// Should only read first 3 files
|
|
419
|
-
expect(mockReadFile).toHaveBeenCalledTimes(3);
|
|
420
|
-
});
|
|
421
|
-
it('should cap each file at 8KB', async () => {
|
|
422
|
-
const mockReadFile = vi.mocked(fs.readFile);
|
|
423
|
-
const diff = `diff --git a/large.tsx b/large.tsx
|
|
424
|
-
+++ b/large.tsx`;
|
|
425
|
-
const largeContent = 'x'.repeat(20000); // 20KB
|
|
426
|
-
mockReadFile.mockResolvedValue(largeContent);
|
|
427
|
-
const result = await gatherSmartContext(diff, mockCwd);
|
|
428
|
-
// Should be capped at 8000 chars
|
|
429
|
-
const contentMatch = result.match(/FULL CONTEXT:.*?\n(.*)/s);
|
|
430
|
-
if (contentMatch) {
|
|
431
|
-
expect(contentMatch[1].length).toBeLessThanOrEqual(8000);
|
|
432
|
-
}
|
|
433
|
-
});
|
|
434
|
-
});
|
|
435
|
-
describe('New Exports Detection', () => {
|
|
436
|
-
it('should detect new exported components per file', async () => {
|
|
437
|
-
const mockReadFile = vi.mocked(fs.readFile);
|
|
438
|
-
const diff = `diff --git a/src/UserCard.tsx b/src/UserCard.tsx
|
|
439
|
-
index abc123..def456 100644
|
|
440
|
-
--- a/src/UserCard.tsx
|
|
441
|
-
+++ b/src/UserCard.tsx
|
|
442
|
-
@@ -1,3 +1,4 @@
|
|
443
|
-
+export function UserCard() {}
|
|
444
|
-
+export const UserList = () => {}`;
|
|
445
|
-
mockReadFile.mockImplementation(async (path) => {
|
|
446
|
-
// Only return content for the source file, not test files
|
|
447
|
-
if (path.includes('UserCard.tsx') && !path.includes('test') && !path.includes('spec') && !path.includes('__tests__')) {
|
|
448
|
-
return 'export function UserCard() {}';
|
|
449
|
-
}
|
|
450
|
-
// All test file patterns should fail
|
|
451
|
-
return Promise.reject(new Error('ENOENT'));
|
|
452
|
-
});
|
|
453
|
-
const result = await gatherSmartContext(diff, mockCwd);
|
|
454
|
-
// Should warn about missing tests for THIS file's exports
|
|
455
|
-
expect(result).toContain('TEST COVERAGE: src/UserCard.tsx');
|
|
456
|
-
expect(result).toContain('No test file found');
|
|
457
|
-
expect(result).toContain('UserCard');
|
|
458
|
-
expect(result).toContain('UserList');
|
|
459
|
-
});
|
|
460
|
-
it('should detect test files when they exist', async () => {
|
|
461
|
-
const mockReadFile = vi.mocked(fs.readFile);
|
|
462
|
-
const diff = `diff --git a/src/UserCard.tsx b/src/UserCard.tsx
|
|
463
|
-
index abc123..def456 100644
|
|
464
|
-
--- a/src/UserCard.tsx
|
|
465
|
-
+++ b/src/UserCard.tsx
|
|
466
|
-
@@ -1,3 +1,4 @@
|
|
467
|
-
+export function UserCard() {}`;
|
|
468
|
-
mockReadFile.mockImplementation(async (path) => {
|
|
469
|
-
if (path.includes('UserCard.tsx') && !path.includes('test'))
|
|
470
|
-
return 'export function UserCard() {}';
|
|
471
|
-
if (path.includes('UserCard.test.tsx'))
|
|
472
|
-
return 'test content';
|
|
473
|
-
return Promise.reject(new Error('ENOENT'));
|
|
474
|
-
});
|
|
475
|
-
const result = await gatherSmartContext(diff, mockCwd);
|
|
476
|
-
// Should confirm test exists
|
|
477
|
-
expect(result).toContain('TEST COVERAGE');
|
|
478
|
-
expect(result).toContain('Test file exists');
|
|
479
|
-
expect(result).toContain('UserCard.test.tsx');
|
|
480
|
-
});
|
|
481
|
-
it('should find test file in spec.ts pattern', async () => {
|
|
482
|
-
const mockReadFile = vi.mocked(fs.readFile);
|
|
483
|
-
const diff = `diff --git a/src/UserService.ts b/src/UserService.ts
|
|
484
|
-
+++ b/src/UserService.ts
|
|
485
|
-
@@ -1,3 +1,4 @@
|
|
486
|
-
+export class UserService {}`;
|
|
487
|
-
mockReadFile.mockImplementation(async (path) => {
|
|
488
|
-
const pathStr = String(path);
|
|
489
|
-
// Source file
|
|
490
|
-
if (pathStr.endsWith('src/UserService.ts')) {
|
|
491
|
-
return 'export class UserService {}';
|
|
492
|
-
}
|
|
493
|
-
// Test file found at .spec.ts pattern
|
|
494
|
-
if (pathStr.includes('UserService.spec.ts')) {
|
|
495
|
-
return 'test content';
|
|
496
|
-
}
|
|
497
|
-
return Promise.reject(new Error('ENOENT'));
|
|
498
|
-
});
|
|
499
|
-
const result = await gatherSmartContext(diff, mockCwd);
|
|
500
|
-
// Should have found the test file and reported it
|
|
501
|
-
expect(result).toContain('TEST COVERAGE');
|
|
502
|
-
expect(result).toContain('Test file exists');
|
|
503
|
-
expect(result).toContain('UserService.spec.ts');
|
|
504
|
-
});
|
|
505
|
-
it('should only check TypeScript/React files for tests', async () => {
|
|
506
|
-
const mockReadFile = vi.mocked(fs.readFile);
|
|
507
|
-
const diff = `diff --git a/README.md b/README.md
|
|
508
|
-
index abc..def 100644
|
|
509
|
-
--- a/README.md
|
|
510
|
-
+++ b/README.md
|
|
511
|
-
@@ -1,1 +1,2 @@
|
|
512
|
-
+export function Something() {}
|
|
513
|
-
diff --git a/src/Component.tsx b/src/Component.tsx
|
|
514
|
-
index ghi..jkl 100644
|
|
515
|
-
--- a/src/Component.tsx
|
|
516
|
-
+++ b/src/Component.tsx
|
|
517
|
-
@@ -1,1 +1,2 @@
|
|
518
|
-
+export function Component() {}`;
|
|
519
|
-
mockReadFile.mockImplementation(async (path) => {
|
|
520
|
-
if (path.includes('README.md'))
|
|
521
|
-
return 'readme content';
|
|
522
|
-
if (path.includes('Component.tsx') && !path.includes('test'))
|
|
523
|
-
return 'component content';
|
|
524
|
-
return Promise.reject(new Error('ENOENT'));
|
|
525
|
-
});
|
|
526
|
-
const result = await gatherSmartContext(diff, mockCwd);
|
|
527
|
-
// Should only check Component.tsx, not README.md
|
|
528
|
-
expect(result).toContain('TEST COVERAGE: src/Component.tsx');
|
|
529
|
-
expect(result).not.toContain('TEST COVERAGE: README.md');
|
|
530
|
-
});
|
|
531
|
-
it('should detect lowercase and async function exports', async () => {
|
|
532
|
-
const mockReadFile = vi.mocked(fs.readFile);
|
|
533
|
-
const diff = `diff --git a/src/api.ts b/src/api.ts
|
|
534
|
-
index abc..def 100644
|
|
535
|
-
--- a/src/api.ts
|
|
536
|
-
+++ b/src/api.ts
|
|
537
|
-
@@ -1,1 +1,3 @@
|
|
538
|
-
+export async function fetchUserData() {}
|
|
539
|
-
+export function parseResponse() {}`;
|
|
540
|
-
mockReadFile.mockImplementation(async (path) => {
|
|
541
|
-
if (path.includes('api.ts') && !path.includes('test')) {
|
|
542
|
-
return 'export async function fetchUserData() {}';
|
|
543
|
-
}
|
|
544
|
-
return Promise.reject(new Error('ENOENT'));
|
|
545
|
-
});
|
|
546
|
-
const result = await gatherSmartContext(diff, mockCwd);
|
|
547
|
-
// Should detect both async and regular lowercase functions
|
|
548
|
-
expect(result).toContain('TEST COVERAGE: src/api.ts');
|
|
549
|
-
expect(result).toContain('fetchUserData');
|
|
550
|
-
expect(result).toContain('parseResponse');
|
|
551
|
-
// Should suggest .test.ts for .ts files
|
|
552
|
-
expect(result).toContain('api.test.ts');
|
|
553
|
-
});
|
|
554
|
-
it('should detect default exports', async () => {
|
|
555
|
-
const mockReadFile = vi.mocked(fs.readFile);
|
|
556
|
-
const diff = `diff --git a/src/MyComponent.tsx b/src/MyComponent.tsx
|
|
557
|
-
index abc..def 100644
|
|
558
|
-
--- a/src/MyComponent.tsx
|
|
559
|
-
+++ b/src/MyComponent.tsx
|
|
560
|
-
@@ -1,1 +1,2 @@
|
|
561
|
-
+export default function MyComponent() {}`;
|
|
562
|
-
mockReadFile.mockImplementation(async (path) => {
|
|
563
|
-
if (path.includes('MyComponent.tsx') && !path.includes('test')) {
|
|
564
|
-
return 'export default function MyComponent() {}';
|
|
565
|
-
}
|
|
566
|
-
return Promise.reject(new Error('ENOENT'));
|
|
567
|
-
});
|
|
568
|
-
const result = await gatherSmartContext(diff, mockCwd);
|
|
569
|
-
// Should detect default export
|
|
570
|
-
expect(result).toContain('TEST COVERAGE: src/MyComponent.tsx');
|
|
571
|
-
expect(result).toContain('MyComponent');
|
|
572
|
-
// Should suggest .test.tsx for .tsx files
|
|
573
|
-
expect(result).toContain('MyComponent.test.tsx');
|
|
574
|
-
});
|
|
575
|
-
it('should find tests in __tests__ directory', async () => {
|
|
576
|
-
const mockReadFile = vi.mocked(fs.readFile);
|
|
577
|
-
const diff = `diff --git a/src/components/Button.tsx b/src/components/Button.tsx
|
|
578
|
-
index abc..def 100644
|
|
579
|
-
--- a/src/components/Button.tsx
|
|
580
|
-
+++ b/src/components/Button.tsx
|
|
581
|
-
@@ -1,1 +1,2 @@
|
|
582
|
-
+export function Button() {}`;
|
|
583
|
-
mockReadFile.mockImplementation(async (path) => {
|
|
584
|
-
const pathStr = String(path);
|
|
585
|
-
if (pathStr.endsWith('src/components/Button.tsx')) {
|
|
586
|
-
return 'export function Button() {}';
|
|
587
|
-
}
|
|
588
|
-
// Test file in __tests__ directory
|
|
589
|
-
if (pathStr.includes('src/__tests__/components/Button.test.tsx')) {
|
|
590
|
-
return 'test content';
|
|
591
|
-
}
|
|
592
|
-
return Promise.reject(new Error('ENOENT'));
|
|
593
|
-
});
|
|
594
|
-
const result = await gatherSmartContext(diff, mockCwd);
|
|
595
|
-
// Should find test in __tests__ directory
|
|
596
|
-
expect(result).toContain('TEST COVERAGE');
|
|
597
|
-
expect(result).toContain('Test file exists');
|
|
598
|
-
expect(result).toContain('__tests__/components/Button.test.tsx');
|
|
599
|
-
});
|
|
600
|
-
it('should detect empty test files correctly', async () => {
|
|
601
|
-
const mockReadFile = vi.mocked(fs.readFile);
|
|
602
|
-
const diff = `diff --git a/src/UserCard.tsx b/src/UserCard.tsx
|
|
603
|
-
index abc..def 100644
|
|
604
|
-
--- a/src/UserCard.tsx
|
|
605
|
-
+++ b/src/UserCard.tsx
|
|
606
|
-
@@ -1,1 +1,2 @@
|
|
607
|
-
+export function UserCard() {}`;
|
|
608
|
-
mockReadFile.mockImplementation(async (path) => {
|
|
609
|
-
if (path.includes('UserCard.tsx') && !path.includes('test')) {
|
|
610
|
-
return 'export function UserCard() {}';
|
|
611
|
-
}
|
|
612
|
-
// Empty test file (should still be detected!)
|
|
613
|
-
if (path.includes('UserCard.test.tsx')) {
|
|
614
|
-
return ''; // Empty string
|
|
615
|
-
}
|
|
616
|
-
return Promise.reject(new Error('ENOENT'));
|
|
617
|
-
});
|
|
618
|
-
const result = await gatherSmartContext(diff, mockCwd);
|
|
619
|
-
// Should recognize empty test file as existing
|
|
620
|
-
expect(result).toContain('TEST COVERAGE');
|
|
621
|
-
expect(result).toContain('Test file exists');
|
|
622
|
-
expect(result).not.toContain('No test file found');
|
|
623
|
-
});
|
|
624
|
-
it('should handle files without exports gracefully', async () => {
|
|
625
|
-
const mockReadFile = vi.mocked(fs.readFile);
|
|
626
|
-
const diff = `diff --git a/src/utils.ts b/src/utils.ts
|
|
627
|
-
index abc..def 100644
|
|
628
|
-
--- a/src/utils.ts
|
|
629
|
-
+++ b/src/utils.ts
|
|
630
|
-
@@ -1,1 +1,2 @@
|
|
631
|
-
+// Just a comment, no exports`;
|
|
632
|
-
mockReadFile.mockResolvedValue('// Just a comment');
|
|
633
|
-
const result = await gatherSmartContext(diff, mockCwd);
|
|
634
|
-
// Should not complain about missing tests if no exports
|
|
635
|
-
expect(result).not.toContain('No test file found');
|
|
636
|
-
// Should still include full file context
|
|
637
|
-
expect(result).toContain('FULL CONTEXT: src/utils.ts');
|
|
638
|
-
});
|
|
639
|
-
});
|
|
640
|
-
describe('Budget Management', () => {
|
|
641
|
-
it('should respect 20KB budget for smart context', async () => {
|
|
642
|
-
const mockReadFile = vi.mocked(fs.readFile);
|
|
643
|
-
const diff = `diff --git a/file1.tsx b/file1.tsx
|
|
644
|
-
+++ b/file1.tsx
|
|
645
|
-
diff --git a/file2.tsx b/file2.tsx
|
|
646
|
-
+++ b/file2.tsx
|
|
647
|
-
diff --git a/file3.tsx b/file3.tsx
|
|
648
|
-
+++ b/file3.tsx`;
|
|
649
|
-
// Each file is 8KB (within per-file limit)
|
|
650
|
-
mockReadFile.mockResolvedValue('x'.repeat(8000));
|
|
651
|
-
const result = await gatherSmartContext(diff, mockCwd);
|
|
652
|
-
// Should not exceed 20KB total (3 files × 8KB = 24KB, but budget is 20KB)
|
|
653
|
-
expect(result.length).toBeLessThanOrEqual(25000); // Small buffer for markers
|
|
654
|
-
});
|
|
655
|
-
it('should handle missing files gracefully', async () => {
|
|
656
|
-
const mockReadFile = vi.mocked(fs.readFile);
|
|
657
|
-
const diff = `diff --git a/missing.tsx b/missing.tsx
|
|
658
|
-
+++ b/missing.tsx`;
|
|
659
|
-
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
|
660
|
-
const result = await gatherSmartContext(diff, mockCwd);
|
|
661
|
-
// Should not throw, just skip the file
|
|
662
|
-
expect(result).toBeDefined();
|
|
663
|
-
});
|
|
664
|
-
});
|
|
665
|
-
describe('Edge Cases', () => {
|
|
666
|
-
it('should handle empty diff', async () => {
|
|
667
|
-
const result = await gatherSmartContext('', mockCwd);
|
|
668
|
-
expect(result).toBe('');
|
|
669
|
-
});
|
|
670
|
-
it('should handle diff with no file changes', async () => {
|
|
671
|
-
const diff = `Some random text
|
|
672
|
-
without any file markers`;
|
|
673
|
-
const result = await gatherSmartContext(diff, mockCwd);
|
|
674
|
-
expect(result).toBe('');
|
|
675
|
-
});
|
|
676
|
-
it('should handle malformed diff gracefully', async () => {
|
|
677
|
-
const mockReadFile = vi.mocked(fs.readFile);
|
|
678
|
-
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
|
679
|
-
const diff = `diff --git a/
|
|
680
|
-
+++ b/
|
|
681
|
-
---malformed---`;
|
|
682
|
-
const result = await gatherSmartContext(diff, mockCwd);
|
|
683
|
-
expect(result).toBeDefined();
|
|
684
|
-
});
|
|
685
|
-
it('should unescape Git-escaped file paths', async () => {
|
|
686
|
-
const mockReadFile = vi.mocked(fs.readFile);
|
|
687
|
-
const diff = `diff --git a/src/components/My\\ Button.tsx b/src/components/My\\ Button.tsx
|
|
688
|
-
index abc..def 100644
|
|
689
|
-
--- a/src/components/My\\ Button.tsx
|
|
690
|
-
+++ b/src/components/My\\ Button.tsx
|
|
691
|
-
@@ -1,1 +1,2 @@
|
|
692
|
-
+export function MyButton() {}`;
|
|
693
|
-
mockReadFile.mockImplementation(async (path) => {
|
|
694
|
-
const pathStr = String(path);
|
|
695
|
-
// Should be called with unescaped path
|
|
696
|
-
if (pathStr.includes('src/components/My Button.tsx')) {
|
|
697
|
-
return 'export function MyButton() {}';
|
|
698
|
-
}
|
|
699
|
-
return Promise.reject(new Error('ENOENT'));
|
|
700
|
-
});
|
|
701
|
-
const result = await gatherSmartContext(diff, mockCwd);
|
|
702
|
-
// Should have read the file with unescaped path
|
|
703
|
-
expect(mockReadFile).toHaveBeenCalledWith(join(mockCwd, 'src/components/My Button.tsx'), 'utf8');
|
|
704
|
-
expect(result).toContain('FULL CONTEXT: src/components/My Button.tsx');
|
|
705
|
-
// Should also detect exports with unescaped path
|
|
706
|
-
expect(result).toContain('TEST COVERAGE: src/components/My Button.tsx');
|
|
707
|
-
expect(result).toContain('MyButton');
|
|
708
|
-
});
|
|
709
|
-
});
|
|
710
|
-
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { gatherContext
|
|
1
|
+
import { gatherContext } from '../review/gatherContext.js';
|
|
2
2
|
import { buildPrompt } from '../review/buildPrompt.js';
|
|
3
3
|
import { invokeAgent } from '../review/invokeAgent.js';
|
|
4
4
|
import { formatOutput } from '../review/formatOutput.js';
|
|
@@ -11,6 +11,30 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
11
11
|
const __dirname = dirname(__filename);
|
|
12
12
|
const packageJson = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8'));
|
|
13
13
|
const VERSION = packageJson.version;
|
|
14
|
+
/**
|
|
15
|
+
* Parse file list from a diff
|
|
16
|
+
*/
|
|
17
|
+
function parseFilesFromDiff(diff) {
|
|
18
|
+
const files = [];
|
|
19
|
+
const lines = diff.split('\n');
|
|
20
|
+
for (const line of lines) {
|
|
21
|
+
// Match "diff --git a/path/to/file b/path/to/file"
|
|
22
|
+
if (line.startsWith('diff --git')) {
|
|
23
|
+
const match = line.match(/b\/(.+)$/);
|
|
24
|
+
if (match && match[1]) {
|
|
25
|
+
files.push(match[1]);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Also match "+++ b/path/to/file"
|
|
29
|
+
else if (line.startsWith('+++') && line.includes('b/')) {
|
|
30
|
+
const match = line.match(/\+\+\+ b\/(.+)$/);
|
|
31
|
+
if (match && match[1] && match[1] !== '/dev/null') {
|
|
32
|
+
files.push(match[1]);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return [...new Set(files)]; // Dedupe
|
|
37
|
+
}
|
|
14
38
|
/**
|
|
15
39
|
* Perform code review with GPT-5 Codex
|
|
16
40
|
*
|
|
@@ -23,6 +47,46 @@ export async function performCodeReview(input, onProgress) {
|
|
|
23
47
|
throw new Error('content is required and cannot be empty. ' +
|
|
24
48
|
'The calling agent should provide the diff or code to review.');
|
|
25
49
|
}
|
|
50
|
+
// Check diff size and suggest chunking for large diffs
|
|
51
|
+
const lineCount = input.content.split('\n').length;
|
|
52
|
+
const LARGE_DIFF_THRESHOLD = 500;
|
|
53
|
+
if (lineCount > LARGE_DIFF_THRESHOLD && !input.force && input.contentType !== 'code') {
|
|
54
|
+
const files = parseFilesFromDiff(input.content);
|
|
55
|
+
const filesList = files.length > 0
|
|
56
|
+
? files.map(f => ` • ${f}`).join('\n')
|
|
57
|
+
: ' (Unable to parse file list from diff)';
|
|
58
|
+
return `⚠️ **Large Diff Detected** (~${lineCount} lines)
|
|
59
|
+
|
|
60
|
+
For optimal review quality, consider chunking this review into smaller pieces.
|
|
61
|
+
|
|
62
|
+
**Why chunk?**
|
|
63
|
+
- More thorough analysis per file/feature
|
|
64
|
+
- Faster feedback on each chunk
|
|
65
|
+
- Better context retention by the AI reviewer
|
|
66
|
+
- Easier to track and address issues
|
|
67
|
+
|
|
68
|
+
**Files in this diff:**
|
|
69
|
+
${filesList}
|
|
70
|
+
|
|
71
|
+
**Suggested approach:**
|
|
72
|
+
1. Review by individual file or logical feature
|
|
73
|
+
2. Aim for <${LARGE_DIFF_THRESHOLD} lines per review
|
|
74
|
+
3. Use focused reviews for critical changes
|
|
75
|
+
|
|
76
|
+
**Want to proceed anyway?**
|
|
77
|
+
Pass \`force: true\` to review the full ${lineCount}-line diff in one go.
|
|
78
|
+
|
|
79
|
+
**Example chunked review:**
|
|
80
|
+
\`\`\`
|
|
81
|
+
# Review file by file:
|
|
82
|
+
await perform_code_review({
|
|
83
|
+
content: await getFileDiff("src/components/Button.tsx"),
|
|
84
|
+
workspaceDir: "/path/to/project",
|
|
85
|
+
focus: "React best practices"
|
|
86
|
+
});
|
|
87
|
+
\`\`\`
|
|
88
|
+
`;
|
|
89
|
+
}
|
|
26
90
|
await onProgress?.('Preparing review…', 10, 100);
|
|
27
91
|
// Gather context with smart precedence
|
|
28
92
|
const shouldSkip = input.skipContextGathering === true;
|
|
@@ -37,22 +101,14 @@ export async function performCodeReview(input, onProgress) {
|
|
|
37
101
|
: shouldSkip
|
|
38
102
|
? ''
|
|
39
103
|
: await gatherContext(input.workspaceDir);
|
|
40
|
-
// Gather smart context based on the diff (full files + test coverage)
|
|
41
|
-
let smartContext = '';
|
|
42
|
-
if (!shouldSkip && !usingCustom && input.workspaceDir) {
|
|
43
|
-
smartContext = await gatherSmartContext(input.content, input.workspaceDir);
|
|
44
|
-
}
|
|
45
|
-
const combinedContext = [context, smartContext].filter(Boolean).join('\n\n');
|
|
46
104
|
// Log context gathering results (informational, not errors!)
|
|
47
|
-
const hasCursorRules =
|
|
105
|
+
const hasCursorRules = context.includes('.cursor/rules');
|
|
48
106
|
if (hasCursorRules) {
|
|
49
|
-
const cursorFiles = (
|
|
50
|
-
|
|
51
|
-
console.error(`[codex-review-mcp] Context: ${context.length} chars with .cursor/rules → ${cursorFiles.map(f => f.replace('<!-- ', '').replace(' -->', '')).join(', ')}${smartInfo}`);
|
|
107
|
+
const cursorFiles = (context.match(/<!-- \.cursor\/rules\/[^>]+ -->/g) || []);
|
|
108
|
+
console.error(`[codex-review-mcp] Context: ${context.length} chars with .cursor/rules → ${cursorFiles.map(f => f.replace('<!-- ', '').replace(' -->', '')).join(', ')}`);
|
|
52
109
|
}
|
|
53
|
-
else if (
|
|
54
|
-
|
|
55
|
-
console.error(`[codex-review-mcp] Context: ${context.length} chars from package.json, tsconfig.json, etc. (no .cursor/rules in repo)${smartInfo}`);
|
|
110
|
+
else if (context.length > 0) {
|
|
111
|
+
console.error(`[codex-review-mcp] Context: ${context.length} chars from package.json, tsconfig.json, etc. (no .cursor/rules in repo)`);
|
|
56
112
|
}
|
|
57
113
|
else {
|
|
58
114
|
console.error(`[codex-review-mcp] No project context found (generic review mode)`);
|
|
@@ -64,13 +120,12 @@ export async function performCodeReview(input, onProgress) {
|
|
|
64
120
|
const isStaticReview = input.contentType === 'code';
|
|
65
121
|
const prompt = buildPrompt({
|
|
66
122
|
diffText: input.content,
|
|
67
|
-
context
|
|
123
|
+
context,
|
|
68
124
|
focus: input.focus,
|
|
69
125
|
version: VERSION,
|
|
70
126
|
isStaticReview
|
|
71
127
|
});
|
|
72
128
|
// Invoke GPT-5 Codex
|
|
73
|
-
const lineCount = input.content.split('\n').length;
|
|
74
129
|
const reviewType = isStaticReview ? 'code' : 'diff';
|
|
75
130
|
await onProgress?.(`Calling GPT-5 Codex with ${reviewType} (~${lineCount} lines)…`, 70, 100);
|
|
76
131
|
const agentMd = await invokeAgent({
|
|
@@ -249,4 +249,74 @@ describe('performCodeReview', () => {
|
|
|
249
249
|
expect(progressCalls).toContain('Gathering project context…');
|
|
250
250
|
});
|
|
251
251
|
});
|
|
252
|
+
describe('Large Diff Detection', () => {
|
|
253
|
+
it('should return chunking guidance for large diffs (>500 lines)', async () => {
|
|
254
|
+
// Create a large diff with 600 lines
|
|
255
|
+
const largeDiff = Array(600)
|
|
256
|
+
.fill(0)
|
|
257
|
+
.map((_, i) => `+line ${i}`)
|
|
258
|
+
.join('\n');
|
|
259
|
+
const fullDiff = `diff --git a/file1.ts b/file1.ts
|
|
260
|
+
+++ b/file1.ts
|
|
261
|
+
${largeDiff}
|
|
262
|
+
diff --git a/file2.ts b/file2.ts
|
|
263
|
+
+++ b/file2.ts
|
|
264
|
+
+more changes`;
|
|
265
|
+
const result = await performCodeReview({
|
|
266
|
+
content: fullDiff,
|
|
267
|
+
contentType: 'diff'
|
|
268
|
+
});
|
|
269
|
+
const lineCount = fullDiff.split('\n').length;
|
|
270
|
+
expect(result).toContain('⚠️ **Large Diff Detected**');
|
|
271
|
+
expect(result).toContain(`~${lineCount} lines`);
|
|
272
|
+
expect(result).toContain('file1.ts');
|
|
273
|
+
expect(result).toContain('file2.ts');
|
|
274
|
+
expect(result).toContain('force: true');
|
|
275
|
+
expect(result).toContain('Why chunk?');
|
|
276
|
+
});
|
|
277
|
+
it('should allow force review of large diffs', async () => {
|
|
278
|
+
const largeDiff = Array(600)
|
|
279
|
+
.fill(0)
|
|
280
|
+
.map((_, i) => `+line ${i}`)
|
|
281
|
+
.join('\n');
|
|
282
|
+
vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
|
|
283
|
+
vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review\n\nLooks good!');
|
|
284
|
+
const result = await performCodeReview({
|
|
285
|
+
content: largeDiff,
|
|
286
|
+
contentType: 'diff',
|
|
287
|
+
force: true
|
|
288
|
+
});
|
|
289
|
+
expect(result).toContain('# Review');
|
|
290
|
+
expect(result).toContain('Looks good!');
|
|
291
|
+
expect(result).not.toContain('Large Diff Detected');
|
|
292
|
+
});
|
|
293
|
+
it('should not trigger chunking guidance for code reviews', async () => {
|
|
294
|
+
const largeCode = Array(600)
|
|
295
|
+
.fill(0)
|
|
296
|
+
.map((_, i) => `const x${i} = ${i};`)
|
|
297
|
+
.join('\n');
|
|
298
|
+
vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
|
|
299
|
+
vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review\n\nLooks good!');
|
|
300
|
+
const result = await performCodeReview({
|
|
301
|
+
content: largeCode,
|
|
302
|
+
contentType: 'code'
|
|
303
|
+
});
|
|
304
|
+
expect(result).not.toContain('Large Diff Detected');
|
|
305
|
+
expect(result).toContain('# Review');
|
|
306
|
+
});
|
|
307
|
+
it('should not trigger chunking guidance for small diffs (<500 lines)', async () => {
|
|
308
|
+
const smallDiff = Array(400)
|
|
309
|
+
.fill(0)
|
|
310
|
+
.map((_, i) => `+line ${i}`)
|
|
311
|
+
.join('\n');
|
|
312
|
+
vi.spyOn(gatherContextModule, 'gatherContext').mockResolvedValue('');
|
|
313
|
+
vi.spyOn(invokeAgentModule, 'invokeAgent').mockResolvedValue('# Review\n\nLooks good!');
|
|
314
|
+
const result = await performCodeReview({
|
|
315
|
+
content: smallDiff,
|
|
316
|
+
contentType: 'diff'
|
|
317
|
+
});
|
|
318
|
+
expect(result).not.toContain('Large Diff Detected');
|
|
319
|
+
expect(result).toContain('# Review');
|
|
320
|
+
});
|
|
321
|
+
});
|
|
252
322
|
});
|