codecritique 2.0.5 → 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/README.md CHANGED
@@ -263,8 +263,13 @@ npx codecritique analyze --file src/components/Button.tsx
263
263
  # Analyze files matching patterns
264
264
  npx codecritique analyze --files "src/**/*.ts" "lib/*.js"
265
265
 
266
- # Analyze changes in feature-branch vs main branch (auto-detects base branch)
266
+ # Review changes in a target branch against its auto-detected parent branch
267
267
  npx codecritique analyze --diff-with feature-branch
268
+
269
+ # Review against an explicit base branch (overrides auto-detection).
270
+ # Useful for stacked PRs where you know the real base, or in CI where the PR
271
+ # base ref is authoritative.
272
+ npx codecritique analyze --diff-with feature-B --base feature-A
268
273
  ```
269
274
 
270
275
  #### Using with Custom Guidelines
@@ -383,9 +388,6 @@ jobs:
383
388
  - name: ⬇️ Checkout repo
384
389
  uses: actions/checkout@v4
385
390
 
386
- - name: Setup master branch for diff analysis
387
- run: git fetch --no-tags --prune origin main:main
388
-
389
391
  - name: Code Review
390
392
  uses: cosmocoder/CodeCritique/.github/actions/pr-review@main
391
393
  with:
@@ -393,11 +395,16 @@ jobs:
393
395
  anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
394
396
  ```
395
397
 
398
+ > No manual `git fetch` or `fetch-depth: 0` is needed. The action fetches the PR base
399
+ > branch (the parent — which for a stacked PR is another feature branch) and deepens
400
+ > the shallow checkout just enough to compute the diff, so parent auto-detection works
401
+ > without the cost of a full-history clone.
402
+
396
403
  #### Required Setup
397
404
 
398
405
  1. **Anthropic API Key**: Store your Anthropic API key as a repository secret named `ANTHROPIC_API_KEY`
399
406
  2. **Permissions**: The workflow must have `contents: write`, `actions: read`, and `pull-requests: write` permissions
400
- 3. **Git Setup**: Ensure the base branch is available for diff analysis (see example above)
407
+ 3. **Git Setup**: None required — the action fetches the PR base/parent branch and deepens the checkout only as much as the diff needs (no `fetch-depth: 0`)
401
408
 
402
409
  #### Input Parameters
403
410
 
@@ -450,7 +457,7 @@ codecritique analyze --file src/components/Button.tsx
450
457
  # Analyze files matching patterns
451
458
  codecritique analyze --files "src/**/*.ts" "lib/*.js"
452
459
 
453
- # Analyze branch diff
460
+ # Review target branch changes against its auto-detected parent branch
454
461
  codecritique analyze --diff-with feature-branch
455
462
 
456
463
  # Generate embeddings
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecritique",
3
- "version": "2.0.5",
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/index.js CHANGED
@@ -29,7 +29,7 @@ import { cleanupClassifier, clearPRComments, getPRCommentsStats, hasPRComments }
29
29
  import { ProjectAnalyzer } from './project-analyzer.js';
30
30
  import { reviewFile, reviewFiles, reviewPullRequest } from './rag-review.js';
31
31
  import { execGitSafe } from './utils/command.js';
32
- import { ensureBranchExists, findBaseBranch } from './utils/git.js';
32
+ import { ensureBranchExists, findParentBranch } from './utils/git.js';
33
33
  import { diagnosticLog, isDebugEnabled, isVerboseEnabled, verboseLog } from './utils/logging.js';
34
34
  import { collectPRLevelFindings, getFileLevelIssueCount, hasPRLevelFindings } from './utils/review-results.js';
35
35
  import { configureCleanStdoutForDataOutput, isBrokenStdoutPipeError, writeStdout } from './utils/stdout.js';
@@ -46,7 +46,11 @@ program.name('codecritique').description('CLI tool for AI-powered code review us
46
46
  program
47
47
  .command('analyze')
48
48
  .description('Analyze code using dynamic context (RAG approach)')
49
- .option('-b, --diff-with <branch>', 'Analyze files changed compared to a branch (triggers PR review mode)')
49
+ .option(
50
+ '-b, --diff-with <branch>',
51
+ 'Review the specified target branch against its auto-detected parent branch (triggers PR review mode)'
52
+ )
53
+ .option('--base <branch>', 'Base branch to diff the target against, overriding auto-detection (e.g. the authoritative PR base ref in CI)')
50
54
  .option('-f, --files <files...>', 'Specific files or glob patterns to review')
51
55
  .option('--file <file>', 'Analyze a single file')
52
56
  .option('-d, --directory <dir>', 'Working directory for git operations (use with --diff-with)')
@@ -163,12 +167,12 @@ Examples:
163
167
  $ codecritique analyze --directory src/components
164
168
  $ codecritique analyze --file src/utils/validation.ts
165
169
  $ codecritique analyze --files "src/**/*.tsx" "lib/*.js"
166
- $ codecritique analyze -b main
170
+ $ codecritique analyze -b feature-branch
167
171
  $ codecritique analyze --doc "Our Eng Guidelines:./ENGINEERING_GUIDELINES.md" --file src/utils/validation.ts
168
172
  $ codecritique analyze --diff-with feature-branch -d /path/to/repo
169
173
  $ codecritique analyze --output json > review-results.json
170
174
  $ codecritique analyze --track-feedback --feedback-path ./feedback-artifacts --file src/utils/validation.ts
171
- $ codecritique analyze --track-feedback --feedback-threshold 0.8 -b main
175
+ $ codecritique analyze --track-feedback --feedback-threshold 0.8 -b feature-branch
172
176
  $ codecritique embeddings:generate --directory src
173
177
  $ codecritique embeddings:generate --exclude "**/*.test.js" "**/*.spec.js"
174
178
  $ codecritique embeddings:generate --exclude-file .embedignore
@@ -357,23 +361,26 @@ async function runCodeReview(options) {
357
361
  diagnosticLog(chalk.bold.blue('AI Code Review (RAG Approach) - Starting analysis...'));
358
362
 
359
363
  // Determine the review mode based on options
360
- // Only support: single file, specific files, or diff with branch
364
+ // Only support: single file, specific files, or target-branch diff review
361
365
  if (options.diffWith) {
366
+ const targetBranch = options.diffWith;
362
367
  // Use directory option as working directory for git commands if specified
363
368
  const gitWorkingDir = options.directory ? path.resolve(options.directory) : process.cwd();
364
- const changedFiles = getChangedFiles(options.diffWith, gitWorkingDir);
369
+ const { changedFiles, baseBranch } = getChangedFiles(targetBranch, gitWorkingDir, options.base);
365
370
  if (changedFiles.length === 0) {
366
- const message = `No changed files found compared to branch '${options.diffWith}'. Exiting.`;
371
+ const message = `No changed files found in target branch '${targetBranch}' compared to its detected parent branch. Exiting.`;
367
372
  diagnosticLog(chalk.yellow(message));
368
373
  await emitEmptyReviewOutputIfNeeded(options, { success: true, results: [], message });
369
374
  return;
370
375
  }
371
- operationDescription = `${changedFiles.length} files changed vs ${options.diffWith}`;
372
- // Add the actual branch name to reviewOptions
376
+ operationDescription = `${changedFiles.length} files changed in target branch ${targetBranch}`;
377
+ // Add the actual branch name to reviewOptions. Pass the already-detected
378
+ // parent branch so the review pipeline doesn't re-run parent detection.
373
379
  const enhancedReviewOptions = {
374
380
  ...reviewOptions,
375
- actualBranch: options.diffWith,
376
- diffWith: options.diffWith,
381
+ actualBranch: targetBranch,
382
+ diffWith: targetBranch,
383
+ baseBranch,
377
384
  };
378
385
  reviewTask = reviewPullRequest(changedFiles, enhancedReviewOptions);
379
386
  }
@@ -400,7 +407,7 @@ async function runCodeReview(options) {
400
407
  console.error(chalk.red('Error: You must specify one of the following:'));
401
408
  console.error(chalk.yellow(' --file <file> Analyze a single file'));
402
409
  console.error(chalk.yellow(' --files <files...> Analyze specific files or glob patterns'));
403
- console.error(chalk.yellow(' -b, --diff-with <branch> Analyze files changed in a branch'));
410
+ console.error(chalk.yellow(' -b, --diff-with <branch> Review a target branch against its auto-detected parent branch'));
404
411
  console.error(chalk.gray('\nOptional:'));
405
412
  console.error(chalk.gray(' -d, --directory <dir> Working directory (for git operations with --diff-with)'));
406
413
  console.error(chalk.gray('\nExamples:'));
@@ -973,24 +980,36 @@ async function expandFilePatterns(patterns, baseDir = process.cwd()) {
973
980
  }
974
981
 
975
982
  /**
976
- * Get list of files changed in a branch compared to the base branch (main/master).
977
- * This shows what changes the specified branch has compared to the base.
983
+ * Get list of files changed in the target branch compared to its base branch.
984
+ * The supplied branch is the branch being reviewed, not the base branch.
985
+ *
986
+ * When `explicitBase` is given (e.g. the authoritative PR base ref in CI) it is
987
+ * used directly. Otherwise the parent is auto-detected (see findParentBranch),
988
+ * so stacked PRs are diffed against the branch they were forked from rather than
989
+ * always against main/master/develop.
978
990
  *
979
- * @param {string} branch - Branch to analyze (the feature/target branch)
991
+ * @param {string} targetBranch - Branch to analyze (the feature/target branch)
980
992
  * @param {string} workingDir - Directory to run git commands in (optional, defaults to cwd)
981
- * @returns {Array<string>} Array of changed file paths relative to git root
993
+ * @param {string} [explicitBase] - Base branch to diff against, overriding auto-detection
994
+ * @returns {{ changedFiles: Array<string>, baseBranch: string|null }} Changed file paths
995
+ * (absolute) and the base branch they were diffed against (null on error).
982
996
  */
983
- function getChangedFiles(branch, workingDir = process.cwd()) {
997
+ function getChangedFiles(targetBranch, workingDir = process.cwd(), explicitBase = undefined) {
984
998
  try {
985
999
  // Get git root directory
986
1000
  const gitRoot = execSync('git rev-parse --show-toplevel', { cwd: workingDir }).toString().trim();
987
1001
  diagnosticLog(chalk.gray(`Git repository: ${gitRoot}`));
988
1002
 
989
- // Ensure the branch exists locally (fetch if needed)
990
- ensureBranchExists(branch, workingDir);
1003
+ // Ensure the target branch exists locally (fetch if needed)
1004
+ ensureBranchExists(targetBranch, workingDir);
991
1005
 
992
- // Find the base branch (main/master)
993
- const baseBranch = findBaseBranch(workingDir);
1006
+ // Use the explicitly provided base (authoritative, e.g. the PR base ref in CI)
1007
+ // when given; otherwise detect the parent branch the target was forked from
1008
+ // (main/master/develop, or another feature branch in stacked-PR workflows).
1009
+ const baseBranch = explicitBase || findParentBranch(targetBranch, workingDir);
1010
+ if (explicitBase) {
1011
+ diagnosticLog(chalk.gray(`Using explicit base branch '${baseBranch}' (auto-detection skipped)`));
1012
+ }
994
1013
 
995
1014
  // Ensure the base branch exists locally as well (crucial for diff operations)
996
1015
  try {
@@ -1001,12 +1020,13 @@ function getChangedFiles(branch, workingDir = process.cwd()) {
1001
1020
  // Continue with the original baseBranch name, it might work with remote refs
1002
1021
  }
1003
1022
 
1004
- diagnosticLog(chalk.gray(`Comparing ${branch} against ${baseBranch}...`));
1023
+ diagnosticLog(chalk.gray(`Reviewing target branch '${targetBranch}' against base branch '${baseBranch}'...`));
1005
1024
 
1006
- // Use three-dot notation to get changes in branch compared to base
1007
- // This shows commits that are in 'branch' but not in 'baseBranch'
1025
+ // Use three-dot notation to get changes in the target branch compared to the base.
1008
1026
  // By adding --diff-filter=d, we exclude deleted files from the list.
1009
- const gitOutput = execGitSafe('git diff', ['--name-only', '--diff-filter=d', `${baseBranch}...${branch}`], { cwd: gitRoot }).toString();
1027
+ const gitOutput = execGitSafe('git diff', ['--name-only', '--diff-filter=d', `${baseBranch}...${targetBranch}`], {
1028
+ cwd: gitRoot,
1029
+ }).toString();
1010
1030
 
1011
1031
  // Split, filter empty lines, resolve paths, and check existence
1012
1032
  const changedFiles = gitOutput
@@ -1015,14 +1035,14 @@ function getChangedFiles(branch, workingDir = process.cwd()) {
1015
1035
  .map((file) => path.resolve(gitRoot, file)); // Get absolute path
1016
1036
 
1017
1037
  if (changedFiles.length > 0) {
1018
- diagnosticLog(chalk.gray(`Found ${changedFiles.length} changed files in ${branch} vs ${baseBranch}`));
1038
+ diagnosticLog(chalk.gray(`Found ${changedFiles.length} changed files in target branch '${targetBranch}' vs base '${baseBranch}'`));
1019
1039
  }
1020
1040
 
1021
- return changedFiles;
1041
+ return { changedFiles, baseBranch };
1022
1042
  }
1023
1043
  catch (err) {
1024
1044
  console.error(chalk.red('Error getting git diff:'), err.message);
1025
- return [];
1045
+ return { changedFiles: [], baseBranch: null };
1026
1046
  }
1027
1047
  }
1028
1048
 
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);
package/src/rag-review.js CHANGED
@@ -10,7 +10,7 @@ import path from 'path';
10
10
  import chalk from 'chalk';
11
11
  import { runAnalysis, gatherUnifiedContextForPR } from './rag-analyzer.js';
12
12
  import { shouldProcessFile } from './utils/file-validation.js';
13
- import { findBaseBranch, getChangedLinesInfo, getFileContentFromGit } from './utils/git.js';
13
+ import { ensureBranchExists, findParentBranch, getChangedLinesInfo, getFileContentFromGit } from './utils/git.js';
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';
@@ -209,8 +209,9 @@ async function reviewPullRequest(changedFilePaths, options = {}) {
209
209
  * @param {boolean} [options.verbose=false] - Enable verbose progress logging
210
210
  * @param {number} [options.concurrency=3] - Maximum number of fallback per-file reviews to run in parallel
211
211
  * @param {string} [options.directory] - Working directory for git operations
212
- * @param {string} [options.diffWith='HEAD'] - Branch or ref to compare against
212
+ * @param {string} [options.diffWith='HEAD'] - Target branch or ref being reviewed against the detected base branch
213
213
  * @param {string} [options.actualBranch] - Actual target branch used for content retrieval
214
+ * @param {string} [options.baseBranch] - Pre-detected parent/base branch to diff against; detected here if omitted
214
215
  * @param {boolean} [options.skipChunking=false] - Skip PR chunking, used internally for chunk review recursion
215
216
  * @returns {Promise<Object>} Aggregated review results
216
217
  */
@@ -221,12 +222,27 @@ async function reviewPullRequestWithCrossFileContext(filesToReview, options = {}
221
222
 
222
223
  // Step 1: Get the base branch and collect diff info for all files in the PR
223
224
  const workingDir = options.directory ? path.resolve(options.directory) : process.cwd();
224
- const baseBranch = findBaseBranch(workingDir);
225
225
  const targetBranch = options.diffWith || 'HEAD'; // The feature branch being reviewed
226
226
 
227
227
  // Get the actual branch name from options passed from index.js
228
228
  const actualTargetBranch = options.actualBranch || targetBranch;
229
229
 
230
+ // Prefer the parent branch already detected (and materialized locally) by the
231
+ // caller to avoid re-running the O(branches) detection. Only detect here when
232
+ // this function is invoked directly without a pre-detected base.
233
+ let baseBranch = options.baseBranch;
234
+ if (!baseBranch) {
235
+ baseBranch = findParentBranch(actualTargetBranch, workingDir);
236
+ // Detection may return a branch that exists only on the remote; ensure it
237
+ // resolves locally before it is used for diffing.
238
+ try {
239
+ ensureBranchExists(baseBranch, workingDir);
240
+ }
241
+ catch (error) {
242
+ verboseLog(verbose, chalk.yellow(`Could not ensure base branch '${baseBranch}' exists locally: ${error.message}`));
243
+ }
244
+ }
245
+
230
246
  verboseLog(verbose, chalk.gray(`Base branch: ${baseBranch}, Target branch: ${targetBranch}`));
231
247
 
232
248
  const prFiles = [];
@@ -319,7 +335,9 @@ async function reviewPullRequestWithCrossFileContext(filesToReview, options = {}
319
335
  verboseLog(
320
336
  verbose,
321
337
  chalk.green(
322
- `De-duplicated context: ${deduplicatedCodeExamples.length} code examples, ${deduplicatedGuidelines.length} guidelines, ${deduplicatedPRComments.length} PR comments, ${deduplicatedCustomDocChunks.length} custom doc chunks`
338
+ `De-duplicated context: ${deduplicatedCodeExamples.length} code examples, ${deduplicatedGuidelines.length} guidelines, ${
339
+ deduplicatedPRComments.length
340
+ } PR comments, ${deduplicatedCustomDocChunks.length} custom doc chunks`
323
341
  )
324
342
  );
325
343
 
@@ -1,7 +1,7 @@
1
1
  import { runAnalysis, gatherUnifiedContextForPR } from './rag-analyzer.js';
2
2
  import { reviewFile, reviewFiles, reviewPullRequest } from './rag-review.js';
3
3
  import { shouldProcessFile } from './utils/file-validation.js';
4
- import { getChangedLinesInfo, getFileContentFromGit } from './utils/git.js';
4
+ import { ensureBranchExists, findParentBranch, getChangedLinesInfo, getFileContentFromGit } from './utils/git.js';
5
5
  import { shouldChunkPR, chunkPRFiles, combineChunkResults } from './utils/pr-chunking.js';
6
6
 
7
7
  vi.mock('./rag-analyzer.js', () => ({
@@ -19,7 +19,8 @@ vi.mock('./utils/file-validation.js', () => ({
19
19
  }));
20
20
 
21
21
  vi.mock('./utils/git.js', () => ({
22
- findBaseBranch: vi.fn().mockReturnValue('main'),
22
+ ensureBranchExists: vi.fn(),
23
+ findParentBranch: vi.fn().mockReturnValue('main'),
23
24
  getChangedLinesInfo: vi.fn().mockReturnValue({
24
25
  hasChanges: true,
25
26
  addedLines: [1, 2, 3],
@@ -210,6 +211,38 @@ describe('rag-review', () => {
210
211
  expect(result.results).toEqual([]);
211
212
  });
212
213
 
214
+ it('should use a pre-detected baseBranch without re-running parent detection', async () => {
215
+ findParentBranch.mockClear();
216
+ getChangedLinesInfo.mockClear();
217
+ runAnalysis.mockResolvedValue({
218
+ success: true,
219
+ results: { issues: [], crossFileIssues: [], summary: 'OK' },
220
+ });
221
+
222
+ await reviewPullRequest(['/src/file.js'], { actualBranch: 'feature-B', baseBranch: 'feature-A' });
223
+
224
+ // Detection is skipped when the caller supplies the base branch...
225
+ expect(findParentBranch).not.toHaveBeenCalled();
226
+ // ...and the supplied base is what the per-file diff is computed against.
227
+ expect(getChangedLinesInfo).toHaveBeenCalledWith('/src/file.js', 'feature-A', 'feature-B', expect.any(String));
228
+ });
229
+
230
+ it('should detect and ensure the parent branch when none is supplied', async () => {
231
+ findParentBranch.mockClear();
232
+ ensureBranchExists.mockClear();
233
+ findParentBranch.mockReturnValue('main');
234
+ runAnalysis.mockResolvedValue({
235
+ success: true,
236
+ results: { issues: [], crossFileIssues: [], summary: 'OK' },
237
+ });
238
+
239
+ await reviewPullRequest(['/src/file.js'], { actualBranch: 'feature-X' });
240
+
241
+ expect(findParentBranch).toHaveBeenCalledWith('feature-X', expect.any(String));
242
+ // A detected (possibly remote-only) base must be materialized locally before diffing.
243
+ expect(ensureBranchExists).toHaveBeenCalledWith('main', expect.any(String));
244
+ });
245
+
213
246
  it('should skip files based on exclusion rules', async () => {
214
247
  shouldProcessFile.mockReturnValue(false);
215
248