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.
@@ -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
+ }
@@ -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 = parseReviewResponse(agentResult.value, diffSummary, contextSources);
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
+ }