specvector 0.3.1 → 0.6.1
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/package.json +1 -1
- package/src/config/index.ts +5 -5
- package/src/index.ts +2 -2
- package/src/mcp/mcp-client.ts +3 -2
- package/src/pipeline/batcher.ts +543 -0
- package/src/pipeline/classifier.ts +361 -0
- package/src/pipeline/index.ts +34 -0
- package/src/pipeline/merger.ts +329 -0
- package/src/review/engine.ts +31 -8
- package/src/review/json-parser.ts +283 -0
- package/src/utils/redact.ts +125 -0
package/package.json
CHANGED
package/src/config/index.ts
CHANGED
|
@@ -280,19 +280,19 @@ export function getStrictnessModifier(strictness: SpecVectorConfig["strictness"]
|
|
|
280
280
|
switch (strictness) {
|
|
281
281
|
case "strict":
|
|
282
282
|
return `Be VERY strict. Flag any potential issue, no matter how minor.
|
|
283
|
-
Focus on: security vulnerabilities,
|
|
283
|
+
Focus on: security vulnerabilities, logic errors, boundary conditions, data flow issues, performance problems, error handling, edge cases.
|
|
284
284
|
Do not approve code that could be improved.`;
|
|
285
285
|
|
|
286
286
|
case "lenient":
|
|
287
287
|
return `Be lenient and focus only on critical issues.
|
|
288
|
-
Only flag: bugs that would cause runtime errors, security vulnerabilities, obvious mistakes.
|
|
289
|
-
Skip: style issues, minor improvements, suggestions.
|
|
288
|
+
Only flag: bugs that would cause runtime errors, logic errors causing crashes or data corruption, security vulnerabilities, obvious mistakes.
|
|
289
|
+
Skip: style issues, minor improvements, suggestions, theoretical problems.
|
|
290
290
|
Approve unless there are blocking issues.`;
|
|
291
291
|
|
|
292
292
|
case "normal":
|
|
293
293
|
default:
|
|
294
294
|
return `Use balanced judgement. Flag important issues but don't be nitpicky.
|
|
295
|
-
Focus on: bugs, security, performance, maintainability.
|
|
296
|
-
Skip: purely stylistic preferences.`;
|
|
295
|
+
Focus on: logic errors that would cause real bugs in production, security, performance, maintainability.
|
|
296
|
+
Skip: purely stylistic preferences, theoretical issues you haven't verified.`;
|
|
297
297
|
}
|
|
298
298
|
}
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { postPRComment } from "./github/comment";
|
|
|
9
9
|
import { parseDiff, getDiffSummary } from "./review/diff-parser";
|
|
10
10
|
import { formatReviewComment, formatReviewSummary } from "./review/formatter";
|
|
11
11
|
import { runReview } from "./review/engine";
|
|
12
|
+
import { redactError } from "./utils/redact";
|
|
12
13
|
|
|
13
14
|
const VERSION = "0.1.0";
|
|
14
15
|
|
|
@@ -132,7 +133,6 @@ async function handleReview(args: string[]): Promise<void> {
|
|
|
132
133
|
displayAndPostReview(mockReview, prNumber, dryRun);
|
|
133
134
|
} else {
|
|
134
135
|
console.log("🤖 Running AI review...");
|
|
135
|
-
console.log(" (this may take 15-30 seconds)");
|
|
136
136
|
console.log("");
|
|
137
137
|
|
|
138
138
|
// Get branch name for Linear context
|
|
@@ -242,6 +242,6 @@ function generateMockReview(filesReviewed: number): import("./types/review").Rev
|
|
|
242
242
|
|
|
243
243
|
// Run the CLI
|
|
244
244
|
main().catch((error) => {
|
|
245
|
-
console.error("Fatal error:", error);
|
|
245
|
+
console.error("Fatal error:", redactError(error));
|
|
246
246
|
process.exit(1);
|
|
247
247
|
});
|
package/src/mcp/mcp-client.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
MCPToolsListResult,
|
|
18
18
|
MCPToolCallResult,
|
|
19
19
|
} from "./types";
|
|
20
|
+
import { redactSecrets, buildSafeEnv } from "../utils/redact";
|
|
20
21
|
|
|
21
22
|
// ============================================================================
|
|
22
23
|
// Constants
|
|
@@ -73,7 +74,7 @@ export async function createMCPClient(
|
|
|
73
74
|
let proc: Subprocess;
|
|
74
75
|
try {
|
|
75
76
|
proc = spawn([config.command, ...config.args], {
|
|
76
|
-
env:
|
|
77
|
+
env: buildSafeEnv(config.env as Record<string, string> | undefined),
|
|
77
78
|
stdin: "pipe",
|
|
78
79
|
stdout: "pipe",
|
|
79
80
|
stderr: "pipe",
|
|
@@ -351,7 +352,7 @@ function startReadingStderr(state: MCPClientState): void {
|
|
|
351
352
|
const text = decoder.decode(value, { stream: true });
|
|
352
353
|
// Log stderr for debugging (could be made configurable)
|
|
353
354
|
if (text.trim()) {
|
|
354
|
-
console.error(`[MCP:${state.config.name}] ${text.trim()}`);
|
|
355
|
+
console.error(`[MCP:${state.config.name}] ${redactSecrets(text.trim())}`);
|
|
355
356
|
}
|
|
356
357
|
}
|
|
357
358
|
} catch {
|
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batched Parallel Review Dispatcher for the Scalable Review Pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Dispatches classified files to the appropriate review path:
|
|
5
|
+
* - FAST_PASS: batched (max 10), single LLM call per batch, no tools
|
|
6
|
+
* - DEEP_DIVE: individual, full agent loop with codebase exploration tools
|
|
7
|
+
* - SKIP: excluded entirely
|
|
8
|
+
*
|
|
9
|
+
* All batches execute in parallel via Promise.allSettled.
|
|
10
|
+
* Individual failures do not block other batches.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ClassifiedFile, ClassificationResult } from "./classifier";
|
|
14
|
+
import type { ReviewFinding, ContextSource } from "../types/review";
|
|
15
|
+
import type { DiffFile } from "../types/diff";
|
|
16
|
+
import type { ReviewConfig } from "../review/engine";
|
|
17
|
+
import { runReview } from "../review/engine";
|
|
18
|
+
import { parseReviewResponseWithFallback, REVIEW_JSON_INSTRUCTION } from "../review/json-parser";
|
|
19
|
+
import { createProvider } from "../llm";
|
|
20
|
+
import type { LLMProvider } from "../llm/provider";
|
|
21
|
+
import { withRetry } from "../llm/provider";
|
|
22
|
+
import { loadConfig, getStrictnessModifier } from "../config";
|
|
23
|
+
import { getLinearContextForReview, getADRContextForReview } from "../context";
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Types
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** Configuration for batch sizes and concurrency. */
|
|
30
|
+
export interface BatchConfig {
|
|
31
|
+
/** Maximum files per FAST_PASS batch (default: 10) */
|
|
32
|
+
maxBatchSize: number;
|
|
33
|
+
/** Maximum concurrent DEEP_DIVE reviews (default: 5) */
|
|
34
|
+
maxConcurrentDeepDives: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Error from a single batch or file review. */
|
|
38
|
+
export interface BatchError {
|
|
39
|
+
/** Label identifying the batch (e.g., "fast-pass-1", "deep-dive:src/auth/login.ts") */
|
|
40
|
+
batch: string;
|
|
41
|
+
/** Error message */
|
|
42
|
+
message: string;
|
|
43
|
+
/** Files affected by this error */
|
|
44
|
+
filesAffected: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Result of the entire batched review pipeline stage. */
|
|
48
|
+
export interface BatchResult {
|
|
49
|
+
/** All findings aggregated from all batches */
|
|
50
|
+
findings: ReviewFinding[];
|
|
51
|
+
/** Errors from failed batches (other batches still completed) */
|
|
52
|
+
errors: BatchError[];
|
|
53
|
+
/** Timing breakdown in milliseconds */
|
|
54
|
+
timing: {
|
|
55
|
+
totalMs: number;
|
|
56
|
+
fastPassMs: number;
|
|
57
|
+
deepDiveMs: number;
|
|
58
|
+
};
|
|
59
|
+
/** Context sources used across all reviews */
|
|
60
|
+
contextSources: ContextSource[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Constants
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
const DEFAULT_BATCH_CONFIG: Required<BatchConfig> = {
|
|
68
|
+
maxBatchSize: 10,
|
|
69
|
+
maxConcurrentDeepDives: 5,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Maximum total characters for the multi-file prompt. */
|
|
73
|
+
const MAX_FAST_PASS_PROMPT_CHARS = 15_000;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* System prompt for FAST_PASS reviews.
|
|
77
|
+
* Same review quality expectations as the full prompt, but without tool-use
|
|
78
|
+
* instructions since FAST_PASS reviews have no codebase exploration tools.
|
|
79
|
+
*/
|
|
80
|
+
export const FAST_PASS_SYSTEM_PROMPT = `You are a pragmatic code reviewer. Your job is to catch REAL problems, not nitpick.
|
|
81
|
+
|
|
82
|
+
## What to Look For (in priority order)
|
|
83
|
+
1. **CRITICAL**: Security vulnerabilities, data loss, crashes
|
|
84
|
+
2. **HIGH**: Bugs that WILL break functionality in production
|
|
85
|
+
3. **MEDIUM**: Significant code quality issues (not style nits)
|
|
86
|
+
|
|
87
|
+
## Business Logic Patterns to Detect
|
|
88
|
+
Focus on real logic errors that cause incorrect behavior:
|
|
89
|
+
- **Off-by-one errors**: Wrong boundary conditions, < vs <=, array index issues
|
|
90
|
+
- **Null/undefined handling**: Missing null checks on values that can be null
|
|
91
|
+
- **Race conditions**: Shared state without synchronization, async ordering bugs
|
|
92
|
+
- **Incorrect boolean logic**: Inverted conditions, wrong operator (AND vs OR)
|
|
93
|
+
- **Missing error paths**: Happy-path-only code that ignores failure cases in data flows
|
|
94
|
+
- **Wrong operator**: Using = instead of ==, + instead of -, incorrect comparisons
|
|
95
|
+
- **State management bugs**: Mutating shared state, stale closures, incorrect resets
|
|
96
|
+
- **Type coercion issues**: Implicit conversions causing unexpected behavior
|
|
97
|
+
|
|
98
|
+
## What NOT to Flag
|
|
99
|
+
- Style preferences or "I would do it differently"
|
|
100
|
+
- Theoretical performance issues without evidence
|
|
101
|
+
- Missing edge case tests for working code
|
|
102
|
+
- "Could be refactored" suggestions
|
|
103
|
+
- Code that works but isn't perfect
|
|
104
|
+
- Naming convention preferences
|
|
105
|
+
- Comment formatting or missing comments
|
|
106
|
+
- Import ordering or grouping
|
|
107
|
+
|
|
108
|
+
## Key Principle
|
|
109
|
+
Most PRs should have 0-2 findings. If you're finding 5+ issues, you're being too picky.
|
|
110
|
+
Only flag issues you'd actually block a PR for in a real code review.
|
|
111
|
+
|
|
112
|
+
## Response Format
|
|
113
|
+
SUMMARY: [1-2 sentences - is this code ready to merge?]
|
|
114
|
+
|
|
115
|
+
FINDINGS:
|
|
116
|
+
- [CRITICAL|HIGH|MEDIUM] [Category]: [Title]
|
|
117
|
+
[Brief description + how to fix]
|
|
118
|
+
File: [filename]
|
|
119
|
+
|
|
120
|
+
If the code is ready to merge, respond with:
|
|
121
|
+
SUMMARY: [Positive assessment]
|
|
122
|
+
FINDINGS: None
|
|
123
|
+
|
|
124
|
+
Maximum 3 findings per file batch. Focus on what matters.`;
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Main Entry Point
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Run batched parallel reviews on classified files.
|
|
132
|
+
*
|
|
133
|
+
* FAST_PASS files → batched, single LLM call per batch, no tools.
|
|
134
|
+
* DEEP_DIVE files → individual, full agent loop with codebase tools.
|
|
135
|
+
* SKIP files → excluded.
|
|
136
|
+
* All batches execute in parallel; individual failures don't block others.
|
|
137
|
+
*/
|
|
138
|
+
export async function runBatchedReviews(
|
|
139
|
+
classification: ClassificationResult,
|
|
140
|
+
config: ReviewConfig,
|
|
141
|
+
batchConfig?: Partial<BatchConfig>,
|
|
142
|
+
): Promise<BatchResult> {
|
|
143
|
+
const totalStart = Date.now();
|
|
144
|
+
const cfg: Required<BatchConfig> = { ...DEFAULT_BATCH_CONFIG, ...batchConfig };
|
|
145
|
+
|
|
146
|
+
// Separate files by risk level
|
|
147
|
+
const fastPassFiles = classification.files.filter((f) => f.risk === "FAST_PASS");
|
|
148
|
+
const deepDiveFiles = classification.files.filter((f) => f.risk === "DEEP_DIVE");
|
|
149
|
+
|
|
150
|
+
// Early return if nothing to review
|
|
151
|
+
if (fastPassFiles.length === 0 && deepDiveFiles.length === 0) {
|
|
152
|
+
console.log(`📊 Nothing to review (${classification.counts.skip} files skipped)`);
|
|
153
|
+
return {
|
|
154
|
+
findings: [],
|
|
155
|
+
errors: [],
|
|
156
|
+
timing: { totalMs: 0, fastPassMs: 0, deepDiveMs: 0 },
|
|
157
|
+
contextSources: [],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
console.log(
|
|
162
|
+
`🚀 Dispatching: ${fastPassFiles.length} FAST_PASS, ${deepDiveFiles.length} DEEP_DIVE (${classification.counts.skip} skipped)`,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// --- Setup shared resources ---
|
|
166
|
+
const fileConfig = await loadConfig(config.workingDir);
|
|
167
|
+
const providerName = config.provider || fileConfig.provider || "openrouter";
|
|
168
|
+
const model = config.model || fileConfig.model || "anthropic/claude-sonnet-4.5";
|
|
169
|
+
const strictness = fileConfig.strictness || "normal";
|
|
170
|
+
|
|
171
|
+
// Fetch external context once
|
|
172
|
+
const contextSources: ContextSource[] = [];
|
|
173
|
+
let contextPrefix = "";
|
|
174
|
+
|
|
175
|
+
const linearResult = await getLinearContextForReview(
|
|
176
|
+
config.branchName,
|
|
177
|
+
config.prTitle,
|
|
178
|
+
config.prBody,
|
|
179
|
+
);
|
|
180
|
+
if (linearResult.context) {
|
|
181
|
+
contextPrefix += linearResult.context + "\n\n";
|
|
182
|
+
if (linearResult.ticketId) {
|
|
183
|
+
contextSources.push({
|
|
184
|
+
type: "linear",
|
|
185
|
+
id: linearResult.ticketId,
|
|
186
|
+
title: linearResult.ticketTitle,
|
|
187
|
+
url: linearResult.ticketUrl,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const adrResult = await getADRContextForReview(config.workingDir, fileConfig.adrPath);
|
|
193
|
+
if (adrResult) {
|
|
194
|
+
contextPrefix += adrResult.formatted + "\n\n";
|
|
195
|
+
for (const adrFile of adrResult.context.files) {
|
|
196
|
+
contextSources.push({
|
|
197
|
+
type: "adr",
|
|
198
|
+
id: adrFile.name,
|
|
199
|
+
title: adrFile.name.replace(".md", ""),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Build FAST_PASS system prompt
|
|
205
|
+
const strictnessGuidance = getStrictnessModifier(strictness);
|
|
206
|
+
const fastPassSystemPrompt =
|
|
207
|
+
contextPrefix +
|
|
208
|
+
FAST_PASS_SYSTEM_PROMPT +
|
|
209
|
+
REVIEW_JSON_INSTRUCTION +
|
|
210
|
+
`\n\n## Strictness Setting: ${strictness.toUpperCase()}\n${strictnessGuidance}`;
|
|
211
|
+
|
|
212
|
+
// --- Build all review promises ---
|
|
213
|
+
|
|
214
|
+
interface WorkItem {
|
|
215
|
+
label: string;
|
|
216
|
+
type: "fast_pass" | "deep_dive";
|
|
217
|
+
files: string[];
|
|
218
|
+
promise: Promise<TimedResult<ReviewFinding[]>>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const work: WorkItem[] = [];
|
|
222
|
+
|
|
223
|
+
// Create shared provider for FAST_PASS batches
|
|
224
|
+
let sharedProvider: LLMProvider | null = null;
|
|
225
|
+
if (fastPassFiles.length > 0) {
|
|
226
|
+
const providerResult = createProvider({
|
|
227
|
+
provider: providerName,
|
|
228
|
+
model,
|
|
229
|
+
apiKey: process.env.OPENROUTER_API_KEY,
|
|
230
|
+
});
|
|
231
|
+
if (providerResult.ok) {
|
|
232
|
+
sharedProvider = providerResult.value;
|
|
233
|
+
} else {
|
|
234
|
+
console.error(`❌ Failed to create provider for FAST_PASS: ${providerResult.error.message}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// FAST_PASS batch promises
|
|
239
|
+
const fastPassBatches = splitIntoBatches(fastPassFiles, cfg.maxBatchSize);
|
|
240
|
+
for (let i = 0; i < fastPassBatches.length; i++) {
|
|
241
|
+
const batch = fastPassBatches[i]!;
|
|
242
|
+
const label = `fast-pass-${i + 1}`;
|
|
243
|
+
const files = batch.map((f) => f.path);
|
|
244
|
+
|
|
245
|
+
if (sharedProvider) {
|
|
246
|
+
work.push({
|
|
247
|
+
label,
|
|
248
|
+
type: "fast_pass",
|
|
249
|
+
files,
|
|
250
|
+
promise: timed(() => reviewFastPassBatch(batch, sharedProvider!, fastPassSystemPrompt)),
|
|
251
|
+
});
|
|
252
|
+
} else {
|
|
253
|
+
work.push({
|
|
254
|
+
label,
|
|
255
|
+
type: "fast_pass",
|
|
256
|
+
files,
|
|
257
|
+
promise: Promise.reject(new Error("LLM provider creation failed")),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// DEEP_DIVE individual promises — concurrency-limited
|
|
263
|
+
const deepDivePromises = runWithConcurrencyLimit(
|
|
264
|
+
deepDiveFiles,
|
|
265
|
+
cfg.maxConcurrentDeepDives,
|
|
266
|
+
(file) => reviewDeepDiveFile(file, config),
|
|
267
|
+
);
|
|
268
|
+
for (let i = 0; i < deepDiveFiles.length; i++) {
|
|
269
|
+
work.push({
|
|
270
|
+
label: `deep-dive:${deepDiveFiles[i]!.path}`,
|
|
271
|
+
type: "deep_dive",
|
|
272
|
+
files: [deepDiveFiles[i]!.path],
|
|
273
|
+
promise: timed(() => deepDivePromises[i]!),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// --- Execute all in parallel ---
|
|
278
|
+
const settled = await Promise.allSettled(work.map((w) => w.promise));
|
|
279
|
+
|
|
280
|
+
// --- Collect results ---
|
|
281
|
+
const findings: ReviewFinding[] = [];
|
|
282
|
+
const errors: BatchError[] = [];
|
|
283
|
+
let maxFastPassMs = 0;
|
|
284
|
+
let maxDeepDiveMs = 0;
|
|
285
|
+
|
|
286
|
+
for (let i = 0; i < settled.length; i++) {
|
|
287
|
+
const result = settled[i]!;
|
|
288
|
+
const item = work[i]!;
|
|
289
|
+
|
|
290
|
+
if (result.status === "fulfilled") {
|
|
291
|
+
findings.push(...result.value.result);
|
|
292
|
+
if (item.type === "fast_pass") {
|
|
293
|
+
maxFastPassMs = Math.max(maxFastPassMs, result.value.durationMs);
|
|
294
|
+
} else {
|
|
295
|
+
maxDeepDiveMs = Math.max(maxDeepDiveMs, result.value.durationMs);
|
|
296
|
+
}
|
|
297
|
+
} else {
|
|
298
|
+
errors.push({
|
|
299
|
+
batch: item.label,
|
|
300
|
+
message: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
301
|
+
filesAffected: item.files,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const totalMs = Date.now() - totalStart;
|
|
307
|
+
|
|
308
|
+
console.log(
|
|
309
|
+
`✅ Batched review complete: ${findings.length} findings, ${errors.length} errors in ${totalMs}ms`,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
findings,
|
|
314
|
+
errors,
|
|
315
|
+
timing: { totalMs, fastPassMs: maxFastPassMs, deepDiveMs: maxDeepDiveMs },
|
|
316
|
+
contextSources,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// FAST_PASS Review
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Review a batch of FAST_PASS files with a single LLM call (no agent loop).
|
|
326
|
+
*/
|
|
327
|
+
export async function reviewFastPassBatch(
|
|
328
|
+
files: ClassifiedFile[],
|
|
329
|
+
provider: LLMProvider,
|
|
330
|
+
systemPrompt: string,
|
|
331
|
+
): Promise<ReviewFinding[]> {
|
|
332
|
+
const task = buildFastPassTask(files);
|
|
333
|
+
|
|
334
|
+
const result = await withRetry(
|
|
335
|
+
() =>
|
|
336
|
+
provider.chat(
|
|
337
|
+
[
|
|
338
|
+
{ role: "system", content: systemPrompt },
|
|
339
|
+
{ role: "user", content: task },
|
|
340
|
+
],
|
|
341
|
+
{ temperature: 0.2 },
|
|
342
|
+
),
|
|
343
|
+
{ maxRetries: 1, delayMs: 2000 },
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
if (!result.ok) {
|
|
347
|
+
throw new Error(`FAST_PASS LLM call failed: ${result.error.message}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const response = result.value.content ?? "";
|
|
351
|
+
const summary = `${files.length} files changed`;
|
|
352
|
+
const parsed = parseReviewResponseWithFallback(response, summary);
|
|
353
|
+
|
|
354
|
+
// Cap findings to prevent noisy FAST_PASS batches
|
|
355
|
+
return parsed.findings.slice(0, MAX_FAST_PASS_FINDINGS);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Maximum findings per FAST_PASS batch (matches prompt instruction). */
|
|
359
|
+
const MAX_FAST_PASS_FINDINGS = 3;
|
|
360
|
+
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
// DEEP_DIVE Review
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Review a single DEEP_DIVE file with the full agent loop.
|
|
367
|
+
*/
|
|
368
|
+
export async function reviewDeepDiveFile(
|
|
369
|
+
file: ClassifiedFile,
|
|
370
|
+
config: ReviewConfig,
|
|
371
|
+
): Promise<ReviewFinding[]> {
|
|
372
|
+
const diff = reconstructFileDiff(file.diffFile);
|
|
373
|
+
const summary = buildFileSummary(file);
|
|
374
|
+
|
|
375
|
+
const result = await runReview(diff, summary, config);
|
|
376
|
+
|
|
377
|
+
if (!result.ok) {
|
|
378
|
+
throw new Error(`DEEP_DIVE review failed for ${file.path}: ${result.error.message}`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return result.value.findings;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
// Prompt Builders
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Build the multi-file review task for a FAST_PASS batch.
|
|
390
|
+
* Includes each file's diff with path headers, truncating if needed.
|
|
391
|
+
*/
|
|
392
|
+
export function buildFastPassTask(files: ClassifiedFile[]): string {
|
|
393
|
+
const header = `Please review these ${files.length} files in a single pass.\n\n`;
|
|
394
|
+
const footer =
|
|
395
|
+
"\n\n## Instructions\n" +
|
|
396
|
+
"Review each file for real issues. " +
|
|
397
|
+
"Report findings with the file path in the File: field.\n";
|
|
398
|
+
|
|
399
|
+
// Per-file overhead: header lines (~80 chars) + code fences (~15 chars) + separator (~2 chars)
|
|
400
|
+
const PER_FILE_OVERHEAD = 100;
|
|
401
|
+
const totalOverhead = files.length * PER_FILE_OVERHEAD;
|
|
402
|
+
const availableChars = MAX_FAST_PASS_PROMPT_CHARS - header.length - footer.length - totalOverhead;
|
|
403
|
+
const perFileLimit = Math.floor(Math.max(availableChars, 0) / Math.max(files.length, 1));
|
|
404
|
+
|
|
405
|
+
const sections: string[] = [];
|
|
406
|
+
|
|
407
|
+
for (const file of files) {
|
|
408
|
+
const diff = reconstructFileDiff(file.diffFile);
|
|
409
|
+
const truncatedDiff =
|
|
410
|
+
diff.length > perFileLimit ? diff.slice(0, perFileLimit) + "\n(truncated)" : diff;
|
|
411
|
+
|
|
412
|
+
sections.push(
|
|
413
|
+
`### File: ${file.path}\n` +
|
|
414
|
+
`Status: ${file.diffFile.status} (+${file.diffFile.additions}/-${file.diffFile.deletions})\n\n` +
|
|
415
|
+
"```diff\n" +
|
|
416
|
+
truncatedDiff +
|
|
417
|
+
"\n```",
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return header + sections.join("\n\n") + footer;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// Helpers
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Reconstruct raw diff text from a parsed DiffFile.
|
|
430
|
+
*/
|
|
431
|
+
export function reconstructFileDiff(file: DiffFile): string {
|
|
432
|
+
const resolvedOldPath = file.oldPath ?? "unknown";
|
|
433
|
+
const resolvedNewPath = file.newPath ?? "unknown";
|
|
434
|
+
|
|
435
|
+
// For git header, use /dev/null for the missing side
|
|
436
|
+
const headerA = file.status === "added" ? "/dev/null" : `a/${resolvedOldPath}`;
|
|
437
|
+
const headerB = file.status === "deleted" ? "/dev/null" : `b/${resolvedNewPath}`;
|
|
438
|
+
|
|
439
|
+
const lines: string[] = [];
|
|
440
|
+
lines.push(`diff --git ${headerA} ${headerB}`);
|
|
441
|
+
|
|
442
|
+
if (file.status === "added") {
|
|
443
|
+
lines.push("new file mode 100644");
|
|
444
|
+
} else if (file.status === "deleted") {
|
|
445
|
+
lines.push("deleted file mode 100644");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
lines.push(`--- ${file.status === "added" ? "/dev/null" : `a/${resolvedOldPath}`}`);
|
|
449
|
+
lines.push(`+++ ${file.status === "deleted" ? "/dev/null" : `b/${resolvedNewPath}`}`);
|
|
450
|
+
|
|
451
|
+
for (const hunk of file.hunks) {
|
|
452
|
+
lines.push(hunk.content);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return lines.join("\n");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Build a summary string for a single file.
|
|
460
|
+
*/
|
|
461
|
+
function buildFileSummary(file: ClassifiedFile): string {
|
|
462
|
+
const status = file.diffFile.status;
|
|
463
|
+
return [
|
|
464
|
+
`Files changed: 1`,
|
|
465
|
+
`Additions: +${file.diffFile.additions}`,
|
|
466
|
+
`Deletions: -${file.diffFile.deletions}`,
|
|
467
|
+
"",
|
|
468
|
+
"Files:",
|
|
469
|
+
` ${status}: ${file.path} +${file.diffFile.additions}/-${file.diffFile.deletions}`,
|
|
470
|
+
].join("\n");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Split an array into batches of a given size.
|
|
475
|
+
* Returns all items in a single batch if batchSize is <= 0.
|
|
476
|
+
*/
|
|
477
|
+
export function splitIntoBatches<T>(items: T[], batchSize: number): T[][] {
|
|
478
|
+
if (items.length === 0) return [];
|
|
479
|
+
if (batchSize <= 0) return [items];
|
|
480
|
+
const batches: T[][] = [];
|
|
481
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
482
|
+
batches.push(items.slice(i, i + batchSize));
|
|
483
|
+
}
|
|
484
|
+
return batches;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Wrapper to measure promise duration. */
|
|
488
|
+
interface TimedResult<T> {
|
|
489
|
+
result: T;
|
|
490
|
+
durationMs: number;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function timed<T>(fn: () => Promise<T>): Promise<TimedResult<T>> {
|
|
494
|
+
const start = Date.now();
|
|
495
|
+
const result = await fn();
|
|
496
|
+
return { result, durationMs: Date.now() - start };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Run async tasks with a concurrency limit.
|
|
501
|
+
* Returns an array of promises (one per item) that resolve in order,
|
|
502
|
+
* but at most `limit` tasks run simultaneously.
|
|
503
|
+
*/
|
|
504
|
+
export function runWithConcurrencyLimit<T, R>(
|
|
505
|
+
items: T[],
|
|
506
|
+
limit: number,
|
|
507
|
+
fn: (item: T) => Promise<R>,
|
|
508
|
+
): Promise<R>[] {
|
|
509
|
+
const effectiveLimit = Math.max(limit, 1);
|
|
510
|
+
let running = 0;
|
|
511
|
+
const results: Promise<R>[] = [];
|
|
512
|
+
const queue: Array<() => void> = [];
|
|
513
|
+
|
|
514
|
+
for (const item of items) {
|
|
515
|
+
results.push(
|
|
516
|
+
new Promise<R>((resolve, reject) => {
|
|
517
|
+
const execute = () => {
|
|
518
|
+
running++;
|
|
519
|
+
fn(item).then(
|
|
520
|
+
(value) => {
|
|
521
|
+
running--;
|
|
522
|
+
resolve(value);
|
|
523
|
+
if (queue.length > 0) queue.shift()!();
|
|
524
|
+
},
|
|
525
|
+
(error) => {
|
|
526
|
+
running--;
|
|
527
|
+
reject(error);
|
|
528
|
+
if (queue.length > 0) queue.shift()!();
|
|
529
|
+
},
|
|
530
|
+
);
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
if (running < effectiveLimit) {
|
|
534
|
+
execute();
|
|
535
|
+
} else {
|
|
536
|
+
queue.push(execute);
|
|
537
|
+
}
|
|
538
|
+
}),
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return results;
|
|
543
|
+
}
|