codecritique 2.0.6 → 2.0.8

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.8",
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) => {
@@ -399,25 +405,78 @@ export class ProjectAnalyzer {
399
405
  return depthA - depthB;
400
406
  });
401
407
 
402
- // Take only the first 500 after sorting to ensure we have shallow files
403
- const sortedFiles = allFiles.slice(0, 500);
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
+ }
427
+ if (sortedFiles.length === 0) {
428
+ return [];
429
+ }
404
430
 
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
- );
431
+ const candidatePaths = sortedFiles.map((file) => file.path).filter(Boolean);
432
+ const contentByPath = new Map();
433
+ if (candidatePaths.length > 0) {
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.
438
+ const contentResults = await table
439
+ .query()
440
+ .select(['path', 'content'])
441
+ .where(`${projectPathFilter} AND path IN (${pathList})`)
442
+ .toArray();
443
+
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
+ }
449
+ contentByPath.set(result.path, result.content || '');
450
+ }
451
+ }
412
452
 
413
- return matches;
414
- });
453
+ return sortedFiles
454
+ .filter((result) => {
455
+ const content = (contentByPath.get(result.path) || '').toLowerCase();
456
+ const pathName = (result.path || '').toLowerCase();
457
+ const name = (result.name || '').toLowerCase();
458
+ const matches = config.terms.some(
459
+ (term) =>
460
+ content.includes(term.toLowerCase()) || pathName.includes(term.toLowerCase()) || name.includes(term.toLowerCase())
461
+ );
462
+
463
+ return matches;
464
+ })
465
+ .map((result) => {
466
+ return {
467
+ ...result,
468
+ content: contentByPath.get(result.path) || '',
469
+ };
470
+ });
415
471
  }
416
472
  else {
417
- query = query.where(projectPathFilter);
473
+ return await table
474
+ .query()
475
+ .select(['path', 'name', 'content', 'type', 'language'])
476
+ .where(projectPathFilter)
477
+ .limit(config.limit || 30)
478
+ .toArray();
418
479
  }
419
-
420
- return await query.limit(config.limit || 30).toArray();
421
480
  }
422
481
  catch (error) {
423
482
  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
 
@@ -457,12 +471,14 @@ async function runAnalysis(filePath, options = {}) {
457
471
  let fullFileContent;
458
472
  if (options.diffOnly && options.diffContent) {
459
473
  content = options.diffContent;
460
- // For PR reviews, always read the full file content for context awareness
461
- fullFileContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : null;
474
+ // For PR reviews, always read the full file content for context awareness.
475
+ // Tolerate a missing/unreadable file (e.g. deleted in the window after the
476
+ // existence check) by degrading to null; downstream falls back to file.content.
477
+ fullFileContent = await readTextFile(filePath).catch(() => null);
462
478
  verboseLog(options, chalk.blue(`Analyzing diff only for ${path.basename(filePath)}`));
463
479
  }
464
480
  else {
465
- content = fs.readFileSync(filePath, 'utf8');
481
+ content = await readTextFile(filePath);
466
482
  fullFileContent = content;
467
483
  verboseLog(options, chalk.blue(`Analyzing full file ${path.basename(filePath)}`));
468
484
  }
@@ -835,6 +851,46 @@ MARKDOWN FORMATTING IN DESCRIPTIONS AND SUGGESTIONS:
835
851
  Your response must start with { and end with } with no additional text.`;
836
852
  }
837
853
 
854
+ function buildCachedProjectContextBlocks({ roleDefinition, customDocsSection, projectArchitectureSection }) {
855
+ const blocks = [];
856
+
857
+ if (roleDefinition?.trim()) {
858
+ blocks.push(`REVIEW ROLE AND PROJECT INSTRUCTIONS
859
+
860
+ ${roleDefinition}`);
861
+ }
862
+
863
+ if (customDocsSection?.trim() && !customDocsSection.includes('No custom instructions provided.')) {
864
+ blocks.push(`CUSTOM PROJECT INSTRUCTIONS
865
+
866
+ ${customDocsSection}`);
867
+ }
868
+
869
+ if (projectArchitectureSection?.trim()) {
870
+ blocks.push(`PROJECT ARCHITECTURE CONTEXT
871
+
872
+ ${projectArchitectureSection}`);
873
+ }
874
+
875
+ return blocks;
876
+ }
877
+
878
+ /**
879
+ * Build the user-prompt sentence that points the model at the cached system blocks.
880
+ * Returns an empty string when no cached blocks were emitted, so the prompt never
881
+ * instructs the model to use context that was not sent.
882
+ *
883
+ * @param {string[]} cachedSystemBlocks - Cached blocks produced for this request
884
+ * @param {string} subject - What is being evaluated (e.g. 'this file')
885
+ * @returns {string} Instruction sentence, or '' when there is no cached context
886
+ */
887
+ function buildCachedContextInstruction(cachedSystemBlocks, subject) {
888
+ if (!cachedSystemBlocks || cachedSystemBlocks.length === 0) {
889
+ return '';
890
+ }
891
+ return `Use the cached project context, project architecture, and custom instructions when evaluating ${subject}.`;
892
+ }
893
+
838
894
  // LLM call function - sends prompt with cached system content for cost optimization
839
895
  async function sendPromptToLLM(promptConfig, llmOptions) {
840
896
  try {
@@ -946,12 +1002,14 @@ async function sendPromptToLLM(promptConfig, llmOptions) {
946
1002
  verboseLog(llmOptions, chalk.gray(` Prompt config has systemPrompt: ${!!promptConfig.systemPrompt}`));
947
1003
  verboseLog(llmOptions, chalk.gray(` Prompt config has userPrompt: ${!!promptConfig.userPrompt}`));
948
1004
  verboseLog(llmOptions, chalk.gray(` System prompt length: ${promptConfig.systemPrompt?.length || 0} chars`));
1005
+ verboseLog(llmOptions, chalk.gray(` Cached system blocks: ${promptConfig.cachedSystemBlocks?.length || 0}`));
949
1006
  verboseLog(llmOptions, chalk.gray(` User prompt length: ${promptConfig.userPrompt?.length || 0} chars`));
950
1007
 
951
1008
  // Send prompt with system (cached) and user (dynamic) content
952
1009
  const response = await llm.sendPromptToClaude(promptConfig.userPrompt, {
953
1010
  ...llmOptions,
954
1011
  system: promptConfig.systemPrompt,
1012
+ cachedSystemBlocks: promptConfig.cachedSystemBlocks || [],
955
1013
  jsonSchema: codeReviewSchema,
956
1014
  });
957
1015
 
@@ -1096,21 +1154,21 @@ ${addLineNumbers(file.content)}
1096
1154
 
1097
1155
  // Build the cached system prompt (contains all static rules)
1098
1156
  const systemPrompt = buildCachedSystemPrompt('code');
1157
+ const cachedSystemBlocks = buildCachedProjectContextBlocks({
1158
+ roleDefinition,
1159
+ customDocsSection,
1160
+ projectArchitectureSection,
1161
+ });
1099
1162
 
1100
1163
  // Build the dynamic user prompt (contains all variable content)
1101
1164
  const userPrompt = finalizePrompt(`
1102
1165
  ## REVIEW TYPE: CODE FILE ANALYSIS
1103
1166
 
1104
- ${roleDefinition}
1105
-
1106
1167
  ${reviewInstructions}
1107
1168
 
1108
- ${customDocsSection}
1109
-
1110
1169
  ${fileSection}
1111
1170
 
1112
- CONTEXT FROM PROJECT:
1113
- ${projectArchitectureSection}
1171
+ ${buildCachedContextInstruction(cachedSystemBlocks, 'this file')}
1114
1172
 
1115
1173
  CONTEXT A: EXPLICIT GUIDELINES FROM DOCUMENTATION
1116
1174
  ${formattedGuidelines}
@@ -1136,7 +1194,7 @@ Analyze the FILE TO REVIEW above using the 4-stage methodology:
1136
1194
  - If you analyzed something and found it's fine, simply don't include it in the output
1137
1195
  `);
1138
1196
 
1139
- return { systemPrompt, userPrompt };
1197
+ return { systemPrompt, userPrompt, cachedSystemBlocks };
1140
1198
  }
1141
1199
 
1142
1200
  /**
@@ -1215,22 +1273,22 @@ ${addLineNumbers(file.content)}
1215
1273
 
1216
1274
  // Build the cached system prompt (contains all static rules)
1217
1275
  const systemPrompt = buildCachedSystemPrompt('test');
1276
+ const cachedSystemBlocks = buildCachedProjectContextBlocks({
1277
+ roleDefinition,
1278
+ customDocsSection,
1279
+ projectArchitectureSection,
1280
+ });
1218
1281
 
1219
1282
  // Build the dynamic user prompt (contains all variable content)
1220
1283
  const userPrompt = finalizePrompt(`
1221
1284
  ## REVIEW TYPE: TEST FILE ANALYSIS
1222
1285
 
1223
- ${roleDefinition}
1224
-
1225
1286
  ${reviewInstructions}
1226
1287
 
1227
1288
  ${fileSection}
1228
1289
 
1229
1290
  ## ANALYSIS CONTEXT
1230
- ${customDocsSection}
1231
-
1232
- CONTEXT FROM PROJECT:
1233
- ${projectArchitectureSection}
1291
+ ${buildCachedContextInstruction(cachedSystemBlocks, 'this test file')}
1234
1292
 
1235
1293
  CONTEXT A: TESTING GUIDELINES AND BEST PRACTICES
1236
1294
  ${formattedGuidelines}
@@ -1253,7 +1311,7 @@ Analyze the TEST FILE above using the 5-stage methodology:
1253
1311
  - If you analyzed something and found it's fine, simply don't include it in the output
1254
1312
  `);
1255
1313
 
1256
- return { systemPrompt, userPrompt };
1314
+ return { systemPrompt, userPrompt, cachedSystemBlocks };
1257
1315
  }
1258
1316
 
1259
1317
  /**
@@ -1371,20 +1429,23 @@ For each file in this PR, you have access to:
1371
1429
 
1372
1430
  // Build the cached system prompt (contains all static rules)
1373
1431
  const systemPrompt = buildCachedSystemPrompt('pr');
1432
+ const cachedSystemBlocks = buildCachedProjectContextBlocks({
1433
+ roleDefinition,
1434
+ customDocsSection,
1435
+ projectArchitectureSection,
1436
+ });
1374
1437
 
1375
1438
  // Build the dynamic user prompt (contains all variable content)
1376
1439
  const userPrompt = finalizePrompt(`
1377
1440
  ## REVIEW TYPE: HOLISTIC PR ANALYSIS
1378
1441
 
1379
- ${roleDefinition}
1380
-
1381
1442
  ## PULL REQUEST OVERVIEW
1382
1443
  - **Total Files**: ${prFiles.length}
1383
1444
  - **Source Files**: ${prFiles.filter((f) => !f.isTest).length}
1384
1445
  - **Test Files**: ${prFiles.filter((f) => f.isTest).length}
1385
1446
 
1386
1447
  ## UNIFIED CONTEXT FROM PROJECT
1387
- ${projectArchitectureSection}
1448
+ ${buildCachedContextInstruction(cachedSystemBlocks, 'this pull request')}
1388
1449
 
1389
1450
  ### PROJECT CODE EXAMPLES
1390
1451
  ${formattedCodeExamples}
@@ -1398,9 +1459,6 @@ ${formattedPRComments}
1398
1459
  ## PR FILES WITH CHANGES
1399
1460
  ${formattedPRFiles}
1400
1461
 
1401
- ## CUSTOM INSTRUCTIONS
1402
- ${customDocsSection}
1403
-
1404
1462
  ## YOUR TASK
1405
1463
 
1406
1464
  Analyze ALL PR FILES above using the 5-stage methodology:
@@ -1418,7 +1476,7 @@ Analyze ALL PR FILES above using the 5-stage methodology:
1418
1476
  - If you analyzed something and found it's fine, simply don't include it in the output
1419
1477
  `);
1420
1478
 
1421
- return { systemPrompt, userPrompt };
1479
+ return { systemPrompt, userPrompt, cachedSystemBlocks };
1422
1480
  }
1423
1481
 
1424
1482
  /**
@@ -1487,7 +1545,7 @@ async function getPRCommentContext(filePath, options = {}) {
1487
1545
  verboseLog(options, chalk.blue(`🔍 Using pre-computed query embedding for PR comment search`));
1488
1546
  // We still need the file content for the search function, but not for embedding
1489
1547
  try {
1490
- fileContent = fs.readFileSync(filePath, 'utf8');
1548
+ fileContent = await readTextFile(filePath);
1491
1549
  const maxEmbeddingLength = 8000; // Keep consistent with original truncation
1492
1550
  contentForSearch = fileContent.length > maxEmbeddingLength ? fileContent.substring(0, maxEmbeddingLength) : fileContent;
1493
1551
  }
@@ -1505,7 +1563,7 @@ async function getPRCommentContext(filePath, options = {}) {
1505
1563
  else {
1506
1564
  // Fallback to original behavior if no pre-computed embedding provided
1507
1565
  try {
1508
- fileContent = fs.readFileSync(filePath, 'utf8');
1566
+ fileContent = await readTextFile(filePath);
1509
1567
  }
1510
1568
  catch (readError) {
1511
1569
  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);