codecritique 2.0.6 → 2.0.7

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.6",
3
+ "version": "2.0.7",
4
4
  "description": "AI-powered code review tool for any programming language",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/llm.js CHANGED
@@ -44,6 +44,44 @@ const DEFAULT_MODEL = 'claude-sonnet-4-5';
44
44
  // Maximum tokens for response
45
45
  const MAX_TOKENS = 4096;
46
46
 
47
+ const MAX_CACHE_CONTROL_BLOCKS = 4;
48
+
49
+ function normalizeSystemBlock(block, cacheControl) {
50
+ const normalizedBlock = typeof block === 'string' ? { type: 'text', text: block } : { type: 'text', ...block };
51
+
52
+ if (cacheControl) {
53
+ return {
54
+ ...normalizedBlock,
55
+ cache_control: normalizedBlock.cache_control || cacheControl,
56
+ };
57
+ }
58
+
59
+ return normalizedBlock;
60
+ }
61
+
62
+ function buildSystemContent(system, cachedSystemBlocks, cacheControl) {
63
+ if (!system && cachedSystemBlocks.length === 0) {
64
+ return 'You are an expert code reviewer with deep knowledge of software engineering principles, design patterns, and best practices.';
65
+ }
66
+
67
+ const systemBlocks = [];
68
+ const appendBlock = (block) => {
69
+ const cacheable = systemBlocks.filter((existingBlock) => existingBlock.cache_control).length < MAX_CACHE_CONTROL_BLOCKS;
70
+ systemBlocks.push(normalizeSystemBlock(block, cacheable ? cacheControl : null));
71
+ };
72
+
73
+ if (Array.isArray(system)) {
74
+ system.forEach(appendBlock);
75
+ }
76
+ else if (system) {
77
+ appendBlock(system);
78
+ }
79
+
80
+ cachedSystemBlocks.filter(Boolean).forEach(appendBlock);
81
+
82
+ return systemBlocks;
83
+ }
84
+
47
85
  /**
48
86
  * Send a prompt to Claude and get a structured JSON response using tool calling.
49
87
  * Uses prompt caching for system prompts to reduce token costs.
@@ -51,12 +89,21 @@ const MAX_TOKENS = 4096;
51
89
  * @param {string} prompt - The prompt to send to Claude
52
90
  * @param {Object} options - Options for the request
53
91
  * @param {string} options.system - System prompt (will be cached for cost optimization)
92
+ * @param {Array<string|Object>} options.cachedSystemBlocks - Additional stable system blocks to cache when possible
54
93
  * @param {Object} options.jsonSchema - JSON schema for structured output
55
94
  * @param {string} options.cacheTtl - Cache TTL: '5m' (default, no extra cost) or '1h' (extended, extra cost for writes)
56
95
  * @returns {Promise<Object>} The response from Claude with structured data
57
96
  */
58
97
  async function sendPromptToClaude(prompt, options = {}) {
59
- const { model = DEFAULT_MODEL, maxTokens = MAX_TOKENS, temperature = 0.7, system = '', jsonSchema = null, cacheTtl = '5m' } = options;
98
+ const {
99
+ model = DEFAULT_MODEL,
100
+ maxTokens = MAX_TOKENS,
101
+ temperature = 0.7,
102
+ system = '',
103
+ cachedSystemBlocks = [],
104
+ jsonSchema = null,
105
+ cacheTtl = '5m',
106
+ } = options;
60
107
 
61
108
  try {
62
109
  verboseLog(options, chalk.cyan('Sending prompt to Claude...'));
@@ -68,15 +115,7 @@ async function sendPromptToClaude(prompt, options = {}) {
68
115
  // TTL options: '5m' (default, no extra cost) or '1h' (extended, extra cost for cache writes)
69
116
  const cacheControl = cacheTtl === '1h' ? { type: 'ephemeral', ttl: '1h' } : { type: 'ephemeral' };
70
117
 
71
- const systemContent = system
72
- ? [
73
- {
74
- type: 'text',
75
- text: system,
76
- cache_control: cacheControl,
77
- },
78
- ]
79
- : 'You are an expert code reviewer with deep knowledge of software engineering principles, design patterns, and best practices.';
118
+ const systemContent = buildSystemContent(system, cachedSystemBlocks, cacheControl);
80
119
 
81
120
  // Build base request parameters
82
121
  const requestParams = {
package/src/llm.test.js CHANGED
@@ -241,6 +241,62 @@ describe('sendPromptToClaude', () => {
241
241
  })
242
242
  );
243
243
  });
244
+
245
+ it('should append additional cached system blocks', async () => {
246
+ mockMessagesCreate.mockResolvedValue({
247
+ content: [{ type: 'text', text: 'Response' }],
248
+ model: 'claude-sonnet-4-5',
249
+ usage: {},
250
+ });
251
+
252
+ await sendPromptToClaude('Test', {
253
+ system: 'Base review rules',
254
+ cachedSystemBlocks: ['Custom project instructions', 'Project architecture context'],
255
+ });
256
+
257
+ expect(mockMessagesCreate).toHaveBeenCalledWith(
258
+ expect.objectContaining({
259
+ system: [
260
+ {
261
+ type: 'text',
262
+ text: 'Base review rules',
263
+ cache_control: { type: 'ephemeral' },
264
+ },
265
+ {
266
+ type: 'text',
267
+ text: 'Custom project instructions',
268
+ cache_control: { type: 'ephemeral' },
269
+ },
270
+ {
271
+ type: 'text',
272
+ text: 'Project architecture context',
273
+ cache_control: { type: 'ephemeral' },
274
+ },
275
+ ],
276
+ })
277
+ );
278
+ });
279
+
280
+ it('should include extra system blocks without exceeding cache breakpoint limits', async () => {
281
+ mockMessagesCreate.mockResolvedValue({
282
+ content: [{ type: 'text', text: 'Response' }],
283
+ model: 'claude-sonnet-4-5',
284
+ usage: {},
285
+ });
286
+
287
+ await sendPromptToClaude('Test', {
288
+ system: 'Base review rules',
289
+ cachedSystemBlocks: ['Block 1', 'Block 2', 'Block 3', 'Block 4'],
290
+ });
291
+
292
+ const request = mockMessagesCreate.mock.calls.at(-1)[0];
293
+ expect(request.system).toHaveLength(5);
294
+ expect(request.system.filter((block) => block.cache_control)).toHaveLength(4);
295
+ expect(request.system.at(-1)).toEqual({
296
+ type: 'text',
297
+ text: 'Block 4',
298
+ });
299
+ });
244
300
  });
245
301
 
246
302
  describe('message format', () => {
@@ -16,6 +16,8 @@ import { isDocumentationFile, isTestFile } from './utils/file-validation.js';
16
16
  import { verboseLog } from './utils/logging.js';
17
17
  import { escapeSqlString } from './utils/string-utils.js';
18
18
 
19
+ const TERM_SEARCH_CONTENT_CANDIDATE_LIMIT = 500;
20
+
19
21
  // Consolidated file classification configuration
20
22
  const FILE_PATTERNS = {
21
23
  config: {
@@ -383,14 +385,18 @@ export class ProjectAnalyzer {
383
385
  // Unified query function
384
386
  const queryFiles = async (config) => {
385
387
  try {
386
- let query = table.query().select(['path', 'name', 'content', 'type', 'language']);
387
-
388
388
  if (config.whereClause) {
389
- query = query.where(`${projectPathFilter} AND (${config.whereClause})`);
389
+ return await table
390
+ .query()
391
+ .select(['path', 'name', 'content', 'type', 'language'])
392
+ .where(`${projectPathFilter} AND (${config.whereClause})`)
393
+ .limit(config.limit || 30)
394
+ .toArray();
390
395
  }
391
396
  else if (config.terms) {
392
- // For term-based searches, query ALL files and sort by depth to prioritize shallow config files
393
- const allFiles = await table.query().select(['path', 'name', 'content', 'type', 'language']).where(projectPathFilter).toArray(); // NO LIMIT - get all files
397
+ // For term-based searches, query metadata first and only fetch content for
398
+ // the shallow candidate set used for matching.
399
+ const allFiles = await table.query().select(['path', 'name', 'type', 'language']).where(projectPathFilter).toArray();
394
400
 
395
401
  // Sort by path depth (shorter paths first) to prioritize config files
396
402
  allFiles.sort((a, b) => {
@@ -400,24 +406,54 @@ export class ProjectAnalyzer {
400
406
  });
401
407
 
402
408
  // Take only the first 500 after sorting to ensure we have shallow files
403
- const sortedFiles = allFiles.slice(0, 500);
409
+ const sortedFiles = allFiles.slice(0, TERM_SEARCH_CONTENT_CANDIDATE_LIMIT);
410
+ if (sortedFiles.length === 0) {
411
+ return [];
412
+ }
404
413
 
405
- return sortedFiles.filter((result) => {
406
- const content = (result.content || '').toLowerCase();
407
- const pathName = (result.path || '').toLowerCase();
408
- const name = (result.name || '').toLowerCase();
409
- const matches = config.terms.some(
410
- (term) => content.includes(term.toLowerCase()) || pathName.includes(term.toLowerCase()) || name.includes(term.toLowerCase())
411
- );
414
+ const candidatePaths = sortedFiles.map((file) => file.path).filter(Boolean);
415
+ const contentByPath = new Map();
416
+ if (candidatePaths.length > 0) {
417
+ const pathList = candidatePaths.map((candidatePath) => `'${escapeSqlString(candidatePath)}'`).join(', ');
418
+ const contentResults = await table
419
+ .query()
420
+ .select(['path', 'content'])
421
+ .where(`${projectPathFilter} AND path IN (${pathList})`)
422
+ .limit(TERM_SEARCH_CONTENT_CANDIDATE_LIMIT)
423
+ .toArray();
424
+
425
+ for (const result of contentResults) {
426
+ contentByPath.set(result.path, result.content || '');
427
+ }
428
+ }
412
429
 
413
- return matches;
414
- });
430
+ return sortedFiles
431
+ .filter((result) => {
432
+ const content = (contentByPath.get(result.path) || '').toLowerCase();
433
+ const pathName = (result.path || '').toLowerCase();
434
+ const name = (result.name || '').toLowerCase();
435
+ const matches = config.terms.some(
436
+ (term) =>
437
+ content.includes(term.toLowerCase()) || pathName.includes(term.toLowerCase()) || name.includes(term.toLowerCase())
438
+ );
439
+
440
+ return matches;
441
+ })
442
+ .map((result) => {
443
+ return {
444
+ ...result,
445
+ content: contentByPath.get(result.path) || '',
446
+ };
447
+ });
415
448
  }
416
449
  else {
417
- query = query.where(projectPathFilter);
450
+ return await table
451
+ .query()
452
+ .select(['path', 'name', 'content', 'type', 'language'])
453
+ .where(projectPathFilter)
454
+ .limit(config.limit || 30)
455
+ .toArray();
418
456
  }
419
-
420
- return await query.limit(config.limit || 30).toArray();
421
457
  }
422
458
  catch (error) {
423
459
  verboseLog({}, chalk.yellow(` ⚠️ Query failed for ${config.category}: ${error.message}`));
@@ -410,6 +410,79 @@ describe('ProjectAnalyzer', () => {
410
410
  expect(queryChain.where).toHaveBeenCalledWith(expect.stringContaining("/mock/we''re/project"));
411
411
  });
412
412
 
413
+ it('should bound content reads for term-based key file searches', async () => {
414
+ const queryCalls = [];
415
+ mockTable.query.mockImplementation(() => {
416
+ const call = {
417
+ select: [],
418
+ where: [],
419
+ limit: [],
420
+ };
421
+ queryCalls.push(call);
422
+
423
+ const chain = {
424
+ select: vi.fn((columns) => {
425
+ call.select.push(columns);
426
+ return chain;
427
+ }),
428
+ where: vi.fn((clause) => {
429
+ call.where.push(clause);
430
+ return chain;
431
+ }),
432
+ limit: vi.fn((limit) => {
433
+ call.limit.push(limit);
434
+ return chain;
435
+ }),
436
+ toArray: vi.fn(async () => {
437
+ const selectedColumns = call.select[0] || [];
438
+ if (selectedColumns.includes('content')) {
439
+ return [
440
+ {
441
+ path: 'package.json',
442
+ name: 'package.json',
443
+ content: '{"name": "test-project"}',
444
+ type: 'json',
445
+ language: 'json',
446
+ },
447
+ ];
448
+ }
449
+
450
+ return [
451
+ {
452
+ path: 'package.json',
453
+ name: 'package.json',
454
+ type: 'json',
455
+ language: 'json',
456
+ },
457
+ ];
458
+ }),
459
+ };
460
+
461
+ return chain;
462
+ });
463
+
464
+ const result = await analyzer.mineKeyFilesFromEmbeddings(mockProjectPath);
465
+
466
+ expect(result.some((file) => file.path === 'package.json')).toBe(true);
467
+ expect(
468
+ queryCalls.some((call) => {
469
+ const selectedColumns = call.select[0] || [];
470
+ return selectedColumns.includes('content') && call.where[0] === "project_path = '/mock/project'" && call.limit.length === 0;
471
+ })
472
+ ).toBe(false);
473
+ expect(
474
+ queryCalls.some((call) => {
475
+ const selectedColumns = call.select[0] || [];
476
+ return (
477
+ selectedColumns.includes('path') &&
478
+ selectedColumns.includes('name') &&
479
+ !selectedColumns.includes('content') &&
480
+ call.where[0] === "project_path = '/mock/project'"
481
+ );
482
+ })
483
+ ).toBe(true);
484
+ });
485
+
413
486
  it('should handle table optimization errors gracefully', async () => {
414
487
  mockTable.optimize.mockRejectedValue(new Error('legacy format'));
415
488
  mockTable.query().toArray.mockResolvedValue([]);
@@ -42,6 +42,20 @@ const embeddingsSystem = getDefaultEmbeddingsSystem();
42
42
  // Track if semantic similarity has been initialized
43
43
  let semanticSimilarityInitialized = false;
44
44
 
45
+ async function fileExists(filePath) {
46
+ try {
47
+ await fs.promises.access(filePath);
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ async function readTextFile(filePath) {
56
+ return await fs.promises.readFile(filePath, 'utf8');
57
+ }
58
+
45
59
  /**
46
60
  * Initialize semantic similarity for feedback filtering
47
61
  * Uses the shared embeddings system from feedback-loader.js
@@ -448,7 +462,7 @@ async function runAnalysis(filePath, options = {}) {
448
462
  }
449
463
 
450
464
  // Check if file exists
451
- if (!fs.existsSync(filePath)) {
465
+ if (!(await fileExists(filePath))) {
452
466
  throw new Error(`File not found: ${filePath}`);
453
467
  }
454
468
 
@@ -458,11 +472,11 @@ async function runAnalysis(filePath, options = {}) {
458
472
  if (options.diffOnly && options.diffContent) {
459
473
  content = options.diffContent;
460
474
  // For PR reviews, always read the full file content for context awareness
461
- fullFileContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : null;
475
+ fullFileContent = await readTextFile(filePath);
462
476
  verboseLog(options, chalk.blue(`Analyzing diff only for ${path.basename(filePath)}`));
463
477
  }
464
478
  else {
465
- content = fs.readFileSync(filePath, 'utf8');
479
+ content = await readTextFile(filePath);
466
480
  fullFileContent = content;
467
481
  verboseLog(options, chalk.blue(`Analyzing full file ${path.basename(filePath)}`));
468
482
  }
@@ -835,6 +849,30 @@ MARKDOWN FORMATTING IN DESCRIPTIONS AND SUGGESTIONS:
835
849
  Your response must start with { and end with } with no additional text.`;
836
850
  }
837
851
 
852
+ function buildCachedProjectContextBlocks({ roleDefinition, customDocsSection, projectArchitectureSection }) {
853
+ const blocks = [];
854
+
855
+ if (roleDefinition?.trim()) {
856
+ blocks.push(`REVIEW ROLE AND PROJECT INSTRUCTIONS
857
+
858
+ ${roleDefinition}`);
859
+ }
860
+
861
+ if (customDocsSection?.trim() && !customDocsSection.includes('No custom instructions provided.')) {
862
+ blocks.push(`CUSTOM PROJECT INSTRUCTIONS
863
+
864
+ ${customDocsSection}`);
865
+ }
866
+
867
+ if (projectArchitectureSection?.trim()) {
868
+ blocks.push(`PROJECT ARCHITECTURE CONTEXT
869
+
870
+ ${projectArchitectureSection}`);
871
+ }
872
+
873
+ return blocks;
874
+ }
875
+
838
876
  // LLM call function - sends prompt with cached system content for cost optimization
839
877
  async function sendPromptToLLM(promptConfig, llmOptions) {
840
878
  try {
@@ -946,12 +984,14 @@ async function sendPromptToLLM(promptConfig, llmOptions) {
946
984
  verboseLog(llmOptions, chalk.gray(` Prompt config has systemPrompt: ${!!promptConfig.systemPrompt}`));
947
985
  verboseLog(llmOptions, chalk.gray(` Prompt config has userPrompt: ${!!promptConfig.userPrompt}`));
948
986
  verboseLog(llmOptions, chalk.gray(` System prompt length: ${promptConfig.systemPrompt?.length || 0} chars`));
987
+ verboseLog(llmOptions, chalk.gray(` Cached system blocks: ${promptConfig.cachedSystemBlocks?.length || 0}`));
949
988
  verboseLog(llmOptions, chalk.gray(` User prompt length: ${promptConfig.userPrompt?.length || 0} chars`));
950
989
 
951
990
  // Send prompt with system (cached) and user (dynamic) content
952
991
  const response = await llm.sendPromptToClaude(promptConfig.userPrompt, {
953
992
  ...llmOptions,
954
993
  system: promptConfig.systemPrompt,
994
+ cachedSystemBlocks: promptConfig.cachedSystemBlocks || [],
955
995
  jsonSchema: codeReviewSchema,
956
996
  });
957
997
 
@@ -1096,21 +1136,21 @@ ${addLineNumbers(file.content)}
1096
1136
 
1097
1137
  // Build the cached system prompt (contains all static rules)
1098
1138
  const systemPrompt = buildCachedSystemPrompt('code');
1139
+ const cachedSystemBlocks = buildCachedProjectContextBlocks({
1140
+ roleDefinition,
1141
+ customDocsSection,
1142
+ projectArchitectureSection,
1143
+ });
1099
1144
 
1100
1145
  // Build the dynamic user prompt (contains all variable content)
1101
1146
  const userPrompt = finalizePrompt(`
1102
1147
  ## REVIEW TYPE: CODE FILE ANALYSIS
1103
1148
 
1104
- ${roleDefinition}
1105
-
1106
1149
  ${reviewInstructions}
1107
1150
 
1108
- ${customDocsSection}
1109
-
1110
1151
  ${fileSection}
1111
1152
 
1112
- CONTEXT FROM PROJECT:
1113
- ${projectArchitectureSection}
1153
+ Use the cached project context, project architecture, and custom instructions when evaluating this file.
1114
1154
 
1115
1155
  CONTEXT A: EXPLICIT GUIDELINES FROM DOCUMENTATION
1116
1156
  ${formattedGuidelines}
@@ -1136,7 +1176,7 @@ Analyze the FILE TO REVIEW above using the 4-stage methodology:
1136
1176
  - If you analyzed something and found it's fine, simply don't include it in the output
1137
1177
  `);
1138
1178
 
1139
- return { systemPrompt, userPrompt };
1179
+ return { systemPrompt, userPrompt, cachedSystemBlocks };
1140
1180
  }
1141
1181
 
1142
1182
  /**
@@ -1215,22 +1255,22 @@ ${addLineNumbers(file.content)}
1215
1255
 
1216
1256
  // Build the cached system prompt (contains all static rules)
1217
1257
  const systemPrompt = buildCachedSystemPrompt('test');
1258
+ const cachedSystemBlocks = buildCachedProjectContextBlocks({
1259
+ roleDefinition,
1260
+ customDocsSection,
1261
+ projectArchitectureSection,
1262
+ });
1218
1263
 
1219
1264
  // Build the dynamic user prompt (contains all variable content)
1220
1265
  const userPrompt = finalizePrompt(`
1221
1266
  ## REVIEW TYPE: TEST FILE ANALYSIS
1222
1267
 
1223
- ${roleDefinition}
1224
-
1225
1268
  ${reviewInstructions}
1226
1269
 
1227
1270
  ${fileSection}
1228
1271
 
1229
1272
  ## ANALYSIS CONTEXT
1230
- ${customDocsSection}
1231
-
1232
- CONTEXT FROM PROJECT:
1233
- ${projectArchitectureSection}
1273
+ Use the cached project context, project architecture, and custom instructions when evaluating this test file.
1234
1274
 
1235
1275
  CONTEXT A: TESTING GUIDELINES AND BEST PRACTICES
1236
1276
  ${formattedGuidelines}
@@ -1253,7 +1293,7 @@ Analyze the TEST FILE above using the 5-stage methodology:
1253
1293
  - If you analyzed something and found it's fine, simply don't include it in the output
1254
1294
  `);
1255
1295
 
1256
- return { systemPrompt, userPrompt };
1296
+ return { systemPrompt, userPrompt, cachedSystemBlocks };
1257
1297
  }
1258
1298
 
1259
1299
  /**
@@ -1371,20 +1411,23 @@ For each file in this PR, you have access to:
1371
1411
 
1372
1412
  // Build the cached system prompt (contains all static rules)
1373
1413
  const systemPrompt = buildCachedSystemPrompt('pr');
1414
+ const cachedSystemBlocks = buildCachedProjectContextBlocks({
1415
+ roleDefinition,
1416
+ customDocsSection,
1417
+ projectArchitectureSection,
1418
+ });
1374
1419
 
1375
1420
  // Build the dynamic user prompt (contains all variable content)
1376
1421
  const userPrompt = finalizePrompt(`
1377
1422
  ## REVIEW TYPE: HOLISTIC PR ANALYSIS
1378
1423
 
1379
- ${roleDefinition}
1380
-
1381
1424
  ## PULL REQUEST OVERVIEW
1382
1425
  - **Total Files**: ${prFiles.length}
1383
1426
  - **Source Files**: ${prFiles.filter((f) => !f.isTest).length}
1384
1427
  - **Test Files**: ${prFiles.filter((f) => f.isTest).length}
1385
1428
 
1386
1429
  ## UNIFIED CONTEXT FROM PROJECT
1387
- ${projectArchitectureSection}
1430
+ Use the cached project context, project architecture, and custom instructions when evaluating this pull request.
1388
1431
 
1389
1432
  ### PROJECT CODE EXAMPLES
1390
1433
  ${formattedCodeExamples}
@@ -1398,9 +1441,6 @@ ${formattedPRComments}
1398
1441
  ## PR FILES WITH CHANGES
1399
1442
  ${formattedPRFiles}
1400
1443
 
1401
- ## CUSTOM INSTRUCTIONS
1402
- ${customDocsSection}
1403
-
1404
1444
  ## YOUR TASK
1405
1445
 
1406
1446
  Analyze ALL PR FILES above using the 5-stage methodology:
@@ -1418,7 +1458,7 @@ Analyze ALL PR FILES above using the 5-stage methodology:
1418
1458
  - If you analyzed something and found it's fine, simply don't include it in the output
1419
1459
  `);
1420
1460
 
1421
- return { systemPrompt, userPrompt };
1461
+ return { systemPrompt, userPrompt, cachedSystemBlocks };
1422
1462
  }
1423
1463
 
1424
1464
  /**
@@ -1487,7 +1527,7 @@ async function getPRCommentContext(filePath, options = {}) {
1487
1527
  verboseLog(options, chalk.blue(`🔍 Using pre-computed query embedding for PR comment search`));
1488
1528
  // We still need the file content for the search function, but not for embedding
1489
1529
  try {
1490
- fileContent = fs.readFileSync(filePath, 'utf8');
1530
+ fileContent = await readTextFile(filePath);
1491
1531
  const maxEmbeddingLength = 8000; // Keep consistent with original truncation
1492
1532
  contentForSearch = fileContent.length > maxEmbeddingLength ? fileContent.substring(0, maxEmbeddingLength) : fileContent;
1493
1533
  }
@@ -1505,7 +1545,7 @@ async function getPRCommentContext(filePath, options = {}) {
1505
1545
  else {
1506
1546
  // Fallback to original behavior if no pre-computed embedding provided
1507
1547
  try {
1508
- fileContent = fs.readFileSync(filePath, 'utf8');
1548
+ fileContent = await readTextFile(filePath);
1509
1549
  }
1510
1550
  catch (readError) {
1511
1551
  debug(`[getPRCommentContext] Could not read file ${filePath}: ${readError.message}`);
@@ -41,6 +41,10 @@ vi.mock('node:fs', () => ({
41
41
  default: {
42
42
  readFileSync: vi.fn(),
43
43
  existsSync: vi.fn().mockReturnValue(true),
44
+ promises: {
45
+ access: vi.fn().mockResolvedValue(undefined),
46
+ readFile: vi.fn(),
47
+ },
44
48
  },
45
49
  }));
46
50
 
@@ -155,6 +159,8 @@ describe('rag-analyzer', () => {
155
159
  mockEmbeddingsSystem.updatePRCommentsIndex.mockReset().mockResolvedValue(undefined);
156
160
  fs.readFileSync.mockReturnValue('const x = 1;\nconsole.log(x);');
157
161
  fs.existsSync.mockReturnValue(true);
162
+ fs.promises.access.mockReset().mockResolvedValue(undefined);
163
+ fs.promises.readFile.mockReset().mockResolvedValue('const x = 1;\nconsole.log(x);');
158
164
  });
159
165
 
160
166
  afterEach(() => {
@@ -197,7 +203,7 @@ describe('rag-analyzer', () => {
197
203
  });
198
204
 
199
205
  it('should return error when file does not exist', async () => {
200
- fs.existsSync.mockReturnValue(false);
206
+ fs.promises.access.mockRejectedValue(new Error('ENOENT'));
201
207
  const result = await runAnalysis('/test/nonexistent.js');
202
208
  expect(result.success).toBe(false);
203
209
  expect(result.error).toContain('File not found');
@@ -369,13 +375,13 @@ describe('rag-analyzer', () => {
369
375
  ['whitespace only', ' \n\n '],
370
376
  ['normal content', 'const x = 1;\nfunction test() {}'],
371
377
  ])('should handle %s', async (_, content) => {
372
- fs.readFileSync.mockReturnValue(content);
378
+ fs.promises.readFile.mockResolvedValue(content);
373
379
  const result = await runAnalysis('/test/file.js');
374
380
  expect(result).toBeDefined();
375
381
  });
376
382
 
377
383
  it('should handle very long files', async () => {
378
- fs.readFileSync.mockReturnValue(createMockLongCode(1000));
384
+ fs.promises.readFile.mockResolvedValue(createMockLongCode(1000));
379
385
  const result = await runAnalysis('/test/long.js');
380
386
  expect(result.success).toBe(true);
381
387
  });
@@ -772,7 +778,9 @@ describe('rag-analyzer', () => {
772
778
  customDocs: [{ content: 'Mandatory internal review rule', document_title: 'Internal Standards' }],
773
779
  });
774
780
 
775
- expect(llm.sendPromptToClaude).toHaveBeenCalledWith(expect.stringContaining('Mandatory internal review rule'), expect.any(Object));
781
+ const [, options] = llm.sendPromptToClaude.mock.calls.at(-1);
782
+ expect(options.cachedSystemBlocks.join('\n')).toContain('Mandatory internal review rule');
783
+ expect(llm.sendPromptToClaude.mock.calls.at(-1)[0]).not.toContain('Mandatory internal review rule');
776
784
  });
777
785
 
778
786
  it('should log selected chunks when verbose', async () => {
@@ -954,7 +962,7 @@ describe('rag-analyzer', () => {
954
962
 
955
963
  describe('gatherUnifiedContextForPR error handling', () => {
956
964
  it('should handle file context gathering errors', async () => {
957
- fs.readFileSync.mockImplementation((path) => {
965
+ fs.promises.readFile.mockImplementation((path) => {
958
966
  if (path.includes('error-file')) {
959
967
  throw new Error('Read error');
960
968
  }
@@ -1181,7 +1189,7 @@ describe('rag-analyzer', () => {
1181
1189
  return <div>{data}</div>;
1182
1190
  }
1183
1191
  `;
1184
- fs.readFileSync.mockReturnValue(richCode);
1192
+ fs.promises.readFile.mockResolvedValue(richCode);
1185
1193
  setupSuccessfulLLMResponse();
1186
1194
  const result = await runAnalysis('/test/Dashboard.jsx');
1187
1195
  expect(result.success).toBe(true);