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 +13 -6
- package/package.json +1 -1
- package/src/index.js +48 -28
- package/src/llm.js +49 -10
- package/src/llm.test.js +56 -0
- package/src/project-analyzer.js +54 -18
- package/src/project-analyzer.test.js +73 -0
- package/src/rag-analyzer.js +66 -26
- package/src/rag-analyzer.test.js +14 -6
- package/src/rag-review.js +22 -4
- package/src/rag-review.test.js +35 -2
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
|
-
#
|
|
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**:
|
|
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
|
-
#
|
|
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
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,
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
369
|
+
const { changedFiles, baseBranch } = getChangedFiles(targetBranch, gitWorkingDir, options.base);
|
|
365
370
|
if (changedFiles.length === 0) {
|
|
366
|
-
const message = `No changed files found
|
|
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
|
|
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:
|
|
376
|
-
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>
|
|
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
|
|
977
|
-
*
|
|
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}
|
|
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
|
-
* @
|
|
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(
|
|
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(
|
|
1003
|
+
// Ensure the target branch exists locally (fetch if needed)
|
|
1004
|
+
ensureBranchExists(targetBranch, workingDir);
|
|
991
1005
|
|
|
992
|
-
//
|
|
993
|
-
|
|
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(`
|
|
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}...${
|
|
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 ${
|
|
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 {
|
|
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', () => {
|
package/src/project-analyzer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
393
|
-
|
|
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,
|
|
409
|
+
const sortedFiles = allFiles.slice(0, TERM_SEARCH_CONTENT_CANDIDATE_LIMIT);
|
|
410
|
+
if (sortedFiles.length === 0) {
|
|
411
|
+
return [];
|
|
412
|
+
}
|
|
404
413
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
const
|
|
410
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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([]);
|
package/src/rag-analyzer.js
CHANGED
|
@@ -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 (!
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
1548
|
+
fileContent = await readTextFile(filePath);
|
|
1509
1549
|
}
|
|
1510
1550
|
catch (readError) {
|
|
1511
1551
|
debug(`[getPRCommentContext] Could not read file ${filePath}: ${readError.message}`);
|
package/src/rag-analyzer.test.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 {
|
|
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'] -
|
|
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, ${
|
|
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
|
|
package/src/rag-review.test.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|