@oddessentials/odd-ai-reviewers 1.12.0 → 1.13.0
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/dist/cli/commands/benchmark.d.ts +14 -0
- package/dist/cli/commands/benchmark.d.ts.map +1 -0
- package/dist/cli/commands/benchmark.js +86 -0
- package/dist/cli/commands/benchmark.js.map +1 -0
- package/dist/cli/commands/local-review.d.ts.map +1 -1
- package/dist/cli/commands/local-review.js +370 -357
- package/dist/cli/commands/local-review.js.map +1 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +3 -0
- package/dist/diff.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +7 -82
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
|
@@ -32,6 +32,7 @@ import { setupSignalHandlers, setPartialResultsContext, clearPartialResultsConte
|
|
|
32
32
|
import { countBySeverity } from '../../report/formats.js';
|
|
33
33
|
import { existsSync } from 'fs';
|
|
34
34
|
import { join, resolve } from 'path';
|
|
35
|
+
import { format as formatConsoleArgs } from 'util';
|
|
35
36
|
import { checkDependenciesForPasses, displayDependencyErrors, displaySkippedPassWarnings, getDependenciesForAgent, } from '../dependencies/index.js';
|
|
36
37
|
// =============================================================================
|
|
37
38
|
// Default Dependencies
|
|
@@ -44,23 +45,6 @@ export function createDefaultDependencies() {
|
|
|
44
45
|
env: process.env,
|
|
45
46
|
exitHandler: (code) => {
|
|
46
47
|
process.exitCode = code;
|
|
47
|
-
const exit = () => process.exit(code);
|
|
48
|
-
const drainingStreams = [process.stdout, process.stderr].filter((stream) => stream.writableNeedDrain);
|
|
49
|
-
if (drainingStreams.length === 0) {
|
|
50
|
-
exit();
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
let remaining = drainingStreams.length;
|
|
54
|
-
for (const stream of drainingStreams) {
|
|
55
|
-
stream.once('drain', () => {
|
|
56
|
-
remaining -= 1;
|
|
57
|
-
if (remaining === 0) {
|
|
58
|
-
exit();
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
// Safety: avoid hanging if drain never fires
|
|
63
|
-
setTimeout(exit, 100);
|
|
64
48
|
},
|
|
65
49
|
stdout: process.stdout,
|
|
66
50
|
stderr: process.stderr,
|
|
@@ -88,6 +72,28 @@ export const ExitCode = {
|
|
|
88
72
|
/** Incomplete review (partial results available) */
|
|
89
73
|
INCOMPLETE: 3,
|
|
90
74
|
};
|
|
75
|
+
async function withConsoleRedirectToStderr(enabled, stderr, fn) {
|
|
76
|
+
if (!enabled) {
|
|
77
|
+
return fn();
|
|
78
|
+
}
|
|
79
|
+
const originalLog = console.log;
|
|
80
|
+
const originalWarn = console.warn;
|
|
81
|
+
const originalInfo = console.info;
|
|
82
|
+
const redirect = (...args) => {
|
|
83
|
+
stderr.write(formatConsoleArgs(...args) + '\n');
|
|
84
|
+
};
|
|
85
|
+
console.log = redirect;
|
|
86
|
+
console.warn = redirect;
|
|
87
|
+
console.info = redirect;
|
|
88
|
+
try {
|
|
89
|
+
return await fn();
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
console.log = originalLog;
|
|
93
|
+
console.warn = originalWarn;
|
|
94
|
+
console.info = originalInfo;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
91
97
|
// =============================================================================
|
|
92
98
|
// Helper Functions
|
|
93
99
|
// =============================================================================
|
|
@@ -536,46 +542,21 @@ export async function runLocalReview(rawOptions, deps = createDefaultDependencie
|
|
|
536
542
|
};
|
|
537
543
|
}
|
|
538
544
|
const { options, warnings } = parseResult.value;
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
? new
|
|
551
|
-
:
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
exitCode: ExitCode.INVALID_ARGS,
|
|
555
|
-
findingsCount: 0,
|
|
556
|
-
partialFindingsCount: 0,
|
|
557
|
-
error: error.message,
|
|
558
|
-
};
|
|
559
|
-
}
|
|
560
|
-
const gitContext = gitResult.value;
|
|
561
|
-
// Apply defaults from git context
|
|
562
|
-
const resolvedOptions = applyOptionDefaults(options, gitContext);
|
|
563
|
-
const diffRange = resolveDiffRange(resolvedOptions, gitContext);
|
|
564
|
-
const baseRef = diffRange.baseRef;
|
|
565
|
-
// 3. Load configuration (with zero-config fallback)
|
|
566
|
-
let config;
|
|
567
|
-
let configSource;
|
|
568
|
-
let configPath;
|
|
569
|
-
let zeroConfigResult;
|
|
570
|
-
try {
|
|
571
|
-
const configResult = await loadConfigWithFallback(gitContext.repoRoot, env, deps, resolvedOptions.config);
|
|
572
|
-
config = configResult.config;
|
|
573
|
-
configSource = configResult.source;
|
|
574
|
-
configPath = configResult.path;
|
|
575
|
-
zeroConfigResult = configResult.zeroConfigResult;
|
|
576
|
-
}
|
|
577
|
-
catch (error) {
|
|
578
|
-
if (error instanceof NoCredentialsError) {
|
|
545
|
+
const isMachineReadableFormat = options.format === 'json' || options.format === 'sarif';
|
|
546
|
+
return withConsoleRedirectToStderr(isMachineReadableFormat, stderr, async () => {
|
|
547
|
+
// Print warnings
|
|
548
|
+
for (const warning of warnings) {
|
|
549
|
+
stderr.write(c.yellow(`Warning: ${warning}`) + '\n');
|
|
550
|
+
}
|
|
551
|
+
// 2. Infer git context
|
|
552
|
+
const inferFn = deps.inferGitContext ?? inferGitContext;
|
|
553
|
+
const gitResult = inferFn(resolve(options.path));
|
|
554
|
+
if (!isOk(gitResult)) {
|
|
555
|
+
const error = gitResult.error.code === GitContextErrorCode.GIT_NOT_FOUND
|
|
556
|
+
? new GitNotFoundError(gitResult.error.message)
|
|
557
|
+
: gitResult.error.code === GitContextErrorCode.INVALID_PATH
|
|
558
|
+
? new InvalidPathError(gitResult.error.message, gitResult.error.path)
|
|
559
|
+
: new NotAGitRepoError(gitResult.error.path ?? options.path);
|
|
579
560
|
stderr.write(formatCLIError(error, colored) + '\n');
|
|
580
561
|
return {
|
|
581
562
|
exitCode: ExitCode.INVALID_ARGS,
|
|
@@ -584,327 +565,359 @@ export async function runLocalReview(rawOptions, deps = createDefaultDependencie
|
|
|
584
565
|
error: error.message,
|
|
585
566
|
};
|
|
586
567
|
}
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
568
|
+
const gitContext = gitResult.value;
|
|
569
|
+
// Apply defaults from git context
|
|
570
|
+
const resolvedOptions = applyOptionDefaults(options, gitContext);
|
|
571
|
+
const diffRange = resolveDiffRange(resolvedOptions, gitContext);
|
|
572
|
+
const baseRef = diffRange.baseRef;
|
|
573
|
+
// 3. Load configuration (with zero-config fallback)
|
|
574
|
+
let config;
|
|
575
|
+
let configSource;
|
|
576
|
+
let configPath;
|
|
577
|
+
let zeroConfigResult;
|
|
578
|
+
try {
|
|
579
|
+
const configResult = await loadConfigWithFallback(gitContext.repoRoot, env, deps, resolvedOptions.config);
|
|
580
|
+
config = configResult.config;
|
|
581
|
+
configSource = configResult.source;
|
|
582
|
+
configPath = configResult.path;
|
|
583
|
+
zeroConfigResult = configResult.zeroConfigResult;
|
|
584
|
+
}
|
|
585
|
+
catch (error) {
|
|
586
|
+
if (error instanceof NoCredentialsError) {
|
|
587
|
+
stderr.write(formatCLIError(error, colored) + '\n');
|
|
588
|
+
return {
|
|
589
|
+
exitCode: ExitCode.INVALID_ARGS,
|
|
590
|
+
findingsCount: 0,
|
|
591
|
+
partialFindingsCount: 0,
|
|
592
|
+
error: error.message,
|
|
593
|
+
};
|
|
605
594
|
}
|
|
595
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
596
|
+
const configError = new InvalidConfigError(resolvedOptions.config ?? '.ai-review.yml', [
|
|
597
|
+
errorMsg,
|
|
598
|
+
]);
|
|
599
|
+
stderr.write(formatCLIError(configError, colored) + '\n');
|
|
600
|
+
return {
|
|
601
|
+
exitCode: ExitCode.INVALID_ARGS,
|
|
602
|
+
findingsCount: 0,
|
|
603
|
+
partialFindingsCount: 0,
|
|
604
|
+
error: errorMsg,
|
|
605
|
+
};
|
|
606
606
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
607
|
+
// Show zero-config guidance only for human-readable terminal output.
|
|
608
|
+
// Machine-readable modes must reserve stdout exclusively for the payload.
|
|
609
|
+
if (configSource === 'zero-config' &&
|
|
610
|
+
zeroConfigResult &&
|
|
611
|
+
!resolvedOptions.quiet &&
|
|
612
|
+
!isMachineReadableFormat) {
|
|
613
|
+
stdout.write(c.yellow(`\nUsing ${zeroConfigResult.provider} (${zeroConfigResult.keySource} found)\n`));
|
|
614
|
+
if (zeroConfigResult.ignoredProviders.length > 0) {
|
|
615
|
+
for (const ignored of zeroConfigResult.ignoredProviders) {
|
|
616
|
+
stdout.write(c.gray(`Note: ${ignored.keySource} also set but ignored due to priority order\n`));
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
stdout.write(c.gray('Tip: Create .ai-review.yml to customize settings\n\n'));
|
|
620
|
+
}
|
|
621
|
+
// 4. Build execution plan (FR-001 through FR-007)
|
|
622
|
+
// The plan is the single source of truth for all downstream code paths.
|
|
623
|
+
// No downstream consumer may read raw CLI flags — they operate on the plan.
|
|
624
|
+
let executionPlan;
|
|
625
|
+
try {
|
|
626
|
+
const planMode = resolvedOptions.dryRun
|
|
627
|
+
? 'dry-run'
|
|
628
|
+
: resolvedOptions.costOnly
|
|
629
|
+
? 'cost-only'
|
|
630
|
+
: 'execute';
|
|
631
|
+
executionPlan = buildExecutionPlan({
|
|
632
|
+
config,
|
|
633
|
+
mode: planMode,
|
|
634
|
+
passFilter: resolvedOptions.pass,
|
|
635
|
+
agentFilter: resolvedOptions.agent,
|
|
636
|
+
provider: zeroConfigResult?.provider ?? config.provider ?? null,
|
|
637
|
+
model: config.models?.default ?? null,
|
|
638
|
+
configSource: configSource === 'zero-config' ? 'zero-config' : (configPath ?? '.ai-review.yml'),
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
catch (error) {
|
|
642
|
+
if (error instanceof ConfigError) {
|
|
643
|
+
stderr.write(formatCLIError(new InvalidConfigError(resolvedOptions.config ?? '.ai-review.yml', [error.message]), colored) + '\n');
|
|
644
|
+
return {
|
|
645
|
+
exitCode: exitCodeFromStatus('config_error'),
|
|
646
|
+
status: 'config_error',
|
|
647
|
+
findingsCount: 0,
|
|
648
|
+
partialFindingsCount: 0,
|
|
649
|
+
error: error.message,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
throw error;
|
|
653
|
+
}
|
|
654
|
+
// Emit plan in verbose mode for debugging
|
|
655
|
+
if (resolvedOptions.verbose && !resolvedOptions.quiet) {
|
|
656
|
+
stderr.write(c.gray('Execution plan:\n'));
|
|
657
|
+
stderr.write(c.gray(serializeExecutionPlan(executionPlan)));
|
|
658
|
+
stderr.write('\n');
|
|
659
|
+
}
|
|
660
|
+
// Build a config-like passes array from the execution plan for downstream compatibility
|
|
661
|
+
const planPasses = executionPlan.passes.map((p) => ({
|
|
662
|
+
name: p.name,
|
|
663
|
+
agents: [...p.agents],
|
|
664
|
+
enabled: true,
|
|
665
|
+
required: p.required,
|
|
666
|
+
}));
|
|
667
|
+
// 5. Handle special modes (dry-run and cost-only) - before dependency check
|
|
668
|
+
// These modes don't execute agents, so dependencies are not required
|
|
669
|
+
if (resolvedOptions.dryRun) {
|
|
670
|
+
const dryRunResult = await executeDryRun(resolvedOptions, gitContext, config, configSource, configPath, deps);
|
|
671
|
+
// Override agents to match the execution plan (FR-002: dry-run shows only plan agents)
|
|
672
|
+
dryRunResult.agents = [...new Set(executionPlan.passes.flatMap((p) => [...p.agents]))];
|
|
673
|
+
const output = formatDryRunOutput(dryRunResult, colored, resolvedOptions.format);
|
|
674
|
+
stdout.write(output);
|
|
675
|
+
return {
|
|
676
|
+
exitCode: ExitCode.SUCCESS,
|
|
677
|
+
status: 'complete',
|
|
678
|
+
findingsCount: 0,
|
|
679
|
+
partialFindingsCount: 0,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
if (resolvedOptions.costOnly) {
|
|
683
|
+
// FR-003: Cost estimation MUST be scoped to the execution plan's passes only
|
|
684
|
+
const planFilteredConfig = {
|
|
685
|
+
...config,
|
|
686
|
+
passes: planPasses.map((p) => ({
|
|
687
|
+
name: p.name,
|
|
688
|
+
agents: [...p.agents],
|
|
689
|
+
enabled: true,
|
|
690
|
+
required: p.required,
|
|
691
|
+
})),
|
|
692
|
+
};
|
|
693
|
+
const costResult = await executeCostOnly(resolvedOptions, gitContext, planFilteredConfig, deps);
|
|
694
|
+
const output = formatCostOutput(costResult, colored);
|
|
695
|
+
stdout.write(output);
|
|
696
|
+
return {
|
|
697
|
+
exitCode: ExitCode.SUCCESS,
|
|
698
|
+
status: 'complete',
|
|
699
|
+
findingsCount: 0,
|
|
700
|
+
partialFindingsCount: 0,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
// 6. Dependency preflight check — scoped to execution plan's passes only (FR-001)
|
|
704
|
+
const checkDepsFn = deps.checkDependenciesForPasses ?? checkDependenciesForPasses;
|
|
705
|
+
const depSummary = checkDepsFn(planPasses);
|
|
706
|
+
if (depSummary.hasBlockingIssues) {
|
|
707
|
+
displayDependencyErrors(depSummary, stderr);
|
|
632
708
|
return {
|
|
633
709
|
exitCode: exitCodeFromStatus('config_error'),
|
|
634
710
|
status: 'config_error',
|
|
635
711
|
findingsCount: 0,
|
|
636
712
|
partialFindingsCount: 0,
|
|
637
|
-
error:
|
|
713
|
+
error: 'Missing required dependencies',
|
|
638
714
|
};
|
|
639
715
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
stderr.write(c.gray(serializeExecutionPlan(executionPlan)));
|
|
646
|
-
stderr.write('\n');
|
|
647
|
-
}
|
|
648
|
-
// Build a config-like passes array from the execution plan for downstream compatibility
|
|
649
|
-
const planPasses = executionPlan.passes.map((p) => ({
|
|
650
|
-
name: p.name,
|
|
651
|
-
agents: [...p.agents],
|
|
652
|
-
enabled: true,
|
|
653
|
-
required: p.required,
|
|
654
|
-
}));
|
|
655
|
-
// 5. Handle special modes (dry-run and cost-only) - before dependency check
|
|
656
|
-
// These modes don't execute agents, so dependencies are not required
|
|
657
|
-
if (resolvedOptions.dryRun) {
|
|
658
|
-
const dryRunResult = await executeDryRun(resolvedOptions, gitContext, config, configSource, configPath, deps);
|
|
659
|
-
// Override agents to match the execution plan (FR-002: dry-run shows only plan agents)
|
|
660
|
-
dryRunResult.agents = [...new Set(executionPlan.passes.flatMap((p) => [...p.agents]))];
|
|
661
|
-
const output = formatDryRunOutput(dryRunResult, colored, resolvedOptions.format);
|
|
662
|
-
stdout.write(output);
|
|
663
|
-
return {
|
|
664
|
-
exitCode: ExitCode.SUCCESS,
|
|
665
|
-
status: 'complete',
|
|
666
|
-
findingsCount: 0,
|
|
667
|
-
partialFindingsCount: 0,
|
|
668
|
-
};
|
|
669
|
-
}
|
|
670
|
-
if (resolvedOptions.costOnly) {
|
|
671
|
-
// FR-003: Cost estimation MUST be scoped to the execution plan's passes only
|
|
672
|
-
const planFilteredConfig = {
|
|
673
|
-
...config,
|
|
674
|
-
passes: planPasses.map((p) => ({
|
|
675
|
-
name: p.name,
|
|
676
|
-
agents: [...p.agents],
|
|
677
|
-
enabled: true,
|
|
678
|
-
required: p.required,
|
|
679
|
-
})),
|
|
680
|
-
};
|
|
681
|
-
const costResult = await executeCostOnly(resolvedOptions, gitContext, planFilteredConfig, deps);
|
|
682
|
-
const output = formatCostOutput(costResult, colored);
|
|
683
|
-
stdout.write(output);
|
|
684
|
-
return {
|
|
685
|
-
exitCode: ExitCode.SUCCESS,
|
|
686
|
-
status: 'complete',
|
|
687
|
-
findingsCount: 0,
|
|
688
|
-
partialFindingsCount: 0,
|
|
689
|
-
};
|
|
690
|
-
}
|
|
691
|
-
// 6. Dependency preflight check — scoped to execution plan's passes only (FR-001)
|
|
692
|
-
const checkDepsFn = deps.checkDependenciesForPasses ?? checkDependenciesForPasses;
|
|
693
|
-
const depSummary = checkDepsFn(planPasses);
|
|
694
|
-
if (depSummary.hasBlockingIssues) {
|
|
695
|
-
displayDependencyErrors(depSummary, stderr);
|
|
696
|
-
return {
|
|
697
|
-
exitCode: exitCodeFromStatus('config_error'),
|
|
698
|
-
status: 'config_error',
|
|
699
|
-
findingsCount: 0,
|
|
700
|
-
partialFindingsCount: 0,
|
|
701
|
-
error: 'Missing required dependencies',
|
|
702
|
-
};
|
|
703
|
-
}
|
|
704
|
-
// Build skipped pass info for warnings (using plan passes, not raw config)
|
|
705
|
-
const skippedPassInfo = buildSkippedPassInfo(planPasses, depSummary);
|
|
706
|
-
// Show warnings for skipped passes (graceful degradation)
|
|
707
|
-
if (skippedPassInfo.length > 0 && !resolvedOptions.quiet) {
|
|
708
|
-
displaySkippedPassWarnings(skippedPassInfo, stderr);
|
|
709
|
-
}
|
|
710
|
-
// Show execution plan's skipped passes
|
|
711
|
-
if (executionPlan.skippedPasses.length > 0 && !resolvedOptions.quiet) {
|
|
712
|
-
for (const sp of executionPlan.skippedPasses) {
|
|
713
|
-
stderr.write(c.yellow(`Note: ${sp.reason}\n`));
|
|
716
|
+
// Build skipped pass info for warnings (using plan passes, not raw config)
|
|
717
|
+
const skippedPassInfo = buildSkippedPassInfo(planPasses, depSummary);
|
|
718
|
+
// Show warnings for skipped passes (graceful degradation)
|
|
719
|
+
if (skippedPassInfo.length > 0 && !resolvedOptions.quiet) {
|
|
720
|
+
displaySkippedPassWarnings(skippedPassInfo, stderr);
|
|
714
721
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const enabledRunnablePasses = runnableConfig.passes.filter((p) => p.enabled);
|
|
720
|
-
if (enabledRunnablePasses.length === 0) {
|
|
721
|
-
const warnMsg = colored
|
|
722
|
-
? `${c.yellow('⚠')} All review passes were skipped due to missing dependencies.\n` +
|
|
723
|
-
` Run 'ai-review check' to see what's missing.\n` +
|
|
724
|
-
` No review will be performed.\n`
|
|
725
|
-
: `Warning: All review passes were skipped due to missing dependencies.\n` +
|
|
726
|
-
` Run 'ai-review check' to see what's missing.\n` +
|
|
727
|
-
` No review will be performed.\n`;
|
|
728
|
-
stderr.write(warnMsg);
|
|
729
|
-
return {
|
|
730
|
-
exitCode: ExitCode.SUCCESS,
|
|
731
|
-
status: 'complete',
|
|
732
|
-
findingsCount: 0,
|
|
733
|
-
partialFindingsCount: 0,
|
|
734
|
-
};
|
|
735
|
-
}
|
|
736
|
-
// Show warnings for other optional missing dependencies
|
|
737
|
-
if (depSummary.hasWarnings && !resolvedOptions.quiet && skippedPassInfo.length === 0) {
|
|
738
|
-
displayDependencyErrors(depSummary, stderr);
|
|
739
|
-
}
|
|
740
|
-
// 7. Load .reviewignore patterns (before diff to filter early)
|
|
741
|
-
const reviewIgnoreResult = await loadReviewIgnore(gitContext.repoRoot);
|
|
742
|
-
// 7. Generate diff once with reviewignore filtering applied
|
|
743
|
-
const getDiffFn = deps.getLocalDiff ?? getLocalDiff;
|
|
744
|
-
const diff = getDiffFn(gitContext.repoRoot, {
|
|
745
|
-
baseRef,
|
|
746
|
-
headRef: diffRange.headRef,
|
|
747
|
-
rangeOperator: diffRange.rangeOperator,
|
|
748
|
-
stagedOnly: resolvedOptions.staged,
|
|
749
|
-
uncommitted: resolvedOptions.uncommitted,
|
|
750
|
-
pathFilter: reviewIgnoreResult.patterns.length > 0
|
|
751
|
-
? { reviewIgnorePatterns: reviewIgnoreResult.patterns }
|
|
752
|
-
: undefined,
|
|
753
|
-
});
|
|
754
|
-
// 8. Check for changes (using already-generated diff)
|
|
755
|
-
if (diff.files.length === 0) {
|
|
756
|
-
// No changes to review - could be no changes or all filtered by .reviewignore
|
|
757
|
-
const headLabel = resolvedOptions.staged
|
|
758
|
-
? 'STAGED'
|
|
759
|
-
: resolvedOptions.uncommitted
|
|
760
|
-
? 'WORKTREE'
|
|
761
|
-
: diffRange.headRef;
|
|
762
|
-
const output = colored
|
|
763
|
-
? `${c.green('✓')} No changes to review\n\n Base: ${baseRef}\n Head: ${headLabel}\n\n No uncommitted or staged changes found.\n`
|
|
764
|
-
: `No changes to review\n\n Base: ${baseRef}\n Head: ${headLabel}\n\n No uncommitted or staged changes found.\n`;
|
|
765
|
-
stdout.write(output);
|
|
766
|
-
return {
|
|
767
|
-
exitCode: ExitCode.SUCCESS,
|
|
768
|
-
findingsCount: 0,
|
|
769
|
-
partialFindingsCount: 0,
|
|
770
|
-
status: 'complete',
|
|
771
|
-
};
|
|
772
|
-
}
|
|
773
|
-
// 9. Build agent context (using runnableConfig with filtered passes)
|
|
774
|
-
const routerEnv = buildRouterEnv(env);
|
|
775
|
-
const agentContext = buildAgentContext(gitContext.repoRoot, diff, runnableConfig, routerEnv);
|
|
776
|
-
const configHash = hashConfig(config); // Use original config for consistent cache key
|
|
777
|
-
// 10. Check budget
|
|
778
|
-
const diffContent = buildCombinedDiff(diff.files, config.limits?.max_diff_lines ?? 5000);
|
|
779
|
-
const estimatedTokensCount = estimateTokens(diffContent);
|
|
780
|
-
const budgetContext = {
|
|
781
|
-
fileCount: diff.files.length,
|
|
782
|
-
diffLines: diff.totalAdditions + diff.totalDeletions,
|
|
783
|
-
estimatedTokens: estimatedTokensCount,
|
|
784
|
-
};
|
|
785
|
-
const budgetCheck = checkBudget(budgetContext, config.limits ?? {
|
|
786
|
-
max_files: 50,
|
|
787
|
-
max_diff_lines: 2000,
|
|
788
|
-
max_tokens_per_pr: 50000,
|
|
789
|
-
max_usd_per_pr: 0.1,
|
|
790
|
-
monthly_budget_usd: 10,
|
|
791
|
-
});
|
|
792
|
-
// 11. Setup signal handlers for graceful shutdown
|
|
793
|
-
// exitOnSignal defaults to true - first Ctrl+C stops execution immediately
|
|
794
|
-
// This is the correct behavior for CLI tools to avoid runaway costs
|
|
795
|
-
setupSignalHandlers({
|
|
796
|
-
// Cleanup must be SYNCHRONOUS to guarantee completion before process.exit()
|
|
797
|
-
// Only uses stdout.write() which is sync - no async I/O allowed here
|
|
798
|
-
cleanup: () => {
|
|
799
|
-
// Log partial results context if available
|
|
800
|
-
const ctx = getPartialResultsContext();
|
|
801
|
-
if (ctx && ctx.completedAgents > 0) {
|
|
802
|
-
const lines = formatPartialResultsMessage(ctx);
|
|
803
|
-
for (const line of lines) {
|
|
804
|
-
stdout.write(line + '\n');
|
|
805
|
-
}
|
|
722
|
+
// Show execution plan's skipped passes
|
|
723
|
+
if (executionPlan.skippedPasses.length > 0 && !resolvedOptions.quiet) {
|
|
724
|
+
for (const sp of executionPlan.skippedPasses) {
|
|
725
|
+
stderr.write(c.yellow(`Note: ${sp.reason}\n`));
|
|
806
726
|
}
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
727
|
+
}
|
|
728
|
+
// Filter config to only include runnable passes from the execution plan
|
|
729
|
+
const runnableConfig = filterToRunnablePasses({ ...config, passes: planPasses }, depSummary.runnablePasses);
|
|
730
|
+
// Check if all passes were filtered out (all optional, all missing dependencies)
|
|
731
|
+
const enabledRunnablePasses = runnableConfig.passes.filter((p) => p.enabled);
|
|
732
|
+
if (enabledRunnablePasses.length === 0) {
|
|
733
|
+
const warnMsg = colored
|
|
734
|
+
? `${c.yellow('⚠')} All review passes were skipped due to missing dependencies.\n` +
|
|
735
|
+
` Run 'ai-review check' to see what's missing.\n` +
|
|
736
|
+
` No review will be performed.\n`
|
|
737
|
+
: `Warning: All review passes were skipped due to missing dependencies.\n` +
|
|
738
|
+
` Run 'ai-review check' to see what's missing.\n` +
|
|
739
|
+
` No review will be performed.\n`;
|
|
740
|
+
stderr.write(warnMsg);
|
|
741
|
+
return {
|
|
742
|
+
exitCode: ExitCode.SUCCESS,
|
|
743
|
+
status: 'complete',
|
|
744
|
+
findingsCount: 0,
|
|
745
|
+
partialFindingsCount: 0,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
// Show warnings for other optional missing dependencies
|
|
749
|
+
if (depSummary.hasWarnings && !resolvedOptions.quiet && skippedPassInfo.length === 0) {
|
|
750
|
+
displayDependencyErrors(depSummary, stderr);
|
|
751
|
+
}
|
|
752
|
+
// 7. Load .reviewignore patterns (before diff to filter early)
|
|
753
|
+
const reviewIgnoreResult = await loadReviewIgnore(gitContext.repoRoot);
|
|
754
|
+
// 7. Generate diff once with reviewignore filtering applied
|
|
755
|
+
const getDiffFn = deps.getLocalDiff ?? getLocalDiff;
|
|
756
|
+
const diff = getDiffFn(gitContext.repoRoot, {
|
|
757
|
+
baseRef,
|
|
758
|
+
headRef: diffRange.headRef,
|
|
759
|
+
rangeOperator: diffRange.rangeOperator,
|
|
760
|
+
stagedOnly: resolvedOptions.staged,
|
|
761
|
+
uncommitted: resolvedOptions.uncommitted,
|
|
762
|
+
pathFilter: reviewIgnoreResult.patterns.length > 0
|
|
763
|
+
? { reviewIgnorePatterns: reviewIgnoreResult.patterns }
|
|
764
|
+
: undefined,
|
|
826
765
|
});
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
766
|
+
// 8. Check for changes (using already-generated diff)
|
|
767
|
+
if (diff.files.length === 0) {
|
|
768
|
+
// No changes to review - could be no changes or all filtered by .reviewignore
|
|
769
|
+
const headLabel = resolvedOptions.staged
|
|
770
|
+
? 'STAGED'
|
|
771
|
+
: resolvedOptions.uncommitted
|
|
772
|
+
? 'WORKTREE'
|
|
773
|
+
: diffRange.headRef;
|
|
774
|
+
const output = colored
|
|
775
|
+
? `${c.green('✓')} No changes to review\n\n Base: ${baseRef}\n Head: ${headLabel}\n\n No uncommitted or staged changes found.\n`
|
|
776
|
+
: `No changes to review\n\n Base: ${baseRef}\n Head: ${headLabel}\n\n No uncommitted or staged changes found.\n`;
|
|
777
|
+
stdout.write(output);
|
|
778
|
+
return {
|
|
779
|
+
exitCode: ExitCode.SUCCESS,
|
|
780
|
+
findingsCount: 0,
|
|
781
|
+
partialFindingsCount: 0,
|
|
782
|
+
status: 'complete',
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
// 9. Build agent context (using runnableConfig with filtered passes)
|
|
786
|
+
const routerEnv = buildRouterEnv(env);
|
|
787
|
+
const agentContext = buildAgentContext(gitContext.repoRoot, diff, runnableConfig, routerEnv);
|
|
788
|
+
const configHash = hashConfig(config); // Use original config for consistent cache key
|
|
789
|
+
// 10. Check budget
|
|
790
|
+
const diffContent = buildCombinedDiff(diff.files, config.limits?.max_diff_lines ?? 5000);
|
|
791
|
+
const estimatedTokensCount = estimateTokens(diffContent);
|
|
792
|
+
const budgetContext = {
|
|
793
|
+
fileCount: diff.files.length,
|
|
794
|
+
diffLines: diff.totalAdditions + diff.totalDeletions,
|
|
795
|
+
estimatedTokens: estimatedTokensCount,
|
|
796
|
+
};
|
|
797
|
+
const budgetCheck = checkBudget(budgetContext, config.limits ?? {
|
|
798
|
+
max_files: 50,
|
|
799
|
+
max_diff_lines: 2000,
|
|
800
|
+
max_tokens_per_pr: 50000,
|
|
801
|
+
max_usd_per_pr: 0.1,
|
|
802
|
+
monthly_budget_usd: 10,
|
|
831
803
|
});
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
804
|
+
// 11. Setup signal handlers for graceful shutdown
|
|
805
|
+
// exitOnSignal defaults to true - first Ctrl+C stops execution immediately
|
|
806
|
+
// This is the correct behavior for CLI tools to avoid runaway costs
|
|
807
|
+
setupSignalHandlers({
|
|
808
|
+
// Cleanup must be SYNCHRONOUS to guarantee completion before process.exit()
|
|
809
|
+
// Only uses stdout.write() which is sync - no async I/O allowed here
|
|
810
|
+
cleanup: () => {
|
|
811
|
+
// Log partial results context if available
|
|
812
|
+
const ctx = getPartialResultsContext();
|
|
813
|
+
if (ctx && ctx.completedAgents > 0) {
|
|
814
|
+
const lines = formatPartialResultsMessage(ctx);
|
|
815
|
+
for (const line of lines) {
|
|
816
|
+
stdout.write(line + '\n');
|
|
817
|
+
}
|
|
846
818
|
}
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
819
|
+
},
|
|
820
|
+
showPartialResultsMessage: true,
|
|
821
|
+
// exitOnSignal: true (default) - process.exit() called on first signal
|
|
822
|
+
exitOnSignal: true,
|
|
823
|
+
logger: {
|
|
824
|
+
log: (msg) => isMachineReadableFormat ? stderr.write(msg + '\n') : stdout.write(msg + '\n'),
|
|
825
|
+
warn: (msg) => stderr.write(msg + '\n'),
|
|
826
|
+
},
|
|
827
|
+
});
|
|
828
|
+
// 12. Execute agent passes
|
|
829
|
+
let executeResult;
|
|
830
|
+
try {
|
|
831
|
+
// Set up partial results tracking (using runnableConfig which excludes skipped passes)
|
|
832
|
+
const totalAgents = runnableConfig.passes.reduce((sum, p) => (p.enabled ? sum + p.agents.length : sum), 0);
|
|
833
|
+
setPartialResultsContext({
|
|
834
|
+
totalAgents,
|
|
835
|
+
completedAgents: 0,
|
|
836
|
+
completedAgentNames: [],
|
|
837
|
+
currentAgent: undefined,
|
|
858
838
|
});
|
|
859
|
-
|
|
839
|
+
const executeFn = deps.executeAllPasses ?? executeAllPasses;
|
|
840
|
+
executeResult = await executeFn(runnableConfig, agentContext, routerEnv, budgetCheck, {
|
|
841
|
+
configHash,
|
|
842
|
+
head: diff.headSha,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
catch (error) {
|
|
846
|
+
clearPartialResultsContext();
|
|
847
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
848
|
+
// FR-021: Extract partial results from FatalExecutionError when available.
|
|
849
|
+
// If agents completed before the failure, report their findings in degraded mode.
|
|
850
|
+
if (error instanceof FatalExecutionError && error.partialResults) {
|
|
851
|
+
stderr.write(c.yellow(`\n⚠ Incomplete review: ${errorMsg}\n`));
|
|
852
|
+
stderr.write(c.yellow(` ${error.partialResults.completeFindings.length} findings from completed agents preserved\n`));
|
|
853
|
+
// Process partial results through the standard pipeline
|
|
854
|
+
const executionTimeMs = Date.now() - startTime;
|
|
855
|
+
const estimatedCostUsd = error.partialResults.allResults.reduce((sum, r) => {
|
|
856
|
+
if ('metrics' in r && r.metrics?.estimatedCostUsd) {
|
|
857
|
+
return sum + r.metrics.estimatedCostUsd;
|
|
858
|
+
}
|
|
859
|
+
return sum;
|
|
860
|
+
}, 0);
|
|
861
|
+
const terminalContext = buildTerminalContext(resolvedOptions, gitContext, configSource, configPath, getPackageVersion());
|
|
862
|
+
terminalContext.executionTimeMs = executionTimeMs;
|
|
863
|
+
terminalContext.estimatedCostUsd = Math.max(0, estimatedCostUsd);
|
|
864
|
+
const processed = processLocalReportFindings(error.partialResults.completeFindings, error.partialResults.partialFindings, diff.files, config, 'local');
|
|
865
|
+
const incompleteStatus = 'incomplete';
|
|
866
|
+
const reportFn = deps.reportToTerminal ?? reportToTerminal;
|
|
867
|
+
await reportFn(processed.complete, processed.partial, terminalContext, config, diff.files, {
|
|
868
|
+
status: incompleteStatus,
|
|
869
|
+
suppressionSummary: processed.suppressionSummary,
|
|
870
|
+
});
|
|
871
|
+
// FR-021: Exit code 3 for incomplete review — partial results are NOT used for gating
|
|
872
|
+
return {
|
|
873
|
+
exitCode: exitCodeFromStatus(incompleteStatus),
|
|
874
|
+
status: incompleteStatus,
|
|
875
|
+
findingsCount: processed.complete.length,
|
|
876
|
+
partialFindingsCount: processed.partial.length,
|
|
877
|
+
error: errorMsg,
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
// Fatal failure with no preserved findings — hard crash, not a degraded run.
|
|
881
|
+
// Use config_error (exit 2) to distinguish from incomplete (exit 3, which has findings).
|
|
882
|
+
stderr.write(c.red(`\nExecution error: ${errorMsg}\n`));
|
|
860
883
|
return {
|
|
861
|
-
exitCode: exitCodeFromStatus(
|
|
862
|
-
status:
|
|
863
|
-
findingsCount:
|
|
864
|
-
partialFindingsCount:
|
|
884
|
+
exitCode: exitCodeFromStatus('config_error'),
|
|
885
|
+
status: 'config_error',
|
|
886
|
+
findingsCount: 0,
|
|
887
|
+
partialFindingsCount: 0,
|
|
865
888
|
error: errorMsg,
|
|
866
889
|
};
|
|
867
890
|
}
|
|
868
|
-
|
|
869
|
-
//
|
|
870
|
-
|
|
891
|
+
clearPartialResultsContext();
|
|
892
|
+
// 13. Calculate execution time and cost
|
|
893
|
+
const executionTimeMs = Date.now() - startTime;
|
|
894
|
+
const estimatedCostUsd = executeResult.allResults.reduce((sum, r) => {
|
|
895
|
+
if ('metrics' in r && r.metrics?.estimatedCostUsd) {
|
|
896
|
+
return sum + r.metrics.estimatedCostUsd;
|
|
897
|
+
}
|
|
898
|
+
return sum;
|
|
899
|
+
}, 0);
|
|
900
|
+
// 14. Build terminal context with execution info
|
|
901
|
+
const terminalContext = buildTerminalContext(resolvedOptions, gitContext, configSource, configPath, getPackageVersion());
|
|
902
|
+
terminalContext.executionTimeMs = executionTimeMs;
|
|
903
|
+
terminalContext.estimatedCostUsd = Math.max(0, estimatedCostUsd); // Clamp to non-negative (FR-REL-002)
|
|
904
|
+
// 15. Post-process findings (FR-018: CLI parity — sanitize → semantic → framework → diff-bound)
|
|
905
|
+
// FR-022: Local mode uses working tree config for suppressions (developer trusted)
|
|
906
|
+
const processed = processLocalReportFindings(executeResult.completeFindings, executeResult.partialFindings, diff.files, config, 'local');
|
|
907
|
+
const runStatus = determineRunStatus(processed.complete, config);
|
|
908
|
+
const exitCode = exitCodeFromStatus(runStatus);
|
|
909
|
+
// 16. Report findings to terminal
|
|
910
|
+
const reportFn = deps.reportToTerminal ?? reportToTerminal;
|
|
911
|
+
const reportResult = await reportFn(processed.complete, processed.partial, terminalContext, config, diff.files, {
|
|
912
|
+
status: runStatus,
|
|
913
|
+
suppressionSummary: processed.suppressionSummary,
|
|
914
|
+
});
|
|
871
915
|
return {
|
|
872
|
-
exitCode
|
|
873
|
-
status:
|
|
874
|
-
findingsCount:
|
|
875
|
-
partialFindingsCount:
|
|
876
|
-
error: errorMsg,
|
|
916
|
+
exitCode,
|
|
917
|
+
status: runStatus,
|
|
918
|
+
findingsCount: reportResult.findingsCount,
|
|
919
|
+
partialFindingsCount: reportResult.partialFindingsCount,
|
|
877
920
|
};
|
|
878
|
-
}
|
|
879
|
-
clearPartialResultsContext();
|
|
880
|
-
// 13. Calculate execution time and cost
|
|
881
|
-
const executionTimeMs = Date.now() - startTime;
|
|
882
|
-
const estimatedCostUsd = executeResult.allResults.reduce((sum, r) => {
|
|
883
|
-
if ('metrics' in r && r.metrics?.estimatedCostUsd) {
|
|
884
|
-
return sum + r.metrics.estimatedCostUsd;
|
|
885
|
-
}
|
|
886
|
-
return sum;
|
|
887
|
-
}, 0);
|
|
888
|
-
// 14. Build terminal context with execution info
|
|
889
|
-
const terminalContext = buildTerminalContext(resolvedOptions, gitContext, configSource, configPath, getPackageVersion());
|
|
890
|
-
terminalContext.executionTimeMs = executionTimeMs;
|
|
891
|
-
terminalContext.estimatedCostUsd = Math.max(0, estimatedCostUsd); // Clamp to non-negative (FR-REL-002)
|
|
892
|
-
// 15. Post-process findings (FR-018: CLI parity — sanitize → semantic → framework → diff-bound)
|
|
893
|
-
// FR-022: Local mode uses working tree config for suppressions (developer trusted)
|
|
894
|
-
const processed = processLocalReportFindings(executeResult.completeFindings, executeResult.partialFindings, diff.files, config, 'local');
|
|
895
|
-
const runStatus = determineRunStatus(processed.complete, config);
|
|
896
|
-
const exitCode = exitCodeFromStatus(runStatus);
|
|
897
|
-
// 16. Report findings to terminal
|
|
898
|
-
const reportFn = deps.reportToTerminal ?? reportToTerminal;
|
|
899
|
-
const reportResult = await reportFn(processed.complete, processed.partial, terminalContext, config, diff.files, {
|
|
900
|
-
status: runStatus,
|
|
901
|
-
suppressionSummary: processed.suppressionSummary,
|
|
902
921
|
});
|
|
903
|
-
return {
|
|
904
|
-
exitCode,
|
|
905
|
-
status: runStatus,
|
|
906
|
-
findingsCount: reportResult.findingsCount,
|
|
907
|
-
partialFindingsCount: reportResult.partialFindingsCount,
|
|
908
|
-
};
|
|
909
922
|
}
|
|
910
923
|
//# sourceMappingURL=local-review.js.map
|