codecritique 2.0.8 → 2.1.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/README.md CHANGED
@@ -202,6 +202,8 @@ For easier integration with non-JavaScript projects, you can use the provided sh
202
202
  - Validates Node.js 24+ requirement
203
203
  - Provides helpful error messages for missing dependencies
204
204
 
205
+ The wrapper forwards only `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_LOG`, `GITHUB_TOKEN`, `GH_TOKEN`, `DEBUG`, `VERBOSE`, `CI`, and `GITHUB_WORKSPACE_PATH` from `.env`; other entries, including process-control variables such as `NODE_OPTIONS` and `PATH`, are ignored. An unreadable `.env` stops the wrapper with an error. Run it only in trusted project directories: `ANTHROPIC_BASE_URL` controls where API requests are sent, while `CI` and `GITHUB_WORKSPACE_PATH` control where embedding data is stored.
206
+
205
207
  ## Quick Start
206
208
 
207
209
  Follow this three-step workflow for optimal code review results:
@@ -541,6 +543,8 @@ DEBUG=true
541
543
  VERBOSE=true
542
544
  ```
543
545
 
546
+ Set `CODECRITIQUE_SKIP_DOTENV=1` to disable `.env` loading, as the shell wrapper does after forwarding its supported variables.
547
+
544
548
  ## Output Formats
545
549
 
546
550
  CodeCritique supports three output formats:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecritique",
3
- "version": "2.0.8",
3
+ "version": "2.1.1",
4
4
  "description": "AI-powered code review tool for any programming language",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,169 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { access, chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { describe, expect, it, onTestFinished } from 'vitest';
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ async function runWrapper(envFile, args = [], command = 'codecritique', extraEnv = {}, envIsDirectory = false, nodeMajorVersion = null) {
11
+ const directory = await mkdtemp(join(tmpdir(), 'codecritique-shell-'));
12
+ onTestFinished(() => rm(directory, { recursive: true, force: true }));
13
+ const fakeCommand = join(directory, command);
14
+ const envInspector = join(directory, 'inspect-entry-env.mjs');
15
+
16
+ if (envIsDirectory) {
17
+ await mkdir(join(directory, '.env'));
18
+ }
19
+ else if (envFile !== null) {
20
+ await writeFile(join(directory, '.env'), envFile);
21
+ }
22
+ await writeFile(
23
+ envInspector,
24
+ `process.argv = ['node'];
25
+ process.exit = code => { throw new Error(\`exit \${code}\`); };
26
+ try { await import(${JSON.stringify(new URL('./index.js', import.meta.url).href)}); } catch {}
27
+ console.log(\`inspected_node_options=\${process.env.NODE_OPTIONS || ''}\`);
28
+ `
29
+ );
30
+ await writeFile(
31
+ fakeCommand,
32
+ `#!/bin/bash
33
+ [ "$1" = "--terminate" ] && kill -TERM $$
34
+ [ "$1" = "--inspect-entry" ] && exec node "${envInspector}"
35
+ [ "$1" = "--inspect-entry-with-dotenv" ] && unset CODECRITIQUE_SKIP_DOTENV && exec node "${envInspector}"
36
+ /usr/bin/env
37
+ printf "arg=%s\\n" "$@"
38
+ `
39
+ );
40
+ await chmod(fakeCommand, 0o755);
41
+ if (nodeMajorVersion === null) {
42
+ await symlink(process.execPath, join(directory, 'node'));
43
+ }
44
+ else {
45
+ await writeFile(
46
+ join(directory, 'node'),
47
+ `#!/bin/bash\nif [ "$1" = "-p" ]; then echo ${nodeMajorVersion}; exit; fi\nif [ "$1" = "--version" ]; then echo v${nodeMajorVersion}.0.0; exit; fi\nexec "${process.execPath}" "$@"\n`
48
+ );
49
+ await chmod(join(directory, 'node'), 0o755);
50
+ }
51
+
52
+ const result = await execFileAsync('/bin/bash', [join(import.meta.dirname, 'codecritique.sh'), ...args], {
53
+ cwd: directory,
54
+ env: { PATH: directory, ...extraEnv },
55
+ });
56
+
57
+ return { ...result, directory };
58
+ }
59
+
60
+ describe('codecritique.sh', () => {
61
+ it.each([
62
+ [20, 'Error: CodeCritique requires Node.js 24 or newer (found v20.0.0).\n'],
63
+ ['not-a-version', 'Error: Unable to determine the Node.js major version (received: not-a-version).\n'],
64
+ ])('rejects unsupported Node version output: %s', async (nodeVersion, stderr) => {
65
+ await expect(runWrapper(null, [], 'codecritique', {}, false, nodeVersion)).rejects.toMatchObject({
66
+ code: 1,
67
+ stderr,
68
+ });
69
+ });
70
+
71
+ it.each([
72
+ ['unquoted spaces and globs', 'ANTHROPIC_API_KEY=value with spaces *\n', 'value with spaces *'],
73
+ ['literal JSON quotes', 'ANTHROPIC_API_KEY={"token":"a*b c"}\n', '{"token":"a*b c"}'],
74
+ ])('loads %s without corruption', async (_label, envFile, expected) => {
75
+ const { stdout } = await runWrapper(envFile, ['--flag', 'value with spaces']);
76
+
77
+ expect(stdout).toContain(`ANTHROPIC_API_KEY=${expected}`);
78
+ expect(stdout).toContain('arg=--flag');
79
+ expect(stdout).toContain('arg=value with spaces');
80
+ });
81
+
82
+ it('does not execute .env contents', async () => {
83
+ const { directory, stdout } = await runWrapper(
84
+ 'ANTHROPIC_API_KEY=test\nMALICIOUS=$(touch executed)\nNODE_OPTIONS=--require=./does-not-exist.cjs\n'
85
+ );
86
+
87
+ expect(stdout).toContain('ANTHROPIC_API_KEY=test');
88
+ expect(stdout).not.toContain('NODE_OPTIONS=');
89
+ await expect(access(join(directory, 'executed'))).rejects.toThrow();
90
+ });
91
+
92
+ it('prevents the real CLI entry point from reloading filtered .env values', async () => {
93
+ const { stdout } = await runWrapper('ANTHROPIC_API_KEY=test\nNODE_OPTIONS=--require=./does-not-exist.cjs\n', ['--inspect-entry']);
94
+
95
+ expect(stdout).toContain('inspected_node_options=\n');
96
+ });
97
+
98
+ it('loads .env at the real CLI entry point when the wrapper sentinel is absent', async () => {
99
+ const { stdout } = await runWrapper('ANTHROPIC_API_KEY=test\nNODE_OPTIONS=loaded-by-dotenv\n', ['--inspect-entry-with-dotenv']);
100
+
101
+ expect(stdout).toContain('inspected_node_options=loaded-by-dotenv\n');
102
+ });
103
+
104
+ it('forwards every supported .env setting', async () => {
105
+ const supportedEnv = {
106
+ ANTHROPIC_API_KEY: 'test',
107
+ ANTHROPIC_BASE_URL: 'https://api.example.test',
108
+ ANTHROPIC_LOG: 'debug',
109
+ GITHUB_TOKEN: 'github-token',
110
+ GH_TOKEN: 'gh-token',
111
+ DEBUG: '1',
112
+ VERBOSE: 'true',
113
+ CI: 'true',
114
+ GITHUB_WORKSPACE_PATH: '/workspace',
115
+ };
116
+ const envFile = Object.entries(supportedEnv)
117
+ .map(([key, value]) => `${key}=${value}`)
118
+ .join('\n');
119
+ const { stdout } = await runWrapper(envFile);
120
+
121
+ for (const [key, value] of Object.entries(supportedEnv)) {
122
+ expect(stdout).toContain(`${key}=${value}`);
123
+ }
124
+ });
125
+
126
+ it('reports null bytes in supported values without an internal stack', async () => {
127
+ await expect(runWrapper('ANTHROPIC_API_KEY=test\0value\n')).rejects.toMatchObject({
128
+ stderr: 'Unable to load .env: ANTHROPIC_API_KEY contains a null byte\n',
129
+ });
130
+ });
131
+
132
+ it('preserves an inherited API key when .env is absent', async () => {
133
+ const { stdout } = await runWrapper(null, [], 'codecritique', { ANTHROPIC_API_KEY: 'inherited' });
134
+
135
+ expect(stdout).toContain('ANTHROPIC_API_KEY=inherited');
136
+ });
137
+
138
+ it('keeps the original .env-over-inherited-value precedence', async () => {
139
+ const { stdout } = await runWrapper('ANTHROPIC_API_KEY=from-file\n', [], 'codecritique', {
140
+ ANTHROPIC_API_KEY: 'from-parent',
141
+ });
142
+
143
+ expect(stdout).toContain('ANTHROPIC_API_KEY=from-file');
144
+ });
145
+
146
+ it.each([null, 'ANTHROPIC_AUTH_TOKEN=token\n'])('warns when no supported Anthropic credentials are configured', async (envFile) => {
147
+ const { stderr } = await runWrapper(envFile);
148
+
149
+ expect(stderr).toContain('Warning: ANTHROPIC_API_KEY is not set.');
150
+ });
151
+
152
+ it('reports unreadable .env paths without an internal stack', async () => {
153
+ await expect(runWrapper(null, [], 'codecritique', {}, true)).rejects.toMatchObject({
154
+ stderr: expect.stringMatching(/^Unable to load \.env: .+\n$/),
155
+ });
156
+ });
157
+
158
+ it('falls back to npx when codecritique is not installed globally', async () => {
159
+ const { stdout } = await runWrapper('ANTHROPIC_API_KEY=test\n', ['--version'], 'npx');
160
+
161
+ expect(stdout).toContain('codecritique not found globally, trying with npx...');
162
+ expect(stdout).toContain('arg=codecritique');
163
+ expect(stdout).toContain('arg=--version');
164
+ });
165
+
166
+ it('preserves signal-derived child exit codes', async () => {
167
+ await expect(runWrapper('ANTHROPIC_API_KEY=test\n', ['--terminate'])).rejects.toMatchObject({ code: 143 });
168
+ });
169
+ });
package/src/index.js CHANGED
@@ -15,7 +15,7 @@ import chalk from 'chalk';
15
15
  import { Spinner } from 'cli-spinner';
16
16
  import { program } from 'commander';
17
17
  import { glob } from 'glob';
18
- import { getDefaultEmbeddingsSystem } from './embeddings/factory.js';
18
+ import { getDefaultEmbeddingsSystem, getDefaultEmbeddingsSystemIfInitialized } from './embeddings/factory.js';
19
19
  import { PRHistoryAnalyzer } from './pr-history/analyzer.js';
20
20
  import {
21
21
  displayAnalysisResults,
@@ -34,8 +34,12 @@ import { diagnosticLog, isDebugEnabled, isVerboseEnabled, verboseLog } from './u
34
34
  import { collectPRLevelFindings, getFileLevelIssueCount, hasPRLevelFindings } from './utils/review-results.js';
35
35
  import { configureCleanStdoutForDataOutput, isBrokenStdoutPipeError, writeStdout } from './utils/stdout.js';
36
36
 
37
- // Create a default embeddings system instance
38
- const embeddingsSystem = getDefaultEmbeddingsSystem();
37
+ async function cleanupEmbeddingsSystemIfInitialized() {
38
+ const embeddingsSystem = getDefaultEmbeddingsSystemIfInitialized();
39
+ if (embeddingsSystem) {
40
+ await embeddingsSystem.cleanup();
41
+ }
42
+ }
39
43
 
40
44
  const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
41
45
 
@@ -103,6 +107,7 @@ program
103
107
  .command('embeddings:clear-all')
104
108
  .description('Clear ALL stored embeddings (affects all projects - use with caution)')
105
109
  .action(async () => {
110
+ const embeddingsSystem = getDefaultEmbeddingsSystem();
106
111
  try {
107
112
  console.log(chalk.red('WARNING: This will clear embeddings for ALL projects on this machine!'));
108
113
  console.log(chalk.cyan('Clearing all embeddings...'));
@@ -227,7 +232,7 @@ process.on('SIGINT', async () => {
227
232
 
228
233
  try {
229
234
  diagnosticLog(chalk.cyan('SIGINT handler: Attempting embeddingsSystem.cleanup()...'));
230
- await embeddingsSystem.cleanup();
235
+ await cleanupEmbeddingsSystemIfInitialized();
231
236
  diagnosticLog(chalk.green('embeddingsSystem.cleanup() completed.'));
232
237
  clearTimeout(forceExitTimeout); // Cleanup finished, clear the timeout
233
238
  diagnosticLog(chalk.cyan('SIGINT handler: Exiting normally (code 0).'));
@@ -251,7 +256,7 @@ process.on('SIGTERM', async () => {
251
256
 
252
257
  try {
253
258
  diagnosticLog(chalk.cyan('SIGTERM handler: Attempting embeddingsSystem.cleanup()...'));
254
- await embeddingsSystem.cleanup();
259
+ await cleanupEmbeddingsSystemIfInitialized();
255
260
  diagnosticLog(chalk.green('embeddingsSystem.cleanup() completed.'));
256
261
  clearTimeout(forceExitTimeout); // Cleanup finished, clear the timeout
257
262
  diagnosticLog(chalk.cyan('SIGTERM handler: Exiting normally (code 0).'));
@@ -468,7 +473,7 @@ async function runCodeReview(options) {
468
473
  // Clean up resources
469
474
  diagnosticLog(chalk.cyan('Cleaning up resources...'));
470
475
  try {
471
- await embeddingsSystem.cleanup();
476
+ await cleanupEmbeddingsSystemIfInitialized();
472
477
  await cleanupClassifier();
473
478
  diagnosticLog(chalk.green('All resources cleaned up successfully'));
474
479
  }
@@ -497,7 +502,7 @@ async function runCodeReview(options) {
497
502
 
498
503
  // Clean up resources even on error
499
504
  try {
500
- await embeddingsSystem.cleanup();
505
+ await cleanupEmbeddingsSystemIfInitialized();
501
506
  await cleanupClassifier();
502
507
  diagnosticLog(chalk.green('All resources cleaned up successfully'));
503
508
  }
@@ -516,6 +521,7 @@ async function runCodeReview(options) {
516
521
  * @param {Object} options - Command options
517
522
  */
518
523
  async function generateEmbeddings(options) {
524
+ const embeddingsSystem = getDefaultEmbeddingsSystem();
519
525
  try {
520
526
  console.log(chalk.bold.blue('AI Code Review - Generating embeddings...'));
521
527
  const startTime = Date.now();
@@ -717,6 +723,7 @@ async function generateEmbeddings(options) {
717
723
  * Clear stored embeddings for the current project
718
724
  */
719
725
  async function clearEmbeddings(options) {
726
+ const embeddingsSystem = getDefaultEmbeddingsSystem();
720
727
  try {
721
728
  // Determine the working directory for project separation
722
729
  // If --directory is specified, use that as the project directory
@@ -751,6 +758,7 @@ async function clearEmbeddings(options) {
751
758
  * Show statistics about stored embeddings
752
759
  */
753
760
  async function showEmbeddingStats(options) {
761
+ const embeddingsSystem = getDefaultEmbeddingsSystem();
754
762
  try {
755
763
  // Determine the working directory for project separation
756
764
  // If --directory is specified, use that as the project directory
package/src/llm.js CHANGED
@@ -18,7 +18,9 @@ import dotenv from 'dotenv';
18
18
  import { verboseLog } from './utils/logging.js';
19
19
 
20
20
  // Load env variables if present; do not enforce key at import time
21
- dotenv.config({ quiet: true });
21
+ if (process.env.CODECRITIQUE_SKIP_DOTENV !== '1') {
22
+ dotenv.config({ quiet: true });
23
+ }
22
24
 
23
25
  let anthropic = null;
24
26
 
@@ -27,6 +27,7 @@ import { isGenericDocument, getGenericDocumentContext } from './utils/document-d
27
27
  import { isTestFile, shouldProcessFile } from './utils/file-validation.js';
28
28
  import { detectFileType, detectLanguageFromExtension } from './utils/language-detection.js';
29
29
  import { debug, verboseLog } from './utils/logging.js';
30
+ import { formatPlannedHolisticFileContext, mergeHolisticFileContextPlans } from './utils/pr-file-context.js';
30
31
  import { addLineNumbers } from './utils/string-utils.js';
31
32
 
32
33
  // Constants for content processing
@@ -36,9 +37,6 @@ const DEFAULT_TRUNCATE_LINES = 300;
36
37
  const GUIDELINE_TRUNCATE_LINES = 400;
37
38
  const MAX_PR_COMMENTS_FOR_CONTEXT = 15;
38
39
 
39
- // Create embeddings system instance
40
- const embeddingsSystem = getDefaultEmbeddingsSystem();
41
-
42
40
  // Track if semantic similarity has been initialized
43
41
  let semanticSimilarityInitialized = false;
44
42
 
@@ -233,6 +231,7 @@ ${ex.content}
233
231
  */
234
232
  async function getProjectSummary(projectPath, options = {}) {
235
233
  const resolvedPath = path.resolve(projectPath);
234
+ const embeddingsSystem = getDefaultEmbeddingsSystem();
236
235
 
237
236
  try {
238
237
  // Retrieve from database
@@ -1376,23 +1375,24 @@ ${g.content}
1376
1375
 
1377
1376
  // Format PR files with their diffs
1378
1377
  const prFiles = file.prFiles || [];
1378
+ const holisticContextPlans = mergeHolisticFileContextPlans(prFiles, context.options || {});
1379
1379
  const formattedPRFiles = prFiles
1380
1380
  .map((prFile, idx) => {
1381
+ const fileContextPlan = holisticContextPlans[idx];
1382
+ const fileContext = formatPlannedHolisticFileContext(prFile, fileContextPlan, context.options || {});
1381
1383
  return `
1382
1384
  ## FILE ${idx + 1}: ${prFile.path}
1383
1385
  **Language**: ${prFile.language}
1384
1386
  **Type**: ${prFile.isTest ? 'Test' : 'Source'} file
1385
1387
  **Summary**: ${prFile.summary}
1388
+ **Context Mode**: ${fileContextPlan.mode === 'full' ? 'Full file content' : 'Focused changed-line context'}
1386
1389
 
1387
1390
  ### Changes (Git Diff):
1388
1391
  \`\`\`diff
1389
1392
  ${prFile.diff}
1390
1393
  \`\`\`
1391
1394
 
1392
- ### Full File Content (For Context - line numbers shown for reference):
1393
- \`\`\`${prFile.language}
1394
- ${addLineNumbers(prFile.fullContent)}
1395
- \`\`\`
1395
+ ${fileContext}
1396
1396
  `;
1397
1397
  })
1398
1398
  .join('\n');
@@ -1406,17 +1406,17 @@ ${addLineNumbers(prFile.fullContent)}
1406
1406
  **CRITICAL CONTEXT AWARENESS INSTRUCTIONS:**
1407
1407
 
1408
1408
  For each file in this PR, you have access to:
1409
- 1. **FULL FILE CONTENT** - The complete file for understanding context and existing code
1409
+ 1. **FILE CONTEXT** - Complete file content when it fits the holistic context budget, or focused changed-line windows for very large files/PRs
1410
1410
  2. **GIT DIFF** - Only the changes to review
1411
1411
 
1412
1412
  **Review Rules:**
1413
1413
  - ONLY critique the CHANGED lines shown in each file's diff (lines with + or -)
1414
- - USE the full file content to understand context, dependencies, and existing implementations
1414
+ - USE the file context to understand dependencies, nearby behavior, and existing implementations
1415
1415
  - DO NOT suggest adding code that already exists in the unchanged portions of any file
1416
- - DO NOT flag issues about missing code if it exists elsewhere in the full file
1417
- - Before flagging cross-file issues, verify the code doesn't already exist in unchanged portions
1418
- - Do NOT flag functions/variables as missing if they exist elsewhere in the full file
1419
- - The unchanged code is part of each file - always check it before making assumptions`;
1416
+ - DO NOT flag issues about missing code when the provided context already shows the code exists
1417
+ - Before flagging cross-file issues, verify the code doesn't already exist in the provided context
1418
+ - Do NOT flag functions/variables as missing if they exist elsewhere in the provided context
1419
+ - The unchanged context is part of each file - always check it before making assumptions`;
1420
1420
 
1421
1421
  let roleDefinition = buildRoleDefinition(baseRole, customDocs, 'pr');
1422
1422
  roleDefinition += '\nAnalyze ALL files together to identify cross-file issues, consistency problems, and overall code quality.';
@@ -1927,6 +1927,7 @@ async function performHolisticPRAnalysis(options) {
1927
1927
  * @returns {Promise<Object>} An object containing the gathered context.
1928
1928
  */
1929
1929
  async function getContextForFile(filePath, content, options = {}) {
1930
+ const embeddingsSystem = getDefaultEmbeddingsSystem();
1930
1931
  const RELEVANT_CHUNK_THRESHOLD = 0.1;
1931
1932
  const W_H1_SIM = 0.2;
1932
1933
  const W_DOC_CONTEXT_MATCH = 0.6;
@@ -2281,6 +2282,7 @@ async function getContextForFile(filePath, content, options = {}) {
2281
2282
  }
2282
2283
 
2283
2284
  async function gatherUnifiedContextForPR(prFiles, options = {}) {
2285
+ const embeddingsSystem = getDefaultEmbeddingsSystem();
2284
2286
  const allProcessedContext = {
2285
2287
  codeExamples: new Map(),
2286
2288
  guidelines: new Map(),
@@ -578,6 +578,70 @@ describe('rag-analyzer', () => {
578
578
  );
579
579
  expect(result.success).toBe(true);
580
580
  });
581
+
582
+ it('should use focused context for oversized holistic PR files', async () => {
583
+ const hugeContent = Array.from({ length: 5000 }, (_, index) => `huge line ${index + 1}`).join('\n');
584
+ llm.sendPromptToClaude.mockResolvedValue(createMockHolisticReviewResponse());
585
+
586
+ const result = await runAnalysis(
587
+ 'PR_HOLISTIC_REVIEW',
588
+ setupHolisticReviewOptions({
589
+ prFiles: [
590
+ createMockPRFile({
591
+ path: 'src/small.js',
592
+ fullContent: 'const small = true;',
593
+ diff: '@@ -1,1 +1,1 @@\n-const small = false;\n+const small = true;',
594
+ }),
595
+ createMockPRFile({
596
+ path: 'src/huge.js',
597
+ fullContent: hugeContent,
598
+ diff: '@@ -2500,1 +2500,1 @@\n-huge line 2500\n+huge line 2500 changed',
599
+ }),
600
+ ],
601
+ prContext: { totalFiles: 2 },
602
+ maxTotalFullContentTokens: 1000,
603
+ })
604
+ );
605
+
606
+ const prompt = llm.sendPromptToClaude.mock.calls.at(-1)[0];
607
+ expect(result.success).toBe(true);
608
+ expect(prompt).toContain('**Context Mode**: Full file content');
609
+ expect(prompt).toContain('**Context Mode**: Focused changed-line context');
610
+ expect(prompt).toContain('const small = true;');
611
+ expect(prompt).toContain('Focused File Context');
612
+ expect(prompt).toContain('2500 | huge line 2500');
613
+ expect(prompt).not.toContain('5000 | huge line 5000');
614
+ });
615
+
616
+ it('should preserve carried holistic context plans for chunked reviews', async () => {
617
+ llm.sendPromptToClaude.mockResolvedValue(createMockHolisticReviewResponse());
618
+
619
+ await runAnalysis(
620
+ 'PR_HOLISTIC_REVIEW',
621
+ setupHolisticReviewOptions({
622
+ prFiles: [
623
+ createMockPRFile({
624
+ path: 'src/chunked.js',
625
+ fullContent: 'const wouldFit = true;',
626
+ diff: '@@ -1,1 +1,1 @@\n-const wouldFit = false;\n+const wouldFit = true;',
627
+ holisticContextPlan: {
628
+ mode: 'focused',
629
+ reason: 'global budget assigned focused context before chunking',
630
+ contextTokens: 5,
631
+ totalTokens: 20,
632
+ contextLineRadius: 0,
633
+ },
634
+ }),
635
+ ],
636
+ prContext: { totalFiles: 1 },
637
+ })
638
+ );
639
+
640
+ const prompt = llm.sendPromptToClaude.mock.calls.at(-1)[0];
641
+ expect(prompt).toContain('**Context Mode**: Focused changed-line context');
642
+ expect(prompt).toContain('global budget assigned focused context before chunking');
643
+ expect(prompt).not.toContain('### Full File Content');
644
+ });
581
645
  });
582
646
 
583
647
  // ==========================================================================
package/src/rag-review.js CHANGED
@@ -14,6 +14,7 @@ import { ensureBranchExists, findParentBranch, getChangedLinesInfo, getFileConte
14
14
  import { detectFileType, detectLanguageFromExtension } from './utils/language-detection.js';
15
15
  import { verboseLog } from './utils/logging.js';
16
16
  import { shouldChunkPR, chunkPRFiles, combineChunkResults } from './utils/pr-chunking.js';
17
+ import { mergeHolisticFileContextPlans } from './utils/pr-file-context.js';
17
18
 
18
19
  /**
19
20
  * Review a single file using RAG approach
@@ -308,8 +309,10 @@ async function reviewPullRequestWithCrossFileContext(filesToReview, options = {}
308
309
  }
309
310
 
310
311
  // Check if PR should be chunked based on size and complexity (skip if this is already a chunk)
312
+ let holisticContextPlans = options.holisticContextPlans;
311
313
  if (!options.skipChunking) {
312
314
  const chunkingDecision = shouldChunkPR(prFiles, options);
315
+ holisticContextPlans = chunkingDecision.holisticContextPlans;
313
316
  verboseLog(verbose, chalk.blue(`PR size assessment: ${chunkingDecision.estimatedTokens} tokens, ${prFiles.length} files`));
314
317
  verboseLog(
315
318
  verbose && chunkingDecision.shouldChunk,
@@ -319,10 +322,12 @@ async function reviewPullRequestWithCrossFileContext(filesToReview, options = {}
319
322
  // If PR is too large, use chunked processing
320
323
  if (chunkingDecision.shouldChunk) {
321
324
  verboseLog(options, chalk.blue(`🔄 Using chunked processing for large PR (${chunkingDecision.estimatedTokens} tokens)`));
322
- return await reviewLargePRInChunks(prFiles, options);
325
+ return await reviewLargePRInChunks(prFiles, { ...options, holisticContextPlans });
323
326
  }
324
327
  }
325
328
 
329
+ holisticContextPlans = mergeHolisticFileContextPlans(prFiles, options, holisticContextPlans);
330
+
326
331
  // Step 2: Gather unified context for the entire PR (for regular-sized PRs)
327
332
  verboseLog(verbose, chalk.blue(`Performing unified context retrieval for ${prFiles.length} PR files...`));
328
333
  const {
@@ -361,7 +366,7 @@ async function reviewPullRequestWithCrossFileContext(filesToReview, options = {}
361
366
  try {
362
367
  // Create a comprehensive review context with all files and their diffs
363
368
  const comprehensiveContext = {
364
- prFiles: prFiles.map((file) => ({
369
+ prFiles: prFiles.map((file, index) => ({
365
370
  path: path.relative(workingDir, file.filePath),
366
371
  language: file.language,
367
372
  isTest: file.isTest,
@@ -369,6 +374,8 @@ async function reviewPullRequestWithCrossFileContext(filesToReview, options = {}
369
374
  summary: file.summary,
370
375
  fullContent: file.content, // Add full file content for context
371
376
  diff: file.diffContent,
377
+ diffInfo: file.diffInfo,
378
+ holisticContextPlan: file.holisticContextPlan || holisticContextPlans[index],
372
379
  baseBranch: file.baseBranch,
373
380
  targetBranch: file.targetBranch,
374
381
  })),
@@ -557,7 +564,7 @@ async function reviewLargePRInChunks(prFiles, options) {
557
564
 
558
565
  // Step 2: Split PR into manageable chunks
559
566
  // Each chunk includes both diff AND full file content, plus ~25k context overhead
560
- const chunks = chunkPRFiles(prFiles, 35000); // Conservative limit accounting for context overhead
567
+ const chunks = chunkPRFiles(prFiles, 35000, options); // Conservative limit accounting for context overhead
561
568
  verboseLog(options, chalk.green(`✂️ Split PR into ${chunks.length} chunks`));
562
569
 
563
570
  chunks.forEach((chunk, i) => {
@@ -270,7 +270,8 @@ describe('rag-review', () => {
270
270
  });
271
271
 
272
272
  // Setup: shouldChunkPR returns true to trigger chunked processing
273
- shouldChunkPR.mockReturnValue({ shouldChunk: true, estimatedTokens: 100000, recommendedChunks: 2 });
273
+ const holisticContextPlans = [{ mode: 'focused', contextTokens: 10, totalTokens: 20 }];
274
+ shouldChunkPR.mockReturnValue({ shouldChunk: true, estimatedTokens: 100000, recommendedChunks: 2, holisticContextPlans });
274
275
 
275
276
  // Setup chunks that will be processed
276
277
  chunkPRFiles.mockReturnValue([
@@ -298,7 +299,7 @@ describe('rag-review', () => {
298
299
 
299
300
  // Verify chunking flow was triggered
300
301
  expect(shouldChunkPR).toHaveBeenCalled();
301
- expect(chunkPRFiles).toHaveBeenCalled();
302
+ expect(chunkPRFiles).toHaveBeenCalledWith(expect.any(Array), 35000, expect.objectContaining({ holisticContextPlans }));
302
303
  expect(combineChunkResults).toHaveBeenCalled();
303
304
  expect(result.success).toBe(true);
304
305
  });