agent-gauntlet 0.1.9 → 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 +515 -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 -221
  38. package/src/gates/check.ts +78 -69
  39. package/src/gates/result.ts +7 -6
  40. package/src/gates/review.test.ts +188 -0
  41. package/src/gates/review.ts +717 -506
  42. package/src/index.ts +16 -15
  43. package/src/output/console.ts +253 -198
  44. package/src/output/logger.ts +65 -51
  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,15 +1,22 @@
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
 
12
18
  const MAX_BUFFER_BYTES = 10 * 1024 * 1024;
19
+ const MAX_LOG_BUFFER_SIZE = 10000;
13
20
 
14
21
  const JSON_SYSTEM_INSTRUCTION = `
15
22
  You are in a read-only mode. You may read files in the repository to gather context.
@@ -46,507 +53,711 @@ If violations are found:
46
53
  If NO violations are found:
47
54
  {
48
55
  "status": "pass",
49
- "message": "No architecture violations found."
56
+ "message": "No problems found"
50
57
  }
51
58
  `;
52
59
 
53
- 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
+ }
54
74
 
55
75
  export class ReviewGateExecutor {
56
- private constructPrompt(config: ReviewConfig, previousViolations: PreviousViolation[] = []): string {
57
- const baseContent = config.promptContent || '';
58
-
59
- if (previousViolations.length > 0) {
60
- return baseContent +
61
- '\n\n' + this.buildPreviousFailuresSection(previousViolations) +
62
- '\n' + JSON_SYSTEM_INSTRUCTION;
63
- }
64
-
65
- return baseContent + '\n' + JSON_SYSTEM_INSTRUCTION;
66
- }
67
-
68
- async execute(
69
- jobId: string,
70
- config: ReviewConfig,
71
- entryPointPath: string,
72
- loggerFactory: (adapterName?: string) => Promise<(output: string) => Promise<void>>,
73
- baseBranch: string,
74
- previousFailures?: Map<string, PreviousViolation[]>,
75
- changeOptions?: { commit?: string; uncommitted?: boolean },
76
- checkUsageLimit: boolean = false
77
- ): Promise<GateResult> {
78
- const startTime = Date.now();
79
- const mainLogger = await loggerFactory();
80
-
81
- try {
82
- await mainLogger(`Starting review: ${config.name}\n`);
83
- await mainLogger(`Entry point: ${entryPointPath}\n`);
84
- await mainLogger(`Base branch: ${baseBranch}\n`);
85
-
86
- const diff = await this.getDiff(entryPointPath, baseBranch, changeOptions);
87
- if (!diff.trim()) {
88
- await mainLogger('No changes found in entry point, skipping review.\n');
89
- await mainLogger('Result: pass - No changes to review\n');
90
- return {
91
- jobId,
92
- status: 'pass',
93
- duration: Date.now() - startTime,
94
- message: 'No changes to review'
95
- };
96
- }
97
-
98
- const required = config.num_reviews ?? 1;
99
- const outputs: Array<{ adapter: string; status: 'pass' | 'fail' | 'error'; message: string }> = [];
100
- const usedAdapters = new Set<string>();
101
-
102
- const preferences = config.cli_preference || [];
103
- const parallel = config.parallel ?? false;
104
-
105
- if (parallel && required > 1) {
106
- // Parallel Execution Logic
107
- // Check health of adapters in parallel, but only as many as needed
108
- const healthyAdapters: string[] = [];
109
- let prefIndex = 0;
110
-
111
- while (healthyAdapters.length < required && prefIndex < preferences.length) {
112
- const batchSize = required - healthyAdapters.length;
113
- const batch = preferences.slice(prefIndex, prefIndex + batchSize);
114
- prefIndex += batchSize;
115
-
116
- const batchResults = await Promise.all(
117
- batch.map(async (toolName) => {
118
- const adapter = getAdapter(toolName);
119
- if (!adapter) return { toolName, status: 'missing' as const };
120
- const health = await adapter.checkHealth({ checkUsageLimit });
121
- return { toolName, ...health };
122
- })
123
- );
124
-
125
- for (const res of batchResults) {
126
- if (res.status === 'healthy') {
127
- healthyAdapters.push(res.toolName);
128
- } else if (res.status === 'unhealthy') {
129
- await mainLogger(`Skipping ${res.toolName}: ${res.message || 'Unhealthy'}\n`);
130
- }
131
- }
132
- }
133
-
134
- if (healthyAdapters.length < required) {
135
- const msg = `Not enough healthy adapters. Need ${required}, found ${healthyAdapters.length}.`;
136
- await mainLogger(`Result: error - ${msg}\n`);
137
- return {
138
- jobId,
139
- status: 'error',
140
- duration: Date.now() - startTime,
141
- message: msg
142
- };
143
- }
144
-
145
- // Launch exactly 'required' reviews in parallel
146
- const selectedAdapters = healthyAdapters.slice(0, required);
147
- await mainLogger(`Starting parallel reviews with: ${selectedAdapters.join(', ')}\n`);
148
-
149
- const results = await Promise.all(
150
- selectedAdapters.map((toolName) =>
151
- this.runSingleReview(toolName, config, diff, loggerFactory, mainLogger, previousFailures, true, checkUsageLimit)
152
- )
153
- );
154
-
155
- for (const res of results) {
156
- if (res) {
157
- outputs.push({ adapter: res.adapter, ...res.evaluation });
158
- usedAdapters.add(res.adapter);
159
- }
160
- }
161
- } else {
162
- // Sequential Execution Logic
163
- for (const toolName of preferences) {
164
- if (usedAdapters.size >= required) break;
165
- const res = await this.runSingleReview(toolName, config, diff, loggerFactory, mainLogger, previousFailures, false, checkUsageLimit);
166
- if (res) {
167
- outputs.push({ adapter: res.adapter, ...res.evaluation });
168
- usedAdapters.add(res.adapter);
169
- }
170
- }
171
- }
172
-
173
- if (usedAdapters.size < required) {
174
- const msg = `Failed to complete ${required} reviews. Completed: ${usedAdapters.size}. See logs for details.`;
175
- await mainLogger(`Result: error - ${msg}\n`);
176
- return {
177
- jobId,
178
- status: 'error',
179
- duration: Date.now() - startTime,
180
- message: msg
181
- };
182
- }
183
-
184
- const failed = outputs.find(result => result.status === 'fail');
185
- const error = outputs.find(result => result.status === 'error');
186
-
187
- let status: 'pass' | 'fail' | 'error' = 'pass';
188
- let message = 'Passed';
189
-
190
- if (error) {
191
- status = 'error';
192
- message = `Error (${error.adapter}): ${error.message}`;
193
- } else if (failed) {
194
- status = 'fail';
195
- message = `Failed (${failed.adapter}): ${failed.message}`;
196
- }
197
-
198
- await mainLogger(`Result: ${status} - ${message}\n`);
199
-
200
- return {
201
- jobId,
202
- status,
203
- duration: Date.now() - startTime,
204
- message
205
- };
206
- } catch (error: any) {
207
- await mainLogger(`Critical Error: ${error.message}\n`);
208
- await mainLogger('Result: error\n');
209
- return {
210
- jobId,
211
- status: 'error',
212
- duration: Date.now() - startTime,
213
- message: error.message
214
- };
215
- }
216
- }
217
-
218
- private async runSingleReview(
219
- toolName: string,
220
- config: ReviewConfig,
221
- diff: string,
222
- loggerFactory: (adapterName?: string) => Promise<(output: string) => Promise<void>>,
223
- mainLogger: (output: string) => Promise<void>,
224
- previousFailures?: Map<string, PreviousViolation[]>,
225
- skipHealthCheck: boolean = false,
226
- checkUsageLimit: boolean = false
227
- ): Promise<{ adapter: string; evaluation: { status: 'pass' | 'fail' | 'error'; message: string; json?: any } } | null> {
228
- const adapter = getAdapter(toolName);
229
- if (!adapter) return null;
230
-
231
- if (!skipHealthCheck) {
232
- const health = await adapter.checkHealth({ checkUsageLimit });
233
- if (health.status === 'missing') return null;
234
- if (health.status === 'unhealthy') {
235
- await mainLogger(`Skipping ${adapter.name}: ${health.message || 'Unhealthy'}\n`);
236
- return null;
237
- }
238
- }
239
-
240
- // Create per-adapter logger
241
- const adapterLogger = await loggerFactory(adapter.name);
242
-
243
- try {
244
- const startMsg = `[START] review:.:${config.name} (${adapter.name})`;
245
- await adapterLogger(`${startMsg}\n`);
246
-
247
- const adapterPreviousViolations = previousFailures?.get(adapter.name) || [];
248
- const finalPrompt = this.constructPrompt(config, adapterPreviousViolations);
249
-
250
- const output = await adapter.execute({
251
- prompt: finalPrompt,
252
- diff,
253
- model: config.model,
254
- timeoutMs: config.timeout ? config.timeout * 1000 : undefined
255
- });
256
-
257
- await adapterLogger(`\n--- Review Output (${adapter.name}) ---\n${output}\n`);
258
-
259
- const evaluation = this.evaluateOutput(output, diff);
260
-
261
- if (evaluation.filteredCount && evaluation.filteredCount > 0) {
262
- await adapterLogger(`Note: ${evaluation.filteredCount} out-of-scope violations filtered\n`);
263
- }
264
-
265
- // Log formatted summary
266
- if (evaluation.json) {
267
- await adapterLogger(`\n--- Parsed Result (${adapter.name}) ---\n`);
268
- if (evaluation.json.status === 'fail' && Array.isArray(evaluation.json.violations)) {
269
- await adapterLogger(`Status: FAIL\n`);
270
- await adapterLogger(`Violations:\n`);
271
- for (const [i, v] of evaluation.json.violations.entries()) {
272
- await adapterLogger(`${i + 1}. ${v.file}:${v.line || '?'} - ${v.issue}\n`);
273
- if (v.fix) await adapterLogger(` Fix: ${v.fix}\n`);
274
- }
275
- } else if (evaluation.json.status === 'pass') {
276
- await adapterLogger(`Status: PASS\n`);
277
- if (evaluation.json.message) await adapterLogger(`Message: ${evaluation.json.message}\n`);
278
- } else {
279
- await adapterLogger(`Status: ${evaluation.json.status}\n`);
280
- await adapterLogger(`Raw: ${JSON.stringify(evaluation.json, null, 2)}\n`);
281
- }
282
- await adapterLogger(`---------------------\n`);
283
- }
284
-
285
- const resultMsg = `Review result (${adapter.name}): ${evaluation.status} - ${evaluation.message}`;
286
- await adapterLogger(`${resultMsg}\n`);
287
- await mainLogger(`${resultMsg}\n`);
288
-
289
- return { adapter: adapter.name, evaluation };
290
- } catch (error: any) {
291
- const errorMsg = `Error running ${adapter.name}: ${error.message}`;
292
- await adapterLogger(`${errorMsg}\n`);
293
- await mainLogger(`${errorMsg}\n`);
294
- return null;
295
- }
296
- }
297
-
298
- private async getDiff(
299
- entryPointPath: string,
300
- baseBranch: string,
301
- options?: { commit?: string; uncommitted?: boolean }
302
- ): Promise<string> {
303
- // If uncommitted mode is explicitly requested
304
- if (options?.uncommitted) {
305
- const pathArg = this.pathArg(entryPointPath);
306
- // Match ChangeDetector.getUncommittedChangedFiles() behavior
307
- const staged = await this.execDiff(`git diff --cached${pathArg}`);
308
- const unstaged = await this.execDiff(`git diff${pathArg}`);
309
- const untracked = await this.untrackedDiff(entryPointPath);
310
- return [staged, unstaged, untracked].filter(Boolean).join('\n');
311
- }
312
-
313
- // If a specific commit is requested
314
- if (options?.commit) {
315
- const pathArg = this.pathArg(entryPointPath);
316
- // Match ChangeDetector.getCommitChangedFiles() behavior
317
- try {
318
- return await this.execDiff(`git diff ${options.commit}^..${options.commit}${pathArg}`);
319
- } catch (error: any) {
320
- // Handle initial commit case
321
- if (error.message?.includes('unknown revision') || error.stderr?.includes('unknown revision')) {
322
- return await this.execDiff(`git diff --root ${options.commit}${pathArg}`);
323
- }
324
- throw error;
325
- }
326
- }
327
-
328
- const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
329
- return isCI
330
- ? this.getCIDiff(entryPointPath, baseBranch)
331
- : this.getLocalDiff(entryPointPath, baseBranch);
332
- }
333
-
334
- private async getCIDiff(entryPointPath: string, baseBranch: string): Promise<string> {
335
- const baseRef = process.env.GITHUB_BASE_REF || baseBranch;
336
- const headRef = process.env.GITHUB_SHA || 'HEAD';
337
- const pathArg = this.pathArg(entryPointPath);
338
-
339
- try {
340
- return await this.execDiff(`git diff ${baseRef}...${headRef}${pathArg}`);
341
- } catch (error) {
342
- const fallback = await this.execDiff(`git diff HEAD^...HEAD${pathArg}`);
343
- return fallback;
344
- }
345
- }
346
-
347
- private async getLocalDiff(entryPointPath: string, baseBranch: string): Promise<string> {
348
- const pathArg = this.pathArg(entryPointPath);
349
- const committed = await this.execDiff(`git diff ${baseBranch}...HEAD${pathArg}`);
350
- const uncommitted = await this.execDiff(`git diff HEAD${pathArg}`);
351
- const untracked = await this.untrackedDiff(entryPointPath);
352
-
353
- return [committed, uncommitted, untracked].filter(Boolean).join('\n');
354
- }
355
-
356
- private async untrackedDiff(entryPointPath: string): Promise<string> {
357
- const pathArg = this.pathArg(entryPointPath);
358
- const { stdout } = await execAsync(`git ls-files --others --exclude-standard${pathArg}`, {
359
- maxBuffer: MAX_BUFFER_BYTES
360
- });
361
- const files = this.parseLines(stdout);
362
- const diffs: string[] = [];
363
-
364
- for (const file of files) {
365
- try {
366
- const diff = await this.execDiff(`git diff --no-index -- /dev/null ${this.quoteArg(file)}`);
367
- if (diff.trim()) diffs.push(diff);
368
- } catch (error: any) {
369
- // Only suppress errors for missing/deleted files (ENOENT or "Could not access")
370
- // Re-throw other errors (permissions, git issues) so they surface properly
371
- const msg = [error.message, error.stderr].filter(Boolean).join('\n');
372
- if (msg.includes('Could not access') || msg.includes('ENOENT') || msg.includes('No such file')) {
373
- // File was deleted/moved between listing and diff; skip it
374
- continue;
375
- }
376
- throw error;
377
- }
378
- }
379
-
380
- return diffs.join('\n');
381
- }
382
-
383
- private async execDiff(command: string): Promise<string> {
384
- try {
385
- const { stdout } = await execAsync(command, { maxBuffer: MAX_BUFFER_BYTES });
386
- return stdout;
387
- } catch (error: any) {
388
- if (typeof error.code === 'number' && error.stdout) {
389
- return error.stdout;
390
- }
391
- throw error;
392
- }
393
- }
394
-
395
- private buildPreviousFailuresSection(violations: PreviousViolation[]): string {
396
- const lines = [
397
- '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
398
- 'PREVIOUS FAILURES TO VERIFY (from last run)',
399
- '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
400
- '',
401
- '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:',
402
- ''
403
- ];
404
-
405
- violations.forEach((v, i) => {
406
- lines.push(`${i + 1}. ${v.file}:${v.line} - ${v.issue}`);
407
- if (v.fix) {
408
- lines.push(` Suggested fix: ${v.fix}`);
409
- }
410
- lines.push('');
411
- });
412
-
413
- lines.push('INSTRUCTIONS:');
414
- lines.push('- Check if each violation listed above has been addressed in the diff');
415
- lines.push('- For violations that are fixed, confirm they no longer appear');
416
- lines.push('- For violations that remain unfixed, include them in your violations array');
417
- lines.push('- Also check for any NEW violations in the changed code');
418
- lines.push('- Return status "pass" only if ALL previous violations are fixed AND no new violations exist');
419
- lines.push('');
420
- lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
421
-
422
- return lines.join('\n');
423
- }
424
-
425
- public evaluateOutput(output: string, diff?: string): {
426
- status: 'pass' | 'fail' | 'error';
427
- message: string;
428
- json?: any;
429
- filteredCount?: number;
430
- } {
431
- const diffRanges = diff ? parseDiff(diff) : undefined;
432
-
433
- try {
434
- // 1. Try to extract from markdown code block first (most reliable)
435
- const jsonBlockMatch = output.match(/```json\s*([\s\S]*?)\s*```/);
436
- if (jsonBlockMatch) {
437
- try {
438
- const json = JSON.parse(jsonBlockMatch[1]);
439
- return this.validateAndReturn(json, diffRanges);
440
- } catch {
441
- // If code block parse fails, fall back to other methods
442
- }
443
- }
444
-
445
- // 2. Fallback: Find the last valid JSON object
446
- // This helps when there are braces in the explanation text before the actual JSON
447
- // We start from the last '}' and search backwards for a matching '{' that creates valid JSON
448
- const end = output.lastIndexOf('}');
449
- if (end !== -1) {
450
- let start = output.lastIndexOf('{', end);
451
- while (start !== -1) {
452
- const candidate = output.substring(start, end + 1);
453
- try {
454
- const json = JSON.parse(candidate);
455
- // If we successfully parsed an object with 'status', it's likely our result
456
- if (json.status) {
457
- return this.validateAndReturn(json, diffRanges);
458
- }
459
- } catch {
460
- // Not valid JSON, keep searching backwards
461
- }
462
- start = output.lastIndexOf('{', start - 1);
463
- }
464
- }
465
-
466
- // 3. Last resort: simplistic extraction (original behavior)
467
- const firstStart = output.indexOf('{');
468
- if (firstStart !== -1 && end !== -1 && end > firstStart) {
469
- try {
470
- const candidate = output.substring(firstStart, end + 1);
471
- const json = JSON.parse(candidate);
472
- return this.validateAndReturn(json, diffRanges);
473
- } catch {
474
- // Ignore
475
- }
476
- }
477
-
478
- return { status: 'error', message: 'No valid JSON object found in output' };
479
-
480
- } catch (error: any) {
481
- return { status: 'error', message: `Failed to parse JSON output: ${error.message}` };
482
- }
483
- }
484
-
485
- private validateAndReturn(
486
- json: any,
487
- diffRanges?: Map<string, DiffFileRange>
488
- ): { status: 'pass' | 'fail' | 'error'; message: string; json?: any; filteredCount?: number } {
489
- // Validate Schema
490
- if (!json.status || (json.status !== 'pass' && json.status !== 'fail')) {
491
- return { status: 'error', message: 'Invalid JSON: missing or invalid "status" field', json };
492
- }
493
-
494
- if (json.status === 'pass') {
495
- return { status: 'pass', message: json.message || 'Passed', json };
496
- }
497
-
498
- // json.status === 'fail'
499
- let filteredCount = 0;
500
-
501
- if (Array.isArray(json.violations) && diffRanges?.size) {
502
- const originalCount = json.violations.length;
503
-
504
- json.violations = json.violations.filter((v: any) => {
505
- const isValid = isValidViolationLocation(v.file, v.line, diffRanges);
506
- if (!isValid) {
507
- // Can't easily access logger here, but could return warning info
508
- // console.warn(`[WARNING] Filtered violation: ${v.file}:${v.line ?? '?'} (not in diff)`);
509
- }
510
- return isValid;
511
- });
512
-
513
- filteredCount = originalCount - json.violations.length;
514
-
515
- // If all filtered out, change to pass
516
- if (json.violations.length === 0) {
517
- return {
518
- status: 'pass',
519
- message: `Passed (${filteredCount} out-of-scope violations filtered)`,
520
- json: { status: 'pass' },
521
- filteredCount
522
- };
523
- }
524
- }
525
-
526
- const violationCount = Array.isArray(json.violations) ? json.violations.length : 'some';
527
-
528
- // Construct a summary message
529
- let msg = `Found ${violationCount} violations`;
530
- if (Array.isArray(json.violations) && json.violations.length > 0) {
531
- const first = json.violations[0];
532
- msg += `. Example: ${first.issue} in ${first.file}`;
533
- }
534
-
535
- return { status: 'fail', message: msg, json, filteredCount };
536
- }
537
-
538
- private parseLines(stdout: string): string[] {
539
- return stdout
540
- .split('\n')
541
- .map(line => line.trim())
542
- .filter(line => line.length > 0);
543
- }
544
-
545
- private pathArg(entryPointPath: string): string {
546
- return ` -- ${this.quoteArg(entryPointPath)}`;
547
- }
548
-
549
- private quoteArg(value: string): string {
550
- return `"${value.replace(/(["\\$`])/g, '\\$1')}"`;
551
- }
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
+ }
552
763
  }