@contextrail/code-review-agent 0.1.1-alpha.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/LICENSE +26 -0
- package/MODEL_RECOMMENDATIONS.md +178 -0
- package/README.md +177 -0
- package/dist/config/defaults.d.ts +72 -0
- package/dist/config/defaults.js +113 -0
- package/dist/config/index.d.ts +34 -0
- package/dist/config/index.js +89 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +603 -0
- package/dist/llm/factory.d.ts +21 -0
- package/dist/llm/factory.js +50 -0
- package/dist/llm/index.d.ts +3 -0
- package/dist/llm/index.js +2 -0
- package/dist/llm/service.d.ts +38 -0
- package/dist/llm/service.js +191 -0
- package/dist/llm/types.d.ts +119 -0
- package/dist/llm/types.js +1 -0
- package/dist/logging/logger.d.ts +9 -0
- package/dist/logging/logger.js +52 -0
- package/dist/mcp/client.d.ts +429 -0
- package/dist/mcp/client.js +173 -0
- package/dist/mcp/mcp-tools.d.ts +292 -0
- package/dist/mcp/mcp-tools.js +40 -0
- package/dist/mcp/token-validation.d.ts +31 -0
- package/dist/mcp/token-validation.js +57 -0
- package/dist/mcp/tools-provider.d.ts +18 -0
- package/dist/mcp/tools-provider.js +24 -0
- package/dist/observability/index.d.ts +2 -0
- package/dist/observability/index.js +1 -0
- package/dist/observability/metrics.d.ts +48 -0
- package/dist/observability/metrics.js +86 -0
- package/dist/orchestrator/agentic-orchestrator.d.ts +29 -0
- package/dist/orchestrator/agentic-orchestrator.js +136 -0
- package/dist/orchestrator/prompts.d.ts +25 -0
- package/dist/orchestrator/prompts.js +98 -0
- package/dist/orchestrator/validation.d.ts +2 -0
- package/dist/orchestrator/validation.js +7 -0
- package/dist/orchestrator/writer.d.ts +4 -0
- package/dist/orchestrator/writer.js +17 -0
- package/dist/output/aggregator.d.ts +30 -0
- package/dist/output/aggregator.js +132 -0
- package/dist/output/prompts.d.ts +32 -0
- package/dist/output/prompts.js +153 -0
- package/dist/output/schema.d.ts +1515 -0
- package/dist/output/schema.js +224 -0
- package/dist/output/writer.d.ts +31 -0
- package/dist/output/writer.js +120 -0
- package/dist/review-inputs/chunking.d.ts +29 -0
- package/dist/review-inputs/chunking.js +113 -0
- package/dist/review-inputs/diff-summary.d.ts +52 -0
- package/dist/review-inputs/diff-summary.js +83 -0
- package/dist/review-inputs/file-patterns.d.ts +40 -0
- package/dist/review-inputs/file-patterns.js +182 -0
- package/dist/review-inputs/filtering.d.ts +31 -0
- package/dist/review-inputs/filtering.js +53 -0
- package/dist/review-inputs/git-diff-provider.d.ts +2 -0
- package/dist/review-inputs/git-diff-provider.js +42 -0
- package/dist/review-inputs/index.d.ts +46 -0
- package/dist/review-inputs/index.js +122 -0
- package/dist/review-inputs/path-validation.d.ts +10 -0
- package/dist/review-inputs/path-validation.js +37 -0
- package/dist/review-inputs/surrounding-context.d.ts +35 -0
- package/dist/review-inputs/surrounding-context.js +180 -0
- package/dist/review-inputs/triage.d.ts +57 -0
- package/dist/review-inputs/triage.js +81 -0
- package/dist/reviewers/executor.d.ts +41 -0
- package/dist/reviewers/executor.js +357 -0
- package/dist/reviewers/findings-merge.d.ts +9 -0
- package/dist/reviewers/findings-merge.js +131 -0
- package/dist/reviewers/iteration.d.ts +17 -0
- package/dist/reviewers/iteration.js +95 -0
- package/dist/reviewers/persistence.d.ts +17 -0
- package/dist/reviewers/persistence.js +55 -0
- package/dist/reviewers/progress-tracker.d.ts +115 -0
- package/dist/reviewers/progress-tracker.js +194 -0
- package/dist/reviewers/prompt.d.ts +42 -0
- package/dist/reviewers/prompt.js +246 -0
- package/dist/reviewers/tool-call-tracker.d.ts +18 -0
- package/dist/reviewers/tool-call-tracker.js +40 -0
- package/dist/reviewers/types.d.ts +12 -0
- package/dist/reviewers/types.js +1 -0
- package/dist/reviewers/validation-rules.d.ts +27 -0
- package/dist/reviewers/validation-rules.js +189 -0
- package/package.json +79 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadConfig, validateConfig } from './config/index.js';
|
|
4
|
+
import { DEFAULT_ORCHESTRATOR_MODEL, DEFAULT_REVIEWER_MODEL } from './config/defaults.js';
|
|
5
|
+
import { createLogger, parseLogLevel } from './logging/logger.js';
|
|
6
|
+
import { McpClient } from './mcp/client.js';
|
|
7
|
+
import { buildReviewInputs, triagePr } from './review-inputs/index.js';
|
|
8
|
+
import { runOrchestrator } from './orchestrator/agentic-orchestrator.js';
|
|
9
|
+
import { runReviewerLoop } from './reviewers/executor.js';
|
|
10
|
+
import { aggregateResults, writeResult, writeTokenBudgetMetrics } from './output/writer.js';
|
|
11
|
+
import { metadataSchema } from './output/schema.js';
|
|
12
|
+
import { generateReviewDecision, normalizeDecisionWithSynthesis, synthesizeFindings } from './output/aggregator.js';
|
|
13
|
+
const parseArgs = (args) => {
|
|
14
|
+
const parsed = {};
|
|
15
|
+
let i = 0;
|
|
16
|
+
while (i < args.length) {
|
|
17
|
+
const arg = args[i];
|
|
18
|
+
if (!arg) {
|
|
19
|
+
i += 1;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (arg === '-h' || arg === '--help') {
|
|
23
|
+
parsed.help = true;
|
|
24
|
+
i += 1;
|
|
25
|
+
}
|
|
26
|
+
else if (arg === '--repo' && i + 1 < args.length) {
|
|
27
|
+
parsed.repo = args[i + 1];
|
|
28
|
+
i += 2;
|
|
29
|
+
}
|
|
30
|
+
else if (arg === '--from' && i + 1 < args.length) {
|
|
31
|
+
parsed.from = args[i + 1];
|
|
32
|
+
i += 2;
|
|
33
|
+
}
|
|
34
|
+
else if (arg === '--to' && i + 1 < args.length) {
|
|
35
|
+
parsed.to = args[i + 1];
|
|
36
|
+
i += 2;
|
|
37
|
+
}
|
|
38
|
+
else if (arg === '--output' && i + 1 < args.length) {
|
|
39
|
+
parsed.output = args[i + 1];
|
|
40
|
+
i += 2;
|
|
41
|
+
}
|
|
42
|
+
else if (arg === '--files' && i + 1 < args.length) {
|
|
43
|
+
const raw = args[i + 1] ?? '';
|
|
44
|
+
const files = raw
|
|
45
|
+
.split(',')
|
|
46
|
+
.map((file) => file.trim())
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
if (!parsed.files) {
|
|
49
|
+
parsed.files = [];
|
|
50
|
+
}
|
|
51
|
+
parsed.files.push(...files);
|
|
52
|
+
i += 2;
|
|
53
|
+
}
|
|
54
|
+
else if (arg === '--file' && i + 1 < args.length) {
|
|
55
|
+
const file = args[i + 1];
|
|
56
|
+
if (file) {
|
|
57
|
+
if (!parsed.files) {
|
|
58
|
+
parsed.files = [];
|
|
59
|
+
}
|
|
60
|
+
parsed.files.push(file);
|
|
61
|
+
}
|
|
62
|
+
i += 2;
|
|
63
|
+
}
|
|
64
|
+
else if (arg === '--log-level' && i + 1 < args.length) {
|
|
65
|
+
parsed.logLevel = args[i + 1];
|
|
66
|
+
i += 2;
|
|
67
|
+
}
|
|
68
|
+
else if (arg === '--pr-description' && i + 1 < args.length) {
|
|
69
|
+
parsed.prDescription = args[i + 1];
|
|
70
|
+
i += 2;
|
|
71
|
+
}
|
|
72
|
+
else if (arg === '--domains' && i + 1 < args.length) {
|
|
73
|
+
const raw = args[i + 1] ?? '';
|
|
74
|
+
const domains = raw
|
|
75
|
+
.split(',')
|
|
76
|
+
.map((domain) => domain.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
if (!parsed.domains) {
|
|
79
|
+
parsed.domains = [];
|
|
80
|
+
}
|
|
81
|
+
parsed.domains.push(...domains);
|
|
82
|
+
i += 2;
|
|
83
|
+
}
|
|
84
|
+
else if (arg === '--max-steps' && i + 1 < args.length) {
|
|
85
|
+
const value = parseInt(args[i + 1] ?? '10', 10);
|
|
86
|
+
parsed.maxSteps = Number.isNaN(value) ? undefined : value;
|
|
87
|
+
i += 2;
|
|
88
|
+
}
|
|
89
|
+
else if (arg === '--max-iterations' && i + 1 < args.length) {
|
|
90
|
+
const value = parseInt(args[i + 1] ?? '2', 10);
|
|
91
|
+
parsed.maxIterations = Number.isNaN(value) ? undefined : value;
|
|
92
|
+
i += 2;
|
|
93
|
+
}
|
|
94
|
+
else if (arg === '--aggregation-max-steps' && i + 1 < args.length) {
|
|
95
|
+
const value = parseInt(args[i + 1] ?? '5', 10);
|
|
96
|
+
parsed.aggregationMaxSteps = Number.isNaN(value) ? undefined : value;
|
|
97
|
+
i += 2;
|
|
98
|
+
}
|
|
99
|
+
else if (arg === '--max-tokens-per-file' && i + 1 < args.length) {
|
|
100
|
+
const value = parseInt(args[i + 1] ?? '20000', 10);
|
|
101
|
+
parsed.maxTokensPerFile = Number.isNaN(value) ? undefined : value;
|
|
102
|
+
i += 2;
|
|
103
|
+
}
|
|
104
|
+
else if (arg === '--context-lines' && i + 1 < args.length) {
|
|
105
|
+
const value = parseInt(args[i + 1] ?? '10', 10);
|
|
106
|
+
parsed.contextLines = Number.isNaN(value) ? undefined : value;
|
|
107
|
+
i += 2;
|
|
108
|
+
}
|
|
109
|
+
else if (!parsed.command && !arg.startsWith('-')) {
|
|
110
|
+
parsed.command = arg;
|
|
111
|
+
i += 1;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
i += 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return parsed;
|
|
118
|
+
};
|
|
119
|
+
const printHelp = (log) => {
|
|
120
|
+
log.info(`code-review-agent
|
|
121
|
+
|
|
122
|
+
Usage:
|
|
123
|
+
code-review-agent review --repo <path> --from <sha> --to <sha> [--output <dir>]
|
|
124
|
+
code-review-agent review --repo <path> --files <file1,file2> [--output <dir>]
|
|
125
|
+
code-review-agent review --repo <path> --file <file> [--file <file> ...] [--output <dir>]
|
|
126
|
+
|
|
127
|
+
Commands:
|
|
128
|
+
review Run code review on git diff
|
|
129
|
+
|
|
130
|
+
Options:
|
|
131
|
+
--repo <path> Repository path (default: current directory)
|
|
132
|
+
--from <sha> Base commit SHA (required)
|
|
133
|
+
--to <sha> Head commit SHA (required)
|
|
134
|
+
--files <list> Comma-separated list of files to review
|
|
135
|
+
--file <path> File to review (repeatable)
|
|
136
|
+
--output <dir> Output directory (default: ./review)
|
|
137
|
+
--log-level <lv> Log level: debug|info|warn|error|silent (overrides DEBUG)
|
|
138
|
+
--max-steps <n> Maximum LLM steps per call (default: 10)
|
|
139
|
+
--max-iterations <n> Maximum reviewer validation iterations (default: 2)
|
|
140
|
+
--aggregation-max-steps <n> Maximum steps for aggregation (default: 5)
|
|
141
|
+
--max-tokens-per-file <n> Token budget for surrounding context per file (default: 20000)
|
|
142
|
+
--context-lines <n> Lines of context around changes (default: 10)
|
|
143
|
+
--pr-description <text> PR description to include as context (optional)
|
|
144
|
+
--domains <list> Comma-separated review focus domains (optional)
|
|
145
|
+
-h, --help Show this help message
|
|
146
|
+
|
|
147
|
+
Environment Variables:
|
|
148
|
+
CONTEXTRAIL_MCP_SERVER_URL ContextRail MCP server URL (required)
|
|
149
|
+
CONTEXTRAIL_MCP_JWT_TOKEN ContextRail MCP authentication token (required)
|
|
150
|
+
OPENROUTER_API_KEY OpenRouter API key (required)
|
|
151
|
+
LLM_MODEL_ORCHESTRATOR Model for orchestrator (default: anthropic/claude-haiku-4.5)
|
|
152
|
+
LLM_MODEL_REVIEWER Model for reviewers (default: anthropic/claude-haiku-4.5)
|
|
153
|
+
MAX_STEPS Maximum LLM steps per call (default: 10)
|
|
154
|
+
MAX_ITERATIONS Maximum reviewer validation iterations (default: 2)
|
|
155
|
+
AGGREGATION_MAX_STEPS Maximum steps for aggregation (default: 5)
|
|
156
|
+
MAX_TOKENS_PER_FILE Token budget for surrounding context per file (default: 20000)
|
|
157
|
+
CONTEXT_LINES Lines of context around changes (default: 10)
|
|
158
|
+
REVIEW_DOMAINS Comma-separated review focus domains (optional)
|
|
159
|
+
|
|
160
|
+
Examples:
|
|
161
|
+
code-review-agent review --repo . --from HEAD^ --to HEAD
|
|
162
|
+
code-review-agent review --repo . --files src/a.ts,src/b.ts
|
|
163
|
+
code-review-agent review --repo . --file src/a.ts --file src/b.ts
|
|
164
|
+
code-review-agent review --repo /path/to/repo --from abc123 --to def456 --output ./review-results
|
|
165
|
+
`);
|
|
166
|
+
};
|
|
167
|
+
const validateArgs = (args) => {
|
|
168
|
+
if (args.help) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (args.command !== 'review') {
|
|
172
|
+
throw new Error(`Unknown command: ${args.command ?? 'none'}. Use 'review' or see --help.`);
|
|
173
|
+
}
|
|
174
|
+
const hasFileList = Array.isArray(args.files) && args.files.length > 0;
|
|
175
|
+
if (!hasFileList) {
|
|
176
|
+
if (!args.from) {
|
|
177
|
+
throw new Error('Missing required argument: --from');
|
|
178
|
+
}
|
|
179
|
+
if (!args.to) {
|
|
180
|
+
throw new Error('Missing required argument: --to');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
const main = async () => {
|
|
185
|
+
try {
|
|
186
|
+
const args = parseArgs(process.argv.slice(2));
|
|
187
|
+
if (args.help) {
|
|
188
|
+
// Keep --help available even when required env vars are not set.
|
|
189
|
+
const helpLog = createLogger(parseLogLevel(args.logLevel ?? process.env.DEBUG));
|
|
190
|
+
printHelp(helpLog);
|
|
191
|
+
process.exit(0);
|
|
192
|
+
}
|
|
193
|
+
const config = loadConfig();
|
|
194
|
+
const log = createLogger(parseLogLevel(args.logLevel ?? config.logLevel));
|
|
195
|
+
log.debug(`Log level set to ${args.logLevel ?? config.logLevel}`);
|
|
196
|
+
// Validate arguments
|
|
197
|
+
validateArgs(args);
|
|
198
|
+
// Override config with CLI args
|
|
199
|
+
if (args.maxSteps !== undefined) {
|
|
200
|
+
config.maxSteps = args.maxSteps;
|
|
201
|
+
}
|
|
202
|
+
if (args.maxIterations !== undefined) {
|
|
203
|
+
config.maxIterations = args.maxIterations;
|
|
204
|
+
}
|
|
205
|
+
if (args.aggregationMaxSteps !== undefined) {
|
|
206
|
+
config.aggregationMaxSteps = args.aggregationMaxSteps;
|
|
207
|
+
}
|
|
208
|
+
if (args.maxTokensPerFile !== undefined) {
|
|
209
|
+
config.maxTokensPerFile = args.maxTokensPerFile;
|
|
210
|
+
}
|
|
211
|
+
if (args.contextLines !== undefined) {
|
|
212
|
+
config.contextLines = args.contextLines;
|
|
213
|
+
}
|
|
214
|
+
if (args.prDescription !== undefined) {
|
|
215
|
+
config.prDescription = args.prDescription;
|
|
216
|
+
}
|
|
217
|
+
if (args.domains && args.domains.length > 0) {
|
|
218
|
+
config.reviewDomains = args.domains;
|
|
219
|
+
}
|
|
220
|
+
// Load and validate config
|
|
221
|
+
const validatedConfig = validateConfig(config);
|
|
222
|
+
// Set defaults
|
|
223
|
+
const repoPath = args.repo ?? process.cwd();
|
|
224
|
+
const outputDir = args.output ?? path.join(process.cwd(), 'review');
|
|
225
|
+
log.info('Starting code review...');
|
|
226
|
+
log.info(`Repository: ${repoPath}`);
|
|
227
|
+
log.info(`Output: ${outputDir}`);
|
|
228
|
+
if (args.files && args.files.length > 0) {
|
|
229
|
+
log.info(`Files: ${args.files.join(', ')}`);
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
log.info(`From: ${args.from}`);
|
|
233
|
+
log.info(`To: ${args.to}`);
|
|
234
|
+
}
|
|
235
|
+
// Connect MCP client
|
|
236
|
+
const mcpClient = new McpClient({
|
|
237
|
+
serverUrl: validatedConfig.mcpServerUrl,
|
|
238
|
+
authToken: validatedConfig.mcpAuthToken,
|
|
239
|
+
clientName: 'code-review-agent',
|
|
240
|
+
clientVersion: '0.1.0',
|
|
241
|
+
logger: log,
|
|
242
|
+
});
|
|
243
|
+
mcpClientInstance = mcpClient; // Store for graceful shutdown
|
|
244
|
+
await mcpClient.connect();
|
|
245
|
+
log.info('Connected to MCP server');
|
|
246
|
+
try {
|
|
247
|
+
// Build review inputs
|
|
248
|
+
log.info('Building review inputs...');
|
|
249
|
+
const inputs = args.files && args.files.length > 0
|
|
250
|
+
? await buildReviewInputs({
|
|
251
|
+
mode: 'file-list',
|
|
252
|
+
files: args.files,
|
|
253
|
+
basePath: repoPath,
|
|
254
|
+
surroundingContext: {
|
|
255
|
+
enabled: true,
|
|
256
|
+
maxTokensPerFile: validatedConfig.maxTokensPerFile,
|
|
257
|
+
contextLines: validatedConfig.contextLines,
|
|
258
|
+
},
|
|
259
|
+
})
|
|
260
|
+
: await buildReviewInputs({
|
|
261
|
+
mode: 'diff',
|
|
262
|
+
repoPath,
|
|
263
|
+
from: args.from,
|
|
264
|
+
to: args.to,
|
|
265
|
+
surroundingContext: {
|
|
266
|
+
enabled: true,
|
|
267
|
+
maxTokensPerFile: validatedConfig.maxTokensPerFile,
|
|
268
|
+
contextLines: validatedConfig.contextLines,
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
log.info(`Found ${inputs.files.length} files to review`);
|
|
272
|
+
if (inputs.files.length > 0) {
|
|
273
|
+
log.debug(` Files: ${inputs.files.slice(0, 10).join(', ')}${inputs.files.length > 10 ? ` ... and ${inputs.files.length - 10} more` : ''}`);
|
|
274
|
+
}
|
|
275
|
+
// Triage PR to determine if it's trivial
|
|
276
|
+
const triageResult = triagePr(inputs);
|
|
277
|
+
log.info(`PR triage: ${triageResult.reason}`);
|
|
278
|
+
if (triageResult.isTrivial) {
|
|
279
|
+
log.info(`Trivial PR detected (${triageResult.isDocsOnly ? 'docs-only' : 'small'}). Skipping full reviewer flow.`);
|
|
280
|
+
// For trivial PRs, we still run orchestrator but with awareness
|
|
281
|
+
// The orchestrator can use this information to select fewer reviewers or skip entirely
|
|
282
|
+
}
|
|
283
|
+
// Run orchestrator
|
|
284
|
+
log.info('Running orchestrator...');
|
|
285
|
+
const prDescription = config.prDescription;
|
|
286
|
+
if (prDescription) {
|
|
287
|
+
log.debug('PR description provided, will be included in prompts');
|
|
288
|
+
}
|
|
289
|
+
if (config.reviewDomains && config.reviewDomains.length > 0) {
|
|
290
|
+
log.debug(`Review domains provided: ${config.reviewDomains.join(', ')}`);
|
|
291
|
+
}
|
|
292
|
+
const orchestratorOutput = await runOrchestrator(inputs, outputDir, {
|
|
293
|
+
mcpClient,
|
|
294
|
+
config: {
|
|
295
|
+
openRouterApiKey: validatedConfig.openRouterApiKey,
|
|
296
|
+
orchestratorModel: validatedConfig.orchestratorModel ?? DEFAULT_ORCHESTRATOR_MODEL,
|
|
297
|
+
maxSteps: validatedConfig.maxSteps,
|
|
298
|
+
prDescription,
|
|
299
|
+
reviewDomains: validatedConfig.reviewDomains,
|
|
300
|
+
},
|
|
301
|
+
logger: log,
|
|
302
|
+
});
|
|
303
|
+
log.info(`Selected reviewers: ${orchestratorOutput.reviewers.join(', ')}`);
|
|
304
|
+
log.debug(`Orchestrator understanding:\n${orchestratorOutput.understanding}`);
|
|
305
|
+
// Run reviewers in parallel with progress logging
|
|
306
|
+
log.info(`Running ${orchestratorOutput.reviewers.length} reviewer(s) in parallel...`);
|
|
307
|
+
const reviewerFailures = [];
|
|
308
|
+
const reviewerResults = await Promise.all(orchestratorOutput.reviewers.map(async (reviewer) => {
|
|
309
|
+
const startTime = Date.now();
|
|
310
|
+
log.info(`[${reviewer}] Starting review...`);
|
|
311
|
+
try {
|
|
312
|
+
const findings = await runReviewerLoop(reviewer, inputs, orchestratorOutput.understanding, outputDir, {
|
|
313
|
+
mcpClient,
|
|
314
|
+
config: {
|
|
315
|
+
openRouterApiKey: validatedConfig.openRouterApiKey,
|
|
316
|
+
reviewerModel: validatedConfig.reviewerModel ?? DEFAULT_REVIEWER_MODEL,
|
|
317
|
+
criticModel: validatedConfig.criticModel,
|
|
318
|
+
maxSteps: validatedConfig.maxSteps,
|
|
319
|
+
maxIterations: validatedConfig.maxIterations,
|
|
320
|
+
prDescription,
|
|
321
|
+
reviewDomains: validatedConfig.reviewDomains,
|
|
322
|
+
},
|
|
323
|
+
logger: log,
|
|
324
|
+
});
|
|
325
|
+
const result = {
|
|
326
|
+
reviewer,
|
|
327
|
+
findings: findings.findings,
|
|
328
|
+
validated: findings.validated,
|
|
329
|
+
notes: findings.notes ?? undefined,
|
|
330
|
+
};
|
|
331
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
332
|
+
log.info(`[${reviewer}] Completed in ${duration}s`);
|
|
333
|
+
// Log reviewer results
|
|
334
|
+
const issueCount = findings.findings.filter((f) => f.severity !== 'pass').length;
|
|
335
|
+
const passCount = findings.findings.filter((f) => f.severity === 'pass').length;
|
|
336
|
+
if (issueCount === 0) {
|
|
337
|
+
log.info(` ✓ ${reviewer}: Clean pass (pass signals: ${passCount}, validated: ${findings.validated})`);
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
log.info(` ✓ ${reviewer}: ${issueCount} issue(s) (pass signals: ${passCount}, validated: ${findings.validated})`);
|
|
341
|
+
}
|
|
342
|
+
log.debug(` Findings: ${JSON.stringify(findings.findings, null, 2)}`);
|
|
343
|
+
if (findings.notes) {
|
|
344
|
+
log.debug(` Notes: ${findings.notes}`);
|
|
345
|
+
}
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
350
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
351
|
+
log.error(`[${reviewer}] Failed after ${duration}s: ${message}`);
|
|
352
|
+
// Provide actionable error guidance
|
|
353
|
+
if (message.includes('No object generated') || message.includes('No output generated')) {
|
|
354
|
+
log.error(`[${reviewer}] TROUBLESHOOTING: The model may not support structured output well.`);
|
|
355
|
+
log.error(`[${reviewer}] Try: 1) Use a different model (e.g., anthropic/claude-haiku-4.5)`);
|
|
356
|
+
log.error(`[${reviewer}] 2) Check model compatibility with structured output`);
|
|
357
|
+
log.error(`[${reviewer}] 3) Review the model's response format requirements`);
|
|
358
|
+
}
|
|
359
|
+
reviewerFailures.push({ reviewer, message });
|
|
360
|
+
return {
|
|
361
|
+
reviewer,
|
|
362
|
+
findings: [],
|
|
363
|
+
validated: false,
|
|
364
|
+
notes: `Error: ${message}`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
}));
|
|
368
|
+
// Aggregate results
|
|
369
|
+
log.info('Aggregating results...');
|
|
370
|
+
log.debug(` Total issue findings to aggregate: ${reviewerResults.reduce((sum, rr) => sum + rr.findings.filter((f) => f.severity !== 'pass').length, 0)}`);
|
|
371
|
+
log.debug(` Total pass signals to aggregate: ${reviewerResults.reduce((sum, rr) => sum + rr.findings.filter((f) => f.severity === 'pass').length, 0)}`);
|
|
372
|
+
log.debug(` Reviewers validated: ${reviewerResults.filter((rr) => rr.validated).length}/${reviewerResults.length}`);
|
|
373
|
+
const metadata = metadataSchema.parse({
|
|
374
|
+
timestamp: new Date().toISOString(),
|
|
375
|
+
mode: inputs.mode,
|
|
376
|
+
fileCount: inputs.files.length,
|
|
377
|
+
});
|
|
378
|
+
// Synthesize findings across reviewers (deduplication, contradictions, compound risks)
|
|
379
|
+
log.info(`Synthesis pass starting (model: ${validatedConfig.orchestratorModel ?? DEFAULT_ORCHESTRATOR_MODEL}, reviewers: ${reviewerResults.length})`);
|
|
380
|
+
const synthesisResult = await synthesizeFindings(reviewerResults, {
|
|
381
|
+
openRouterApiKey: validatedConfig.openRouterApiKey,
|
|
382
|
+
model: validatedConfig.orchestratorModel ?? DEFAULT_ORCHESTRATOR_MODEL,
|
|
383
|
+
maxSteps: validatedConfig.aggregationMaxSteps,
|
|
384
|
+
logger: log,
|
|
385
|
+
});
|
|
386
|
+
log.info(`Synthesis pass complete: ${synthesisResult.synthesis.findings.length} deduped findings, ${synthesisResult.synthesis.contradictions.length} contradictions, ${synthesisResult.synthesis.compoundRisks.length} compound risks`);
|
|
387
|
+
log.debug(` Synthesis: ${synthesisResult.synthesis.findings.length} deduplicated findings`);
|
|
388
|
+
log.debug(` Contradictions: ${synthesisResult.synthesis.contradictions.length}`);
|
|
389
|
+
log.debug(` Compound risks: ${synthesisResult.synthesis.compoundRisks.length}`);
|
|
390
|
+
const synthesisSeverityCounts = synthesisResult.synthesis.findings.reduce((acc, finding) => {
|
|
391
|
+
acc[finding.severity] += 1;
|
|
392
|
+
return acc;
|
|
393
|
+
}, { critical: 0, major: 0, minor: 0, info: 0, pass: 0 });
|
|
394
|
+
log.debug(` Synthesis severity: ${JSON.stringify(synthesisSeverityCounts)}`);
|
|
395
|
+
// Generate review decision based on synthesized findings
|
|
396
|
+
log.info(`Decision pass starting (model: ${validatedConfig.orchestratorModel ?? DEFAULT_ORCHESTRATOR_MODEL}, synthesis findings: ${synthesisResult.synthesis.findings.length})`);
|
|
397
|
+
const decisionResult = await generateReviewDecision(orchestratorOutput.understanding, synthesisResult.synthesis, {
|
|
398
|
+
openRouterApiKey: validatedConfig.openRouterApiKey,
|
|
399
|
+
model: validatedConfig.orchestratorModel ?? DEFAULT_ORCHESTRATOR_MODEL,
|
|
400
|
+
maxSteps: validatedConfig.aggregationMaxSteps,
|
|
401
|
+
logger: log,
|
|
402
|
+
});
|
|
403
|
+
const normalizedDecision = normalizeDecisionWithSynthesis(decisionResult.decision, synthesisResult.synthesis);
|
|
404
|
+
const decisionEvidence = {
|
|
405
|
+
findings: synthesisResult.synthesis.findings.length,
|
|
406
|
+
blockingFindings: synthesisResult.synthesis.findings.filter((f) => f.severity === 'critical' || f.severity === 'major').length,
|
|
407
|
+
contradictions: synthesisResult.synthesis.contradictions.length,
|
|
408
|
+
compoundRisks: synthesisResult.synthesis.compoundRisks.length,
|
|
409
|
+
modelDecision: decisionResult.decision.decision,
|
|
410
|
+
normalizedDecision: normalizedDecision.decision,
|
|
411
|
+
};
|
|
412
|
+
log.debug(` Decision evidence: ${JSON.stringify(decisionEvidence)}`);
|
|
413
|
+
if (normalizedDecision.decision !== decisionResult.decision.decision) {
|
|
414
|
+
log.warn(`Decision normalized from ${decisionResult.decision.decision} to ${normalizedDecision.decision} to match synthesized findings.`);
|
|
415
|
+
}
|
|
416
|
+
log.info(`Decision pass complete: ${normalizedDecision.decision}`);
|
|
417
|
+
log.debug(` Aggregation decision: ${normalizedDecision.decision}`);
|
|
418
|
+
log.debug(` Decision summary: ${normalizedDecision.summary}`);
|
|
419
|
+
log.debug(` Decision rationale: ${normalizedDecision.rationale}`);
|
|
420
|
+
const result = aggregateResults(metadata, orchestratorOutput.understanding, normalizedDecision, reviewerResults, reviewerFailures, synthesisResult.synthesis);
|
|
421
|
+
// Write result.json
|
|
422
|
+
await writeResult(outputDir, result);
|
|
423
|
+
const resultPath = path.join(outputDir, 'result.json');
|
|
424
|
+
log.info(`Results written to ${resultPath}`);
|
|
425
|
+
log.debug(` Full review results available at: ${resultPath}`);
|
|
426
|
+
// Write token budget metrics
|
|
427
|
+
const aggregationUsage = [];
|
|
428
|
+
if (synthesisResult.usage) {
|
|
429
|
+
aggregationUsage.push(synthesisResult.usage);
|
|
430
|
+
}
|
|
431
|
+
if (decisionResult.usage) {
|
|
432
|
+
aggregationUsage.push(decisionResult.usage);
|
|
433
|
+
}
|
|
434
|
+
await writeTokenBudgetMetrics(outputDir, orchestratorOutput.reviewers, aggregationUsage.length > 0 ? aggregationUsage : undefined);
|
|
435
|
+
log.info(`Token budget metrics written to ${path.join(outputDir, 'token-budget.json')}`);
|
|
436
|
+
// Print summary
|
|
437
|
+
log.info('\n═══════════════════════════════════════════════════════════');
|
|
438
|
+
log.info('Review Summary');
|
|
439
|
+
log.info('═══════════════════════════════════════════════════════════');
|
|
440
|
+
log.info(` Files reviewed: ${result.metadata.fileCount}`);
|
|
441
|
+
log.info(` Reviewers executed: ${reviewerResults.length}`);
|
|
442
|
+
log.info(` Total issue findings: ${result.summary.totalFindings}`);
|
|
443
|
+
log.info(` Critical: ${result.summary.bySeverity.critical}`);
|
|
444
|
+
log.info(` Major: ${result.summary.bySeverity.major}`);
|
|
445
|
+
log.info(` Minor: ${result.summary.bySeverity.minor}`);
|
|
446
|
+
log.info(` Info: ${result.summary.bySeverity.info}`);
|
|
447
|
+
log.info(` Pass signals: ${result.summary.bySeverity.pass}`);
|
|
448
|
+
// Show per-reviewer breakdown
|
|
449
|
+
if (result.summary.totalFindings > 0) {
|
|
450
|
+
log.info('\n Findings by reviewer:');
|
|
451
|
+
for (const [reviewer, count] of Object.entries(result.summary.byReviewer)) {
|
|
452
|
+
log.info(` ${reviewer}: ${count} finding(s)`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// Show decision details
|
|
456
|
+
log.info(`\n Decision: ${result.decision.decision.toUpperCase()}`);
|
|
457
|
+
log.info(` Summary: ${result.decision.summary}`);
|
|
458
|
+
log.info(` Rationale: ${result.decision.rationale}`);
|
|
459
|
+
// Show reviewer pass summaries (positive signal)
|
|
460
|
+
const passReviewers = reviewerResults
|
|
461
|
+
.map((rr) => ({
|
|
462
|
+
reviewer: rr.reviewer,
|
|
463
|
+
passFinding: rr.findings.find((f) => f.severity === 'pass'),
|
|
464
|
+
notes: rr.notes,
|
|
465
|
+
validated: rr.validated,
|
|
466
|
+
}))
|
|
467
|
+
.filter((rr) => rr.validated && (rr.passFinding || rr.notes));
|
|
468
|
+
if (passReviewers.length > 0) {
|
|
469
|
+
log.info('\n Reviewer pass summaries:');
|
|
470
|
+
for (const rr of passReviewers) {
|
|
471
|
+
const passLine = rr.notes?.trim() ?? rr.passFinding?.title ?? 'PASS';
|
|
472
|
+
log.info(` ${rr.reviewer}: ${passLine.split('\n')[0] ?? passLine}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// Debug: Show synthesized (deduplicated) findings when available.
|
|
476
|
+
if (result.synthesis && result.synthesis.findings.length > 0) {
|
|
477
|
+
log.debug('\n Deduplicated findings (synthesis):');
|
|
478
|
+
for (const finding of result.synthesis.findings) {
|
|
479
|
+
log.debug(` [${finding.severity.toUpperCase()}] ${finding.title}`);
|
|
480
|
+
if (finding.file) {
|
|
481
|
+
log.debug(` File: ${finding.file}${finding.line ? `:${finding.line}` : ''}`);
|
|
482
|
+
}
|
|
483
|
+
if (finding.sourceReviewers && finding.sourceReviewers.length > 0) {
|
|
484
|
+
log.debug(` Source reviewers: ${finding.sourceReviewers.join(', ')}`);
|
|
485
|
+
}
|
|
486
|
+
if (finding.contextTitles && finding.contextTitles.length > 0) {
|
|
487
|
+
log.debug(` ContextRail Standards: ${finding.contextTitles.join(', ')}`);
|
|
488
|
+
}
|
|
489
|
+
log.debug(` ${finding.description}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
// Fallback: Show raw reviewer findings when synthesis is unavailable.
|
|
494
|
+
const hasAnyEntries = reviewerResults.some((rr) => rr.findings.length > 0);
|
|
495
|
+
if (hasAnyEntries) {
|
|
496
|
+
log.debug('\n All findings:');
|
|
497
|
+
for (const rr of reviewerResults) {
|
|
498
|
+
if (rr.findings.length > 0) {
|
|
499
|
+
log.debug(`\n ${rr.reviewer}:`);
|
|
500
|
+
for (const finding of rr.findings) {
|
|
501
|
+
log.debug(` [${finding.severity.toUpperCase()}] ${finding.title}`);
|
|
502
|
+
if (finding.file) {
|
|
503
|
+
log.debug(` File: ${finding.file}${finding.line ? `:${finding.line}` : ''}`);
|
|
504
|
+
}
|
|
505
|
+
if (finding.contextTitles && finding.contextTitles.length > 0) {
|
|
506
|
+
log.debug(` ContextRail Standards: ${finding.contextTitles.join(', ')}`);
|
|
507
|
+
}
|
|
508
|
+
log.debug(` ${finding.description}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
// Show failures if any
|
|
515
|
+
if (result.failures && result.failures.length > 0) {
|
|
516
|
+
log.warn('\n Reviewer failures:');
|
|
517
|
+
for (const failure of result.failures) {
|
|
518
|
+
log.warn(` ${failure.reviewer}: ${failure.message}`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
log.info('═══════════════════════════════════════════════════════════\n');
|
|
522
|
+
// Set exit code based on reviewer statuses
|
|
523
|
+
const hasFailures = reviewerResults.some((rr) => !rr.validated);
|
|
524
|
+
if (hasFailures) {
|
|
525
|
+
log.error('❌ Review failed: Some reviewers did not validate');
|
|
526
|
+
process.exit(1);
|
|
527
|
+
}
|
|
528
|
+
else if (result.decision.decision === 'request-changes') {
|
|
529
|
+
log.error('❌ Review decision: REQUEST CHANGES');
|
|
530
|
+
log.error(` Reason: ${result.decision.rationale}`);
|
|
531
|
+
process.exit(1);
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
if (result.summary.totalFindings === 0) {
|
|
535
|
+
log.info('✅ Review passed: No findings - code is clean!');
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
log.info('✅ Review passed: Findings present but decision is APPROVE');
|
|
539
|
+
log.info(` ${result.summary.totalFindings} finding(s) were reviewed and deemed acceptable`);
|
|
540
|
+
}
|
|
541
|
+
log.info(` Decision rationale: ${result.decision.rationale}`);
|
|
542
|
+
process.exit(0);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
finally {
|
|
546
|
+
await mcpClient.close();
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
const log = createLogger(parseLogLevel(process.env.DEBUG));
|
|
551
|
+
const normalizedError = error instanceof Error ? error : new Error('Unknown error');
|
|
552
|
+
const message = normalizedError.message;
|
|
553
|
+
const stack = normalizedError.stack;
|
|
554
|
+
log.error({ msg: 'Fatal error', error: message, stack });
|
|
555
|
+
if (stack && process.env.NODE_ENV === 'development') {
|
|
556
|
+
log.debug({ msg: 'Error stack trace', stack });
|
|
557
|
+
}
|
|
558
|
+
await gracefulShutdown('SIGTERM', normalizedError);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
// Set up process-level error handlers with structured logging
|
|
563
|
+
let mcpClientInstance = null;
|
|
564
|
+
const gracefulShutdown = async (signal, error) => {
|
|
565
|
+
const log = createLogger(parseLogLevel(process.env.DEBUG));
|
|
566
|
+
log.warn({ msg: `Received ${signal}. Shutting down...`, signal });
|
|
567
|
+
try {
|
|
568
|
+
if (mcpClientInstance) {
|
|
569
|
+
await mcpClientInstance.close();
|
|
570
|
+
}
|
|
571
|
+
process.exit(error ? 1 : 0);
|
|
572
|
+
}
|
|
573
|
+
catch (shutdownError) {
|
|
574
|
+
const message = shutdownError instanceof Error ? shutdownError.message : 'Unknown error';
|
|
575
|
+
log.error({ msg: 'Error during shutdown', error: message });
|
|
576
|
+
process.exit(1);
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
process.on('uncaughtException', async (error) => {
|
|
580
|
+
const log = createLogger(parseLogLevel(process.env.DEBUG));
|
|
581
|
+
log.error({
|
|
582
|
+
msg: 'Uncaught exception',
|
|
583
|
+
error: error.message,
|
|
584
|
+
stack: error.stack,
|
|
585
|
+
name: error.name,
|
|
586
|
+
});
|
|
587
|
+
await gracefulShutdown('SIGTERM', error);
|
|
588
|
+
});
|
|
589
|
+
process.on('unhandledRejection', async (reason) => {
|
|
590
|
+
const log = createLogger(parseLogLevel(process.env.DEBUG));
|
|
591
|
+
const message = reason instanceof Error ? reason.message : String(reason);
|
|
592
|
+
const stack = reason instanceof Error ? reason.stack : undefined;
|
|
593
|
+
log.error({
|
|
594
|
+
msg: 'Unhandled rejection',
|
|
595
|
+
error: message,
|
|
596
|
+
stack,
|
|
597
|
+
});
|
|
598
|
+
await gracefulShutdown('SIGTERM', reason instanceof Error ? reason : new Error(message));
|
|
599
|
+
});
|
|
600
|
+
// Capture SIGTERM and SIGINT for graceful shutdown
|
|
601
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
602
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
603
|
+
main();
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { LlmService } from './service.js';
|
|
2
|
+
import type { ModelProvider } from './types.js';
|
|
3
|
+
import type { Logger } from '../logging/logger.js';
|
|
4
|
+
import { LlmMetricsCollector } from '../observability/metrics.js';
|
|
5
|
+
import type { McpClient } from '../mcp/client.js';
|
|
6
|
+
/**
|
|
7
|
+
* Create an OpenRouter model provider.
|
|
8
|
+
*/
|
|
9
|
+
export declare const createOpenRouterModelProvider: (apiKey: string) => ModelProvider;
|
|
10
|
+
/**
|
|
11
|
+
* Create an LLM service with OpenRouter and optional MCP tools.
|
|
12
|
+
*/
|
|
13
|
+
export declare const createLlmService: (config: {
|
|
14
|
+
openRouterApiKey: string;
|
|
15
|
+
mcpClient?: McpClient;
|
|
16
|
+
logger?: Logger;
|
|
17
|
+
enableMetrics?: boolean;
|
|
18
|
+
}) => {
|
|
19
|
+
service: LlmService;
|
|
20
|
+
metrics?: LlmMetricsCollector;
|
|
21
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
|
2
|
+
import { LlmService } from './service.js';
|
|
3
|
+
import { LlmMetricsCollector } from '../observability/metrics.js';
|
|
4
|
+
import { createToolsProvider } from '../mcp/tools-provider.js';
|
|
5
|
+
/**
|
|
6
|
+
* Create an OpenRouter model provider.
|
|
7
|
+
*/
|
|
8
|
+
export const createOpenRouterModelProvider = (apiKey) => {
|
|
9
|
+
const openrouter = createOpenRouter({ apiKey });
|
|
10
|
+
return (modelName) => openrouter(modelName);
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Create an LLM service with OpenRouter and optional MCP tools.
|
|
14
|
+
*/
|
|
15
|
+
export const createLlmService = (config) => {
|
|
16
|
+
const { openRouterApiKey, mcpClient, logger, enableMetrics = true } = config;
|
|
17
|
+
// Create model provider
|
|
18
|
+
const modelProvider = createOpenRouterModelProvider(openRouterApiKey);
|
|
19
|
+
// Create tools provider if MCP client is available
|
|
20
|
+
const toolsProvider = mcpClient ? createToolsProvider(mcpClient) : undefined;
|
|
21
|
+
// Create metrics collector if enabled
|
|
22
|
+
const metrics = enableMetrics ? new LlmMetricsCollector(logger) : undefined;
|
|
23
|
+
// Create observability hooks
|
|
24
|
+
const hooks = metrics
|
|
25
|
+
? {
|
|
26
|
+
onCallStart: (metadata) => {
|
|
27
|
+
logger?.debug(`[LLM] Starting: ${metadata.operation} (model: ${metadata.model})`);
|
|
28
|
+
},
|
|
29
|
+
onCallComplete: (metadata, usage) => {
|
|
30
|
+
metrics.recordCall(metadata, usage);
|
|
31
|
+
logger?.debug(`[LLM] Completed: ${metadata.operation} (tokens: ${usage?.totalTokens ?? 'unknown'})`);
|
|
32
|
+
},
|
|
33
|
+
onCallError: (metadata, error) => {
|
|
34
|
+
metrics.recordError(metadata, error);
|
|
35
|
+
logger?.error(`[LLM] Error: ${metadata.operation}`, error);
|
|
36
|
+
},
|
|
37
|
+
onToolCall: (metadata, toolName) => {
|
|
38
|
+
metrics.recordToolCall(metadata, toolName);
|
|
39
|
+
logger?.debug(`[LLM] Tool call: ${toolName} (operation: ${metadata.operation})`);
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
: undefined;
|
|
43
|
+
const service = new LlmService({
|
|
44
|
+
modelProvider,
|
|
45
|
+
toolsProvider,
|
|
46
|
+
hooks,
|
|
47
|
+
logger,
|
|
48
|
+
});
|
|
49
|
+
return { service, metrics };
|
|
50
|
+
};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { LlmService } from './service.js';
|
|
2
|
+
export { createLlmService, createOpenRouterModelProvider } from './factory.js';
|
|
3
|
+
export type { LlmCallResult, LlmCallConfig, LlmCallMetadata, TokenUsage, ModelProvider, ToolsProvider, LlmObservabilityHooks, ToolChoice, } from './types.js';
|