agent-gauntlet 0.1.10 → 0.1.11

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.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -2
  3. package/src/cli-adapters/claude.ts +139 -108
  4. package/src/cli-adapters/codex.ts +141 -117
  5. package/src/cli-adapters/cursor.ts +152 -0
  6. package/src/cli-adapters/gemini.ts +171 -139
  7. package/src/cli-adapters/github-copilot.ts +153 -0
  8. package/src/cli-adapters/index.ts +77 -48
  9. package/src/commands/check.test.ts +24 -20
  10. package/src/commands/check.ts +65 -59
  11. package/src/commands/detect.test.ts +38 -32
  12. package/src/commands/detect.ts +74 -61
  13. package/src/commands/health.test.ts +67 -53
  14. package/src/commands/health.ts +167 -145
  15. package/src/commands/help.test.ts +37 -37
  16. package/src/commands/help.ts +30 -22
  17. package/src/commands/index.ts +9 -9
  18. package/src/commands/init.test.ts +118 -107
  19. package/src/commands/init.ts +514 -417
  20. package/src/commands/list.test.ts +87 -70
  21. package/src/commands/list.ts +28 -24
  22. package/src/commands/rerun.ts +142 -119
  23. package/src/commands/review.test.ts +26 -20
  24. package/src/commands/review.ts +65 -59
  25. package/src/commands/run.test.ts +22 -20
  26. package/src/commands/run.ts +64 -58
  27. package/src/commands/shared.ts +44 -35
  28. package/src/config/loader.test.ts +112 -90
  29. package/src/config/loader.ts +132 -123
  30. package/src/config/schema.ts +49 -47
  31. package/src/config/types.ts +15 -13
  32. package/src/config/validator.ts +521 -454
  33. package/src/core/change-detector.ts +122 -104
  34. package/src/core/entry-point.test.ts +60 -62
  35. package/src/core/entry-point.ts +76 -67
  36. package/src/core/job.ts +69 -59
  37. package/src/core/runner.ts +261 -230
  38. package/src/gates/check.ts +78 -69
  39. package/src/gates/result.ts +7 -7
  40. package/src/gates/review.test.ts +174 -138
  41. package/src/gates/review.ts +716 -561
  42. package/src/index.ts +16 -15
  43. package/src/output/console.ts +253 -214
  44. package/src/output/logger.ts +64 -52
  45. package/src/templates/run_gauntlet.template.md +18 -0
  46. package/src/utils/diff-parser.ts +64 -62
  47. package/src/utils/log-parser.ts +227 -206
  48. package/src/utils/sanitizer.ts +1 -1
@@ -1,11 +1,17 @@
1
- import { exec } from 'node:child_process';
2
- import { promisify } from 'node:util';
3
- import { ReviewGateConfig, ReviewPromptFrontmatter } from '../config/types.js';
4
- import { GateResult } from './result.js';
5
- import { CLIAdapter, getAdapter } from '../cli-adapters/index.js';
6
- import { Logger } from '../output/logger.js';
7
- import { parseDiff, isValidViolationLocation, type DiffFileRange } from '../utils/diff-parser.js';
8
- import { type PreviousViolation } from '../utils/log-parser.js';
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { getAdapter } from "../cli-adapters/index.js";
4
+ import type {
5
+ ReviewGateConfig,
6
+ ReviewPromptFrontmatter,
7
+ } from "../config/types.js";
8
+ import {
9
+ type DiffFileRange,
10
+ isValidViolationLocation,
11
+ parseDiff,
12
+ } from "../utils/diff-parser.js";
13
+ import type { PreviousViolation } from "../utils/log-parser.js";
14
+ import type { GateResult } from "./result.js";
9
15
 
10
16
  const execAsync = promisify(exec);
11
17
 
@@ -47,562 +53,711 @@ If violations are found:
47
53
  If NO violations are found:
48
54
  {
49
55
  "status": "pass",
50
- "message": "No architecture violations found."
56
+ "message": "No problems found"
51
57
  }
52
58
  `;
53
59
 
54
- type ReviewConfig = ReviewGateConfig & ReviewPromptFrontmatter & { promptContent?: string };
60
+ type ReviewConfig = ReviewGateConfig &
61
+ ReviewPromptFrontmatter & { promptContent?: string };
62
+
63
+ interface ReviewJsonOutput {
64
+ status: "pass" | "fail";
65
+ message?: string;
66
+ violations?: Array<{
67
+ file: string;
68
+ line: number | string;
69
+ issue: string;
70
+ fix?: string;
71
+ priority: "critical" | "high" | "medium" | "low";
72
+ }>;
73
+ }
55
74
 
56
75
  export class ReviewGateExecutor {
57
- private constructPrompt(config: ReviewConfig, previousViolations: PreviousViolation[] = []): string {
58
- const baseContent = config.promptContent || '';
59
-
60
- if (previousViolations.length > 0) {
61
- return baseContent +
62
- '\n\n' + this.buildPreviousFailuresSection(previousViolations) +
63
- '\n' + JSON_SYSTEM_INSTRUCTION;
64
- }
65
-
66
- return baseContent + '\n' + JSON_SYSTEM_INSTRUCTION;
67
- }
68
-
69
- async execute(
70
- jobId: string,
71
- config: ReviewConfig,
72
- entryPointPath: string,
73
- loggerFactory: (adapterName?: string) => Promise<{ logger: (output: string) => Promise<void>; logPath: string }>,
74
- baseBranch: string,
75
- previousFailures?: Map<string, PreviousViolation[]>,
76
- changeOptions?: { commit?: string; uncommitted?: boolean },
77
- checkUsageLimit: boolean = false
78
- ): Promise<GateResult> {
79
- const startTime = Date.now();
80
- const logBuffer: string[] = [];
81
- let logSequence = 0; // Monotonic counter for dedup
82
- const activeLoggers: Array<(output: string, index: number) => Promise<void>> = [];
83
- const logPaths: string[] = [];
84
- const logPathsSet = new Set<string>(); // O(1) lookup
85
-
86
- const mainLogger = async (output: string) => {
87
- const seq = logSequence++;
88
- // Atomic length check and push
89
- // We check length directly on the array property to ensure we use the current value.
90
- // Even if we exceed the limit slightly due to concurrency (impossible in single-threaded JS),
91
- // it's a soft limit.
92
- if (logBuffer.length < MAX_LOG_BUFFER_SIZE) {
93
- logBuffer.push(output);
94
- }
95
- // Use allSettled to prevent failures from stopping the main logger
96
- await Promise.allSettled(activeLoggers.map(l => l(output, seq)));
97
- };
98
-
99
- const getAdapterLogger = async (adapterName: string) => {
100
- const { logger, logPath } = await loggerFactory(adapterName);
101
- if (!logPathsSet.has(logPath)) {
102
- logPathsSet.add(logPath);
103
- logPaths.push(logPath);
104
- }
105
-
106
- // Robust synchronization using index tracking.
107
- // We add the logger to activeLoggers FIRST to catch all future messages.
108
- // We also flush the buffer.
109
- // We use 'seenIndices' to prevent duplicates if a message arrives via both paths
110
- // (e.g. added to buffer and sent to activeLoggers simultaneously).
111
- // This acts as the atomic counter mechanism requested to safely handle race conditions.
112
- // Even if mainLogger pushes to buffer and calls activeLoggers during the snapshot flush,
113
- // seenIndices will prevent double logging.
114
- const seenIndices = new Set<number>();
115
-
116
- const safeLogger = async (msg: string, index: number) => {
117
- if (seenIndices.has(index)) return;
118
- seenIndices.add(index);
119
- await logger(msg);
120
- };
121
-
122
- activeLoggers.push(safeLogger);
123
-
124
- // Flush existing buffer
125
- const snapshot = [...logBuffer];
126
- // We pass the loop index 'i' which corresponds to the buffer index
127
- await Promise.all(snapshot.map((msg, i) => safeLogger(msg, i)));
128
-
129
- return logger;
130
- };
131
-
132
- try {
133
- await mainLogger(`Starting review: ${config.name}\n`);
134
- await mainLogger(`Entry point: ${entryPointPath}\n`);
135
- await mainLogger(`Base branch: ${baseBranch}\n`);
136
-
137
- const diff = await this.getDiff(entryPointPath, baseBranch, changeOptions);
138
- if (!diff.trim()) {
139
- await mainLogger('No changes found in entry point, skipping review.\n');
140
- await mainLogger('Result: pass - No changes to review\n');
141
- return {
142
- jobId,
143
- status: 'pass',
144
- duration: Date.now() - startTime,
145
- message: 'No changes to review',
146
- logPaths
147
- };
148
- }
149
-
150
- const required = config.num_reviews ?? 1;
151
- const outputs: Array<{ adapter: string; status: 'pass' | 'fail' | 'error'; message: string }> = [];
152
- const usedAdapters = new Set<string>();
153
-
154
- const preferences = config.cli_preference || [];
155
- const parallel = config.parallel ?? false;
156
-
157
- if (parallel && required > 1) {
158
- // Parallel Execution Logic
159
- // Check health of adapters in parallel, but only as many as needed
160
- const healthyAdapters: string[] = [];
161
- let prefIndex = 0;
162
-
163
- while (healthyAdapters.length < required && prefIndex < preferences.length) {
164
- const batchSize = required - healthyAdapters.length;
165
- const batch = preferences.slice(prefIndex, prefIndex + batchSize);
166
- prefIndex += batchSize;
167
-
168
- const batchResults = await Promise.all(
169
- batch.map(async (toolName) => {
170
- const adapter = getAdapter(toolName);
171
- if (!adapter) return { toolName, status: 'missing' as const };
172
- const health = await adapter.checkHealth({ checkUsageLimit });
173
- return { toolName, ...health };
174
- })
175
- );
176
-
177
- for (const res of batchResults) {
178
- if (res.status === 'healthy') {
179
- healthyAdapters.push(res.toolName);
180
- } else if (res.status === 'unhealthy') {
181
- await mainLogger(`Skipping ${res.toolName}: ${res.message || 'Unhealthy'}\n`);
182
- }
183
- }
184
- }
185
-
186
- if (healthyAdapters.length < required) {
187
- const msg = `Not enough healthy adapters. Need ${required}, found ${healthyAdapters.length}.`;
188
- await mainLogger(`Result: error - ${msg}\n`);
189
- return {
190
- jobId,
191
- status: 'error',
192
- duration: Date.now() - startTime,
193
- message: msg,
194
- logPaths
195
- };
196
- }
197
-
198
- // Launch exactly 'required' reviews in parallel
199
- const selectedAdapters = healthyAdapters.slice(0, required);
200
- await mainLogger(`Starting parallel reviews with: ${selectedAdapters.join(', ')}\n`);
201
-
202
- const results = await Promise.all(
203
- selectedAdapters.map((toolName) =>
204
- this.runSingleReview(toolName, config, diff, getAdapterLogger, mainLogger, previousFailures, true, checkUsageLimit)
205
- )
206
- );
207
-
208
- for (const res of results) {
209
- if (res) {
210
- outputs.push({ adapter: res.adapter, ...res.evaluation });
211
- usedAdapters.add(res.adapter);
212
- }
213
- }
214
- } else {
215
- // Sequential Execution Logic
216
- for (const toolName of preferences) {
217
- if (usedAdapters.size >= required) break;
218
- const res = await this.runSingleReview(toolName, config, diff, getAdapterLogger, mainLogger, previousFailures, false, checkUsageLimit);
219
- if (res) {
220
- outputs.push({ adapter: res.adapter, ...res.evaluation });
221
- usedAdapters.add(res.adapter);
222
- }
223
- }
224
- }
225
-
226
- if (usedAdapters.size < required) {
227
- const msg = `Failed to complete ${required} reviews. Completed: ${usedAdapters.size}. See logs for details.`;
228
- await mainLogger(`Result: error - ${msg}\n`);
229
- return {
230
- jobId,
231
- status: 'error',
232
- duration: Date.now() - startTime,
233
- message: msg,
234
- logPaths
235
- };
236
- }
237
-
238
- const failed = outputs.find(result => result.status === 'fail');
239
- const error = outputs.find(result => result.status === 'error');
240
-
241
- let status: 'pass' | 'fail' | 'error' = 'pass';
242
- let message = 'Passed';
243
-
244
- if (error) {
245
- status = 'error';
246
- message = `Error (${error.adapter}): ${error.message}`;
247
- } else if (failed) {
248
- status = 'fail';
249
- message = `Failed (${failed.adapter}): ${failed.message}`;
250
- }
251
-
252
- await mainLogger(`Result: ${status} - ${message}\n`);
253
-
254
- return {
255
- jobId,
256
- status,
257
- duration: Date.now() - startTime,
258
- message,
259
- logPaths
260
- };
261
- } catch (error: any) {
262
- await mainLogger(`Critical Error: ${error.message}\n`);
263
- await mainLogger('Result: error\n');
264
- return {
265
- jobId,
266
- status: 'error',
267
- duration: Date.now() - startTime,
268
- message: error.message,
269
- logPaths
270
- };
271
- }
272
- }
273
-
274
- private async runSingleReview(
275
- toolName: string,
276
- config: ReviewConfig,
277
- diff: string,
278
- getAdapterLogger: (adapterName: string) => Promise<(output: string) => Promise<void>>,
279
- mainLogger: (output: string) => Promise<void>,
280
- previousFailures?: Map<string, PreviousViolation[]>,
281
- skipHealthCheck: boolean = false,
282
- checkUsageLimit: boolean = false
283
- ): Promise<{ adapter: string; evaluation: { status: 'pass' | 'fail' | 'error'; message: string; json?: any } } | null> {
284
- const adapter = getAdapter(toolName);
285
- if (!adapter) return null;
286
-
287
- if (!skipHealthCheck) {
288
- const health = await adapter.checkHealth({ checkUsageLimit });
289
- if (health.status === 'missing') return null;
290
- if (health.status === 'unhealthy') {
291
- await mainLogger(`Skipping ${adapter.name}: ${health.message || 'Unhealthy'}\n`);
292
- return null;
293
- }
294
- }
295
-
296
- // Create per-adapter logger
297
- const adapterLogger = await getAdapterLogger(adapter.name);
298
-
299
- try {
300
- const startMsg = `[START] review:.:${config.name} (${adapter.name})`;
301
- await adapterLogger(`${startMsg}\n`);
302
-
303
- const adapterPreviousViolations = previousFailures?.get(adapter.name) || [];
304
- const finalPrompt = this.constructPrompt(config, adapterPreviousViolations);
305
-
306
- const output = await adapter.execute({
307
- prompt: finalPrompt,
308
- diff,
309
- model: config.model,
310
- timeoutMs: config.timeout ? config.timeout * 1000 : undefined
311
- });
312
-
313
- await adapterLogger(`\n--- Review Output (${adapter.name}) ---\n${output}\n`);
314
-
315
- const evaluation = this.evaluateOutput(output, diff);
316
-
317
- if (evaluation.filteredCount && evaluation.filteredCount > 0) {
318
- await adapterLogger(`Note: ${evaluation.filteredCount} out-of-scope violations filtered\n`);
319
- }
320
-
321
- // Log formatted summary
322
- if (evaluation.json) {
323
- await adapterLogger(`\n--- Parsed Result (${adapter.name}) ---\n`);
324
- if (evaluation.json.status === 'fail' && Array.isArray(evaluation.json.violations)) {
325
- await adapterLogger(`Status: FAIL\n`);
326
- await adapterLogger(`Violations:\n`);
327
- for (const [i, v] of evaluation.json.violations.entries()) {
328
- await adapterLogger(`${i + 1}. ${v.file}:${v.line || '?'} - ${v.issue}\n`);
329
- if (v.fix) await adapterLogger(` Fix: ${v.fix}\n`);
330
- }
331
- } else if (evaluation.json.status === 'pass') {
332
- await adapterLogger(`Status: PASS\n`);
333
- if (evaluation.json.message) await adapterLogger(`Message: ${evaluation.json.message}\n`);
334
- } else {
335
- await adapterLogger(`Status: ${evaluation.json.status}\n`);
336
- await adapterLogger(`Raw: ${JSON.stringify(evaluation.json, null, 2)}\n`);
337
- }
338
- await adapterLogger(`---------------------\n`);
339
- }
340
-
341
- const resultMsg = `Review result (${adapter.name}): ${evaluation.status} - ${evaluation.message}`;
342
- await adapterLogger(`${resultMsg}\n`);
343
- await mainLogger(`${resultMsg}\n`);
344
-
345
- return { adapter: adapter.name, evaluation };
346
- } catch (error: any) {
347
- const errorMsg = `Error running ${adapter.name}: ${error.message}`;
348
- await adapterLogger(`${errorMsg}\n`);
349
- await mainLogger(`${errorMsg}\n`);
350
- return null;
351
- }
352
- }
353
-
354
- private async getDiff(
355
- entryPointPath: string,
356
- baseBranch: string,
357
- options?: { commit?: string; uncommitted?: boolean }
358
- ): Promise<string> {
359
- // If uncommitted mode is explicitly requested
360
- if (options?.uncommitted) {
361
- const pathArg = this.pathArg(entryPointPath);
362
- // Match ChangeDetector.getUncommittedChangedFiles() behavior
363
- const staged = await this.execDiff(`git diff --cached${pathArg}`);
364
- const unstaged = await this.execDiff(`git diff${pathArg}`);
365
- const untracked = await this.untrackedDiff(entryPointPath);
366
- return [staged, unstaged, untracked].filter(Boolean).join('\n');
367
- }
368
-
369
- // If a specific commit is requested
370
- if (options?.commit) {
371
- const pathArg = this.pathArg(entryPointPath);
372
- // Match ChangeDetector.getCommitChangedFiles() behavior
373
- try {
374
- return await this.execDiff(`git diff ${options.commit}^..${options.commit}${pathArg}`);
375
- } catch (error: any) {
376
- // Handle initial commit case
377
- if (error.message?.includes('unknown revision') || error.stderr?.includes('unknown revision')) {
378
- return await this.execDiff(`git diff --root ${options.commit}${pathArg}`);
379
- }
380
- throw error;
381
- }
382
- }
383
-
384
- const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
385
- return isCI
386
- ? this.getCIDiff(entryPointPath, baseBranch)
387
- : this.getLocalDiff(entryPointPath, baseBranch);
388
- }
389
-
390
- private async getCIDiff(entryPointPath: string, baseBranch: string): Promise<string> {
391
- const baseRef = process.env.GITHUB_BASE_REF || baseBranch;
392
- const headRef = process.env.GITHUB_SHA || 'HEAD';
393
- const pathArg = this.pathArg(entryPointPath);
394
-
395
- try {
396
- return await this.execDiff(`git diff ${baseRef}...${headRef}${pathArg}`);
397
- } catch (error) {
398
- const fallback = await this.execDiff(`git diff HEAD^...HEAD${pathArg}`);
399
- return fallback;
400
- }
401
- }
402
-
403
- private async getLocalDiff(entryPointPath: string, baseBranch: string): Promise<string> {
404
- const pathArg = this.pathArg(entryPointPath);
405
- const committed = await this.execDiff(`git diff ${baseBranch}...HEAD${pathArg}`);
406
- const uncommitted = await this.execDiff(`git diff HEAD${pathArg}`);
407
- const untracked = await this.untrackedDiff(entryPointPath);
408
-
409
- return [committed, uncommitted, untracked].filter(Boolean).join('\n');
410
- }
411
-
412
- private async untrackedDiff(entryPointPath: string): Promise<string> {
413
- const pathArg = this.pathArg(entryPointPath);
414
- const { stdout } = await execAsync(`git ls-files --others --exclude-standard${pathArg}`, {
415
- maxBuffer: MAX_BUFFER_BYTES
416
- });
417
- const files = this.parseLines(stdout);
418
- const diffs: string[] = [];
419
-
420
- for (const file of files) {
421
- try {
422
- const diff = await this.execDiff(`git diff --no-index -- /dev/null ${this.quoteArg(file)}`);
423
- if (diff.trim()) diffs.push(diff);
424
- } catch (error: any) {
425
- // Only suppress errors for missing/deleted files (ENOENT or "Could not access")
426
- // Re-throw other errors (permissions, git issues) so they surface properly
427
- const msg = [error.message, error.stderr].filter(Boolean).join('\n');
428
- if (msg.includes('Could not access') || msg.includes('ENOENT') || msg.includes('No such file')) {
429
- // File was deleted/moved between listing and diff; skip it
430
- continue;
431
- }
432
- throw error;
433
- }
434
- }
435
-
436
- return diffs.join('\n');
437
- }
438
-
439
- private async execDiff(command: string): Promise<string> {
440
- try {
441
- const { stdout } = await execAsync(command, { maxBuffer: MAX_BUFFER_BYTES });
442
- return stdout;
443
- } catch (error: any) {
444
- if (typeof error.code === 'number' && error.stdout) {
445
- return error.stdout;
446
- }
447
- throw error;
448
- }
449
- }
450
-
451
- private buildPreviousFailuresSection(violations: PreviousViolation[]): string {
452
- const lines = [
453
- '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
454
- 'PREVIOUS FAILURES TO VERIFY (from last run)',
455
- '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
456
- '',
457
- 'The following violations were identified in the previous review. Your PRIMARY TASK is to verify whether these specific issues have been fixed in the current changes:',
458
- ''
459
- ];
460
-
461
- violations.forEach((v, i) => {
462
- lines.push(`${i + 1}. ${v.file}:${v.line} - ${v.issue}`);
463
- if (v.fix) {
464
- lines.push(` Suggested fix: ${v.fix}`);
465
- }
466
- lines.push('');
467
- });
468
-
469
- lines.push('INSTRUCTIONS:');
470
- lines.push('- Check if each violation listed above has been addressed in the diff');
471
- lines.push('- For violations that are fixed, confirm they no longer appear');
472
- lines.push('- For violations that remain unfixed, include them in your violations array');
473
- lines.push('- Also check for any NEW violations in the changed code');
474
- lines.push('- Return status "pass" only if ALL previous violations are fixed AND no new violations exist');
475
- lines.push('');
476
- lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
477
-
478
- return lines.join('\n');
479
- }
480
-
481
- public evaluateOutput(output: string, diff?: string): {
482
- status: 'pass' | 'fail' | 'error';
483
- message: string;
484
- json?: any;
485
- filteredCount?: number;
486
- } {
487
- const diffRanges = diff ? parseDiff(diff) : undefined;
488
-
489
- try {
490
- // 1. Try to extract from markdown code block first (most reliable)
491
- const jsonBlockMatch = output.match(/```json\s*([\s\S]*?)\s*```/);
492
- if (jsonBlockMatch) {
493
- try {
494
- const json = JSON.parse(jsonBlockMatch[1]);
495
- return this.validateAndReturn(json, diffRanges);
496
- } catch {
497
- // If code block parse fails, fall back to other methods
498
- }
499
- }
500
-
501
- // 2. Fallback: Find the last valid JSON object
502
- // This helps when there are braces in the explanation text before the actual JSON
503
- // We start from the last '}' and search backwards for a matching '{' that creates valid JSON
504
- const end = output.lastIndexOf('}');
505
- if (end !== -1) {
506
- let start = output.lastIndexOf('{', end);
507
- while (start !== -1) {
508
- const candidate = output.substring(start, end + 1);
509
- try {
510
- const json = JSON.parse(candidate);
511
- // If we successfully parsed an object with 'status', it's likely our result
512
- if (json.status) {
513
- return this.validateAndReturn(json, diffRanges);
514
- }
515
- } catch {
516
- // Not valid JSON, keep searching backwards
517
- }
518
- start = output.lastIndexOf('{', start - 1);
519
- }
520
- }
521
-
522
- // 3. Last resort: simplistic extraction (original behavior)
523
- const firstStart = output.indexOf('{');
524
- if (firstStart !== -1 && end !== -1 && end > firstStart) {
525
- try {
526
- const candidate = output.substring(firstStart, end + 1);
527
- const json = JSON.parse(candidate);
528
- return this.validateAndReturn(json, diffRanges);
529
- } catch {
530
- // Ignore
531
- }
532
- }
533
-
534
- return { status: 'error', message: 'No valid JSON object found in output' };
535
-
536
- } catch (error: any) {
537
- return { status: 'error', message: `Failed to parse JSON output: ${error.message}` };
538
- }
539
- }
540
-
541
- private validateAndReturn(
542
- json: any,
543
- diffRanges?: Map<string, DiffFileRange>
544
- ): { status: 'pass' | 'fail' | 'error'; message: string; json?: any; filteredCount?: number } {
545
- // Validate Schema
546
- if (!json.status || (json.status !== 'pass' && json.status !== 'fail')) {
547
- return { status: 'error', message: 'Invalid JSON: missing or invalid "status" field', json };
548
- }
549
-
550
- if (json.status === 'pass') {
551
- return { status: 'pass', message: json.message || 'Passed', json };
552
- }
553
-
554
- // json.status === 'fail'
555
- let filteredCount = 0;
556
-
557
- if (Array.isArray(json.violations) && diffRanges?.size) {
558
- const originalCount = json.violations.length;
559
-
560
- json.violations = json.violations.filter((v: any) => {
561
- const isValid = isValidViolationLocation(v.file, v.line, diffRanges);
562
- if (!isValid) {
563
- // Can't easily access logger here, but could return warning info
564
- // console.warn(`[WARNING] Filtered violation: ${v.file}:${v.line ?? '?'} (not in diff)`);
565
- }
566
- return isValid;
567
- });
568
-
569
- filteredCount = originalCount - json.violations.length;
570
-
571
- // If all filtered out, change to pass
572
- if (json.violations.length === 0) {
573
- return {
574
- status: 'pass',
575
- message: `Passed (${filteredCount} out-of-scope violations filtered)`,
576
- json: { status: 'pass' },
577
- filteredCount
578
- };
579
- }
580
- }
581
-
582
- const violationCount = Array.isArray(json.violations) ? json.violations.length : 'some';
583
-
584
- // Construct a summary message
585
- let msg = `Found ${violationCount} violations`;
586
- if (Array.isArray(json.violations) && json.violations.length > 0) {
587
- const first = json.violations[0];
588
- msg += `. Example: ${first.issue} in ${first.file}`;
589
- }
590
-
591
- return { status: 'fail', message: msg, json, filteredCount };
592
- }
593
-
594
- private parseLines(stdout: string): string[] {
595
- return stdout
596
- .split('\n')
597
- .map(line => line.trim())
598
- .filter(line => line.length > 0);
599
- }
600
-
601
- private pathArg(entryPointPath: string): string {
602
- return ` -- ${this.quoteArg(entryPointPath)}`;
603
- }
604
-
605
- private quoteArg(value: string): string {
606
- return `"${value.replace(/(["\\$`])/g, '\\$1')}"`;
607
- }
76
+ private constructPrompt(
77
+ config: ReviewConfig,
78
+ previousViolations: PreviousViolation[] = [],
79
+ ): string {
80
+ const baseContent = config.promptContent || "";
81
+
82
+ if (previousViolations.length > 0) {
83
+ return (
84
+ baseContent +
85
+ "\n\n" +
86
+ this.buildPreviousFailuresSection(previousViolations) +
87
+ "\n" +
88
+ JSON_SYSTEM_INSTRUCTION
89
+ );
90
+ }
91
+
92
+ return `${baseContent}\n${JSON_SYSTEM_INSTRUCTION}`;
93
+ }
94
+
95
+ async execute(
96
+ jobId: string,
97
+ config: ReviewConfig,
98
+ entryPointPath: string,
99
+ loggerFactory: (adapterName?: string) => Promise<{
100
+ logger: (output: string) => Promise<void>;
101
+ logPath: string;
102
+ }>,
103
+ baseBranch: string,
104
+ previousFailures?: Map<string, PreviousViolation[]>,
105
+ changeOptions?: { commit?: string; uncommitted?: boolean },
106
+ checkUsageLimit: boolean = false,
107
+ ): Promise<GateResult> {
108
+ const startTime = Date.now();
109
+ const logBuffer: string[] = [];
110
+ let logSequence = 0; // Monotonic counter for dedup
111
+ const activeLoggers: Array<
112
+ (output: string, index: number) => Promise<void>
113
+ > = [];
114
+ const logPaths: string[] = [];
115
+ const logPathsSet = new Set<string>(); // O(1) lookup
116
+
117
+ const mainLogger = async (output: string) => {
118
+ const seq = logSequence++;
119
+ // Atomic length check and push
120
+ // We check length directly on the array property to ensure we use the current value.
121
+ // Even if we exceed the limit slightly due to concurrency (impossible in single-threaded JS),
122
+ // it's a soft limit.
123
+ if (logBuffer.length < MAX_LOG_BUFFER_SIZE) {
124
+ logBuffer.push(output);
125
+ }
126
+ // Use allSettled to prevent failures from stopping the main logger
127
+ await Promise.allSettled(activeLoggers.map((l) => l(output, seq)));
128
+ };
129
+
130
+ const getAdapterLogger = async (adapterName: string) => {
131
+ const { logger, logPath } = await loggerFactory(adapterName);
132
+ if (!logPathsSet.has(logPath)) {
133
+ logPathsSet.add(logPath);
134
+ logPaths.push(logPath);
135
+ }
136
+
137
+ // Robust synchronization using index tracking.
138
+ // We add the logger to activeLoggers FIRST to catch all future messages.
139
+ // We also flush the buffer.
140
+ // We use 'seenIndices' to prevent duplicates if a message arrives via both paths
141
+ // (e.g. added to buffer and sent to activeLoggers simultaneously).
142
+ // This acts as the atomic counter mechanism requested to safely handle race conditions.
143
+ // Even if mainLogger pushes to buffer and calls activeLoggers during the snapshot flush,
144
+ // seenIndices will prevent double logging.
145
+ const seenIndices = new Set<number>();
146
+
147
+ const safeLogger = async (msg: string, index: number) => {
148
+ if (seenIndices.has(index)) return;
149
+ seenIndices.add(index);
150
+ await logger(msg);
151
+ };
152
+
153
+ activeLoggers.push(safeLogger);
154
+
155
+ // Flush existing buffer
156
+ const snapshot = [...logBuffer];
157
+ // We pass the loop index 'i' which corresponds to the buffer index
158
+ await Promise.all(snapshot.map((msg, i) => safeLogger(msg, i)));
159
+
160
+ return logger;
161
+ };
162
+
163
+ try {
164
+ await mainLogger(`Starting review: ${config.name}\n`);
165
+ await mainLogger(`Entry point: ${entryPointPath}\n`);
166
+ await mainLogger(`Base branch: ${baseBranch}\n`);
167
+
168
+ const diff = await this.getDiff(
169
+ entryPointPath,
170
+ baseBranch,
171
+ changeOptions,
172
+ );
173
+ if (!diff.trim()) {
174
+ await mainLogger("No changes found in entry point, skipping review.\n");
175
+ await mainLogger("Result: pass - No changes to review\n");
176
+ return {
177
+ jobId,
178
+ status: "pass",
179
+ duration: Date.now() - startTime,
180
+ message: "No changes to review",
181
+ logPaths,
182
+ };
183
+ }
184
+
185
+ const required = config.num_reviews ?? 1;
186
+ const outputs: Array<{
187
+ adapter: string;
188
+ status: "pass" | "fail" | "error";
189
+ message: string;
190
+ }> = [];
191
+ const usedAdapters = new Set<string>();
192
+
193
+ const preferences = config.cli_preference || [];
194
+ const parallel = config.parallel ?? false;
195
+
196
+ if (parallel && required > 1) {
197
+ // Parallel Execution Logic
198
+ // Check health of adapters in parallel, but only as many as needed
199
+ const healthyAdapters: string[] = [];
200
+ let prefIndex = 0;
201
+
202
+ while (
203
+ healthyAdapters.length < required &&
204
+ prefIndex < preferences.length
205
+ ) {
206
+ const batchSize = required - healthyAdapters.length;
207
+ const batch = preferences.slice(prefIndex, prefIndex + batchSize);
208
+ prefIndex += batchSize;
209
+
210
+ const batchResults = await Promise.all(
211
+ batch.map(async (toolName) => {
212
+ const adapter = getAdapter(toolName);
213
+ if (!adapter) return { toolName, status: "missing" as const };
214
+ const health = await adapter.checkHealth({ checkUsageLimit });
215
+ return { toolName, ...health };
216
+ }),
217
+ );
218
+
219
+ for (const res of batchResults) {
220
+ if (res.status === "healthy") {
221
+ healthyAdapters.push(res.toolName);
222
+ } else if (res.status === "unhealthy") {
223
+ await mainLogger(
224
+ `Skipping ${res.toolName}: ${res.message || "Unhealthy"}\n`,
225
+ );
226
+ }
227
+ }
228
+ }
229
+
230
+ if (healthyAdapters.length < required) {
231
+ const msg = `Not enough healthy adapters. Need ${required}, found ${healthyAdapters.length}.`;
232
+ await mainLogger(`Result: error - ${msg}\n`);
233
+ return {
234
+ jobId,
235
+ status: "error",
236
+ duration: Date.now() - startTime,
237
+ message: msg,
238
+ logPaths,
239
+ };
240
+ }
241
+
242
+ // Launch exactly 'required' reviews in parallel
243
+ const selectedAdapters = healthyAdapters.slice(0, required);
244
+ await mainLogger(
245
+ `Starting parallel reviews with: ${selectedAdapters.join(", ")}\n`,
246
+ );
247
+
248
+ const results = await Promise.all(
249
+ selectedAdapters.map((toolName) =>
250
+ this.runSingleReview(
251
+ toolName,
252
+ config,
253
+ diff,
254
+ getAdapterLogger,
255
+ mainLogger,
256
+ previousFailures,
257
+ true,
258
+ checkUsageLimit,
259
+ ),
260
+ ),
261
+ );
262
+
263
+ for (const res of results) {
264
+ if (res) {
265
+ outputs.push({ adapter: res.adapter, ...res.evaluation });
266
+ usedAdapters.add(res.adapter);
267
+ }
268
+ }
269
+ } else {
270
+ // Sequential Execution Logic
271
+ for (const toolName of preferences) {
272
+ if (usedAdapters.size >= required) break;
273
+ const res = await this.runSingleReview(
274
+ toolName,
275
+ config,
276
+ diff,
277
+ getAdapterLogger,
278
+ mainLogger,
279
+ previousFailures,
280
+ false,
281
+ checkUsageLimit,
282
+ );
283
+ if (res) {
284
+ outputs.push({ adapter: res.adapter, ...res.evaluation });
285
+ usedAdapters.add(res.adapter);
286
+ }
287
+ }
288
+ }
289
+
290
+ if (usedAdapters.size < required) {
291
+ const msg = `Failed to complete ${required} reviews. Completed: ${usedAdapters.size}. See logs for details.`;
292
+ await mainLogger(`Result: error - ${msg}\n`);
293
+ return {
294
+ jobId,
295
+ status: "error",
296
+ duration: Date.now() - startTime,
297
+ message: msg,
298
+ logPaths,
299
+ };
300
+ }
301
+
302
+ const failed = outputs.find((result) => result.status === "fail");
303
+ const error = outputs.find((result) => result.status === "error");
304
+
305
+ let status: "pass" | "fail" | "error" = "pass";
306
+ let message = "Passed";
307
+
308
+ if (error) {
309
+ status = "error";
310
+ message = `Error (${error.adapter}): ${error.message}`;
311
+ } else if (failed) {
312
+ status = "fail";
313
+ message = `Failed (${failed.adapter}): ${failed.message}`;
314
+ }
315
+
316
+ await mainLogger(`Result: ${status} - ${message}\n`);
317
+
318
+ return {
319
+ jobId,
320
+ status,
321
+ duration: Date.now() - startTime,
322
+ message,
323
+ logPaths,
324
+ };
325
+ } catch (error: unknown) {
326
+ const err = error as { message?: string };
327
+ await mainLogger(`Critical Error: ${err.message}\n`);
328
+ await mainLogger("Result: error\n");
329
+ return {
330
+ jobId,
331
+ status: "error",
332
+ duration: Date.now() - startTime,
333
+ message: err.message,
334
+ logPaths,
335
+ };
336
+ }
337
+ }
338
+
339
+ private async runSingleReview(
340
+ toolName: string,
341
+ config: ReviewConfig,
342
+ diff: string,
343
+ getAdapterLogger: (
344
+ adapterName: string,
345
+ ) => Promise<(output: string) => Promise<void>>,
346
+ mainLogger: (output: string) => Promise<void>,
347
+ previousFailures?: Map<string, PreviousViolation[]>,
348
+ skipHealthCheck: boolean = false,
349
+ checkUsageLimit: boolean = false,
350
+ ): Promise<{
351
+ adapter: string;
352
+ evaluation: {
353
+ status: "pass" | "fail" | "error";
354
+ message: string;
355
+ json?: ReviewJsonOutput;
356
+ };
357
+ } | null> {
358
+ const adapter = getAdapter(toolName);
359
+ if (!adapter) return null;
360
+
361
+ if (!skipHealthCheck) {
362
+ const health = await adapter.checkHealth({ checkUsageLimit });
363
+ if (health.status === "missing") return null;
364
+ if (health.status === "unhealthy") {
365
+ await mainLogger(
366
+ `Skipping ${adapter.name}: ${health.message || "Unhealthy"}\n`,
367
+ );
368
+ return null;
369
+ }
370
+ }
371
+
372
+ // Create per-adapter logger
373
+ const adapterLogger = await getAdapterLogger(adapter.name);
374
+
375
+ try {
376
+ const startMsg = `[START] review:.:${config.name} (${adapter.name})`;
377
+ await adapterLogger(`${startMsg}\n`);
378
+
379
+ const adapterPreviousViolations =
380
+ previousFailures?.get(adapter.name) || [];
381
+ const finalPrompt = this.constructPrompt(
382
+ config,
383
+ adapterPreviousViolations,
384
+ );
385
+
386
+ const output = await adapter.execute({
387
+ prompt: finalPrompt,
388
+ diff,
389
+ model: config.model,
390
+ timeoutMs: config.timeout ? config.timeout * 1000 : undefined,
391
+ });
392
+
393
+ await adapterLogger(
394
+ `\n--- Review Output (${adapter.name}) ---\n${output}\n`,
395
+ );
396
+
397
+ const evaluation = this.evaluateOutput(output, diff);
398
+
399
+ if (evaluation.filteredCount && evaluation.filteredCount > 0) {
400
+ await adapterLogger(
401
+ `Note: ${evaluation.filteredCount} out-of-scope violations filtered\n`,
402
+ );
403
+ }
404
+
405
+ // Log formatted summary
406
+ if (evaluation.json) {
407
+ await adapterLogger(`\n--- Parsed Result (${adapter.name}) ---\n`);
408
+ if (
409
+ evaluation.json.status === "fail" &&
410
+ Array.isArray(evaluation.json.violations)
411
+ ) {
412
+ await adapterLogger(`Status: FAIL\n`);
413
+ await adapterLogger(`Violations:\n`);
414
+ for (const [i, v] of evaluation.json.violations.entries()) {
415
+ await adapterLogger(
416
+ `${i + 1}. ${v.file}:${v.line || "?"} - ${v.issue}\n`,
417
+ );
418
+ if (v.fix) await adapterLogger(` Fix: ${v.fix}\n`);
419
+ }
420
+ } else if (evaluation.json.status === "pass") {
421
+ await adapterLogger(`Status: PASS\n`);
422
+ if (evaluation.json.message)
423
+ await adapterLogger(`Message: ${evaluation.json.message}\n`);
424
+ } else {
425
+ await adapterLogger(`Status: ${evaluation.json.status}\n`);
426
+ await adapterLogger(
427
+ `Raw: ${JSON.stringify(evaluation.json, null, 2)}\n`,
428
+ );
429
+ }
430
+ await adapterLogger(`---------------------\n`);
431
+ }
432
+
433
+ const resultMsg = `Review result (${adapter.name}): ${evaluation.status} - ${evaluation.message}`;
434
+ await adapterLogger(`${resultMsg}\n`);
435
+ await mainLogger(`${resultMsg}\n`);
436
+
437
+ return { adapter: adapter.name, evaluation };
438
+ } catch (error: unknown) {
439
+ const err = error as { message?: string };
440
+ const errorMsg = `Error running ${adapter.name}: ${err.message}`;
441
+ await adapterLogger(`${errorMsg}\n`);
442
+ await mainLogger(`${errorMsg}\n`);
443
+ return null;
444
+ }
445
+ }
446
+
447
+ private async getDiff(
448
+ entryPointPath: string,
449
+ baseBranch: string,
450
+ options?: { commit?: string; uncommitted?: boolean },
451
+ ): Promise<string> {
452
+ // If uncommitted mode is explicitly requested
453
+ if (options?.uncommitted) {
454
+ const pathArg = this.pathArg(entryPointPath);
455
+ // Match ChangeDetector.getUncommittedChangedFiles() behavior
456
+ const staged = await this.execDiff(`git diff --cached${pathArg}`);
457
+ const unstaged = await this.execDiff(`git diff${pathArg}`);
458
+ const untracked = await this.untrackedDiff(entryPointPath);
459
+ return [staged, unstaged, untracked].filter(Boolean).join("\n");
460
+ }
461
+
462
+ // If a specific commit is requested
463
+ if (options?.commit) {
464
+ const pathArg = this.pathArg(entryPointPath);
465
+ // Match ChangeDetector.getCommitChangedFiles() behavior
466
+ try {
467
+ return await this.execDiff(
468
+ `git diff ${options.commit}^..${options.commit}${pathArg}`,
469
+ );
470
+ } catch (error: unknown) {
471
+ // Handle initial commit case
472
+ const err = error as { message?: string; stderr?: string };
473
+ if (
474
+ err.message?.includes("unknown revision") ||
475
+ err.stderr?.includes("unknown revision")
476
+ ) {
477
+ return await this.execDiff(
478
+ `git diff --root ${options.commit}${pathArg}`,
479
+ );
480
+ }
481
+ throw error;
482
+ }
483
+ }
484
+
485
+ const isCI =
486
+ process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
487
+ return isCI
488
+ ? this.getCIDiff(entryPointPath, baseBranch)
489
+ : this.getLocalDiff(entryPointPath, baseBranch);
490
+ }
491
+
492
+ private async getCIDiff(
493
+ entryPointPath: string,
494
+ baseBranch: string,
495
+ ): Promise<string> {
496
+ const baseRef = process.env.GITHUB_BASE_REF || baseBranch;
497
+ const headRef = process.env.GITHUB_SHA || "HEAD";
498
+ const pathArg = this.pathArg(entryPointPath);
499
+
500
+ try {
501
+ return await this.execDiff(`git diff ${baseRef}...${headRef}${pathArg}`);
502
+ } catch (_error) {
503
+ const fallback = await this.execDiff(`git diff HEAD^...HEAD${pathArg}`);
504
+ return fallback;
505
+ }
506
+ }
507
+
508
+ private async getLocalDiff(
509
+ entryPointPath: string,
510
+ baseBranch: string,
511
+ ): Promise<string> {
512
+ const pathArg = this.pathArg(entryPointPath);
513
+ const committed = await this.execDiff(
514
+ `git diff ${baseBranch}...HEAD${pathArg}`,
515
+ );
516
+ const uncommitted = await this.execDiff(`git diff HEAD${pathArg}`);
517
+ const untracked = await this.untrackedDiff(entryPointPath);
518
+
519
+ return [committed, uncommitted, untracked].filter(Boolean).join("\n");
520
+ }
521
+
522
+ private async untrackedDiff(entryPointPath: string): Promise<string> {
523
+ const pathArg = this.pathArg(entryPointPath);
524
+ const { stdout } = await execAsync(
525
+ `git ls-files --others --exclude-standard${pathArg}`,
526
+ {
527
+ maxBuffer: MAX_BUFFER_BYTES,
528
+ },
529
+ );
530
+ const files = this.parseLines(stdout);
531
+ const diffs: string[] = [];
532
+
533
+ for (const file of files) {
534
+ try {
535
+ const diff = await this.execDiff(
536
+ `git diff --no-index -- /dev/null ${this.quoteArg(file)}`,
537
+ );
538
+ if (diff.trim()) diffs.push(diff);
539
+ } catch (error: unknown) {
540
+ // Only suppress errors for missing/deleted files (ENOENT or "Could not access")
541
+ // Re-throw other errors (permissions, git issues) so they surface properly
542
+ const err = error as { message?: string; stderr?: string };
543
+ const msg = [err.message, err.stderr].filter(Boolean).join("\n");
544
+ if (
545
+ msg.includes("Could not access") ||
546
+ msg.includes("ENOENT") ||
547
+ msg.includes("No such file")
548
+ ) {
549
+ // File was deleted/moved between listing and diff; skip it
550
+ continue;
551
+ }
552
+ throw error;
553
+ }
554
+ }
555
+
556
+ return diffs.join("\n");
557
+ }
558
+
559
+ private async execDiff(command: string): Promise<string> {
560
+ try {
561
+ const { stdout } = await execAsync(command, {
562
+ maxBuffer: MAX_BUFFER_BYTES,
563
+ });
564
+ return stdout;
565
+ } catch (error: unknown) {
566
+ const err = error as { code?: number; stdout?: string };
567
+ if (typeof err.code === "number" && err.stdout) {
568
+ return err.stdout;
569
+ }
570
+ throw error;
571
+ }
572
+ }
573
+
574
+ private buildPreviousFailuresSection(
575
+ violations: PreviousViolation[],
576
+ ): string {
577
+ const lines = [
578
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
579
+ "PREVIOUS FAILURES TO VERIFY (from last run)",
580
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
581
+ "",
582
+ "The following violations were identified in the previous review. Your PRIMARY TASK is to verify whether these specific issues have been fixed in the current changes:",
583
+ "",
584
+ ];
585
+
586
+ violations.forEach((v, i) => {
587
+ lines.push(`${i + 1}. ${v.file}:${v.line} - ${v.issue}`);
588
+ if (v.fix) {
589
+ lines.push(` Suggested fix: ${v.fix}`);
590
+ }
591
+ lines.push("");
592
+ });
593
+
594
+ lines.push("INSTRUCTIONS:");
595
+ lines.push(
596
+ "- Check if each violation listed above has been addressed in the diff",
597
+ );
598
+ lines.push(
599
+ "- For violations that are fixed, confirm they no longer appear",
600
+ );
601
+ lines.push(
602
+ "- For violations that remain unfixed, include them in your violations array",
603
+ );
604
+ lines.push("- Also check for any NEW violations in the changed code");
605
+ lines.push(
606
+ '- Return status "pass" only if ALL previous violations are fixed AND no new violations exist',
607
+ );
608
+ lines.push("");
609
+ lines.push("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
610
+
611
+ return lines.join("\n");
612
+ }
613
+
614
+ public evaluateOutput(
615
+ output: string,
616
+ diff?: string,
617
+ ): {
618
+ status: "pass" | "fail" | "error";
619
+ message: string;
620
+ json?: ReviewJsonOutput;
621
+ filteredCount?: number;
622
+ } {
623
+ const diffRanges = diff ? parseDiff(diff) : undefined;
624
+
625
+ try {
626
+ // 1. Try to extract from markdown code block first (most reliable)
627
+ const jsonBlockMatch = output.match(/```json\s*([\s\S]*?)\s*```/);
628
+ if (jsonBlockMatch) {
629
+ try {
630
+ const json = JSON.parse(jsonBlockMatch[1]);
631
+ return this.validateAndReturn(json, diffRanges);
632
+ } catch {
633
+ // If code block parse fails, fall back to other methods
634
+ }
635
+ }
636
+
637
+ // 2. Fallback: Find the last valid JSON object
638
+ // This helps when there are braces in the explanation text before the actual JSON
639
+ // We start from the last '}' and search backwards for a matching '{' that creates valid JSON
640
+ const end = output.lastIndexOf("}");
641
+ if (end !== -1) {
642
+ let start = output.lastIndexOf("{", end);
643
+ while (start !== -1) {
644
+ const candidate = output.substring(start, end + 1);
645
+ try {
646
+ const json = JSON.parse(candidate);
647
+ // If we successfully parsed an object with 'status', it's likely our result
648
+ if (json.status) {
649
+ return this.validateAndReturn(json, diffRanges);
650
+ }
651
+ } catch {
652
+ // Not valid JSON, keep searching backwards
653
+ }
654
+ start = output.lastIndexOf("{", start - 1);
655
+ }
656
+ }
657
+
658
+ // 3. Last resort: simplistic extraction (original behavior)
659
+ const firstStart = output.indexOf("{");
660
+ if (firstStart !== -1 && end !== -1 && end > firstStart) {
661
+ try {
662
+ const candidate = output.substring(firstStart, end + 1);
663
+ const json = JSON.parse(candidate);
664
+ return this.validateAndReturn(json, diffRanges);
665
+ } catch {
666
+ // Ignore
667
+ }
668
+ }
669
+
670
+ return {
671
+ status: "error",
672
+ message: "No valid JSON object found in output",
673
+ };
674
+ } catch (error: unknown) {
675
+ const err = error as { message?: string };
676
+ return {
677
+ status: "error",
678
+ message: `Failed to parse JSON output: ${err.message}`,
679
+ };
680
+ }
681
+ }
682
+
683
+ private validateAndReturn(
684
+ json: ReviewJsonOutput,
685
+ diffRanges?: Map<string, DiffFileRange>,
686
+ ): {
687
+ status: "pass" | "fail" | "error";
688
+ message: string;
689
+ json?: ReviewJsonOutput;
690
+ filteredCount?: number;
691
+ } {
692
+ // Validate Schema
693
+ if (!json.status || (json.status !== "pass" && json.status !== "fail")) {
694
+ return {
695
+ status: "error",
696
+ message: 'Invalid JSON: missing or invalid "status" field',
697
+ json,
698
+ };
699
+ }
700
+
701
+ if (json.status === "pass") {
702
+ return { status: "pass", message: json.message || "Passed", json };
703
+ }
704
+
705
+ // json.status === 'fail'
706
+ let filteredCount = 0;
707
+
708
+ if (Array.isArray(json.violations) && diffRanges?.size) {
709
+ const originalCount = json.violations.length;
710
+
711
+ json.violations = json.violations.filter(
712
+ (v: { file: string; line: number | string }) => {
713
+ const isValid = isValidViolationLocation(v.file, v.line, diffRanges);
714
+ if (!isValid) {
715
+ // Can't easily access logger here, but could return warning info
716
+ // console.warn(`[WARNING] Filtered violation: ${v.file}:${v.line ?? '?'} (not in diff)`);
717
+ }
718
+ return isValid;
719
+ },
720
+ );
721
+
722
+ filteredCount = originalCount - json.violations.length;
723
+
724
+ // If all filtered out, change to pass
725
+ if (json.violations.length === 0) {
726
+ return {
727
+ status: "pass",
728
+ message: `Passed (${filteredCount} out-of-scope violations filtered)`,
729
+ json: { status: "pass" },
730
+ filteredCount,
731
+ };
732
+ }
733
+ }
734
+
735
+ const violationCount = Array.isArray(json.violations)
736
+ ? json.violations.length
737
+ : "some";
738
+
739
+ // Construct a summary message
740
+ let msg = `Found ${violationCount} violations`;
741
+ if (Array.isArray(json.violations) && json.violations.length > 0) {
742
+ const first = json.violations[0];
743
+ msg += `. Example: ${first.issue} in ${first.file}`;
744
+ }
745
+
746
+ return { status: "fail", message: msg, json, filteredCount };
747
+ }
748
+
749
+ private parseLines(stdout: string): string[] {
750
+ return stdout
751
+ .split("\n")
752
+ .map((line) => line.trim())
753
+ .filter((line) => line.length > 0);
754
+ }
755
+
756
+ private pathArg(entryPointPath: string): string {
757
+ return ` -- ${this.quoteArg(entryPointPath)}`;
758
+ }
759
+
760
+ private quoteArg(value: string): string {
761
+ return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
762
+ }
608
763
  }