codex-review-mcp 2.5.1 → 2.7.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.
@@ -7,8 +7,22 @@ export function buildPrompt({ diffText, context, focus, version, isStaticReview
7
7
  '',
8
8
  'You are an expert TypeScript, React 18, Material UI, and Emotion UI code reviewer. Be concise, specific, and actionable.',
9
9
  '',
10
- '⚠️ CRITICAL CONSTRAINT: Review ONLY the files and changes shown in the diff below.',
11
- 'Do NOT reference, suggest, or mention files that are not in this diff.',
10
+ '⚠️ CRITICAL CONSTRAINTS:',
11
+ '1. Review ONLY the files and changes shown in the diff below.',
12
+ '2. Do NOT reference, suggest, or mention files that are not in this diff.',
13
+ '3. EVERY finding MUST include:',
14
+ ' - Exact file path and line number(s) that exist in the diff',
15
+ ' - A short quoted code snippet from that exact location as evidence',
16
+ ' - If you cannot quote the code, the finding is invalid',
17
+ '',
18
+ '💡 WHEN IN DOUBT, ASK - DON\'T GUESS:',
19
+ 'If you need more information to give accurate feedback, ask clarifying questions instead of making assumptions.',
20
+ 'Good questions help the coding agent provide better context. Examples:',
21
+ ' • "I see a new `useUserData` hook but don\'t see its tests - are they in a separate PR?"',
22
+ ' • "This component uses `CustomModal` - is this a new pattern or existing abstraction I should reference?"',
23
+ ' • "The error handling here is minimal - is there a global error boundary handling this?"',
24
+ ' • "I need to see the full `UserService` interface to verify this usage is type-safe"',
25
+ 'Asking questions is ALWAYS better than asserting something you cannot verify from the diff.',
12
26
  '',
13
27
  'PRIORITY HIERARCHY:',
14
28
  '1. Project documentation (.cursor/rules/*, CODE_REVIEW.md, CONTRIBUTING.md) - HIGHEST PRIORITY',
@@ -24,10 +38,35 @@ export function buildPrompt({ diffText, context, focus, version, isStaticReview
24
38
  '- When project guidelines conflict with best practices, follow the project',
25
39
  focusLine,
26
40
  '',
41
+ 'COMMON CODE SMELLS TO FLAG:',
42
+ '- Hardcoded pixel values or magic numbers (should use theme.spacing(), theme.ms(), or constants)',
43
+ '- Unnecessary useEffect hooks (prefer derived state, useMemo, or direct calculations)',
44
+ '- setTimeout/setInterval without clear cleanup or justification',
45
+ '- Direct DOM manipulation (document.querySelector, etc.) instead of React refs',
46
+ '- Inline styles with hardcoded colors/sizes instead of theme values',
47
+ '',
27
48
  `\n---\n${reviewType}${isStaticReview ? ' - Code Files' : ' (unified=0)'}:\n`,
28
49
  diffText,
29
50
  context ? '\n---\nProject context and guidelines (FOLLOW THESE STRICTLY):\n' + context : '',
30
- '\n---\nOutput strictly as Markdown with the following sections:\n',
31
- '1) Title + scope summary\n2) Quick Summary (3–6 bullets)\n3) Issues table: severity | file:lines | category | explanation | suggested fix\n4) Inline suggested edits for top issues\n5) Positive notes (mention when code follows project guidelines well)\n6) Next steps',
51
+ '',
52
+ '---',
53
+ 'BEFORE YOU RESPOND - VALIDATE EACH FINDING:',
54
+ '- [ ] File path exists in the diff above',
55
+ '- [ ] Line number(s) exist in that file',
56
+ '- [ ] Code snippet is quoted verbatim from that location',
57
+ '- [ ] Issue ties to project rules/patterns (not just generic advice)',
58
+ '- [ ] If you cannot verify all above → ASK a specific question instead of asserting',
59
+ '',
60
+ 'Remember: A good clarifying question is more valuable than a questionable assertion.',
61
+ '',
62
+ '---',
63
+ 'Output strictly as Markdown with the following sections:',
64
+ '1) Title + scope summary',
65
+ '2) Quick Summary (3–6 bullets)',
66
+ '3) Issues table: severity | file:lines | category | explanation | suggested fix',
67
+ '4) Inline suggested edits for top issues',
68
+ '5) Clarifying Questions (if any) - list specific questions that would help provide better feedback',
69
+ '6) Positive notes (mention when code follows project guidelines well)',
70
+ '7) Next steps',
32
71
  ].join('\n');
33
72
  }
@@ -62,10 +62,12 @@ describe('buildPrompt', () => {
62
62
  });
63
63
  // Verify key sections are present
64
64
  expect(prompt).toContain('You are an expert TypeScript, React 18, Material UI, and Emotion UI code reviewer');
65
- expect(prompt).toContain('CRITICAL CONSTRAINT: Review ONLY the files');
65
+ expect(prompt).toContain('CRITICAL CONSTRAINTS');
66
+ expect(prompt).toContain('Review ONLY the files');
67
+ expect(prompt).toContain('EVERY finding MUST include');
68
+ expect(prompt).toContain('BEFORE YOU RESPOND - VALIDATE EACH FINDING');
66
69
  expect(prompt).toContain('PRIORITY HIERARCHY');
67
70
  expect(prompt).toContain('CORE PRINCIPLES');
68
- // Severity is determined by GPT-5 based on context, not pre-defined
69
71
  expect(prompt).toContain('Output strictly as Markdown with the following sections');
70
72
  });
71
73
  it('should include the diff text', () => {
@@ -92,8 +94,11 @@ describe('buildPrompt', () => {
92
94
  const prompt = buildPrompt({
93
95
  diffText: mockDiff,
94
96
  });
95
- expect(prompt).toContain('CRITICAL CONSTRAINT: Review ONLY the files');
97
+ expect(prompt).toContain('CRITICAL CONSTRAINTS');
98
+ expect(prompt).toContain('Review ONLY the files');
96
99
  expect(prompt).toContain('Do NOT reference, suggest, or mention files that are not in this diff');
100
+ expect(prompt).toContain('EVERY finding MUST include');
101
+ expect(prompt).toContain('quoted code snippet from that exact location as evidence');
97
102
  });
98
103
  it('should include guidance hierarchy with proper priorities', () => {
99
104
  const prompt = buildPrompt({
@@ -83,6 +83,140 @@ 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
+ }
86
220
  export async function gatherContext(baseDir) {
87
221
  const chunks = [];
88
222
  const processedPaths = new Set();
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { gatherContext } from './gatherContext.js';
2
+ import { gatherContext, gatherSmartContext } from './gatherContext.js';
3
3
  import { promises as fs } from 'node:fs';
4
+ import { join } from 'node:path';
4
5
  vi.mock('node:fs', () => ({
5
6
  promises: {
6
7
  readFile: vi.fn(),
@@ -353,3 +354,357 @@ describe('gatherContext', () => {
353
354
  });
354
355
  });
355
356
  });
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 } from '../review/gatherContext.js';
1
+ import { gatherContext, gatherSmartContext } 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';
@@ -37,14 +37,22 @@ export async function performCodeReview(input, onProgress) {
37
37
  : shouldSkip
38
38
  ? ''
39
39
  : 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');
40
46
  // Log context gathering results (informational, not errors!)
41
- const hasCursorRules = context.includes('.cursor/rules');
47
+ const hasCursorRules = combinedContext.includes('.cursor/rules');
42
48
  if (hasCursorRules) {
43
- const cursorFiles = (context.match(/<!-- \.cursor\/rules\/[^>]+ -->/g) || []);
44
- console.error(`[codex-review-mcp] Context: ${context.length} chars with .cursor/rules ${cursorFiles.map(f => f.replace('<!-- ', '').replace(' -->', '')).join(', ')}`);
49
+ const cursorFiles = (combinedContext.match(/<!-- \.cursor\/rules\/[^>]+ -->/g) || []);
50
+ const smartInfo = smartContext ? ` + ${smartContext.length} smart context` : '';
51
+ console.error(`[codex-review-mcp] Context: ${context.length} chars with .cursor/rules → ${cursorFiles.map(f => f.replace('<!-- ', '').replace(' -->', '')).join(', ')}${smartInfo}`);
45
52
  }
46
- else if (context.length > 0) {
47
- console.error(`[codex-review-mcp] Context: ${context.length} chars from package.json, tsconfig.json, etc. (no .cursor/rules in repo)`);
53
+ else if (combinedContext.length > 0) {
54
+ const smartInfo = smartContext ? ` + ${smartContext.length} smart context` : '';
55
+ console.error(`[codex-review-mcp] Context: ${context.length} chars from package.json, tsconfig.json, etc. (no .cursor/rules in repo)${smartInfo}`);
48
56
  }
49
57
  else {
50
58
  console.error(`[codex-review-mcp] No project context found (generic review mode)`);
@@ -56,7 +64,7 @@ export async function performCodeReview(input, onProgress) {
56
64
  const isStaticReview = input.contentType === 'code';
57
65
  const prompt = buildPrompt({
58
66
  diffText: input.content,
59
- context,
67
+ context: combinedContext,
60
68
  focus: input.focus,
61
69
  version: VERSION,
62
70
  isStaticReview
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-review-mcp",
3
- "version": "2.5.1",
3
+ "version": "2.7.0",
4
4
  "main": "index.js",
5
5
  "scripts": {
6
6
  "build": "tsc",