codecritique 2.1.2 → 2.2.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/README.md CHANGED
@@ -418,9 +418,12 @@ jobs:
418
418
  | `model` | LLM model to use (e.g., `claude-sonnet-4-6`) | No | Auto-selected |
419
419
  | `max-tokens` | Maximum tokens for LLM response | No | Auto-calculated |
420
420
  | `cache-ttl` | Cache TTL for LLM prompts: "5m" (default, no extra cost) or "1h" (extended, extra cost for cache writes) | No | `5m` |
421
+ | `batch` | Use Anthropic Message Batches for lower-cost, asynchronous reviews | No | `false` |
421
422
  | `concurrency` | Concurrency for processing multiple files | No | `3` |
422
423
  | `custom-docs` | Custom documents (format: `"title:path,title:path"`) | No | `''` |
423
424
 
425
+ Batch mode trades latency for Anthropic's Message Batches API discount. Processing may take up to 24 hours, and batch requests are not eligible for Zero Data Retention. GitHub-hosted jobs can run for at most six hours; the action polls in-process and cannot resume a batch after the job ends. If holistic analysis falls back to per-file reviews, each concurrency wave can incur a separate batch wait and further increase runtime.
426
+
424
427
  > **Note**: The action uses sensible defaults for all review parameters. It always:
425
428
  >
426
429
  > - Uses JSON output format for parsing results
@@ -545,6 +548,10 @@ VERBOSE=true
545
548
 
546
549
  Set `CODECRITIQUE_SKIP_DOTENV=1` to disable `.env` loading, as the shell wrapper does after forwarding its supported variables.
547
550
 
551
+ ### Trust and Prompt Injection
552
+
553
+ Repository files, diffs, custom documents, and PR comments are untrusted data included in LLM prompts. A malicious repository can attempt to influence generated review text despite prompt boundaries and truncation. Treat CodeCritique output as advisory, review findings before acting on them, and never grant generated suggestions automatic execution privileges.
554
+
548
555
  ## Output Formats
549
556
 
550
557
  CodeCritique supports three output formats:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecritique",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "description": "AI-powered code review tool for any programming language",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -42,6 +42,7 @@ async function cleanupEmbeddingsSystemIfInitialized() {
42
42
  }
43
43
 
44
44
  const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
45
+ const parseIntegerOption = (value) => Number.parseInt(value, 10);
45
46
 
46
47
  // Configure command-line interface
47
48
  program.name('codecritique').description('CLI tool for AI-powered code review using the RAG approach').version(packageJson.version);
@@ -64,11 +65,12 @@ program
64
65
  .option('--verbose', 'Show verbose output')
65
66
  .option('--model <model>', 'LLM model to use (e.g., claude-sonnet-4-6)')
66
67
  .option('--temperature <number>', 'LLM temperature', parseFloat, 0.2)
67
- .option('--max-tokens <number>', 'LLM max tokens', parseInt, 8192)
68
+ .option('--max-tokens <number>', 'LLM max tokens', parseIntegerOption, 8192)
68
69
  .option('--cache-ttl <ttl>', 'Cache TTL for LLM prompts: "5m" (default, no extra cost) or "1h" (extended, extra cost for writes)', '5m')
70
+ .option('--batch', 'Use the asynchronous Anthropic Message Batches API for lower-cost reviews')
69
71
  .option('--similarity-threshold <number>', 'Threshold for finding similar code examples', parseFloat, 0.6)
70
- .option('--max-examples <number>', 'Max similar code examples to use', parseInt, 5)
71
- .option('--concurrency <number>', 'Concurrency for processing multiple files', parseInt, 3)
72
+ .option('--max-examples <number>', 'Max similar code examples to use', parseIntegerOption, 5)
73
+ .option('--concurrency <number>', 'Concurrency for processing multiple files', parseIntegerOption, 3)
72
74
  .option(
73
75
  '--doc <specs...>',
74
76
  'A document to provide custom instructions to the LLM (e.g., "Engineering Guidelines:./docs/guidelines.md"). Can be specified multiple times.'
@@ -142,11 +144,11 @@ program
142
144
  .option('-t, --token <token>', 'GitHub API token (or set GITHUB_TOKEN env var)')
143
145
  .option('--since <date>', 'Only analyze PRs since this date (ISO format)')
144
146
  .option('--until <date>', 'Only analyze PRs until this date (ISO format)')
145
- .option('--limit <number>', 'Limit number of PRs to analyze', parseInt)
147
+ .option('--limit <number>', 'Limit number of PRs to analyze', parseIntegerOption)
146
148
  .option('--resume', 'Resume interrupted analysis')
147
149
  .option('--clear', 'Clear existing data before analysis')
148
- .option('--concurrency <number>', 'Number of concurrent requests', parseInt, 2)
149
- .option('--batch-size <number>', 'Batch size for processing', parseInt, 50)
150
+ .option('--concurrency <number>', 'Number of concurrent requests', parseIntegerOption, 2)
151
+ .option('--batch-size <number>', 'Batch size for processing', parseIntegerOption, 50)
150
152
  .option('--verbose', 'Show verbose output')
151
153
  .action(analyzePRHistory);
152
154
 
@@ -349,6 +351,7 @@ async function runCodeReview(options) {
349
351
  temperature: options.temperature,
350
352
  maxTokens: options.maxTokens,
351
353
  cacheTtl: options.cacheTtl,
354
+ batch: options.batch,
352
355
  similarityThreshold: options.similarityThreshold,
353
356
  maxExamples: options.maxExamples,
354
357
  concurrency: options.concurrency,
@@ -1092,11 +1095,12 @@ async function emitEmptyReviewOutputIfNeeded(options, aggregateResult = {}) {
1092
1095
  }
1093
1096
 
1094
1097
  async function emitErrorReviewOutputIfNeeded(options, error, aggregateResult = {}) {
1095
- if (!shouldEmitReviewOutput(options)) {
1098
+ if (!shouldEmitReviewOutput(options) && !aggregateResult.results?.length) {
1096
1099
  return false;
1097
1100
  }
1098
1101
 
1099
- await outputReviewResults([createReviewErrorResult(error)], options, {
1102
+ const reviewResults = aggregateResult.results?.length ? aggregateResult.results : [{ filePath: 'review', success: false, error }];
1103
+ await outputReviewResults(reviewResults, options, {
1100
1104
  ...aggregateResult,
1101
1105
  success: false,
1102
1106
  error,
@@ -1104,14 +1108,6 @@ async function emitErrorReviewOutputIfNeeded(options, error, aggregateResult = {
1104
1108
  return true;
1105
1109
  }
1106
1110
 
1107
- function createReviewErrorResult(error) {
1108
- return {
1109
- filePath: 'review',
1110
- success: false,
1111
- error,
1112
- };
1113
- }
1114
-
1115
1111
  /**
1116
1112
  * Output results in JSON format
1117
1113
  *
@@ -1139,6 +1135,8 @@ async function outputJson(reviewResults, options, aggregateResult = {}) {
1139
1135
  }, 0),
1140
1136
  skippedFiles: reviewResults.filter((r) => r.skipped).length,
1141
1137
  errorFiles: reviewResults.filter((r) => !r.success).length,
1138
+ incomplete: aggregateResult.success === false,
1139
+ error: aggregateResult.success === false ? aggregateResult.error : undefined,
1142
1140
  },
1143
1141
  details: reviewResults.map((r) => {
1144
1142
  if (!r.success) {
@@ -1151,6 +1149,8 @@ async function outputJson(reviewResults, options, aggregateResult = {}) {
1151
1149
  return {
1152
1150
  filePath: r.filePath,
1153
1151
  success: true,
1152
+ partial: r.partial || undefined,
1153
+ error: r.partial ? r.error : undefined,
1154
1154
  language: r.language,
1155
1155
  review: r.results, // Contains summary and actionable issues (with optional codeSuggestion)
1156
1156
  // Optionally include similar examples if needed
@@ -1207,6 +1207,9 @@ async function outputMarkdown(reviewResults, options, aggregateResult = {}) {
1207
1207
  if (errorFiles > 0) {
1208
1208
  lines.push(`- **Errors:** ${errorFiles}`);
1209
1209
  }
1210
+ if (aggregateResult.success === false) {
1211
+ lines.push(`- **Review Incomplete:** ${aggregateResult.error || 'Some review work failed.'}`);
1212
+ }
1210
1213
 
1211
1214
  appendPRLevelFindingsMarkdown(lines, prLevelFindings);
1212
1215
 
@@ -1218,6 +1221,9 @@ async function outputMarkdown(reviewResults, options, aggregateResult = {}) {
1218
1221
  lines.push(`**Error:** ${fileResult.error}`, '');
1219
1222
  return;
1220
1223
  }
1224
+ if (fileResult.partial) {
1225
+ lines.push(`**Partial review:** ${fileResult.error || 'Some review segments failed.'}`, '');
1226
+ }
1221
1227
  if (fileResult.skipped) {
1222
1228
  lines.push('*Skipped (based on exclusion patterns or file type).*', '');
1223
1229
  return;
@@ -1295,6 +1301,9 @@ function outputText(reviewResults, cliOptions, aggregateResult = {}) {
1295
1301
  if (errorFiles > 0) {
1296
1302
  console.log(`Errors: ${chalk.red(errorFiles)}`);
1297
1303
  }
1304
+ if (aggregateResult.success === false) {
1305
+ console.warn(chalk.yellow(`Review Incomplete: ${aggregateResult.error || 'Some review work failed.'}`));
1306
+ }
1298
1307
  console.log(chalk.bold.blue('================================================'));
1299
1308
 
1300
1309
  outputPRLevelFindingsText(prLevelFindings);
@@ -1306,6 +1315,9 @@ function outputText(reviewResults, cliOptions, aggregateResult = {}) {
1306
1315
  console.log(chalk.bold.red('================================================'));
1307
1316
  return;
1308
1317
  }
1318
+ if (fileResult.partial) {
1319
+ console.warn(chalk.yellow(`\nPartial review for ${fileResult.filePath}: ${fileResult.error || 'Some review segments failed.'}`));
1320
+ }
1309
1321
  if (fileResult.skipped) {
1310
1322
  verboseLog(cliOptions, chalk.yellow(`\nSkipped: ${fileResult.filePath}`));
1311
1323
  return;
@@ -0,0 +1,86 @@
1
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { program } from 'commander';
5
+ import { expect, it, onTestFinished, vi } from 'vitest';
6
+
7
+ const { originalArgv, originalListeners, reviewFile } = vi.hoisted(() => {
8
+ const originalArgv = process.argv;
9
+ const originalListeners = Object.fromEntries(['SIGINT', 'SIGTERM', 'exit'].map((event) => [event, process.listeners(event)]));
10
+ process.argv = ['node', 'index.js', 'analyze', '--file', 'src/index.js', '--max-examples', '12'];
11
+ return {
12
+ originalArgv,
13
+ originalListeners,
14
+ reviewFile: vi.fn().mockResolvedValue({
15
+ success: false,
16
+ error: 'Review incomplete',
17
+ results: [
18
+ {
19
+ filePath: 'src/index.js',
20
+ success: true,
21
+ partial: true,
22
+ error: 'Batch expired',
23
+ results: { issues: [{ severity: 'high', description: 'Visible partial finding', lineNumbers: [1] }] },
24
+ },
25
+ ],
26
+ }),
27
+ };
28
+ });
29
+
30
+ vi.unmock('chalk');
31
+ vi.mock('./rag-review.js', () => ({
32
+ reviewFile,
33
+ reviewFiles: vi.fn(),
34
+ reviewPullRequest: vi.fn(),
35
+ }));
36
+
37
+ import './index.js';
38
+
39
+ it('parses integer options and renders incomplete results in every output format', async () => {
40
+ const directory = await mkdtemp(join(tmpdir(), 'codecritique-output-'));
41
+ onTestFinished(async () => {
42
+ process.argv = originalArgv;
43
+ for (const [event, listeners] of Object.entries(originalListeners)) {
44
+ process
45
+ .listeners(event)
46
+ .filter((listener) => !listeners.includes(listener))
47
+ .forEach((listener) => process.removeListener(event, listener));
48
+ }
49
+ await rm(directory, { recursive: true, force: true });
50
+ });
51
+ await vi.waitFor(() => expect(reviewFile).toHaveBeenCalled());
52
+ expect(reviewFile.mock.calls[0][1].maxExamples).toBe(12);
53
+ await vi.waitFor(() => expect(console.log).toHaveBeenCalledWith(' Visible partial finding'));
54
+ expect(console.warn.mock.calls.some(([message]) => String(message).includes('Partial review for src/index.js'))).toBe(true);
55
+ expect(console.warn.mock.calls.some(([message]) => String(message).includes('Review incomplete'))).toBe(true);
56
+
57
+ const jsonPath = join(directory, 'review.json');
58
+ await program.parseAsync(['node', 'index.js', 'analyze', '--file', 'src/index.js', '--output', 'json', '--output-file', jsonPath]);
59
+ const jsonOutput = JSON.parse(await readFile(jsonPath, 'utf8'));
60
+ expect(jsonOutput.summary).toEqual(expect.objectContaining({ errorFiles: 0, incomplete: true, error: 'Review incomplete' }));
61
+ expect(jsonOutput.details).toEqual([
62
+ expect.objectContaining({
63
+ success: true,
64
+ partial: true,
65
+ error: 'Batch expired',
66
+ review: expect.objectContaining({ issues: [expect.objectContaining({ description: 'Visible partial finding' })] }),
67
+ }),
68
+ ]);
69
+
70
+ const markdownPath = join(directory, 'review.md');
71
+ await program.parseAsync([
72
+ 'node',
73
+ 'index.js',
74
+ 'analyze',
75
+ '--file',
76
+ 'src/index.js',
77
+ '--output',
78
+ 'markdown',
79
+ '--output-file',
80
+ markdownPath,
81
+ ]);
82
+ const markdownOutput = await readFile(markdownPath, 'utf8');
83
+ expect(markdownOutput).toContain('**Review Incomplete:** Review incomplete');
84
+ expect(markdownOutput).toContain('**Partial review:** Batch expired');
85
+ expect(markdownOutput).toContain('Visible partial finding');
86
+ });
package/src/llm.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * requests, reducing input token costs by 75%.
13
13
  */
14
14
 
15
+ import { setTimeout as delay } from 'node:timers/promises';
15
16
  import { Anthropic } from '@anthropic-ai/sdk';
16
17
  import chalk from 'chalk';
17
18
  import dotenv from 'dotenv';
@@ -46,6 +47,9 @@ const DEFAULT_MODEL = 'claude-sonnet-4-6';
46
47
  // Maximum tokens for response
47
48
  const MAX_TOKENS = 4096;
48
49
 
50
+ const DEFAULT_BATCH_POLL_INTERVAL_MS = 10000;
51
+ const MAX_BATCH_POLL_FAILURES = 3;
52
+
49
53
  const MAX_CACHE_CONTROL_BLOCKS = 4;
50
54
 
51
55
  function normalizeSystemBlock(block, cacheControl) {
@@ -84,6 +88,47 @@ function buildSystemContent(system, cachedSystemBlocks, cacheControl) {
84
88
  return systemBlocks;
85
89
  }
86
90
 
91
+ async function createBatchedMessage(client, requestParams, options) {
92
+ const customId = 'codecritique-review';
93
+ let batch = await client.messages.batches.create({
94
+ requests: [{ custom_id: customId, params: requestParams }],
95
+ });
96
+
97
+ verboseLog(options, chalk.cyan(`Submitted Claude message batch ${batch.id}; waiting for completion...`));
98
+ let consecutivePollFailures = 0;
99
+
100
+ while (batch.processing_status !== 'ended') {
101
+ await delay(DEFAULT_BATCH_POLL_INTERVAL_MS);
102
+ try {
103
+ batch = await client.messages.batches.retrieve(batch.id);
104
+ consecutivePollFailures = 0;
105
+ }
106
+ catch (error) {
107
+ consecutivePollFailures++;
108
+ if (consecutivePollFailures >= MAX_BATCH_POLL_FAILURES) {
109
+ throw new Error(`Unable to poll Claude message batch ${batch.id} after ${MAX_BATCH_POLL_FAILURES} attempts: ${error.message}`);
110
+ }
111
+ console.warn(chalk.yellow(`Unable to poll Claude message batch ${batch.id}; retrying: ${error.message}`));
112
+ }
113
+ }
114
+
115
+ const results = await client.messages.batches.results(batch.id);
116
+ for await (const response of results) {
117
+ if (response.custom_id !== customId) {
118
+ continue;
119
+ }
120
+
121
+ if (response.result.type === 'succeeded') {
122
+ return response.result.message;
123
+ }
124
+
125
+ const detail = response.result.type === 'errored' ? `: ${response.result.error.error.message}` : '';
126
+ throw new Error(`Claude batch request ${response.result.type}${detail}`);
127
+ }
128
+
129
+ throw new Error('Claude batch response did not contain the review request');
130
+ }
131
+
87
132
  /**
88
133
  * Send a prompt to Claude and get a structured JSON response using tool calling.
89
134
  * Uses prompt caching for system prompts to reduce token costs.
@@ -94,6 +139,7 @@ function buildSystemContent(system, cachedSystemBlocks, cacheControl) {
94
139
  * @param {Array<string|Object>} options.cachedSystemBlocks - Additional stable system blocks to cache when possible
95
140
  * @param {Object} options.jsonSchema - JSON schema for structured output
96
141
  * @param {string} options.cacheTtl - Cache TTL: '5m' (default, no extra cost) or '1h' (extended, extra cost for writes)
142
+ * @param {boolean} [options.batch=false] - Use the asynchronous Message Batches API
97
143
  * @returns {Promise<Object>} The response from Claude with structured data
98
144
  */
99
145
  async function sendPromptToClaude(prompt, options = {}) {
@@ -145,7 +191,9 @@ async function sendPromptToClaude(prompt, options = {}) {
145
191
  requestParams.tool_choice = { type: 'tool', name: 'return_json' };
146
192
  }
147
193
 
148
- const response = await client.messages.create(requestParams);
194
+ const response = options.batch
195
+ ? await createBatchedMessage(client, requestParams, options)
196
+ : await client.messages.create(requestParams);
149
197
 
150
198
  // Log response structure for debugging
151
199
  verboseLog(options, chalk.gray(` Response stop_reason: ${response.stop_reason}`));
package/src/llm.test.js CHANGED
@@ -2,15 +2,31 @@ import { sendPromptToClaude } from './llm.js';
2
2
 
3
3
  // Create hoisted mock for the Anthropic SDK
4
4
  const mockMessagesCreate = vi.hoisted(() => vi.fn());
5
+ const mockBatchesCreate = vi.hoisted(() => vi.fn());
6
+ const mockBatchesRetrieve = vi.hoisted(() => vi.fn());
7
+ const mockBatchesResults = vi.hoisted(() => vi.fn());
8
+
9
+ vi.mock('node:timers/promises', () => ({ setTimeout: vi.fn() }));
5
10
 
6
11
  vi.mock('@anthropic-ai/sdk', () => ({
7
12
  Anthropic: class MockAnthropic {
8
13
  messages = {
9
14
  create: mockMessagesCreate,
15
+ batches: {
16
+ create: mockBatchesCreate,
17
+ retrieve: mockBatchesRetrieve,
18
+ results: mockBatchesResults,
19
+ },
10
20
  };
11
21
  },
12
22
  }));
13
23
 
24
+ function batchResults(...responses) {
25
+ return (async function* iterateResults() {
26
+ yield* responses;
27
+ })();
28
+ }
29
+
14
30
  describe('sendPromptToClaude', () => {
15
31
  beforeEach(() => {
16
32
  mockConsoleSelective('log', 'error');
@@ -85,6 +101,72 @@ describe('sendPromptToClaude', () => {
85
101
  })
86
102
  );
87
103
  });
104
+
105
+ it('should retry polling and unwrap a successful batch request', async () => {
106
+ const message = {
107
+ content: [{ type: 'text', text: 'Batched response' }],
108
+ model: 'claude-sonnet-4-6',
109
+ usage: { input_tokens: 100, output_tokens: 50 },
110
+ };
111
+ mockBatchesCreate.mockResolvedValue({ id: 'msgbatch_123', processing_status: 'in_progress' });
112
+ mockBatchesRetrieve
113
+ .mockRejectedValueOnce(new Error('Temporary network error'))
114
+ .mockRejectedValueOnce(new Error('Temporary network error'))
115
+ .mockResolvedValueOnce({ id: 'msgbatch_123', processing_status: 'in_progress' })
116
+ .mockRejectedValueOnce(new Error('Temporary network error'))
117
+ .mockRejectedValueOnce(new Error('Temporary network error'))
118
+ .mockResolvedValueOnce({ id: 'msgbatch_123', processing_status: 'ended' });
119
+ mockBatchesResults.mockResolvedValue(batchResults({ custom_id: 'codecritique-review', result: { type: 'succeeded', message } }));
120
+
121
+ const result = await sendPromptToClaude('Review this code', { batch: true });
122
+
123
+ expect(result.content).toBe('Batched response');
124
+ expect(mockMessagesCreate).not.toHaveBeenCalled();
125
+ expect(mockBatchesCreate).toHaveBeenCalledWith({
126
+ requests: [
127
+ {
128
+ custom_id: 'codecritique-review',
129
+ params: expect.objectContaining({
130
+ model: 'claude-sonnet-4-6',
131
+ messages: [{ role: 'user', content: 'Review this code' }],
132
+ }),
133
+ },
134
+ ],
135
+ });
136
+ expect(mockBatchesRetrieve).toHaveBeenCalledTimes(6);
137
+ expect(mockBatchesRetrieve).toHaveBeenCalledWith('msgbatch_123');
138
+ expect(mockBatchesResults).toHaveBeenCalledWith('msgbatch_123');
139
+ });
140
+
141
+ it('should stop polling after repeated retrieval failures', async () => {
142
+ mockBatchesCreate.mockResolvedValue({ id: 'msgbatch_123', processing_status: 'in_progress' });
143
+ mockBatchesRetrieve.mockRejectedValue(new Error('Unauthorized'));
144
+
145
+ await expect(sendPromptToClaude('Review this code', { batch: true })).rejects.toThrow(
146
+ 'Unable to poll Claude message batch msgbatch_123 after 3 attempts: Unauthorized'
147
+ );
148
+ expect(mockBatchesRetrieve).toHaveBeenCalledTimes(3);
149
+ });
150
+
151
+ it.each([
152
+ [{ type: 'errored', error: { error: { message: 'Request overloaded' } } }, 'Claude batch request errored: Request overloaded'],
153
+ [{ type: 'canceled' }, 'Claude batch request canceled'],
154
+ [{ type: 'expired' }, 'Claude batch request expired'],
155
+ ])('should report an unsuccessful batch result', async (batchResult, expectedMessage) => {
156
+ mockBatchesCreate.mockResolvedValue({ id: 'msgbatch_123', processing_status: 'ended' });
157
+ mockBatchesResults.mockResolvedValue(batchResults({ custom_id: 'codecritique-review', result: batchResult }));
158
+
159
+ await expect(sendPromptToClaude('Review this code', { batch: true })).rejects.toThrow(expectedMessage);
160
+ });
161
+
162
+ it('should reject a batch response without the review result', async () => {
163
+ mockBatchesCreate.mockResolvedValue({ id: 'msgbatch_123', processing_status: 'ended' });
164
+ mockBatchesResults.mockResolvedValue(batchResults());
165
+
166
+ await expect(sendPromptToClaude('Review this code', { batch: true })).rejects.toThrow(
167
+ 'Claude batch response did not contain the review request'
168
+ );
169
+ });
88
170
  });
89
171
 
90
172
  describe('structured JSON response with tool calling', () => {
@@ -782,6 +782,7 @@ async function callLLMForAnalysis(context, options = {}) {
782
782
  isJsonMode: true, // Standardize on using JSON mode if available
783
783
  verbose: options.verbose || false, // Pass verbose flag for cache statistics
784
784
  cacheTtl: options.cacheTtl || '5m', // Pass cache TTL option (default: 5m, no extra cost)
785
+ batch: options.batch || false,
785
786
  });
786
787
 
787
788
  verboseLog(options, chalk.blue('Received LLM response, attempting to parse...'));
@@ -2343,7 +2344,8 @@ async function gatherUnifiedContextForPR(prFiles, options = {}) {
2343
2344
  // Aggregate and deduplicate results
2344
2345
  for (const context of allContexts) {
2345
2346
  (context.finalCodeExamples || []).forEach((example) => {
2346
- const key = example.path;
2347
+ const normalizedContent = example.content?.trim();
2348
+ const key = normalizedContent ? `content:${normalizedContent}` : example.path ? `path:${example.path}` : null;
2347
2349
  if (
2348
2350
  key &&
2349
2351
  (!allProcessedContext.codeExamples.has(key) || example.similarity > allProcessedContext.codeExamples.get(key).similarity)
@@ -196,6 +196,14 @@ describe('rag-analyzer', () => {
196
196
  expect(result.error).toContain('LLM unavailable');
197
197
  });
198
198
 
199
+ it('should forward batch transport options to the LLM', async () => {
200
+ setupSuccessfulLLMResponse();
201
+
202
+ await runAnalysis('/test/file.js', { batch: true });
203
+
204
+ expect(llm.sendPromptToClaude).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ batch: true }));
205
+ });
206
+
199
207
  it('should initialize embeddings system', async () => {
200
208
  setupSuccessfulLLMResponse();
201
209
  await runAnalysis('/test/file.js');
@@ -1136,6 +1144,32 @@ describe('rag-analyzer', () => {
1136
1144
  expect(context.codeExamples[0].similarity).toBeGreaterThanOrEqual(0.7);
1137
1145
  }
1138
1146
  });
1147
+
1148
+ it('should deduplicate identical code content from different paths', async () => {
1149
+ mockEmbeddingsSystem.findSimilarCode
1150
+ .mockResolvedValueOnce([{ path: '/first.js', content: 'shared implementation', similarity: 0.7 }])
1151
+ .mockResolvedValueOnce([{ path: '/second.js', content: ' shared implementation ', similarity: 0.9 }]);
1152
+ const context = await gatherUnifiedContextForPR([
1153
+ { filePath: '/src/file1.js', content: 'code1' },
1154
+ { filePath: '/src/file2.js', content: 'code2' },
1155
+ ]);
1156
+
1157
+ expect(context.codeExamples).toEqual([
1158
+ expect.objectContaining({ path: '/second.js', content: ' shared implementation ', similarity: 0.9 }),
1159
+ ]);
1160
+ });
1161
+
1162
+ it('should keep content keys separate from path fallbacks', async () => {
1163
+ mockEmbeddingsSystem.findSimilarCode
1164
+ .mockResolvedValueOnce([{ path: '/first.js', content: '', similarity: 0.9 }])
1165
+ .mockResolvedValueOnce([{ path: '/second.js', content: ' /first.js ', similarity: 0.8 }]);
1166
+ const context = await gatherUnifiedContextForPR([
1167
+ { filePath: '/src/file1.js', content: 'code1' },
1168
+ { filePath: '/src/file2.js', content: 'code2' },
1169
+ ]);
1170
+
1171
+ expect(context.codeExamples.map((example) => example.path)).toEqual(['/first.js', '/second.js']);
1172
+ });
1139
1173
  });
1140
1174
 
1141
1175
  // ==========================================================================
package/src/rag-review.js CHANGED
@@ -399,6 +399,9 @@ async function reviewPullRequestWithCrossFileContext(filesToReview, options = {}
399
399
 
400
400
  // Create a synthetic "file" path for holistic analysis
401
401
  const holisticResult = await runAnalysis('PR_HOLISTIC_REVIEW', holisticOptions);
402
+ if (!holisticResult?.success) {
403
+ throw new Error(holisticResult?.error || 'Holistic PR analysis failed');
404
+ }
402
405
 
403
406
  // Convert holistic result to individual file results format for compatibility
404
407
  const results = prFiles.map((file) => {
@@ -525,8 +528,10 @@ async function reviewPullRequestWithCrossFileContext(filesToReview, options = {}
525
528
  }
526
529
 
527
530
  // Return fallback results
531
+ const failedReviewCount = results.filter((result) => !result?.success).length;
528
532
  return {
529
- success: true,
533
+ success: failedReviewCount === 0,
534
+ error: failedReviewCount > 0 ? `${failedReviewCount} file review(s) failed after holistic review fallback` : undefined,
530
535
  results: results,
531
536
  prContext: prContext,
532
537
  sharedContextStats: {
@@ -436,6 +436,36 @@ describe('rag-review', () => {
436
436
  expect(result.success).toBe(true);
437
437
  });
438
438
 
439
+ it('should retain successful fallback results when another file fails', async () => {
440
+ shouldProcessFile.mockReturnValue(true);
441
+ getFileContentFromGit.mockReturnValue('const x = 1;');
442
+ getChangedLinesInfo.mockReturnValue({
443
+ hasChanges: true,
444
+ addedLines: [1],
445
+ removedLines: [],
446
+ fullDiff: '+ code',
447
+ });
448
+ shouldChunkPR.mockReturnValue({ shouldChunk: false, estimatedTokens: 1000 });
449
+ runAnalysis
450
+ .mockResolvedValueOnce({ success: false, error: 'Claude batch request expired' })
451
+ .mockResolvedValueOnce({
452
+ success: true,
453
+ filePath: '/src/a.js',
454
+ results: { issues: [{ description: 'Known issue' }] },
455
+ })
456
+ .mockResolvedValueOnce({ success: false, filePath: '/src/b.js', error: 'Claude batch request expired' });
457
+
458
+ const result = await reviewPullRequest(['/src/a.js', '/src/b.js'], { batch: true });
459
+
460
+ expect(result.success).toBe(false);
461
+ expect(result.error).toBe('1 file review(s) failed after holistic review fallback');
462
+ expect(result.results).toEqual([
463
+ expect.objectContaining({ success: true, filePath: '/src/a.js' }),
464
+ expect.objectContaining({ success: false, filePath: '/src/b.js', error: 'Claude batch request expired' }),
465
+ ]);
466
+ expect(runAnalysis).toHaveBeenLastCalledWith('/src/b.js', expect.objectContaining({ batch: true, isPRReview: true, diffOnly: true }));
467
+ });
468
+
439
469
  it('should handle multiple files with different statuses', async () => {
440
470
  runAnalysis
441
471
  .mockResolvedValueOnce({