codecritique 2.0.7 → 2.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecritique",
3
- "version": "2.0.7",
3
+ "version": "2.1.0",
4
4
  "description": "AI-powered code review tool for any programming language",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -405,8 +405,25 @@ export class ProjectAnalyzer {
405
405
  return depthA - depthB;
406
406
  });
407
407
 
408
- // Take only the first 500 after sorting to ensure we have shallow files
409
- const sortedFiles = allFiles.slice(0, TERM_SEARCH_CONTENT_CANDIDATE_LIMIT);
408
+ // Dedupe by path before capping: transient duplicate rows can exist for
409
+ // the same file (different content_hash) when a partial run hasn't pruned
410
+ // stale embeddings, and they would otherwise consume candidate slots and
411
+ // bloat the content query. Keep the shallowest occurrence (allFiles is
412
+ // already depth-sorted), then take the first 500 distinct shallow files.
413
+ const seenPaths = new Set();
414
+ const sortedFiles = [];
415
+ for (const file of allFiles) {
416
+ if (file.path) {
417
+ if (seenPaths.has(file.path)) {
418
+ continue;
419
+ }
420
+ seenPaths.add(file.path);
421
+ }
422
+ sortedFiles.push(file);
423
+ if (sortedFiles.length >= TERM_SEARCH_CONTENT_CANDIDATE_LIMIT) {
424
+ break;
425
+ }
426
+ }
410
427
  if (sortedFiles.length === 0) {
411
428
  return [];
412
429
  }
@@ -415,14 +432,20 @@ export class ProjectAnalyzer {
415
432
  const contentByPath = new Map();
416
433
  if (candidatePaths.length > 0) {
417
434
  const pathList = candidatePaths.map((candidatePath) => `'${escapeSqlString(candidatePath)}'`).join(', ');
435
+ // No row limit here: the result set is already bounded by the IN list of
436
+ // distinct candidate paths. A limit could truncate rows when duplicate
437
+ // rows exist for a path, silently dropping content for matched files.
418
438
  const contentResults = await table
419
439
  .query()
420
440
  .select(['path', 'content'])
421
441
  .where(`${projectPathFilter} AND path IN (${pathList})`)
422
- .limit(TERM_SEARCH_CONTENT_CANDIDATE_LIMIT)
423
442
  .toArray();
424
443
 
425
444
  for (const result of contentResults) {
445
+ // Prefer non-empty content when duplicate rows exist for a path.
446
+ if (contentByPath.get(result.path)) {
447
+ continue;
448
+ }
426
449
  contentByPath.set(result.path, result.content || '');
427
450
  }
428
451
  }
@@ -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
@@ -471,8 +472,10 @@ async function runAnalysis(filePath, options = {}) {
471
472
  let fullFileContent;
472
473
  if (options.diffOnly && options.diffContent) {
473
474
  content = options.diffContent;
474
- // For PR reviews, always read the full file content for context awareness
475
- fullFileContent = await readTextFile(filePath);
475
+ // For PR reviews, always read the full file content for context awareness.
476
+ // Tolerate a missing/unreadable file (e.g. deleted in the window after the
477
+ // existence check) by degrading to null; downstream falls back to file.content.
478
+ fullFileContent = await readTextFile(filePath).catch(() => null);
476
479
  verboseLog(options, chalk.blue(`Analyzing diff only for ${path.basename(filePath)}`));
477
480
  }
478
481
  else {
@@ -873,6 +876,22 @@ ${projectArchitectureSection}`);
873
876
  return blocks;
874
877
  }
875
878
 
879
+ /**
880
+ * Build the user-prompt sentence that points the model at the cached system blocks.
881
+ * Returns an empty string when no cached blocks were emitted, so the prompt never
882
+ * instructs the model to use context that was not sent.
883
+ *
884
+ * @param {string[]} cachedSystemBlocks - Cached blocks produced for this request
885
+ * @param {string} subject - What is being evaluated (e.g. 'this file')
886
+ * @returns {string} Instruction sentence, or '' when there is no cached context
887
+ */
888
+ function buildCachedContextInstruction(cachedSystemBlocks, subject) {
889
+ if (!cachedSystemBlocks || cachedSystemBlocks.length === 0) {
890
+ return '';
891
+ }
892
+ return `Use the cached project context, project architecture, and custom instructions when evaluating ${subject}.`;
893
+ }
894
+
876
895
  // LLM call function - sends prompt with cached system content for cost optimization
877
896
  async function sendPromptToLLM(promptConfig, llmOptions) {
878
897
  try {
@@ -1150,7 +1169,7 @@ ${reviewInstructions}
1150
1169
 
1151
1170
  ${fileSection}
1152
1171
 
1153
- Use the cached project context, project architecture, and custom instructions when evaluating this file.
1172
+ ${buildCachedContextInstruction(cachedSystemBlocks, 'this file')}
1154
1173
 
1155
1174
  CONTEXT A: EXPLICIT GUIDELINES FROM DOCUMENTATION
1156
1175
  ${formattedGuidelines}
@@ -1270,7 +1289,7 @@ ${reviewInstructions}
1270
1289
  ${fileSection}
1271
1290
 
1272
1291
  ## ANALYSIS CONTEXT
1273
- Use the cached project context, project architecture, and custom instructions when evaluating this test file.
1292
+ ${buildCachedContextInstruction(cachedSystemBlocks, 'this test file')}
1274
1293
 
1275
1294
  CONTEXT A: TESTING GUIDELINES AND BEST PRACTICES
1276
1295
  ${formattedGuidelines}
@@ -1358,23 +1377,24 @@ ${g.content}
1358
1377
 
1359
1378
  // Format PR files with their diffs
1360
1379
  const prFiles = file.prFiles || [];
1380
+ const holisticContextPlans = mergeHolisticFileContextPlans(prFiles, context.options || {});
1361
1381
  const formattedPRFiles = prFiles
1362
1382
  .map((prFile, idx) => {
1383
+ const fileContextPlan = holisticContextPlans[idx];
1384
+ const fileContext = formatPlannedHolisticFileContext(prFile, fileContextPlan, context.options || {});
1363
1385
  return `
1364
1386
  ## FILE ${idx + 1}: ${prFile.path}
1365
1387
  **Language**: ${prFile.language}
1366
1388
  **Type**: ${prFile.isTest ? 'Test' : 'Source'} file
1367
1389
  **Summary**: ${prFile.summary}
1390
+ **Context Mode**: ${fileContextPlan.mode === 'full' ? 'Full file content' : 'Focused changed-line context'}
1368
1391
 
1369
1392
  ### Changes (Git Diff):
1370
1393
  \`\`\`diff
1371
1394
  ${prFile.diff}
1372
1395
  \`\`\`
1373
1396
 
1374
- ### Full File Content (For Context - line numbers shown for reference):
1375
- \`\`\`${prFile.language}
1376
- ${addLineNumbers(prFile.fullContent)}
1377
- \`\`\`
1397
+ ${fileContext}
1378
1398
  `;
1379
1399
  })
1380
1400
  .join('\n');
@@ -1388,17 +1408,17 @@ ${addLineNumbers(prFile.fullContent)}
1388
1408
  **CRITICAL CONTEXT AWARENESS INSTRUCTIONS:**
1389
1409
 
1390
1410
  For each file in this PR, you have access to:
1391
- 1. **FULL FILE CONTENT** - The complete file for understanding context and existing code
1411
+ 1. **FILE CONTEXT** - Complete file content when it fits the holistic context budget, or focused changed-line windows for very large files/PRs
1392
1412
  2. **GIT DIFF** - Only the changes to review
1393
1413
 
1394
1414
  **Review Rules:**
1395
1415
  - ONLY critique the CHANGED lines shown in each file's diff (lines with + or -)
1396
- - USE the full file content to understand context, dependencies, and existing implementations
1416
+ - USE the file context to understand dependencies, nearby behavior, and existing implementations
1397
1417
  - DO NOT suggest adding code that already exists in the unchanged portions of any file
1398
- - DO NOT flag issues about missing code if it exists elsewhere in the full file
1399
- - Before flagging cross-file issues, verify the code doesn't already exist in unchanged portions
1400
- - Do NOT flag functions/variables as missing if they exist elsewhere in the full file
1401
- - The unchanged code is part of each file - always check it before making assumptions`;
1418
+ - DO NOT flag issues about missing code when the provided context already shows the code exists
1419
+ - Before flagging cross-file issues, verify the code doesn't already exist in the provided context
1420
+ - Do NOT flag functions/variables as missing if they exist elsewhere in the provided context
1421
+ - The unchanged context is part of each file - always check it before making assumptions`;
1402
1422
 
1403
1423
  let roleDefinition = buildRoleDefinition(baseRole, customDocs, 'pr');
1404
1424
  roleDefinition += '\nAnalyze ALL files together to identify cross-file issues, consistency problems, and overall code quality.';
@@ -1427,7 +1447,7 @@ For each file in this PR, you have access to:
1427
1447
  - **Test Files**: ${prFiles.filter((f) => f.isTest).length}
1428
1448
 
1429
1449
  ## UNIFIED CONTEXT FROM PROJECT
1430
- Use the cached project context, project architecture, and custom instructions when evaluating this pull request.
1450
+ ${buildCachedContextInstruction(cachedSystemBlocks, 'this pull request')}
1431
1451
 
1432
1452
  ### PROJECT CODE EXAMPLES
1433
1453
  ${formattedCodeExamples}
@@ -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
  });