codecritique 2.0.4 → 2.0.6

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.4",
3
+ "version": "2.0.6",
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,9 +29,10 @@ 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';
33
- import { verboseLog } from './utils/logging.js';
32
+ import { ensureBranchExists, findParentBranch } from './utils/git.js';
33
+ import { diagnosticLog, isDebugEnabled, isVerboseEnabled, verboseLog } from './utils/logging.js';
34
34
  import { collectPRLevelFindings, getFileLevelIssueCount, hasPRLevelFindings } from './utils/review-results.js';
35
+ import { configureCleanStdoutForDataOutput, isBrokenStdoutPipeError, writeStdout } from './utils/stdout.js';
35
36
 
36
37
  // Create a default embeddings system instance
37
38
  const embeddingsSystem = getDefaultEmbeddingsSystem();
@@ -45,7 +46,11 @@ program.name('codecritique').description('CLI tool for AI-powered code review us
45
46
  program
46
47
  .command('analyze')
47
48
  .description('Analyze code using dynamic context (RAG approach)')
48
- .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)')
49
54
  .option('-f, --files <files...>', 'Specific files or glob patterns to review')
50
55
  .option('--file <file>', 'Analyze a single file')
51
56
  .option('-d, --directory <dir>', 'Working directory for git operations (use with --diff-with)')
@@ -162,12 +167,12 @@ Examples:
162
167
  $ codecritique analyze --directory src/components
163
168
  $ codecritique analyze --file src/utils/validation.ts
164
169
  $ codecritique analyze --files "src/**/*.tsx" "lib/*.js"
165
- $ codecritique analyze -b main
170
+ $ codecritique analyze -b feature-branch
166
171
  $ codecritique analyze --doc "Our Eng Guidelines:./ENGINEERING_GUIDELINES.md" --file src/utils/validation.ts
167
172
  $ codecritique analyze --diff-with feature-branch -d /path/to/repo
168
173
  $ codecritique analyze --output json > review-results.json
169
174
  $ codecritique analyze --track-feedback --feedback-path ./feedback-artifacts --file src/utils/validation.ts
170
- $ codecritique analyze --track-feedback --feedback-threshold 0.8 -b main
175
+ $ codecritique analyze --track-feedback --feedback-threshold 0.8 -b feature-branch
171
176
  $ codecritique embeddings:generate --directory src
172
177
  $ codecritique embeddings:generate --exclude "**/*.test.js" "**/*.spec.js"
173
178
  $ codecritique embeddings:generate --exclude-file .embedignore
@@ -213,7 +218,7 @@ else {
213
218
 
214
219
  // Register process event handlers for cleanup (embeddings cleanup primarily)
215
220
  process.on('SIGINT', async () => {
216
- console.log(chalk.yellow('\nReceived SIGINT. Attempting graceful shutdown...'));
221
+ diagnosticLog(chalk.yellow('\nReceived SIGINT. Attempting graceful shutdown...'));
217
222
  // Set a timeout to force exit if cleanup hangs
218
223
  const forceExitTimeout = setTimeout(() => {
219
224
  console.error(chalk.red('Cleanup timed out after 10 seconds. Forcing exit...'));
@@ -221,23 +226,23 @@ process.on('SIGINT', async () => {
221
226
  }, 10000); // 10 seconds timeout
222
227
 
223
228
  try {
224
- console.log(chalk.cyan('SIGINT handler: Attempting embeddingsSystem.cleanup()...'));
229
+ diagnosticLog(chalk.cyan('SIGINT handler: Attempting embeddingsSystem.cleanup()...'));
225
230
  await embeddingsSystem.cleanup();
226
- console.log(chalk.green('embeddingsSystem.cleanup() completed.'));
231
+ diagnosticLog(chalk.green('embeddingsSystem.cleanup() completed.'));
227
232
  clearTimeout(forceExitTimeout); // Cleanup finished, clear the timeout
228
- console.log(chalk.cyan('SIGINT handler: Exiting normally (code 0).'));
233
+ diagnosticLog(chalk.cyan('SIGINT handler: Exiting normally (code 0).'));
229
234
  process.exit(0); // Exit normally
230
235
  }
231
236
  catch (err) {
232
237
  console.error(chalk.red('Error during embeddingsSystem.cleanup():'), err.message);
233
238
  clearTimeout(forceExitTimeout);
234
- console.log(chalk.cyan('SIGINT handler: Exiting after error (code 1).'));
239
+ diagnosticLog(chalk.cyan('SIGINT handler: Exiting after error (code 1).'));
235
240
  process.exit(1); // Exit with error code
236
241
  }
237
242
  });
238
243
 
239
244
  process.on('SIGTERM', async () => {
240
- console.log(chalk.yellow('\nReceived SIGTERM. Attempting graceful shutdown...'));
245
+ diagnosticLog(chalk.yellow('\nReceived SIGTERM. Attempting graceful shutdown...'));
241
246
  // Set a timeout to force exit if cleanup hangs
242
247
  const forceExitTimeout = setTimeout(() => {
243
248
  console.error(chalk.red('Cleanup timed out after 10 seconds. Forcing exit...'));
@@ -245,17 +250,17 @@ process.on('SIGTERM', async () => {
245
250
  }, 10000);
246
251
 
247
252
  try {
248
- console.log(chalk.cyan('SIGTERM handler: Attempting embeddingsSystem.cleanup()...'));
253
+ diagnosticLog(chalk.cyan('SIGTERM handler: Attempting embeddingsSystem.cleanup()...'));
249
254
  await embeddingsSystem.cleanup();
250
- console.log(chalk.green('embeddingsSystem.cleanup() completed.'));
255
+ diagnosticLog(chalk.green('embeddingsSystem.cleanup() completed.'));
251
256
  clearTimeout(forceExitTimeout); // Cleanup finished, clear the timeout
252
- console.log(chalk.cyan('SIGTERM handler: Exiting normally (code 0).'));
257
+ diagnosticLog(chalk.cyan('SIGTERM handler: Exiting normally (code 0).'));
253
258
  process.exit(0); // Exit normally
254
259
  }
255
260
  catch (err) {
256
261
  console.error(chalk.red('Error during embeddingsSystem.cleanup():'), err.message);
257
262
  clearTimeout(forceExitTimeout);
258
- console.log(chalk.cyan('SIGTERM handler: Exiting after error (code 1).'));
263
+ diagnosticLog(chalk.cyan('SIGTERM handler: Exiting after error (code 1).'));
259
264
  process.exit(1); // Exit with error code
260
265
  }
261
266
  });
@@ -263,7 +268,7 @@ process.on('SIGTERM', async () => {
263
268
  // Ensure cleanup on normal exit
264
269
  process.on('exit', () => {
265
270
  // Note: Async cleanup might not fully complete here
266
- console.log(chalk.cyan('Exiting...'));
271
+ diagnosticLog(chalk.cyan('Exiting...'));
267
272
  });
268
273
 
269
274
  // REMOVED: Old options processing logic for ignore/severity
@@ -273,6 +278,7 @@ process.on('exit', () => {
273
278
 
274
279
  // Main function to run the code review (Refactored to use cag-review.js)
275
280
  async function runCodeReview(options) {
281
+ configureCleanStdoutForDataOutput(options);
276
282
  let reviewTask = null;
277
283
  let operationDescription = '';
278
284
  const startTime = Date.now();
@@ -281,7 +287,7 @@ async function runCodeReview(options) {
281
287
  // If --directory is specified, use that as the project directory
282
288
  // Otherwise, use the current working directory
283
289
  const projectPath = options.directory ? path.resolve(options.directory) : process.cwd();
284
- console.log(chalk.gray(`Using project path for analysis: ${projectPath}`));
290
+ diagnosticLog(chalk.gray(`Using project path for analysis: ${projectPath}`));
285
291
 
286
292
  // Parse custom documents
287
293
  const customDocs = [];
@@ -322,12 +328,12 @@ async function runCodeReview(options) {
322
328
  .trim(); // Remove leading/trailing whitespace
323
329
 
324
330
  customDocs.push({ title, content });
325
- console.log(chalk.gray(`Loaded custom document '${title}' from ${filePath} (${content.length} chars)`));
331
+ diagnosticLog(chalk.gray(`Loaded custom document '${title}' from ${filePath} (${content.length} chars)`));
326
332
 
327
333
  // Debug: Show a clean preview of the content
328
334
  const previewLength = 200;
329
335
  const preview = content.substring(0, previewLength).replace(/\n/g, '\\n');
330
- console.log(chalk.gray(` Content preview: ${preview}${content.length > previewLength ? '...' : ''}`));
336
+ diagnosticLog(chalk.gray(` Content preview: ${preview}${content.length > previewLength ? '...' : ''}`));
331
337
  }
332
338
  }
333
339
 
@@ -352,24 +358,29 @@ async function runCodeReview(options) {
352
358
  };
353
359
 
354
360
  try {
355
- console.log(chalk.bold.blue('AI Code Review (RAG Approach) - Starting analysis...'));
361
+ diagnosticLog(chalk.bold.blue('AI Code Review (RAG Approach) - Starting analysis...'));
356
362
 
357
363
  // Determine the review mode based on options
358
- // Only support: single file, specific files, or diff with branch
364
+ // Only support: single file, specific files, or target-branch diff review
359
365
  if (options.diffWith) {
366
+ const targetBranch = options.diffWith;
360
367
  // Use directory option as working directory for git commands if specified
361
368
  const gitWorkingDir = options.directory ? path.resolve(options.directory) : process.cwd();
362
- const changedFiles = getChangedFiles(options.diffWith, gitWorkingDir);
369
+ const { changedFiles, baseBranch } = getChangedFiles(targetBranch, gitWorkingDir, options.base);
363
370
  if (changedFiles.length === 0) {
364
- console.log(chalk.yellow(`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.`;
372
+ diagnosticLog(chalk.yellow(message));
373
+ await emitEmptyReviewOutputIfNeeded(options, { success: true, results: [], message });
365
374
  return;
366
375
  }
367
- operationDescription = `${changedFiles.length} files changed vs ${options.diffWith}`;
368
- // 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.
369
379
  const enhancedReviewOptions = {
370
380
  ...reviewOptions,
371
- actualBranch: options.diffWith,
372
- diffWith: options.diffWith,
381
+ actualBranch: targetBranch,
382
+ diffWith: targetBranch,
383
+ baseBranch,
373
384
  };
374
385
  reviewTask = reviewPullRequest(changedFiles, enhancedReviewOptions);
375
386
  }
@@ -383,7 +394,9 @@ async function runCodeReview(options) {
383
394
  else if (options.files && options.files.length > 0) {
384
395
  const filesToAnalyze = await expandFilePatterns(options.files);
385
396
  if (filesToAnalyze.length === 0) {
386
- console.log(chalk.yellow('No files found matching the specified patterns. Exiting.'));
397
+ const message = 'No files found matching the specified patterns. Exiting.';
398
+ diagnosticLog(chalk.yellow(message));
399
+ await emitEmptyReviewOutputIfNeeded(options, { success: true, results: [], message });
387
400
  return;
388
401
  }
389
402
  operationDescription = `${filesToAnalyze.length} specific files/patterns`;
@@ -394,7 +407,7 @@ async function runCodeReview(options) {
394
407
  console.error(chalk.red('Error: You must specify one of the following:'));
395
408
  console.error(chalk.yellow(' --file <file> Analyze a single file'));
396
409
  console.error(chalk.yellow(' --files <files...> Analyze specific files or glob patterns'));
397
- 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'));
398
411
  console.error(chalk.gray('\nOptional:'));
399
412
  console.error(chalk.gray(' -d, --directory <dir> Working directory (for git operations with --diff-with)'));
400
413
  console.error(chalk.gray('\nExamples:'));
@@ -408,7 +421,7 @@ async function runCodeReview(options) {
408
421
  process.exit(1);
409
422
  }
410
423
 
411
- console.log(chalk.cyan(`Starting review for ${operationDescription}...`));
424
+ diagnosticLog(chalk.cyan(`Starting review for ${operationDescription}...`));
412
425
 
413
426
  // Execute the selected review task
414
427
  const reviewResult = await reviewTask;
@@ -421,60 +434,77 @@ async function runCodeReview(options) {
421
434
  // Process and output results
422
435
  if (reviewResult && reviewResult.success) {
423
436
  if (reviewResult.results && reviewResult.results.length > 0) {
424
- console.log(chalk.green(`Found ${reviewResult.results.length} result items to display`));
425
- // Determine output function based on format option
426
- const outputFn = options.output === 'json' ? outputJson : options.output === 'markdown' ? outputMarkdown : outputText;
427
- // Pass the detailed results array to the output function
428
- outputFn(reviewResult.results, options, reviewResult);
429
- console.log(chalk.bold.green(`\nAnalysis complete for ${operationDescription}! (${duration}s)`));
437
+ diagnosticLog(chalk.green(`Found ${reviewResult.results.length} result items to display`));
438
+ await outputReviewResults(reviewResult.results, options, reviewResult);
439
+ diagnosticLog(chalk.bold.green(`\nAnalysis complete for ${operationDescription}! (${duration}s)`));
430
440
  }
431
441
  else {
432
442
  // No results to display (e.g., all files were excluded/skipped)
433
443
  const message = reviewResult.message || 'All files were excluded from review (e.g., config files, lock files).';
434
- console.log(chalk.yellow(message));
444
+ diagnosticLog(chalk.yellow(message));
435
445
 
436
- // Still output empty results if outputFile is specified (for CI/CD pipelines)
437
- if (options.outputFile) {
438
- const outputFn = options.output === 'json' ? outputJson : options.output === 'markdown' ? outputMarkdown : outputText;
439
- outputFn([], options, reviewResult);
440
- console.log(chalk.yellow(`Empty results written to: ${options.outputFile}`));
446
+ if (await emitEmptyReviewOutputIfNeeded(options, reviewResult)) {
447
+ if (options.outputFile) {
448
+ diagnosticLog(chalk.yellow(`Empty results written to: ${options.outputFile}`));
449
+ }
441
450
  }
442
451
 
443
- console.log(chalk.bold.yellow(`\nReview complete for ${operationDescription} - no reviewable files found (${duration}s)`));
452
+ diagnosticLog(chalk.bold.yellow(`\nReview complete for ${operationDescription} - no reviewable files found (${duration}s)`));
444
453
  }
445
454
  }
446
455
  else {
456
+ const error = reviewResult?.error || 'Code review process failed.';
447
457
  console.error(chalk.red('\nCode review process failed.'));
448
- if (reviewResult && reviewResult.error) {
449
- console.error(chalk.red(`Error: ${reviewResult.error}`));
458
+ console.error(chalk.red(`Error: ${error}`));
459
+
460
+ if (await emitErrorReviewOutputIfNeeded(options, error, reviewResult || {})) {
461
+ if (options.outputFile) {
462
+ diagnosticLog(chalk.yellow(`Error results written to: ${options.outputFile}`));
463
+ }
450
464
  }
465
+ // AI review output is advisory; report review-task failures without failing CI.
451
466
  }
452
467
 
453
468
  // Clean up resources
454
- console.log(chalk.cyan('Cleaning up resources...'));
469
+ diagnosticLog(chalk.cyan('Cleaning up resources...'));
455
470
  try {
456
471
  await embeddingsSystem.cleanup();
457
472
  await cleanupClassifier();
458
- console.log(chalk.green('All resources cleaned up successfully'));
473
+ diagnosticLog(chalk.green('All resources cleaned up successfully'));
459
474
  }
460
475
  catch (cleanupErr) {
461
476
  console.error(chalk.yellow('Error during cleanup:'), cleanupErr.message);
462
- process.exit(1);
477
+ process.exitCode = 1;
478
+ return;
463
479
  }
464
480
  }
465
481
  catch (err) {
466
- console.error(chalk.red(`\nError during code review (${operationDescription}):`), err.message);
467
- console.error(err.stack);
482
+ if (!isBrokenStdoutPipeError(err)) {
483
+ console.error(chalk.red(`\nError during code review (${operationDescription}):`), err.message);
484
+ if (shouldPrintErrorStack(options, err)) {
485
+ console.error(err.stack);
486
+ }
487
+ }
488
+
489
+ try {
490
+ await emitErrorReviewOutputIfNeeded(options, err.message, { success: false, error: err.message });
491
+ }
492
+ catch (outputErr) {
493
+ if (!isBrokenStdoutPipeError(outputErr)) {
494
+ console.error(chalk.red('Error writing review error output:'), outputErr.message);
495
+ }
496
+ }
497
+
468
498
  // Clean up resources even on error
469
499
  try {
470
500
  await embeddingsSystem.cleanup();
471
501
  await cleanupClassifier();
472
- console.log(chalk.green('All resources cleaned up successfully'));
502
+ diagnosticLog(chalk.green('All resources cleaned up successfully'));
473
503
  }
474
504
  catch (cleanupErr) {
475
505
  console.error(chalk.red('Error during cleanup:'), cleanupErr.message);
476
506
  }
477
- process.exit(1);
507
+ process.exitCode = 1;
478
508
  }
479
509
  }
480
510
 
@@ -950,24 +980,36 @@ async function expandFilePatterns(patterns, baseDir = process.cwd()) {
950
980
  }
951
981
 
952
982
  /**
953
- * Get list of files changed in a branch compared to the base branch (main/master).
954
- * 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.
955
990
  *
956
- * @param {string} branch - Branch to analyze (the feature/target branch)
991
+ * @param {string} targetBranch - Branch to analyze (the feature/target branch)
957
992
  * @param {string} workingDir - Directory to run git commands in (optional, defaults to cwd)
958
- * @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).
959
996
  */
960
- function getChangedFiles(branch, workingDir = process.cwd()) {
997
+ function getChangedFiles(targetBranch, workingDir = process.cwd(), explicitBase = undefined) {
961
998
  try {
962
999
  // Get git root directory
963
1000
  const gitRoot = execSync('git rev-parse --show-toplevel', { cwd: workingDir }).toString().trim();
964
- console.log(chalk.gray(`Git repository: ${gitRoot}`));
1001
+ diagnosticLog(chalk.gray(`Git repository: ${gitRoot}`));
965
1002
 
966
- // Ensure the branch exists locally (fetch if needed)
967
- ensureBranchExists(branch, workingDir);
1003
+ // Ensure the target branch exists locally (fetch if needed)
1004
+ ensureBranchExists(targetBranch, workingDir);
968
1005
 
969
- // Find the base branch (main/master)
970
- 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
+ }
971
1013
 
972
1014
  // Ensure the base branch exists locally as well (crucial for diff operations)
973
1015
  try {
@@ -978,12 +1020,13 @@ function getChangedFiles(branch, workingDir = process.cwd()) {
978
1020
  // Continue with the original baseBranch name, it might work with remote refs
979
1021
  }
980
1022
 
981
- console.log(chalk.gray(`Comparing ${branch} against ${baseBranch}...`));
1023
+ diagnosticLog(chalk.gray(`Reviewing target branch '${targetBranch}' against base branch '${baseBranch}'...`));
982
1024
 
983
- // Use three-dot notation to get changes in branch compared to base
984
- // 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.
985
1026
  // By adding --diff-filter=d, we exclude deleted files from the list.
986
- 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();
987
1030
 
988
1031
  // Split, filter empty lines, resolve paths, and check existence
989
1032
  const changedFiles = gitOutput
@@ -992,14 +1035,14 @@ function getChangedFiles(branch, workingDir = process.cwd()) {
992
1035
  .map((file) => path.resolve(gitRoot, file)); // Get absolute path
993
1036
 
994
1037
  if (changedFiles.length > 0) {
995
- console.log(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}'`));
996
1039
  }
997
1040
 
998
- return changedFiles;
1041
+ return { changedFiles, baseBranch };
999
1042
  }
1000
1043
  catch (err) {
1001
1044
  console.error(chalk.red('Error getting git diff:'), err.message);
1002
- return [];
1045
+ return { changedFiles: [], baseBranch: null };
1003
1046
  }
1004
1047
  }
1005
1048
 
@@ -1009,6 +1052,58 @@ function getChangedFiles(branch, workingDir = process.cwd()) {
1009
1052
  // --- Output Formatting Functions --- //
1010
1053
  // These consume the normalized results returned by the RAG review pipeline
1011
1054
 
1055
+ function getReviewOutputFunction(outputFormat) {
1056
+ if (outputFormat === 'json') {
1057
+ return outputJson;
1058
+ }
1059
+ if (outputFormat === 'markdown') {
1060
+ return outputMarkdown;
1061
+ }
1062
+ return outputText;
1063
+ }
1064
+
1065
+ function shouldEmitReviewOutput(options = {}) {
1066
+ return Boolean(options.outputFile || options.output === 'json' || options.output === 'markdown');
1067
+ }
1068
+
1069
+ function shouldPrintErrorStack(options, error) {
1070
+ return Boolean(error?.stack && (isVerboseEnabled(options) || isDebugEnabled()));
1071
+ }
1072
+
1073
+ async function outputReviewResults(reviewResults, options, aggregateResult = {}) {
1074
+ await getReviewOutputFunction(options.output)(reviewResults, options, aggregateResult);
1075
+ }
1076
+
1077
+ async function emitEmptyReviewOutputIfNeeded(options, aggregateResult = {}) {
1078
+ if (!shouldEmitReviewOutput(options)) {
1079
+ return false;
1080
+ }
1081
+
1082
+ await outputReviewResults([], options, aggregateResult);
1083
+ return true;
1084
+ }
1085
+
1086
+ async function emitErrorReviewOutputIfNeeded(options, error, aggregateResult = {}) {
1087
+ if (!shouldEmitReviewOutput(options)) {
1088
+ return false;
1089
+ }
1090
+
1091
+ await outputReviewResults([createReviewErrorResult(error)], options, {
1092
+ ...aggregateResult,
1093
+ success: false,
1094
+ error,
1095
+ });
1096
+ return true;
1097
+ }
1098
+
1099
+ function createReviewErrorResult(error) {
1100
+ return {
1101
+ filePath: 'review',
1102
+ success: false,
1103
+ error,
1104
+ };
1105
+ }
1106
+
1012
1107
  /**
1013
1108
  * Output results in JSON format
1014
1109
  *
@@ -1016,7 +1111,7 @@ function getChangedFiles(branch, workingDir = process.cwd()) {
1016
1111
  * @param {Object} options - Command line options
1017
1112
  * @param {Object} aggregateResult - Full review result with PR-level context
1018
1113
  */
1019
- function outputJson(reviewResults, options, aggregateResult = {}) {
1114
+ async function outputJson(reviewResults, options, aggregateResult = {}) {
1020
1115
  const prLevelFindings = collectPRLevelFindings(reviewResults, aggregateResult);
1021
1116
  const fileLevelIssues = getFileLevelIssueCount(reviewResults);
1022
1117
 
@@ -1069,7 +1164,7 @@ function outputJson(reviewResults, options, aggregateResult = {}) {
1069
1164
  }
1070
1165
  else {
1071
1166
  // Write JSON output to stdout (process.stdout is not buffered)
1072
- process.stdout.write(jsonOutput);
1167
+ await writeStdout(jsonOutput);
1073
1168
  }
1074
1169
  }
1075
1170
 
@@ -1080,7 +1175,7 @@ function outputJson(reviewResults, options, aggregateResult = {}) {
1080
1175
  * @param {Object} options - Command line options
1081
1176
  * @param {Object} aggregateResult - Full review result with PR-level context
1082
1177
  */
1083
- function outputMarkdown(reviewResults, options, aggregateResult = {}) {
1178
+ async function outputMarkdown(reviewResults, options, aggregateResult = {}) {
1084
1179
  const totalFiles = reviewResults.length;
1085
1180
  const filesWithIssues = reviewResults.filter((r) => r.success && !r.skipped && r.results?.issues?.length > 0).length;
1086
1181
  const skippedFiles = reviewResults.filter((r) => r.skipped).length;
@@ -1164,7 +1259,7 @@ function outputMarkdown(reviewResults, options, aggregateResult = {}) {
1164
1259
  return;
1165
1260
  }
1166
1261
 
1167
- process.stdout.write(markdownOutput);
1262
+ await writeStdout(markdownOutput);
1168
1263
  }
1169
1264
 
1170
1265
  /**
package/src/llm.js CHANGED
@@ -18,7 +18,7 @@ import dotenv from 'dotenv';
18
18
  import { verboseLog } from './utils/logging.js';
19
19
 
20
20
  // Load env variables if present; do not enforce key at import time
21
- dotenv.config();
21
+ dotenv.config({ quiet: true });
22
22
 
23
23
  let anthropic = null;
24
24
 
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