specvector 0.3.3 → 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/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 +5 -4
- package/src/review/json-parser.ts +283 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding Merger & Deduplication for the Scalable Review Pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Takes raw findings from BatchResult, deduplicates semantically similar
|
|
5
|
+
* findings, generalizes patterns that appear in 3+ files, sorts by severity,
|
|
6
|
+
* and produces a ReviewResult for the formatter.
|
|
7
|
+
*
|
|
8
|
+
* This is a pure function — no LLM calls, no IO.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { BatchResult, BatchError } from "./batcher";
|
|
12
|
+
import type {
|
|
13
|
+
ReviewFinding,
|
|
14
|
+
ReviewResult,
|
|
15
|
+
ReviewStats,
|
|
16
|
+
Severity,
|
|
17
|
+
} from "../types/review";
|
|
18
|
+
import { calculateStats, determineRecommendation } from "../types/review";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** Configuration for the merger. */
|
|
25
|
+
export interface MergerConfig {
|
|
26
|
+
/** Jaccard similarity threshold for title deduplication (default: 0.7) */
|
|
27
|
+
similarityThreshold: number;
|
|
28
|
+
/** Minimum files for pattern generalization (default: 3) */
|
|
29
|
+
patternThreshold: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_MERGER_CONFIG: MergerConfig = {
|
|
33
|
+
similarityThreshold: 0.7,
|
|
34
|
+
patternThreshold: 3,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Severity sort order (lower = higher priority). */
|
|
38
|
+
const SEVERITY_ORDER: Record<Severity, number> = {
|
|
39
|
+
CRITICAL: 0,
|
|
40
|
+
HIGH: 1,
|
|
41
|
+
MEDIUM: 2,
|
|
42
|
+
LOW: 3,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Main Entry Point
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Merge findings from a BatchResult into a single ReviewResult.
|
|
51
|
+
*
|
|
52
|
+
* Pipeline: deduplicate → generalize patterns → sort → build ReviewResult.
|
|
53
|
+
*/
|
|
54
|
+
export function mergeFindings(
|
|
55
|
+
batchResult: BatchResult,
|
|
56
|
+
filesReviewed: number,
|
|
57
|
+
config?: Partial<MergerConfig>,
|
|
58
|
+
): ReviewResult {
|
|
59
|
+
const cfg: MergerConfig = { ...DEFAULT_MERGER_CONFIG, ...config };
|
|
60
|
+
|
|
61
|
+
// Pipeline
|
|
62
|
+
const deduplicated = deduplicateFindings(batchResult.findings, cfg.similarityThreshold);
|
|
63
|
+
const generalized = generalizePatterns(deduplicated, cfg.patternThreshold);
|
|
64
|
+
const sorted = sortFindings(generalized);
|
|
65
|
+
|
|
66
|
+
// Compute stats, recommendation, and summary
|
|
67
|
+
const stats = calculateStats(sorted);
|
|
68
|
+
const recommendation = determineRecommendation(stats);
|
|
69
|
+
const summary = buildSummary(sorted, batchResult.errors, stats);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
findings: sorted,
|
|
73
|
+
summary,
|
|
74
|
+
recommendation,
|
|
75
|
+
stats,
|
|
76
|
+
filesReviewed,
|
|
77
|
+
contextSources:
|
|
78
|
+
batchResult.contextSources.length > 0
|
|
79
|
+
? batchResult.contextSources
|
|
80
|
+
: undefined,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Deduplication
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Internal representation of a finding with collected file references.
|
|
90
|
+
*/
|
|
91
|
+
interface FindingCluster {
|
|
92
|
+
/** Representative finding (longest description) */
|
|
93
|
+
representative: ReviewFinding;
|
|
94
|
+
/** All files from merged findings */
|
|
95
|
+
affectedFiles: string[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Deduplicate semantically similar findings.
|
|
100
|
+
*
|
|
101
|
+
* Groups findings into clusters by similarity, keeps the most detailed
|
|
102
|
+
* representative for each cluster, and collects all affected file paths.
|
|
103
|
+
*/
|
|
104
|
+
export function deduplicateFindings(
|
|
105
|
+
findings: ReviewFinding[],
|
|
106
|
+
similarityThreshold: number = DEFAULT_MERGER_CONFIG.similarityThreshold,
|
|
107
|
+
): ReviewFinding[] {
|
|
108
|
+
if (findings.length === 0) return [];
|
|
109
|
+
|
|
110
|
+
// Sort deterministically before clustering so that input order
|
|
111
|
+
// (which depends on non-deterministic Promise.allSettled resolution)
|
|
112
|
+
// does not affect which clusters form.
|
|
113
|
+
const sorted = [...findings].sort((a, b) => {
|
|
114
|
+
const sevDiff = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
|
|
115
|
+
if (sevDiff !== 0) return sevDiff;
|
|
116
|
+
const titleDiff = a.title.localeCompare(b.title);
|
|
117
|
+
if (titleDiff !== 0) return titleDiff;
|
|
118
|
+
return (a.file ?? "").localeCompare(b.file ?? "");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const clusters: FindingCluster[] = [];
|
|
122
|
+
|
|
123
|
+
for (const finding of sorted) {
|
|
124
|
+
let merged = false;
|
|
125
|
+
|
|
126
|
+
for (const cluster of clusters) {
|
|
127
|
+
if (areSimilarFindings(finding, cluster.representative, similarityThreshold)) {
|
|
128
|
+
// Merge into existing cluster
|
|
129
|
+
if (finding.file && !cluster.affectedFiles.includes(finding.file)) {
|
|
130
|
+
cluster.affectedFiles.push(finding.file);
|
|
131
|
+
}
|
|
132
|
+
// Keep the longer description as representative
|
|
133
|
+
if (finding.description.length > cluster.representative.description.length) {
|
|
134
|
+
const prev = cluster.representative;
|
|
135
|
+
const files = cluster.affectedFiles;
|
|
136
|
+
cluster.representative = { ...finding };
|
|
137
|
+
cluster.affectedFiles = files;
|
|
138
|
+
// Carry forward suggestion from previous representative if new one lacks it
|
|
139
|
+
if (!cluster.representative.suggestion && prev.suggestion) {
|
|
140
|
+
cluster.representative.suggestion = prev.suggestion;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
merged = true;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!merged) {
|
|
149
|
+
clusters.push({
|
|
150
|
+
representative: { ...finding },
|
|
151
|
+
affectedFiles: finding.file ? [finding.file] : [],
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Convert clusters back to findings, attaching affectedFiles metadata
|
|
157
|
+
return clusters.map((cluster) => {
|
|
158
|
+
const finding = { ...cluster.representative };
|
|
159
|
+
if (cluster.affectedFiles.length > 1) {
|
|
160
|
+
// Store affected files for pattern generalization
|
|
161
|
+
(finding as FindingWithFiles)._affectedFiles = cluster.affectedFiles;
|
|
162
|
+
}
|
|
163
|
+
return finding;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Internal extension to carry affected files through the pipeline. */
|
|
168
|
+
interface FindingWithFiles extends ReviewFinding {
|
|
169
|
+
_affectedFiles?: string[];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Similarity
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Check if two findings are semantically similar (candidates for deduplication).
|
|
178
|
+
*
|
|
179
|
+
* Criteria:
|
|
180
|
+
* - Same severity
|
|
181
|
+
* - Same category (both null or both equal)
|
|
182
|
+
* - Title Jaccard similarity >= threshold
|
|
183
|
+
* - Different files (don't merge findings pointing to the same file)
|
|
184
|
+
*/
|
|
185
|
+
export function areSimilarFindings(
|
|
186
|
+
a: ReviewFinding,
|
|
187
|
+
b: ReviewFinding,
|
|
188
|
+
threshold: number = DEFAULT_MERGER_CONFIG.similarityThreshold,
|
|
189
|
+
): boolean {
|
|
190
|
+
// Severity must match
|
|
191
|
+
if (a.severity !== b.severity) return false;
|
|
192
|
+
|
|
193
|
+
// Category must match (both undefined or both equal)
|
|
194
|
+
if ((a.category ?? null) !== (b.category ?? null)) return false;
|
|
195
|
+
|
|
196
|
+
// Don't merge findings about the same file (they're likely different issues)
|
|
197
|
+
if (a.file && b.file && a.file === b.file) return false;
|
|
198
|
+
|
|
199
|
+
// Title similarity via Jaccard
|
|
200
|
+
return jaccardSimilarity(a.title, b.title) >= threshold;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Compute Jaccard similarity between two strings based on word tokens.
|
|
205
|
+
* Returns a value between 0 (no overlap) and 1 (identical).
|
|
206
|
+
*/
|
|
207
|
+
export function jaccardSimilarity(a: string, b: string): number {
|
|
208
|
+
const wordsA = tokenize(a);
|
|
209
|
+
const wordsB = tokenize(b);
|
|
210
|
+
|
|
211
|
+
if (wordsA.size === 0 && wordsB.size === 0) return 1;
|
|
212
|
+
if (wordsA.size === 0 || wordsB.size === 0) return 0;
|
|
213
|
+
|
|
214
|
+
let intersectionSize = 0;
|
|
215
|
+
for (const word of wordsA) {
|
|
216
|
+
if (wordsB.has(word)) intersectionSize++;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const unionSize = new Set([...wordsA, ...wordsB]).size;
|
|
220
|
+
return intersectionSize / unionSize;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Tokenize a string into a set of lowercase words.
|
|
225
|
+
*/
|
|
226
|
+
function tokenize(text: string): Set<string> {
|
|
227
|
+
return new Set(
|
|
228
|
+
text
|
|
229
|
+
.toLowerCase()
|
|
230
|
+
.split(/\s+/)
|
|
231
|
+
.filter((w) => w.length > 0),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Pattern Generalization
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Generalize findings that appear in many files into pattern comments.
|
|
241
|
+
*
|
|
242
|
+
* If a finding has been deduplicated across >= threshold files, it becomes
|
|
243
|
+
* a repo-wide pattern comment without a specific file reference.
|
|
244
|
+
*/
|
|
245
|
+
export function generalizePatterns(
|
|
246
|
+
findings: ReviewFinding[],
|
|
247
|
+
threshold: number = DEFAULT_MERGER_CONFIG.patternThreshold,
|
|
248
|
+
): ReviewFinding[] {
|
|
249
|
+
return findings.map((finding) => {
|
|
250
|
+
const files = (finding as FindingWithFiles)._affectedFiles;
|
|
251
|
+
if (files && files.length >= threshold) {
|
|
252
|
+
// Generalize to pattern comment
|
|
253
|
+
const fileList = files.join(", ");
|
|
254
|
+
const result: ReviewFinding = {
|
|
255
|
+
...finding,
|
|
256
|
+
title: `${finding.title} (pattern)`,
|
|
257
|
+
description: `${finding.description}\n\nFound in ${files.length} files: ${fileList}`,
|
|
258
|
+
file: undefined,
|
|
259
|
+
line: undefined,
|
|
260
|
+
};
|
|
261
|
+
// Clean internal metadata
|
|
262
|
+
delete (result as FindingWithFiles)._affectedFiles;
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Below threshold — keep file reference, clean metadata
|
|
267
|
+
const result = { ...finding };
|
|
268
|
+
delete (result as FindingWithFiles)._affectedFiles;
|
|
269
|
+
|
|
270
|
+
// If deduplicated across 2 files, note the other file in description
|
|
271
|
+
if (files && files.length === 2) {
|
|
272
|
+
const otherFile = files.find((f) => f !== finding.file);
|
|
273
|
+
if (otherFile) {
|
|
274
|
+
result.description = `${finding.description}\n\nAlso found in: ${otherFile}`;
|
|
275
|
+
// Clear line — it may reference the wrong file after representative swap
|
|
276
|
+
result.line = undefined;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return result;
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// Sorting
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Sort findings by severity (CRITICAL > HIGH > MEDIUM > LOW),
|
|
290
|
+
* then alphabetically by title within the same severity.
|
|
291
|
+
*/
|
|
292
|
+
export function sortFindings(findings: ReviewFinding[]): ReviewFinding[] {
|
|
293
|
+
return [...findings].sort((a, b) => {
|
|
294
|
+
const severityDiff = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
|
|
295
|
+
if (severityDiff !== 0) return severityDiff;
|
|
296
|
+
return a.title.localeCompare(b.title);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// Summary Builder
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Build a human-readable summary from merged findings and batch errors.
|
|
306
|
+
*/
|
|
307
|
+
function buildSummary(findings: ReviewFinding[], errors: BatchError[], stats: ReviewStats): string {
|
|
308
|
+
const parts: string[] = [];
|
|
309
|
+
|
|
310
|
+
if (findings.length === 0) {
|
|
311
|
+
parts.push("No issues found. Code looks good to merge.");
|
|
312
|
+
} else {
|
|
313
|
+
const counts: string[] = [];
|
|
314
|
+
if (stats.critical > 0) counts.push(`${stats.critical} critical`);
|
|
315
|
+
if (stats.high > 0) counts.push(`${stats.high} high`);
|
|
316
|
+
if (stats.medium > 0) counts.push(`${stats.medium} medium`);
|
|
317
|
+
if (stats.low > 0) counts.push(`${stats.low} low`);
|
|
318
|
+
parts.push(`Found ${findings.length} issue${findings.length === 1 ? "" : "s"}: ${counts.join(", ")}.`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (errors.length > 0) {
|
|
322
|
+
const totalAffected = errors.reduce((sum, e) => sum + e.filesAffected.length, 0);
|
|
323
|
+
parts.push(
|
|
324
|
+
`Note: ${errors.length} review batch${errors.length === 1 ? "" : "es"} failed (${totalAffected} file${totalAffected === 1 ? "" : "s"} not reviewed). Findings may be incomplete.`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return parts.join(" ");
|
|
329
|
+
}
|
package/src/review/engine.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { createOutlineTool } from "../agent/tools/outline";
|
|
|
17
17
|
import { createFindSymbolTool } from "../agent/tools/find-symbol";
|
|
18
18
|
import { calculateStats, determineRecommendation } from "../types/review";
|
|
19
19
|
import type { ReviewResult, ReviewFinding, Severity, ContextSource } from "../types/review";
|
|
20
|
+
import { parseReviewResponseWithFallback, REVIEW_JSON_INSTRUCTION } from "./json-parser";
|
|
20
21
|
import type { Result } from "../types/result";
|
|
21
22
|
import { ok, err } from "../types/result";
|
|
22
23
|
import { loadConfig, getStrictnessModifier } from "../config";
|
|
@@ -96,9 +97,9 @@ export async function runReview(
|
|
|
96
97
|
createFindSymbolTool({ workingDir: config.workingDir }),
|
|
97
98
|
];
|
|
98
99
|
|
|
99
|
-
// Build system prompt with strictness modifier
|
|
100
|
+
// Build system prompt with strictness modifier and JSON instruction
|
|
100
101
|
const strictnessGuidance = getStrictnessModifier(strictness);
|
|
101
|
-
let systemPrompt = REVIEW_SYSTEM_PROMPT + `\n\n## Strictness Setting: ${strictness.toUpperCase()}\n${strictnessGuidance}`;
|
|
102
|
+
let systemPrompt = REVIEW_SYSTEM_PROMPT + REVIEW_JSON_INSTRUCTION + `\n\n## Strictness Setting: ${strictness.toUpperCase()}\n${strictnessGuidance}`;
|
|
102
103
|
|
|
103
104
|
// Track context sources for citation
|
|
104
105
|
const contextSources: ContextSource[] = [];
|
|
@@ -163,8 +164,8 @@ export async function runReview(
|
|
|
163
164
|
});
|
|
164
165
|
}
|
|
165
166
|
|
|
166
|
-
// Parse the response into structured findings
|
|
167
|
-
const reviewResult =
|
|
167
|
+
// Parse the response into structured findings (JSON first, regex fallback)
|
|
168
|
+
const reviewResult = parseReviewResponseWithFallback(agentResult.value, diffSummary, contextSources);
|
|
168
169
|
|
|
169
170
|
return ok(reviewResult);
|
|
170
171
|
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured JSON Response Parser for LLM Review Output.
|
|
3
|
+
*
|
|
4
|
+
* Parses LLM responses that contain chain-of-thought reasoning followed
|
|
5
|
+
* by a JSON block conforming to the ReviewFinding schema.
|
|
6
|
+
*
|
|
7
|
+
* Falls back to the existing regex parser (parseReviewResponse) when
|
|
8
|
+
* JSON extraction or validation fails.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { parseReviewResponse } from "./engine";
|
|
12
|
+
import { calculateStats, determineRecommendation } from "../types/review";
|
|
13
|
+
import type {
|
|
14
|
+
ReviewFinding,
|
|
15
|
+
ReviewResult,
|
|
16
|
+
Severity,
|
|
17
|
+
ContextSource,
|
|
18
|
+
} from "../types/review";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** Shape of the JSON block the LLM is expected to produce. */
|
|
25
|
+
export interface StructuredReviewResponse {
|
|
26
|
+
summary: string;
|
|
27
|
+
findings: StructuredFinding[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A single finding in the JSON output. Mirrors ReviewFinding. */
|
|
31
|
+
interface StructuredFinding {
|
|
32
|
+
severity: string;
|
|
33
|
+
title: string;
|
|
34
|
+
description: string;
|
|
35
|
+
file?: string;
|
|
36
|
+
line?: number;
|
|
37
|
+
suggestion?: string;
|
|
38
|
+
category?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Valid severity values (upper-case). */
|
|
42
|
+
const VALID_SEVERITIES: ReadonlySet<string> = new Set([
|
|
43
|
+
"CRITICAL",
|
|
44
|
+
"HIGH",
|
|
45
|
+
"MEDIUM",
|
|
46
|
+
"LOW",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Prompt Instruction
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Prompt suffix that instructs the LLM to output chain-of-thought
|
|
55
|
+
* reasoning followed by a JSON block.
|
|
56
|
+
*
|
|
57
|
+
* Append this to both REVIEW_SYSTEM_PROMPT and FAST_PASS_SYSTEM_PROMPT.
|
|
58
|
+
*/
|
|
59
|
+
export const REVIEW_JSON_INSTRUCTION = `
|
|
60
|
+
|
|
61
|
+
## Output Format — IMPORTANT
|
|
62
|
+
IGNORE the "Response Format" section above. Instead, use this JSON format:
|
|
63
|
+
|
|
64
|
+
After your reasoning, output your review as a single JSON code block.
|
|
65
|
+
The JSON must conform to this schema:
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
"summary": "1-2 sentence summary of the review",
|
|
69
|
+
"findings": [
|
|
70
|
+
{
|
|
71
|
+
"severity": "CRITICAL | HIGH | MEDIUM | LOW",
|
|
72
|
+
"category": "Security | Performance | Code Quality | ...",
|
|
73
|
+
"title": "Short title of the finding",
|
|
74
|
+
"description": "Detailed description and how to fix",
|
|
75
|
+
"file": "path/to/file.ts",
|
|
76
|
+
"line": 42,
|
|
77
|
+
"suggestion": "Suggested fix (optional)"
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
If the code is clean, use an empty findings array:
|
|
83
|
+
|
|
84
|
+
{ "summary": "Code looks good to merge.", "findings": [] }
|
|
85
|
+
|
|
86
|
+
Important:
|
|
87
|
+
- Think step by step FIRST (chain of thought), then output the JSON block
|
|
88
|
+
- severity must be one of: CRITICAL, HIGH, MEDIUM, LOW
|
|
89
|
+
- file, line, suggestion, and category are optional
|
|
90
|
+
- The JSON block MUST be wrapped in \`\`\`json fences`;
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// JSON Extraction
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Extract a JSON string from mixed text+JSON content.
|
|
98
|
+
*
|
|
99
|
+
* Priority:
|
|
100
|
+
* 1. Fenced ```json ... ``` block (most explicit)
|
|
101
|
+
* 2. Fenced ``` ... ``` block (no language tag, if content looks like JSON)
|
|
102
|
+
*
|
|
103
|
+
* Returns null if no JSON-like content is found.
|
|
104
|
+
* Note: bare JSON extraction (no fences) is intentionally omitted — the prompt
|
|
105
|
+
* explicitly instructs the LLM to use fenced blocks, and bare brace scanning
|
|
106
|
+
* is fragile (cannot distinguish braces inside JSON strings from structural braces).
|
|
107
|
+
*/
|
|
108
|
+
export function extractJSON(text: string): string | null {
|
|
109
|
+
// 1. Fenced ```json block — take the last one, which is likely
|
|
110
|
+
// the final output after CoT reasoning
|
|
111
|
+
const jsonFenced = findLastFencedBlock(text, "json");
|
|
112
|
+
if (jsonFenced !== null) return jsonFenced;
|
|
113
|
+
|
|
114
|
+
// 2. Fenced ``` block (no language tag)
|
|
115
|
+
const plainFenced = findLastFencedBlock(text, "");
|
|
116
|
+
if (plainFenced !== null && looksLikeJSON(plainFenced)) return plainFenced;
|
|
117
|
+
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Find the last fenced code block with the given language tag.
|
|
123
|
+
* Empty string matches blocks with no language tag.
|
|
124
|
+
*/
|
|
125
|
+
function findLastFencedBlock(text: string, lang: string): string | null {
|
|
126
|
+
// Build pattern: ```lang\n...\n``` with closing ``` anchored to line start
|
|
127
|
+
// to avoid matching inline triple-backtick snippets in CoT reasoning.
|
|
128
|
+
const escapedLang = lang.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
129
|
+
const pattern = lang
|
|
130
|
+
? new RegExp("```" + escapedLang + "\\s*\\n([\\s\\S]*?)^```", "gm")
|
|
131
|
+
: new RegExp("```\\s*\\n([\\s\\S]*?)^```", "gm");
|
|
132
|
+
|
|
133
|
+
let lastMatch: string | null = null;
|
|
134
|
+
let match: RegExpExecArray | null;
|
|
135
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
136
|
+
if (match[1] !== undefined) {
|
|
137
|
+
lastMatch = match[1].trim();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return lastMatch;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Quick heuristic: does the string start with `{`? */
|
|
144
|
+
function looksLikeJSON(text: string): boolean {
|
|
145
|
+
return text.trim().startsWith("{");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// JSON Response Parser
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Parse a JSON-formatted review response into a ReviewResult.
|
|
154
|
+
*
|
|
155
|
+
* Returns null if the response doesn't contain valid JSON or if
|
|
156
|
+
* required fields are missing / invalid. The caller should fall back
|
|
157
|
+
* to the regex parser in that case.
|
|
158
|
+
*/
|
|
159
|
+
export function parseJSONReviewResponse(
|
|
160
|
+
response: string,
|
|
161
|
+
diffSummary: string,
|
|
162
|
+
contextSources: ContextSource[] = [],
|
|
163
|
+
): ReviewResult | null {
|
|
164
|
+
// Step 1: Extract JSON
|
|
165
|
+
const jsonStr = extractJSON(response);
|
|
166
|
+
if (jsonStr === null) return null;
|
|
167
|
+
|
|
168
|
+
// Step 2: Parse JSON
|
|
169
|
+
let parsed: unknown;
|
|
170
|
+
try {
|
|
171
|
+
parsed = JSON.parse(jsonStr);
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Step 3: Validate top-level shape
|
|
177
|
+
if (!isObject(parsed)) return null;
|
|
178
|
+
const obj = parsed as Record<string, unknown>;
|
|
179
|
+
|
|
180
|
+
const summary =
|
|
181
|
+
typeof obj.summary === "string" ? obj.summary : "";
|
|
182
|
+
const rawFindings = Array.isArray(obj.findings) ? obj.findings : null;
|
|
183
|
+
if (rawFindings === null) return null;
|
|
184
|
+
|
|
185
|
+
// Step 4: Validate and convert each finding (skip invalid ones)
|
|
186
|
+
const findings: ReviewFinding[] = [];
|
|
187
|
+
for (const raw of rawFindings) {
|
|
188
|
+
const finding = validateFinding(raw);
|
|
189
|
+
if (finding !== null) {
|
|
190
|
+
findings.push(finding);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Step 5: Build ReviewResult
|
|
195
|
+
const filesReviewed = extractFilesReviewed(diffSummary);
|
|
196
|
+
const stats = calculateStats(findings);
|
|
197
|
+
const recommendation = determineRecommendation(stats);
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
findings,
|
|
201
|
+
summary: summary || (findings.length === 0 ? "No issues found." : `Found ${findings.length} issue${findings.length === 1 ? "" : "s"}.`),
|
|
202
|
+
recommendation,
|
|
203
|
+
stats,
|
|
204
|
+
filesReviewed,
|
|
205
|
+
contextSources: contextSources.length > 0 ? contextSources : undefined,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Validate a single finding from the JSON response.
|
|
211
|
+
* Returns null if required fields are missing or severity is invalid.
|
|
212
|
+
*/
|
|
213
|
+
function validateFinding(raw: unknown): ReviewFinding | null {
|
|
214
|
+
if (!isObject(raw)) return null;
|
|
215
|
+
const obj = raw as Record<string, unknown>;
|
|
216
|
+
|
|
217
|
+
// Required fields — trim before checking to reject whitespace-only values
|
|
218
|
+
const severity = typeof obj.severity === "string" ? obj.severity.trim().toUpperCase() : null;
|
|
219
|
+
const title = typeof obj.title === "string" ? obj.title.trim() : null;
|
|
220
|
+
const description = typeof obj.description === "string" ? obj.description.trim() : null;
|
|
221
|
+
|
|
222
|
+
if (!severity || !title || !description) return null;
|
|
223
|
+
if (!VALID_SEVERITIES.has(severity)) return null;
|
|
224
|
+
|
|
225
|
+
const finding: ReviewFinding = {
|
|
226
|
+
severity: severity as Severity,
|
|
227
|
+
title,
|
|
228
|
+
description,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// Optional fields
|
|
232
|
+
if (typeof obj.file === "string" && obj.file.trim().length > 0) {
|
|
233
|
+
finding.file = obj.file.trim();
|
|
234
|
+
}
|
|
235
|
+
if (typeof obj.line === "number" && Number.isInteger(obj.line) && obj.line > 0) {
|
|
236
|
+
finding.line = obj.line;
|
|
237
|
+
}
|
|
238
|
+
if (typeof obj.suggestion === "string" && obj.suggestion.length > 0) {
|
|
239
|
+
finding.suggestion = obj.suggestion.trim();
|
|
240
|
+
}
|
|
241
|
+
if (typeof obj.category === "string" && obj.category.length > 0) {
|
|
242
|
+
finding.category = obj.category.trim();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return finding;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function isObject(val: unknown): val is Record<string, unknown> {
|
|
249
|
+
return typeof val === "object" && val !== null && !Array.isArray(val);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function extractFilesReviewed(diffSummary: string): number {
|
|
253
|
+
const match = diffSummary.match(/(\d+)\s*files?\s*changed/i);
|
|
254
|
+
if (match?.[1]) return parseInt(match[1], 10);
|
|
255
|
+
// Fallback: look for just a number at the start (e.g., "3 files changed")
|
|
256
|
+
const numMatch = diffSummary.match(/^(\d+)/);
|
|
257
|
+
return numMatch?.[1] ? parseInt(numMatch[1], 10) : 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Parse-with-Fallback Wrapper
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Parse an LLM review response, trying JSON first, then regex fallback.
|
|
266
|
+
*
|
|
267
|
+
* This is the drop-in replacement for `parseReviewResponse()` at both
|
|
268
|
+
* integration points (engine.ts agent loop, batcher.ts FAST_PASS).
|
|
269
|
+
*/
|
|
270
|
+
export function parseReviewResponseWithFallback(
|
|
271
|
+
response: string,
|
|
272
|
+
diffSummary: string,
|
|
273
|
+
contextSources: ContextSource[] = [],
|
|
274
|
+
): ReviewResult {
|
|
275
|
+
// Try JSON parser first
|
|
276
|
+
const jsonResult = parseJSONReviewResponse(response, diffSummary, contextSources);
|
|
277
|
+
if (jsonResult !== null) {
|
|
278
|
+
return jsonResult;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Fall back to existing regex parser
|
|
282
|
+
return parseReviewResponse(response, diffSummary, contextSources);
|
|
283
|
+
}
|