@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
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Individual finding schema.
|
|
4
|
+
* Matches the finding structure from reviewerFindingsSchema.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Azure JSON Schema validator workaround:
|
|
8
|
+
* Azure incorrectly requires ALL properties to be in the 'required' array, even optional ones.
|
|
9
|
+
* To work around this, we make optional fields nullable and required, then filter nulls in post-processing.
|
|
10
|
+
* This satisfies Azure's broken validator while maintaining optional semantics.
|
|
11
|
+
*/
|
|
12
|
+
export const findingSchema = z.object({
|
|
13
|
+
severity: z.enum(['critical', 'major', 'minor', 'info', 'pass']).describe('Severity level - REQUIRED'),
|
|
14
|
+
title: z.string().describe('Finding title - REQUIRED'),
|
|
15
|
+
description: z.string().describe('Detailed description - REQUIRED'),
|
|
16
|
+
rationale: z.string().min(1).describe('Required explanation of why this finding was identified - REQUIRED'),
|
|
17
|
+
// Azure workaround: Make optional fields nullable and required, filter nulls later.
|
|
18
|
+
// IMPORTANT: Do NOT use .default(null) here. In AI SDK JSON Schema conversion,
|
|
19
|
+
// .default() keeps the field out of "required", which Azure rejects.
|
|
20
|
+
suggestedFix: z
|
|
21
|
+
.string()
|
|
22
|
+
.nullable()
|
|
23
|
+
.describe('Optional suggested fix or remediation steps - Use null if not applicable (will be filtered out)'),
|
|
24
|
+
file: z
|
|
25
|
+
.string()
|
|
26
|
+
.nullable()
|
|
27
|
+
.describe('File path (relative to repo root) - Use null if finding is not file-specific (will be filtered out)'),
|
|
28
|
+
line: z
|
|
29
|
+
.number()
|
|
30
|
+
.int()
|
|
31
|
+
.positive()
|
|
32
|
+
.nullable()
|
|
33
|
+
.describe('Start line number - Use null if finding is not line-specific (will be filtered out)'),
|
|
34
|
+
endLine: z
|
|
35
|
+
.number()
|
|
36
|
+
.int()
|
|
37
|
+
.positive()
|
|
38
|
+
.nullable()
|
|
39
|
+
.describe('End line number - Use null if finding does not span multiple lines (will be filtered out)'),
|
|
40
|
+
contextIdsUsed: z
|
|
41
|
+
.array(z.string())
|
|
42
|
+
.nullable()
|
|
43
|
+
.describe('ContextRail IDs used in the review - Use null if no ContextRail standards were referenced (will be filtered out)'),
|
|
44
|
+
contextIdsViolated: z
|
|
45
|
+
.array(z.string())
|
|
46
|
+
.nullable()
|
|
47
|
+
.describe('ContextRail IDs violated in the review - Use null if no ContextRail standards were violated (will be filtered out)'),
|
|
48
|
+
contextTitles: z
|
|
49
|
+
.array(z.string())
|
|
50
|
+
.nullable()
|
|
51
|
+
.describe('ContextRail standard names violated in the review - Use null if no ContextRail standards were referenced/violated (will be filtered out)'),
|
|
52
|
+
});
|
|
53
|
+
/**
|
|
54
|
+
* Normalize LLM finding shape for provider/schema compatibility.
|
|
55
|
+
* Azure requires all properties to be present in JSON schema outputs, so
|
|
56
|
+
* nullable fields must remain present (as null) rather than being removed.
|
|
57
|
+
* Empty attribution arrays are normalized to null for consistency.
|
|
58
|
+
*/
|
|
59
|
+
export const cleanFinding = (finding) => {
|
|
60
|
+
const toNullable = (value) => value ?? null;
|
|
61
|
+
return {
|
|
62
|
+
...finding,
|
|
63
|
+
// Keep runtime payloads resilient if upstream providers emit undefined.
|
|
64
|
+
suggestedFix: toNullable(finding.suggestedFix),
|
|
65
|
+
file: toNullable(finding.file),
|
|
66
|
+
line: toNullable(finding.line),
|
|
67
|
+
endLine: toNullable(finding.endLine),
|
|
68
|
+
contextIdsUsed: finding.contextIdsUsed && finding.contextIdsUsed.length > 0 ? finding.contextIdsUsed : null,
|
|
69
|
+
contextIdsViolated: finding.contextIdsViolated && finding.contextIdsViolated.length > 0 ? finding.contextIdsViolated : null,
|
|
70
|
+
contextTitles: finding.contextTitles && finding.contextTitles.length > 0 ? finding.contextTitles : null,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Reviewer findings schema.
|
|
75
|
+
* Used by agentic executor output.
|
|
76
|
+
*/
|
|
77
|
+
export const reviewerFindingsSchema = z.object({
|
|
78
|
+
findings: z.array(findingSchema).describe('List of findings from the review'),
|
|
79
|
+
validated: z.boolean().describe('Whether findings have been validated and are ready'),
|
|
80
|
+
notes: z.string().nullable().describe('Additional notes about the review (null if none)'),
|
|
81
|
+
});
|
|
82
|
+
/**
|
|
83
|
+
* Per-reviewer result schema.
|
|
84
|
+
* Includes reviewer name and their findings.
|
|
85
|
+
*/
|
|
86
|
+
export const reviewerResultSchema = z.object({
|
|
87
|
+
reviewer: z.string().describe('Reviewer name (e.g., reviewer-security)'),
|
|
88
|
+
findings: z.array(findingSchema).describe('List of findings from this reviewer'),
|
|
89
|
+
validated: z.boolean().describe('Whether findings have been validated'),
|
|
90
|
+
notes: z.string().optional().describe('Additional notes about the review'),
|
|
91
|
+
});
|
|
92
|
+
/**
|
|
93
|
+
* Overall review decision schema.
|
|
94
|
+
*/
|
|
95
|
+
export const reviewDecisionSchema = z.object({
|
|
96
|
+
decision: z.enum(['approve', 'request-changes']).describe('Overall review decision'),
|
|
97
|
+
summary: z.string().describe('Concise overall review summary'),
|
|
98
|
+
rationale: z.string().describe('Reasoning for the decision'),
|
|
99
|
+
});
|
|
100
|
+
/**
|
|
101
|
+
* Summary statistics schema.
|
|
102
|
+
*/
|
|
103
|
+
export const summarySchema = z.object({
|
|
104
|
+
totalFindings: z.number().int().nonnegative().describe('Total number of non-pass findings across all reviewers'),
|
|
105
|
+
bySeverity: z
|
|
106
|
+
.object({
|
|
107
|
+
critical: z.number().int().nonnegative(),
|
|
108
|
+
major: z.number().int().nonnegative(),
|
|
109
|
+
minor: z.number().int().nonnegative(),
|
|
110
|
+
info: z.number().int().nonnegative(),
|
|
111
|
+
pass: z.number().int().nonnegative(),
|
|
112
|
+
})
|
|
113
|
+
.describe('Count of findings by severity level'),
|
|
114
|
+
byReviewer: z.record(z.string(), z.number().int().nonnegative()).describe('Count of findings by reviewer name'),
|
|
115
|
+
});
|
|
116
|
+
/**
|
|
117
|
+
* Review metadata schema.
|
|
118
|
+
*/
|
|
119
|
+
export const metadataSchema = z.object({
|
|
120
|
+
timestamp: z.string().describe('ISO 8601 timestamp of review completion'),
|
|
121
|
+
mode: z.enum(['diff', 'file-list']).describe('Review input mode'),
|
|
122
|
+
fileCount: z.number().int().positive().describe('Number of files reviewed'),
|
|
123
|
+
});
|
|
124
|
+
/**
|
|
125
|
+
* Complete aggregated review result schema.
|
|
126
|
+
*/
|
|
127
|
+
export const reviewResultSchema = z.object({
|
|
128
|
+
metadata: metadataSchema,
|
|
129
|
+
understanding: z.string().describe('Orchestrator understanding of the changes'),
|
|
130
|
+
decision: reviewDecisionSchema,
|
|
131
|
+
failures: z
|
|
132
|
+
.array(z.object({
|
|
133
|
+
reviewer: z.string().describe('Reviewer name that failed'),
|
|
134
|
+
message: z.string().describe('Failure message'),
|
|
135
|
+
}))
|
|
136
|
+
.optional()
|
|
137
|
+
.describe('Reviewer failures encountered during execution'),
|
|
138
|
+
synthesis: z
|
|
139
|
+
.lazy(() => synthesisResultSchema)
|
|
140
|
+
.optional()
|
|
141
|
+
.describe('Optional synthesized/deduplicated findings and contradictions'),
|
|
142
|
+
reviewers: z.array(reviewerResultSchema).min(1).describe('Review results from each reviewer'),
|
|
143
|
+
summary: summarySchema,
|
|
144
|
+
});
|
|
145
|
+
/**
|
|
146
|
+
* Token usage schema for a single operation (iteration, chunk, etc.)
|
|
147
|
+
*/
|
|
148
|
+
export const tokenUsageSchema = z.object({
|
|
149
|
+
promptTokens: z.number().int().nonnegative().describe('Number of prompt tokens used'),
|
|
150
|
+
completionTokens: z.number().int().nonnegative().describe('Number of completion tokens used'),
|
|
151
|
+
totalTokens: z.number().int().nonnegative().describe('Total tokens used'),
|
|
152
|
+
});
|
|
153
|
+
/**
|
|
154
|
+
* Per-chunk token usage schema.
|
|
155
|
+
*/
|
|
156
|
+
export const chunkTokenUsageSchema = z.object({
|
|
157
|
+
chunkIndex: z.number().int().nonnegative().describe('Chunk index (0-based)'),
|
|
158
|
+
usage: tokenUsageSchema,
|
|
159
|
+
});
|
|
160
|
+
/**
|
|
161
|
+
* Per-iteration token usage schema.
|
|
162
|
+
*/
|
|
163
|
+
export const iterationTokenUsageSchema = z.object({
|
|
164
|
+
iteration: z.number().int().positive().describe('Iteration number (1-based)'),
|
|
165
|
+
usage: tokenUsageSchema,
|
|
166
|
+
chunks: z.array(chunkTokenUsageSchema).optional().describe('Per-chunk usage if chunked'),
|
|
167
|
+
});
|
|
168
|
+
/**
|
|
169
|
+
* Per-reviewer token budget metrics schema.
|
|
170
|
+
*/
|
|
171
|
+
export const reviewerTokenMetricsSchema = z.object({
|
|
172
|
+
reviewer: z.string().describe('Reviewer name'),
|
|
173
|
+
totalUsage: tokenUsageSchema.describe('Total token usage across all iterations'),
|
|
174
|
+
iterations: z.array(iterationTokenUsageSchema).describe('Token usage per iteration'),
|
|
175
|
+
});
|
|
176
|
+
/**
|
|
177
|
+
* Aggregation token usage schema.
|
|
178
|
+
*/
|
|
179
|
+
export const aggregationTokenUsageSchema = z.object({
|
|
180
|
+
operation: z.string().describe('Operation name (e.g., "review-decision")'),
|
|
181
|
+
usage: tokenUsageSchema,
|
|
182
|
+
});
|
|
183
|
+
/**
|
|
184
|
+
* Complete token budget metrics schema.
|
|
185
|
+
*/
|
|
186
|
+
export const tokenBudgetMetricsSchema = z.object({
|
|
187
|
+
reviewers: z.array(reviewerTokenMetricsSchema).describe('Token usage per reviewer'),
|
|
188
|
+
aggregation: z.array(aggregationTokenUsageSchema).optional().describe('Token usage for aggregation operations'),
|
|
189
|
+
totalUsage: tokenUsageSchema.describe('Total token usage across all reviewers and operations'),
|
|
190
|
+
});
|
|
191
|
+
/**
|
|
192
|
+
* Synthesized finding schema.
|
|
193
|
+
* Includes source reviewer attribution for deduplicated findings.
|
|
194
|
+
*/
|
|
195
|
+
export const synthesizedFindingSchema = findingSchema.extend({
|
|
196
|
+
sourceReviewers: z.array(z.string()).min(1).describe('Reviewer names that identified this finding'),
|
|
197
|
+
});
|
|
198
|
+
/**
|
|
199
|
+
* Contradiction schema.
|
|
200
|
+
* Flags when reviewers disagree about the same issue.
|
|
201
|
+
*/
|
|
202
|
+
export const contradictionSchema = z.object({
|
|
203
|
+
findingA: z.string().describe('Description of first finding/claim'),
|
|
204
|
+
findingB: z.string().describe('Description of contradictory finding/claim'),
|
|
205
|
+
reviewerA: z.string().describe('Reviewer name for finding A'),
|
|
206
|
+
reviewerB: z.string().describe('Reviewer name for finding B'),
|
|
207
|
+
context: z.string().describe('Context explaining the contradiction'),
|
|
208
|
+
});
|
|
209
|
+
/**
|
|
210
|
+
* Synthesis result schema.
|
|
211
|
+
* Output from cross-reviewer synthesis step.
|
|
212
|
+
*/
|
|
213
|
+
export const synthesisResultSchema = z.object({
|
|
214
|
+
findings: z.array(synthesizedFindingSchema).describe('Deduplicated findings with source attribution'),
|
|
215
|
+
contradictions: z.array(contradictionSchema).describe('List of contradictions between reviewers'),
|
|
216
|
+
compoundRisks: z
|
|
217
|
+
.array(z.object({
|
|
218
|
+
description: z.string().describe('Description of the compound risk'),
|
|
219
|
+
affectedFindings: z.array(z.string()).describe('Titles of findings that contribute to this risk'),
|
|
220
|
+
severity: z.enum(['critical', 'major', 'minor', 'info']).describe('Overall severity of compound risk'),
|
|
221
|
+
}))
|
|
222
|
+
.describe('Compound risks identified from multiple findings'),
|
|
223
|
+
notes: z.string().nullable().describe('Additional synthesis notes (null if none)'),
|
|
224
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ReviewResult, type ReviewerResult, type Metadata, type ReviewDecision, type AggregationTokenUsage, type SynthesisResult } from './schema.js';
|
|
2
|
+
/**
|
|
3
|
+
* Aggregate orchestrator and reviewer outputs into final result structure.
|
|
4
|
+
*
|
|
5
|
+
* @param metadata - Review metadata (timestamp, mode, fileCount)
|
|
6
|
+
* @param understanding - Orchestrator's understanding of changes
|
|
7
|
+
* @param reviewerResults - Array of reviewer results (name + findings)
|
|
8
|
+
* @returns Aggregated review result with computed summary statistics
|
|
9
|
+
*/
|
|
10
|
+
export declare const aggregateResults: (metadata: Metadata, understanding: string, decision: ReviewDecision, reviewerResults: ReviewerResult[], failures?: Array<{
|
|
11
|
+
reviewer: string;
|
|
12
|
+
message: string;
|
|
13
|
+
}>, synthesis?: SynthesisResult) => ReviewResult;
|
|
14
|
+
/**
|
|
15
|
+
* Validate result against schema and write to result.json.
|
|
16
|
+
* Throws error if validation fails.
|
|
17
|
+
*
|
|
18
|
+
* @param outputDir - Output directory path
|
|
19
|
+
* @param result - Review result to validate and write
|
|
20
|
+
* @throws Error if validation fails or file write fails
|
|
21
|
+
*/
|
|
22
|
+
export declare const writeResult: (outputDir: string, result: ReviewResult) => Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Aggregate token budget metrics from all reviewers and write to token-budget.json.
|
|
25
|
+
*
|
|
26
|
+
* @param outputDir - Output directory path
|
|
27
|
+
* @param reviewerNames - List of reviewer names that were executed
|
|
28
|
+
* @param aggregationUsage - Optional aggregation token usage
|
|
29
|
+
* @throws Error if file read/write fails
|
|
30
|
+
*/
|
|
31
|
+
export declare const writeTokenBudgetMetrics: (outputDir: string, reviewerNames: string[], aggregationUsage?: AggregationTokenUsage[]) => Promise<void>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { reviewResultSchema, tokenBudgetMetricsSchema, } from './schema.js';
|
|
4
|
+
/**
|
|
5
|
+
* Aggregate orchestrator and reviewer outputs into final result structure.
|
|
6
|
+
*
|
|
7
|
+
* @param metadata - Review metadata (timestamp, mode, fileCount)
|
|
8
|
+
* @param understanding - Orchestrator's understanding of changes
|
|
9
|
+
* @param reviewerResults - Array of reviewer results (name + findings)
|
|
10
|
+
* @returns Aggregated review result with computed summary statistics
|
|
11
|
+
*/
|
|
12
|
+
export const aggregateResults = (metadata, understanding, decision, reviewerResults, failures, synthesis) => {
|
|
13
|
+
// Collect all findings across reviewers
|
|
14
|
+
const allFindings = reviewerResults.flatMap((rr) => rr.findings);
|
|
15
|
+
const summaryFindings = synthesis?.findings ?? allFindings;
|
|
16
|
+
const summaryNonPassFindings = summaryFindings.filter((f) => f.severity !== 'pass');
|
|
17
|
+
// Compute summary statistics
|
|
18
|
+
const bySeverity = {
|
|
19
|
+
critical: summaryFindings.filter((f) => f.severity === 'critical').length,
|
|
20
|
+
major: summaryFindings.filter((f) => f.severity === 'major').length,
|
|
21
|
+
minor: summaryFindings.filter((f) => f.severity === 'minor').length,
|
|
22
|
+
info: summaryFindings.filter((f) => f.severity === 'info').length,
|
|
23
|
+
pass: summaryFindings.filter((f) => f.severity === 'pass').length,
|
|
24
|
+
};
|
|
25
|
+
const byReviewer = {};
|
|
26
|
+
for (const rr of reviewerResults) {
|
|
27
|
+
byReviewer[rr.reviewer] = rr.findings.filter((f) => f.severity !== 'pass').length;
|
|
28
|
+
}
|
|
29
|
+
const summary = {
|
|
30
|
+
totalFindings: summaryNonPassFindings.length,
|
|
31
|
+
bySeverity,
|
|
32
|
+
byReviewer,
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
metadata,
|
|
36
|
+
understanding,
|
|
37
|
+
decision,
|
|
38
|
+
failures: failures && failures.length > 0 ? failures : undefined,
|
|
39
|
+
synthesis,
|
|
40
|
+
reviewers: reviewerResults,
|
|
41
|
+
summary,
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Validate result against schema and write to result.json.
|
|
46
|
+
* Throws error if validation fails.
|
|
47
|
+
*
|
|
48
|
+
* @param outputDir - Output directory path
|
|
49
|
+
* @param result - Review result to validate and write
|
|
50
|
+
* @throws Error if validation fails or file write fails
|
|
51
|
+
*/
|
|
52
|
+
export const writeResult = async (outputDir, result) => {
|
|
53
|
+
// Validate against schema
|
|
54
|
+
const validatedResult = reviewResultSchema.parse(result);
|
|
55
|
+
// Write result.json
|
|
56
|
+
await mkdir(outputDir, { recursive: true });
|
|
57
|
+
const resultPath = path.join(outputDir, 'result.json');
|
|
58
|
+
await writeFile(resultPath, JSON.stringify(validatedResult, null, 2), 'utf-8');
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Aggregate token budget metrics from all reviewers and write to token-budget.json.
|
|
62
|
+
*
|
|
63
|
+
* @param outputDir - Output directory path
|
|
64
|
+
* @param reviewerNames - List of reviewer names that were executed
|
|
65
|
+
* @param aggregationUsage - Optional aggregation token usage
|
|
66
|
+
* @throws Error if file read/write fails
|
|
67
|
+
*/
|
|
68
|
+
export const writeTokenBudgetMetrics = async (outputDir, reviewerNames, aggregationUsage) => {
|
|
69
|
+
const reviewersDir = path.join(outputDir, 'reviewers');
|
|
70
|
+
const reviewerMetrics = [];
|
|
71
|
+
// Read token metrics from each reviewer directory
|
|
72
|
+
for (const reviewer of reviewerNames) {
|
|
73
|
+
const reviewerDir = path.join(reviewersDir, reviewer);
|
|
74
|
+
const tokenBudgetPath = path.join(reviewerDir, 'token-budget.json');
|
|
75
|
+
try {
|
|
76
|
+
const content = await readFile(tokenBudgetPath, 'utf-8');
|
|
77
|
+
const metrics = JSON.parse(content);
|
|
78
|
+
reviewerMetrics.push(metrics);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
// If token-budget.json doesn't exist, skip this reviewer.
|
|
82
|
+
// This can happen when a reviewer fails before completing an iteration.
|
|
83
|
+
if (error.code !== 'ENOENT') {
|
|
84
|
+
console.warn(`Error reading token budget metrics for reviewer ${reviewer}:`, error);
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Calculate total usage across all reviewers and aggregation
|
|
90
|
+
let totalPromptTokens = 0;
|
|
91
|
+
let totalCompletionTokens = 0;
|
|
92
|
+
let totalTokens = 0;
|
|
93
|
+
for (const rm of reviewerMetrics) {
|
|
94
|
+
totalPromptTokens += rm.totalUsage.promptTokens;
|
|
95
|
+
totalCompletionTokens += rm.totalUsage.completionTokens;
|
|
96
|
+
totalTokens += rm.totalUsage.totalTokens;
|
|
97
|
+
}
|
|
98
|
+
if (aggregationUsage) {
|
|
99
|
+
for (const agg of aggregationUsage) {
|
|
100
|
+
totalPromptTokens += agg.usage.promptTokens;
|
|
101
|
+
totalCompletionTokens += agg.usage.completionTokens;
|
|
102
|
+
totalTokens += agg.usage.totalTokens;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const metrics = {
|
|
106
|
+
reviewers: reviewerMetrics,
|
|
107
|
+
aggregation: aggregationUsage && aggregationUsage.length > 0 ? aggregationUsage : undefined,
|
|
108
|
+
totalUsage: {
|
|
109
|
+
promptTokens: totalPromptTokens,
|
|
110
|
+
completionTokens: totalCompletionTokens,
|
|
111
|
+
totalTokens,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
// Validate against schema
|
|
115
|
+
const validatedMetrics = tokenBudgetMetricsSchema.parse(metrics);
|
|
116
|
+
// Write token-budget.json
|
|
117
|
+
await mkdir(outputDir, { recursive: true });
|
|
118
|
+
const metricsPath = path.join(outputDir, 'token-budget.json');
|
|
119
|
+
await writeFile(metricsPath, JSON.stringify(validatedMetrics, null, 2), 'utf-8');
|
|
120
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ReviewInputs } from './index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Configuration for chunking review inputs.
|
|
4
|
+
*/
|
|
5
|
+
export type ChunkingConfig = {
|
|
6
|
+
/**
|
|
7
|
+
* Maximum tokens per chunk (approximate).
|
|
8
|
+
* Default: 100000 (roughly 400KB of text)
|
|
9
|
+
*/
|
|
10
|
+
maxTokensPerChunk?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Maximum files per chunk.
|
|
13
|
+
* Default: 50
|
|
14
|
+
*/
|
|
15
|
+
maxFilesPerChunk?: number;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Chunk of review inputs for processing.
|
|
19
|
+
*/
|
|
20
|
+
export type ReviewInputChunk = ReviewInputs;
|
|
21
|
+
/**
|
|
22
|
+
* Chunk review inputs into smaller batches for processing.
|
|
23
|
+
* Chunks are created based on token budget and file count limits.
|
|
24
|
+
*
|
|
25
|
+
* @param inputs - Review inputs to chunk
|
|
26
|
+
* @param config - Chunking configuration
|
|
27
|
+
* @returns Array of input chunks (may be single chunk if inputs are small)
|
|
28
|
+
*/
|
|
29
|
+
export declare const chunkReviewInputs: (inputs: ReviewInputs, config?: ChunkingConfig) => ReviewInputChunk[];
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { isDiffInputs, isFileListInputs } from './index.js';
|
|
2
|
+
const DEFAULT_MAX_TOKENS_PER_CHUNK = 100_000;
|
|
3
|
+
const DEFAULT_MAX_FILES_PER_CHUNK = 50;
|
|
4
|
+
/**
|
|
5
|
+
* Rough token estimation: ~4 characters per token.
|
|
6
|
+
* This is a conservative estimate for code/diff content.
|
|
7
|
+
*/
|
|
8
|
+
const estimateTokens = (text) => {
|
|
9
|
+
return Math.ceil(text.length / 4);
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Chunk diff inputs based on token budget and file count limits.
|
|
13
|
+
*
|
|
14
|
+
* @param inputs - Diff review inputs to chunk
|
|
15
|
+
* @param config - Chunking configuration
|
|
16
|
+
* @returns Array of input chunks
|
|
17
|
+
*/
|
|
18
|
+
const chunkDiffInputs = (inputs, config) => {
|
|
19
|
+
const maxTokens = config.maxTokensPerChunk ?? DEFAULT_MAX_TOKENS_PER_CHUNK;
|
|
20
|
+
const maxFiles = config.maxFilesPerChunk ?? DEFAULT_MAX_FILES_PER_CHUNK;
|
|
21
|
+
// If inputs fit within limits, return as single chunk
|
|
22
|
+
const totalTokens = Object.values(inputs.diffs).reduce((sum, diff) => sum + estimateTokens(diff), 0);
|
|
23
|
+
const totalFiles = inputs.files.length;
|
|
24
|
+
if (totalTokens <= maxTokens && totalFiles <= maxFiles) {
|
|
25
|
+
return [inputs];
|
|
26
|
+
}
|
|
27
|
+
// Need to chunk: group files into chunks
|
|
28
|
+
const chunks = [];
|
|
29
|
+
let currentChunkFiles = [];
|
|
30
|
+
let currentChunkDiffs = {};
|
|
31
|
+
let currentChunkTokens = 0;
|
|
32
|
+
let currentChunkFileCount = 0;
|
|
33
|
+
for (const file of inputs.files) {
|
|
34
|
+
const diff = inputs.diffs[file];
|
|
35
|
+
if (!diff) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const fileTokens = estimateTokens(diff);
|
|
39
|
+
// Check if adding this file would exceed limits
|
|
40
|
+
const wouldExceedTokens = currentChunkTokens + fileTokens > maxTokens;
|
|
41
|
+
const wouldExceedFiles = currentChunkFileCount >= maxFiles;
|
|
42
|
+
if ((wouldExceedTokens || wouldExceedFiles) && currentChunkFiles.length > 0) {
|
|
43
|
+
// Finalize current chunk
|
|
44
|
+
chunks.push({
|
|
45
|
+
mode: 'diff',
|
|
46
|
+
files: currentChunkFiles,
|
|
47
|
+
diffs: currentChunkDiffs,
|
|
48
|
+
});
|
|
49
|
+
// Start new chunk
|
|
50
|
+
currentChunkFiles = [];
|
|
51
|
+
currentChunkDiffs = {};
|
|
52
|
+
currentChunkTokens = 0;
|
|
53
|
+
currentChunkFileCount = 0;
|
|
54
|
+
}
|
|
55
|
+
// Add file to current chunk
|
|
56
|
+
currentChunkFiles.push(file);
|
|
57
|
+
currentChunkDiffs[file] = diff;
|
|
58
|
+
currentChunkTokens += fileTokens;
|
|
59
|
+
currentChunkFileCount += 1;
|
|
60
|
+
}
|
|
61
|
+
// Add final chunk if it has files
|
|
62
|
+
if (currentChunkFiles.length > 0) {
|
|
63
|
+
chunks.push({
|
|
64
|
+
mode: 'diff',
|
|
65
|
+
files: currentChunkFiles,
|
|
66
|
+
diffs: currentChunkDiffs,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return chunks;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Chunk file-list inputs based on file count limits.
|
|
73
|
+
*
|
|
74
|
+
* @param inputs - File-list review inputs to chunk
|
|
75
|
+
* @param config - Chunking configuration
|
|
76
|
+
* @returns Array of input chunks
|
|
77
|
+
*/
|
|
78
|
+
const chunkFileListInputs = (inputs, config) => {
|
|
79
|
+
const maxFiles = config.maxFilesPerChunk ?? DEFAULT_MAX_FILES_PER_CHUNK;
|
|
80
|
+
// If inputs fit within limits, return as single chunk
|
|
81
|
+
if (inputs.files.length <= maxFiles) {
|
|
82
|
+
return [inputs];
|
|
83
|
+
}
|
|
84
|
+
// Need to chunk: split files into groups
|
|
85
|
+
const chunks = [];
|
|
86
|
+
for (let i = 0; i < inputs.files.length; i += maxFiles) {
|
|
87
|
+
const chunkFiles = inputs.files.slice(i, i + maxFiles);
|
|
88
|
+
chunks.push({
|
|
89
|
+
mode: 'file-list',
|
|
90
|
+
files: chunkFiles,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return chunks;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Chunk review inputs into smaller batches for processing.
|
|
97
|
+
* Chunks are created based on token budget and file count limits.
|
|
98
|
+
*
|
|
99
|
+
* @param inputs - Review inputs to chunk
|
|
100
|
+
* @param config - Chunking configuration
|
|
101
|
+
* @returns Array of input chunks (may be single chunk if inputs are small)
|
|
102
|
+
*/
|
|
103
|
+
export const chunkReviewInputs = (inputs, config = {}) => {
|
|
104
|
+
if (isDiffInputs(inputs)) {
|
|
105
|
+
return chunkDiffInputs(inputs, config);
|
|
106
|
+
}
|
|
107
|
+
if (isFileListInputs(inputs)) {
|
|
108
|
+
return chunkFileListInputs(inputs, config);
|
|
109
|
+
}
|
|
110
|
+
// TypeScript exhaustiveness check
|
|
111
|
+
const _exhaustive = inputs;
|
|
112
|
+
return _exhaustive;
|
|
113
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ReviewInputs } from './index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Statistics for a single file diff.
|
|
4
|
+
*/
|
|
5
|
+
export type FileDiffStats = {
|
|
6
|
+
/**
|
|
7
|
+
* File path (relative to repo root).
|
|
8
|
+
*/
|
|
9
|
+
file: string;
|
|
10
|
+
/**
|
|
11
|
+
* File extension (e.g., '.ts', '.js', '.md').
|
|
12
|
+
* Empty string if no extension.
|
|
13
|
+
*/
|
|
14
|
+
fileType: string;
|
|
15
|
+
/**
|
|
16
|
+
* Number of lines added.
|
|
17
|
+
*/
|
|
18
|
+
added: number;
|
|
19
|
+
/**
|
|
20
|
+
* Number of lines removed.
|
|
21
|
+
*/
|
|
22
|
+
removed: number;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Summary of diff statistics across all files.
|
|
26
|
+
*/
|
|
27
|
+
export type DiffSummary = {
|
|
28
|
+
/**
|
|
29
|
+
* Per-file diff statistics.
|
|
30
|
+
*/
|
|
31
|
+
files: FileDiffStats[];
|
|
32
|
+
/**
|
|
33
|
+
* Total files changed.
|
|
34
|
+
*/
|
|
35
|
+
totalFiles: number;
|
|
36
|
+
/**
|
|
37
|
+
* Total lines added across all files.
|
|
38
|
+
*/
|
|
39
|
+
totalAdded: number;
|
|
40
|
+
/**
|
|
41
|
+
* Total lines removed across all files.
|
|
42
|
+
*/
|
|
43
|
+
totalRemoved: number;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Generate diff summary statistics from review inputs.
|
|
47
|
+
* Only processes diff inputs; file-list inputs return empty summary.
|
|
48
|
+
*
|
|
49
|
+
* @param inputs - Review inputs (diff or file-list)
|
|
50
|
+
* @returns Diff summary with per-file stats and totals
|
|
51
|
+
*/
|
|
52
|
+
export declare const generateDiffSummary: (inputs: ReviewInputs) => DiffSummary;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { isDiffInputs } from './index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Parse a git diff to count added and removed lines.
|
|
5
|
+
* Handles unified diff format (default git diff output).
|
|
6
|
+
*
|
|
7
|
+
* @param diff - Git diff content
|
|
8
|
+
* @returns Object with added and removed line counts
|
|
9
|
+
*/
|
|
10
|
+
const parseDiffStats = (diff) => {
|
|
11
|
+
let added = 0;
|
|
12
|
+
let removed = 0;
|
|
13
|
+
// Git unified diff format:
|
|
14
|
+
// Lines starting with '+' are additions (except '+++')
|
|
15
|
+
// Lines starting with '-' are removals (except '---')
|
|
16
|
+
// Lines starting with '@@' are hunk headers
|
|
17
|
+
const lines = diff.split('\n');
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
// Skip hunk headers and file headers
|
|
20
|
+
if (line.startsWith('@@') || line.startsWith('+++') || line.startsWith('---')) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
// Count additions (lines starting with '+')
|
|
24
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
25
|
+
added++;
|
|
26
|
+
}
|
|
27
|
+
// Count removals (lines starting with '-')
|
|
28
|
+
if (line.startsWith('-') && !line.startsWith('---')) {
|
|
29
|
+
removed++;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { added, removed };
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Extract file extension from a file path.
|
|
36
|
+
*
|
|
37
|
+
* @param filePath - File path
|
|
38
|
+
* @returns File extension (e.g., '.ts', '.js', '.md') or empty string
|
|
39
|
+
*/
|
|
40
|
+
const getFileType = (filePath) => {
|
|
41
|
+
const ext = path.extname(filePath);
|
|
42
|
+
return ext;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Generate diff summary statistics from review inputs.
|
|
46
|
+
* Only processes diff inputs; file-list inputs return empty summary.
|
|
47
|
+
*
|
|
48
|
+
* @param inputs - Review inputs (diff or file-list)
|
|
49
|
+
* @returns Diff summary with per-file stats and totals
|
|
50
|
+
*/
|
|
51
|
+
export const generateDiffSummary = (inputs) => {
|
|
52
|
+
if (!isDiffInputs(inputs)) {
|
|
53
|
+
// File-list mode: return empty summary
|
|
54
|
+
return {
|
|
55
|
+
files: [],
|
|
56
|
+
totalFiles: inputs.files.length,
|
|
57
|
+
totalAdded: 0,
|
|
58
|
+
totalRemoved: 0,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const fileStats = [];
|
|
62
|
+
let totalAdded = 0;
|
|
63
|
+
let totalRemoved = 0;
|
|
64
|
+
for (const file of inputs.files) {
|
|
65
|
+
const diff = inputs.diffs[file] || '';
|
|
66
|
+
const { added, removed } = parseDiffStats(diff);
|
|
67
|
+
const fileType = getFileType(file);
|
|
68
|
+
fileStats.push({
|
|
69
|
+
file,
|
|
70
|
+
fileType,
|
|
71
|
+
added,
|
|
72
|
+
removed,
|
|
73
|
+
});
|
|
74
|
+
totalAdded += added;
|
|
75
|
+
totalRemoved += removed;
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
files: fileStats,
|
|
79
|
+
totalFiles: fileStats.length,
|
|
80
|
+
totalAdded,
|
|
81
|
+
totalRemoved,
|
|
82
|
+
};
|
|
83
|
+
};
|