agent-gauntlet 0.10.0 → 0.11.0

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 (71) hide show
  1. package/README.md +25 -23
  2. package/dist/index.js +9226 -0
  3. package/dist/index.js.map +65 -0
  4. package/dist/scripts/status.js +280 -0
  5. package/dist/scripts/status.js.map +10 -0
  6. package/package.json +22 -8
  7. package/src/built-in-reviews/code-quality.md +0 -25
  8. package/src/built-in-reviews/index.ts +0 -28
  9. package/src/bun-plugins.d.ts +0 -4
  10. package/src/cli-adapters/claude.ts +0 -327
  11. package/src/cli-adapters/codex.ts +0 -290
  12. package/src/cli-adapters/cursor.ts +0 -128
  13. package/src/cli-adapters/gemini.ts +0 -510
  14. package/src/cli-adapters/github-copilot.ts +0 -141
  15. package/src/cli-adapters/index.ts +0 -250
  16. package/src/cli-adapters/thinking-budget.ts +0 -23
  17. package/src/commands/check.ts +0 -311
  18. package/src/commands/ci/index.ts +0 -15
  19. package/src/commands/ci/init.ts +0 -96
  20. package/src/commands/ci/list-jobs.ts +0 -90
  21. package/src/commands/clean.ts +0 -54
  22. package/src/commands/detect.ts +0 -173
  23. package/src/commands/health.ts +0 -169
  24. package/src/commands/help.ts +0 -34
  25. package/src/commands/index.ts +0 -13
  26. package/src/commands/init.ts +0 -1878
  27. package/src/commands/list.ts +0 -33
  28. package/src/commands/review.ts +0 -311
  29. package/src/commands/run.ts +0 -29
  30. package/src/commands/shared.ts +0 -267
  31. package/src/commands/stop-hook.ts +0 -567
  32. package/src/commands/validate.ts +0 -20
  33. package/src/commands/wait-ci.ts +0 -518
  34. package/src/config/ci-loader.ts +0 -33
  35. package/src/config/ci-schema.ts +0 -28
  36. package/src/config/global.ts +0 -87
  37. package/src/config/loader.ts +0 -301
  38. package/src/config/schema.ts +0 -165
  39. package/src/config/stop-hook-config.ts +0 -130
  40. package/src/config/types.ts +0 -65
  41. package/src/config/validator.ts +0 -592
  42. package/src/core/change-detector.ts +0 -137
  43. package/src/core/diff-stats.ts +0 -442
  44. package/src/core/entry-point.ts +0 -190
  45. package/src/core/job.ts +0 -96
  46. package/src/core/run-executor.ts +0 -621
  47. package/src/core/runner.ts +0 -290
  48. package/src/gates/check.ts +0 -118
  49. package/src/gates/resolve-check-command.ts +0 -21
  50. package/src/gates/result.ts +0 -54
  51. package/src/gates/review.ts +0 -1333
  52. package/src/hooks/adapters/claude-stop-hook.ts +0 -99
  53. package/src/hooks/adapters/cursor-stop-hook.ts +0 -122
  54. package/src/hooks/adapters/types.ts +0 -94
  55. package/src/hooks/stop-hook-handler.ts +0 -748
  56. package/src/index.ts +0 -47
  57. package/src/output/app-logger.ts +0 -214
  58. package/src/output/console-log.ts +0 -168
  59. package/src/output/console.ts +0 -359
  60. package/src/output/logger.ts +0 -126
  61. package/src/output/sinks/console-sink.ts +0 -59
  62. package/src/output/sinks/file-sink.ts +0 -110
  63. package/src/scripts/status.ts +0 -433
  64. package/src/templates/workflow.yml +0 -79
  65. package/src/types/gauntlet-status.ts +0 -79
  66. package/src/utils/debug-log.ts +0 -392
  67. package/src/utils/diff-parser.ts +0 -103
  68. package/src/utils/execution-state.ts +0 -472
  69. package/src/utils/log-parser.ts +0 -696
  70. package/src/utils/sanitizer.ts +0 -3
  71. package/src/utils/session-ref.ts +0 -91
@@ -1,1333 +0,0 @@
1
- import { exec } from "node:child_process";
2
- import fs from "node:fs/promises";
3
- import path from "node:path";
4
- import { promisify } from "node:util";
5
- import { getAdapter, isUsageLimit } from "../cli-adapters/index.js";
6
- import type { AdapterConfig, LoadedReviewGateConfig } from "../config/types.js";
7
- import { getCategoryLogger } from "../output/app-logger.js";
8
- import {
9
- type DiffFileRange,
10
- isValidViolationLocation,
11
- parseDiff,
12
- } from "../utils/diff-parser.js";
13
- import {
14
- getUnhealthyAdapters,
15
- isAdapterCoolingDown,
16
- markAdapterHealthy,
17
- markAdapterUnhealthy,
18
- } from "../utils/execution-state.js";
19
- import type {
20
- GateResult,
21
- PreviousViolation,
22
- ReviewFullJsonOutput,
23
- } from "./result.js";
24
-
25
- const log = getCategoryLogger("gate", "review");
26
-
27
- const execAsync = promisify(exec);
28
-
29
- const MAX_BUFFER_BYTES = 10 * 1024 * 1024;
30
- const MAX_LOG_BUFFER_SIZE = 10000;
31
- const REVIEW_ADAPTER_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
32
-
33
- /** Chars-per-token approximation for rough token estimates. */
34
- const CHARS_PER_TOKEN = 4;
35
-
36
- export const JSON_SYSTEM_INSTRUCTION = `
37
- You are in a read-only mode. You may read files in the repository to gather context.
38
- Do NOT attempt to modify files or run shell commands that change system state.
39
- Do NOT access files outside the repository root.
40
- Do NOT access the .git/ directory or read git history/commit information.
41
- Use your available file-reading and search tools to find information.
42
- If the diff is insufficient or ambiguous, use your tools to read the full file content or related files.
43
-
44
- CRITICAL SCOPE RESTRICTIONS:
45
- - ONLY review the code changes shown in the diff below
46
- - DO NOT review commit history or existing code outside the diff
47
- - All violations MUST reference file paths and line numbers that appear IN THE DIFF
48
- - The "file" field must match a file from the diff
49
- - The "line" field must be within a changed region (lines starting with + in the diff)
50
-
51
- IMPORTANT: You must output ONLY a valid JSON object. Do not output any markdown text, explanations, or code blocks outside of the JSON.
52
- Each violation MUST include a "priority" field with one of: "critical", "high", "medium", "low".
53
- Each violation MUST include a "status" field set to "new".
54
-
55
- If violations are found:
56
- {
57
- "status": "fail",
58
- "violations": [
59
- {
60
- "file": "path/to/file.rb",
61
- "line": 10,
62
- "issue": "Description of the violation",
63
- "fix": "Suggestion on how to fix it",
64
- "priority": "high",
65
- "status": "new"
66
- }
67
- ]
68
- }
69
-
70
- If NO violations are found:
71
- {
72
- "status": "pass",
73
- "message": "No problems found"
74
- }
75
- `;
76
-
77
- type ReviewConfig = LoadedReviewGateConfig;
78
-
79
- interface ReviewJsonOutput {
80
- status: "pass" | "fail";
81
- message?: string;
82
- violations?: Array<{
83
- file: string;
84
- line: number | string;
85
- issue: string;
86
- fix?: string;
87
- priority: "critical" | "high" | "medium" | "low";
88
- status: "new" | "fixed" | "skipped";
89
- result?: string | null;
90
- }>;
91
- }
92
-
93
- export class ReviewGateExecutor {
94
- private constructPrompt(
95
- config: ReviewConfig,
96
- previousViolations: PreviousViolation[] = [],
97
- ): string {
98
- const baseContent = config.promptContent || "";
99
-
100
- if (previousViolations.length > 0) {
101
- return (
102
- baseContent +
103
- "\n\n" +
104
- this.buildPreviousFailuresSection(previousViolations) +
105
- "\n" +
106
- JSON_SYSTEM_INSTRUCTION
107
- );
108
- }
109
-
110
- return `${baseContent}\n${JSON_SYSTEM_INSTRUCTION}`;
111
- }
112
-
113
- async execute(
114
- jobId: string,
115
- config: ReviewConfig,
116
- entryPointPath: string,
117
- loggerFactory: (
118
- adapterName?: string,
119
- reviewIndex?: number,
120
- ) => Promise<{
121
- logger: (output: string) => Promise<void>;
122
- logPath: string;
123
- }>,
124
- baseBranch: string,
125
- previousFailures?: Map<string, PreviousViolation[]>,
126
- changeOptions?: {
127
- commit?: string;
128
- uncommitted?: boolean;
129
- fixBase?: string;
130
- },
131
- rerunThreshold: "critical" | "high" | "medium" | "low" = "high",
132
- passedSlots?: Map<number, { adapter: string; passIteration: number }>,
133
- logDir?: string,
134
- adapterConfigs?: Record<string, AdapterConfig>,
135
- ): Promise<GateResult> {
136
- const startTime = Date.now();
137
- const logBuffer: string[] = [];
138
- let logSequence = 0;
139
- const activeLoggers: Array<
140
- (output: string, index: number) => Promise<void>
141
- > = [];
142
- const logPaths: string[] = [];
143
- const logPathsSet = new Set<string>();
144
-
145
- const mainLogger = async (output: string) => {
146
- const seq = logSequence++;
147
- if (logBuffer.length < MAX_LOG_BUFFER_SIZE) {
148
- logBuffer.push(output);
149
- }
150
- await Promise.allSettled(activeLoggers.map((l) => l(output, seq)));
151
- };
152
-
153
- const getAdapterLogger = async (
154
- adapterName: string,
155
- reviewIndex: number,
156
- ) => {
157
- const { logger, logPath } = await loggerFactory(adapterName, reviewIndex);
158
- if (!logPathsSet.has(logPath)) {
159
- logPathsSet.add(logPath);
160
- logPaths.push(logPath);
161
- }
162
-
163
- const seenIndices = new Set<number>();
164
-
165
- const safeLogger = async (msg: string, index: number) => {
166
- if (seenIndices.has(index)) return;
167
- seenIndices.add(index);
168
- await logger(msg);
169
- };
170
-
171
- activeLoggers.push(safeLogger);
172
-
173
- const snapshot = [...logBuffer];
174
- await Promise.all(snapshot.map((msg, i) => safeLogger(msg, i)));
175
-
176
- return logger;
177
- };
178
-
179
- try {
180
- log.debug(`Starting review: ${config.name} | entry=${entryPointPath}`);
181
- await mainLogger(`Starting review: ${config.name}\n`);
182
- await mainLogger(`Entry point: ${entryPointPath}\n`);
183
- await mainLogger(`Base branch: ${baseBranch}\n`);
184
-
185
- const diff = await this.getDiff(
186
- entryPointPath,
187
- baseBranch,
188
- changeOptions,
189
- );
190
-
191
- // Compute and log diff size stats
192
- const diffLines = diff.split("\n").length;
193
- const diffChars = diff.length;
194
- const diffEstTokens = Math.ceil(diffChars / CHARS_PER_TOKEN);
195
- const diffFileRanges = parseDiff(diff);
196
- const diffFiles = diffFileRanges.size;
197
- const diffSizeMsg = `[diff-stats] files=${diffFiles} lines=${diffLines} chars=${diffChars} est_tokens=${diffEstTokens}`;
198
- log.debug(diffSizeMsg);
199
- await mainLogger(`${diffSizeMsg}\n`);
200
-
201
- if (!diff.trim()) {
202
- log.debug(`Empty diff after trim, returning pass`);
203
- await mainLogger("No changes found in entry point, skipping review.\n");
204
- await mainLogger("Result: pass - No changes to review\n");
205
- return {
206
- jobId,
207
- status: "pass",
208
- duration: Date.now() - startTime,
209
- message: "No changes to review",
210
- logPaths,
211
- };
212
- }
213
-
214
- const required = config.num_reviews ?? 1;
215
- const outputs: Array<{
216
- adapter: string;
217
- reviewIndex: number;
218
- duration?: number;
219
- status: "pass" | "fail" | "error";
220
- message: string;
221
- json?: ReviewJsonOutput;
222
- skipped?: Array<{
223
- file: string;
224
- line: number | string;
225
- issue: string;
226
- result?: string | null;
227
- }>;
228
- }> = [];
229
-
230
- const preferences = config.cli_preference || [];
231
- const parallel = config.parallel ?? false;
232
- log.debug(
233
- `Checking adapters: ${preferences.join(", ") || "(none configured)"}`,
234
- );
235
-
236
- // Determine healthy adapters using cooldown-based filtering
237
- const healthyAdapters: string[] = [];
238
- const unhealthyMap = logDir ? await getUnhealthyAdapters(logDir) : {};
239
-
240
- for (const toolName of preferences) {
241
- const adapter = getAdapter(toolName);
242
- if (!adapter) {
243
- log.debug(`Adapter ${toolName}: not found`);
244
- continue;
245
- }
246
-
247
- // Check cooldown status
248
- const unhealthyEntry = unhealthyMap[toolName];
249
- if (unhealthyEntry) {
250
- if (isAdapterCoolingDown(unhealthyEntry)) {
251
- log.debug(`Adapter ${toolName}: cooling down`);
252
- await mainLogger(
253
- `Skipping ${toolName}: cooling down (${unhealthyEntry.reason})\n`,
254
- );
255
- continue;
256
- }
257
-
258
- // Cooldown expired - probe binary availability
259
- const health = await adapter.checkHealth();
260
- if (health.status === "healthy") {
261
- log.debug(
262
- `Adapter ${toolName}: cooldown expired, binary available, clearing unhealthy flag`,
263
- );
264
- if (logDir) {
265
- await markAdapterHealthy(logDir, toolName);
266
- }
267
- } else {
268
- log.debug(
269
- `Adapter ${toolName}: cooldown expired but binary missing`,
270
- );
271
- await mainLogger(
272
- `Skipping ${toolName}: ${health.message || "Missing"}\n`,
273
- );
274
- continue;
275
- }
276
- } else {
277
- // Not in unhealthy list - check binary availability
278
- const health = await adapter.checkHealth();
279
- if (health.status !== "healthy") {
280
- log.debug(
281
- `Adapter ${toolName}: ${health.status}${health.message ? ` - ${health.message}` : ""}`,
282
- );
283
- await mainLogger(
284
- `Skipping ${toolName}: ${health.message || "Unhealthy"}\n`,
285
- );
286
- continue;
287
- }
288
- }
289
-
290
- healthyAdapters.push(toolName);
291
- }
292
-
293
- if (healthyAdapters.length === 0) {
294
- const msg = "Review dispatch failed: no healthy adapters available";
295
- log.error(`ERROR: ${msg}`);
296
- await mainLogger(`Result: error - ${msg}\n`);
297
- return {
298
- jobId,
299
- status: "error",
300
- duration: Date.now() - startTime,
301
- message: msg,
302
- logPaths,
303
- };
304
- }
305
- log.debug(`Healthy adapters: ${healthyAdapters.join(", ")}`);
306
-
307
- // Round-robin assignment over healthy adapters
308
- const assignments: Array<{
309
- adapter: string;
310
- reviewIndex: number;
311
- skip?: boolean;
312
- skipReason?: string;
313
- passIteration?: number;
314
- }> = [];
315
- for (let i = 0; i < required; i++) {
316
- const adapter = healthyAdapters[i % healthyAdapters.length];
317
- if (!adapter) continue;
318
- assignments.push({
319
- adapter,
320
- reviewIndex: i + 1,
321
- });
322
- }
323
-
324
- // Skip logic for passed slots (only when num_reviews > 1 and in rerun mode)
325
- if (required > 1 && passedSlots && passedSlots.size > 0) {
326
- // Identify which slots passed (with same adapter) and which failed
327
- const passedIndexes: number[] = [];
328
- const failedIndexes: number[] = [];
329
-
330
- for (const assignment of assignments) {
331
- const passed = passedSlots.get(assignment.reviewIndex);
332
- // Only consider as passed if same adapter is assigned
333
- if (passed && passed.adapter === assignment.adapter) {
334
- passedIndexes.push(assignment.reviewIndex);
335
- assignment.passIteration = passed.passIteration;
336
- } else {
337
- failedIndexes.push(assignment.reviewIndex);
338
- }
339
- }
340
-
341
- if (failedIndexes.length > 0) {
342
- // Some slots failed: run failed slots, skip passed slots
343
- for (const assignment of assignments) {
344
- if (assignment.passIteration !== undefined) {
345
- assignment.skip = true;
346
- assignment.skipReason = `previously passed in iteration ${assignment.passIteration} (num_reviews > 1)`;
347
- }
348
- }
349
- } else if (passedIndexes.length === assignments.length) {
350
- // All slots passed: safety latch - run slot 1, skip rest
351
- for (const assignment of assignments) {
352
- if (assignment.reviewIndex === 1) {
353
- assignment.skip = false;
354
- // Log safety latch message
355
- await mainLogger(
356
- `Running @1: safety latch (all slots previously passed)\n`,
357
- );
358
- } else {
359
- assignment.skip = true;
360
- assignment.skipReason = `previously passed in iteration ${assignment.passIteration} (num_reviews > 1)`;
361
- }
362
- }
363
- }
364
- }
365
-
366
- // Log skip messages
367
- for (const assignment of assignments) {
368
- if (assignment.skip && assignment.skipReason) {
369
- await mainLogger(
370
- `Skipping @${assignment.reviewIndex}: ${assignment.skipReason}\n`,
371
- );
372
- }
373
- }
374
-
375
- const dispatchMsg = `Dispatching ${required} review(s) via round-robin: ${assignments.map((a) => `${a.adapter}@${a.reviewIndex}`).join(", ")}`;
376
- log.debug(dispatchMsg);
377
- await mainLogger(`${dispatchMsg}\n`);
378
-
379
- // Separate assignments into running and skipped
380
- const runningAssignments = assignments.filter((a) => !a.skip);
381
- const skippedAssignments = assignments.filter((a) => a.skip);
382
- log.debug(
383
- `Running: ${runningAssignments.length}, Skipped: ${skippedAssignments.length}`,
384
- );
385
-
386
- // Track skipped slots for output
387
- const skippedSlotOutputs: Array<{
388
- adapter: string;
389
- reviewIndex: number;
390
- status: "skipped_prior_pass";
391
- message: string;
392
- passIteration: number;
393
- }> = [];
394
-
395
- // Handle skipped slots: write JSON log with status "skipped_prior_pass"
396
- for (const assignment of skippedAssignments) {
397
- const { logger, logPath } = await loggerFactory(
398
- assignment.adapter,
399
- assignment.reviewIndex,
400
- );
401
-
402
- // Write to log file explaining the skip
403
- const skipMessage = `[${new Date().toISOString()}] Review skipped: previously passed in iteration ${assignment.passIteration}\n`;
404
- await logger(skipMessage);
405
- await logger(`Adapter: ${assignment.adapter}\n`);
406
- await logger(`Review index: @${assignment.reviewIndex}\n`);
407
- await logger(`Status: skipped_prior_pass\n`);
408
-
409
- const jsonPath = logPath.replace(/\.log$/, ".json");
410
- const skippedOutput: ReviewFullJsonOutput = {
411
- adapter: assignment.adapter,
412
- timestamp: new Date().toISOString(),
413
- status: "skipped_prior_pass",
414
- rawOutput: "",
415
- violations: [],
416
- passIteration: assignment.passIteration,
417
- };
418
- await fs.writeFile(jsonPath, JSON.stringify(skippedOutput, null, 2));
419
-
420
- if (!logPathsSet.has(logPath)) {
421
- logPathsSet.add(logPath);
422
- logPaths.push(logPath);
423
- }
424
-
425
- skippedSlotOutputs.push({
426
- adapter: assignment.adapter,
427
- reviewIndex: assignment.reviewIndex,
428
- status: "skipped_prior_pass",
429
- message: `Skipped: previously passed in iteration ${assignment.passIteration}`,
430
- passIteration: assignment.passIteration ?? 0,
431
- });
432
- }
433
-
434
- if (parallel && runningAssignments.length > 1) {
435
- // Parallel execution
436
- const results = await Promise.all(
437
- runningAssignments.map((assignment) =>
438
- this.runSingleReview(
439
- assignment.adapter,
440
- assignment.reviewIndex,
441
- config,
442
- diff,
443
- getAdapterLogger,
444
- mainLogger,
445
- loggerFactory,
446
- previousFailures,
447
- rerunThreshold,
448
- logDir,
449
- adapterConfigs,
450
- ),
451
- ),
452
- );
453
-
454
- for (const res of results) {
455
- if (res) {
456
- outputs.push({
457
- adapter: res.adapter,
458
- reviewIndex: res.reviewIndex,
459
- duration: res.duration,
460
- ...res.evaluation,
461
- });
462
- }
463
- }
464
- } else {
465
- // Sequential execution
466
- for (const assignment of runningAssignments) {
467
- const res = await this.runSingleReview(
468
- assignment.adapter,
469
- assignment.reviewIndex,
470
- config,
471
- diff,
472
- getAdapterLogger,
473
- mainLogger,
474
- loggerFactory,
475
- previousFailures,
476
- rerunThreshold,
477
- logDir,
478
- adapterConfigs,
479
- );
480
- if (res) {
481
- outputs.push({
482
- adapter: res.adapter,
483
- reviewIndex: res.reviewIndex,
484
- duration: res.duration,
485
- ...res.evaluation,
486
- });
487
- }
488
- }
489
- }
490
-
491
- // Check if all running reviews completed (skipped ones don't count)
492
- if (outputs.length < runningAssignments.length) {
493
- const msg = `Failed to complete reviews. Expected: ${runningAssignments.length}, Completed: ${outputs.length}. See logs for details.`;
494
- await mainLogger(`Result: error - ${msg}\n`);
495
- return {
496
- jobId,
497
- status: "error",
498
- duration: Date.now() - startTime,
499
- message: msg,
500
- logPaths,
501
- };
502
- }
503
-
504
- const failed = outputs.filter((result) => result.status === "fail");
505
- const errored = outputs.filter((result) => result.status === "error");
506
- const allSkipped = outputs.flatMap((result) => result.skipped || []);
507
-
508
- let status: "pass" | "fail" | "error" = "pass";
509
- let message = "Passed";
510
-
511
- if (errored.length > 0) {
512
- status = "error";
513
- message = `Error in ${errored.length} adapter(s)`;
514
- } else if (failed.length > 0) {
515
- status = "fail";
516
- message = `Failed by ${failed.length} adapter(s)`;
517
- }
518
-
519
- // Add skipped slot count to message if any
520
- if (skippedSlotOutputs.length > 0) {
521
- message += ` (${skippedSlotOutputs.length} skipped due to prior pass)`;
522
- }
523
-
524
- const subResults = outputs.map((out) => {
525
- const specificLog = logPaths.find((p) => {
526
- const filename = path.basename(p);
527
- return (
528
- filename.includes(`_${out.adapter}@${out.reviewIndex}.`) &&
529
- filename.endsWith(".log")
530
- );
531
- });
532
-
533
- let logPath = specificLog;
534
- if (specificLog && out.json && out.status === "fail") {
535
- logPath = specificLog.replace(/\.log$/, ".json");
536
- }
537
-
538
- const errorCount =
539
- out.json && Array.isArray(out.json.violations)
540
- ? out.json.violations.filter((v) => !v.status || v.status === "new")
541
- .length
542
- : out.status === "fail" || out.status === "error"
543
- ? 1
544
- : 0;
545
-
546
- const fixedCount =
547
- out.json && Array.isArray(out.json.violations)
548
- ? out.json.violations.filter((v) => v.status === "fixed").length
549
- : 0;
550
-
551
- return {
552
- nameSuffix: `(${out.adapter}@${out.reviewIndex})`,
553
- status: out.status,
554
- duration: out.duration,
555
- message: out.message,
556
- logPath,
557
- errorCount,
558
- fixedCount,
559
- skipped: out.skipped,
560
- };
561
- });
562
-
563
- // Add skipped slot subResults (they don't affect gate status)
564
- for (const skipped of skippedSlotOutputs) {
565
- const specificLog = logPaths.find((p) => {
566
- const filename = path.basename(p);
567
- return (
568
- filename.includes(`_${skipped.adapter}@${skipped.reviewIndex}.`) &&
569
- filename.endsWith(".log")
570
- );
571
- });
572
-
573
- subResults.push({
574
- nameSuffix: `(${skipped.adapter}@${skipped.reviewIndex})`,
575
- status: "pass" as const, // Show as pass since it previously passed
576
- duration: undefined,
577
- message: skipped.message,
578
- logPath: specificLog?.replace(/\.log$/, ".json"),
579
- errorCount: 0,
580
- fixedCount: 0,
581
- skipped: undefined,
582
- });
583
- }
584
-
585
- // Sort subResults by review index for consistent ordering
586
- subResults.sort((a, b) => {
587
- const aIndex = parseInt(a.nameSuffix.match(/@(\d+)/)?.[1] || "0", 10);
588
- const bIndex = parseInt(b.nameSuffix.match(/@(\d+)/)?.[1] || "0", 10);
589
- return aIndex - bIndex;
590
- });
591
-
592
- log.debug(`Complete: ${status} - ${message}`);
593
- await mainLogger(`Result: ${status} - ${message}\n`);
594
-
595
- return {
596
- jobId,
597
- status,
598
- duration: Date.now() - startTime,
599
- message,
600
- logPaths,
601
- subResults,
602
- skipped: allSkipped,
603
- };
604
- } catch (error: unknown) {
605
- const err = error as { message?: string; stack?: string };
606
- const errMsg = err.message || "Unknown error";
607
- const errStack = err.stack || "";
608
- // nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
609
- log.error(`CRITICAL ERROR: ${errMsg} ${errStack}`);
610
- await mainLogger(`Critical Error: ${errMsg}\n`);
611
- await mainLogger("Result: error\n");
612
- return {
613
- jobId,
614
- status: "error",
615
- duration: Date.now() - startTime,
616
- message: errMsg,
617
- logPaths,
618
- };
619
- }
620
- }
621
-
622
- private async runSingleReview(
623
- toolName: string,
624
- reviewIndex: number,
625
- config: ReviewConfig,
626
- diff: string,
627
- getAdapterLogger: (
628
- adapterName: string,
629
- reviewIndex: number,
630
- ) => Promise<(output: string) => Promise<void>>,
631
- mainLogger: (output: string) => Promise<void>,
632
- loggerFactory: (
633
- adapterName?: string,
634
- reviewIndex?: number,
635
- ) => Promise<{
636
- logger: (output: string) => Promise<void>;
637
- logPath: string;
638
- }>,
639
- previousFailures?: Map<string, PreviousViolation[]>,
640
- rerunThreshold: "critical" | "high" | "medium" | "low" = "high",
641
- logDir?: string,
642
- adapterConfigs?: Record<string, AdapterConfig>,
643
- ): Promise<{
644
- adapter: string;
645
- reviewIndex: number;
646
- duration: number;
647
- evaluation: {
648
- status: "pass" | "fail" | "error";
649
- message: string;
650
- json?: ReviewJsonOutput;
651
- skipped?: Array<{
652
- file: string;
653
- line: number | string;
654
- issue: string;
655
- result?: string | null;
656
- }>;
657
- };
658
- } | null> {
659
- const reviewStartTime = Date.now();
660
- const adapter = getAdapter(toolName);
661
- if (!adapter) return null;
662
-
663
- if (!adapter.name || typeof adapter.name !== "string") {
664
- await mainLogger(
665
- `Error: Invalid adapter name: ${JSON.stringify(adapter.name)}\n`,
666
- );
667
- return null;
668
- }
669
- const adapterLogger = await getAdapterLogger(adapter.name, reviewIndex);
670
- const { logPath } = await loggerFactory(adapter.name, reviewIndex);
671
-
672
- try {
673
- const startMsg = `[START] review:.:${config.name} (${adapter.name}@${reviewIndex})`;
674
- await adapterLogger(`${startMsg}\n`);
675
-
676
- // Look up previous violations by review index key, falling back to adapter name for legacy logs
677
- const indexKey = String(reviewIndex);
678
- const adapterPreviousViolations =
679
- previousFailures?.get(indexKey) ??
680
- previousFailures?.get(adapter.name) ??
681
- [];
682
- const finalPrompt = this.constructPrompt(
683
- config,
684
- adapterPreviousViolations,
685
- );
686
-
687
- // Log prompt + diff size so we can compare against actual token usage from telemetry
688
- const promptChars = finalPrompt.length;
689
- const diffChars = diff.length;
690
- const totalInputChars = promptChars + diffChars;
691
- const promptEstTokens = Math.ceil(promptChars / CHARS_PER_TOKEN);
692
- const diffEstTokens = Math.ceil(diffChars / CHARS_PER_TOKEN);
693
- const totalEstTokens = promptEstTokens + diffEstTokens;
694
- const inputSizeMsg = `[input-stats] prompt_chars=${promptChars} diff_chars=${diffChars} total_chars=${totalInputChars} prompt_est_tokens=${promptEstTokens} diff_est_tokens=${diffEstTokens} total_est_tokens=${totalEstTokens}`;
695
- await adapterLogger(`${inputSizeMsg}\n`);
696
-
697
- const adapterCfg = adapterConfigs?.[toolName];
698
- const output = await adapter.execute({
699
- prompt: finalPrompt,
700
- diff,
701
- model: config.model,
702
- timeoutMs: config.timeout
703
- ? config.timeout * 1000
704
- : REVIEW_ADAPTER_TIMEOUT_MS,
705
- onOutput: (chunk: string) => {
706
- // Stream output to log file in real-time
707
- adapterLogger(chunk);
708
- },
709
- allowToolUse: adapterCfg?.allow_tool_use,
710
- thinkingBudget: adapterCfg?.thinking_budget,
711
- });
712
-
713
- await adapterLogger(
714
- `\n--- Review Output (${adapter.name}) ---\n${output}\n`,
715
- );
716
-
717
- const evaluation = this.evaluateOutput(output, diff);
718
-
719
- // Check for usage limit only when output failed to parse as valid JSON.
720
- // This avoids false positives when a review legitimately mentions "usage limit".
721
- if (evaluation.status === "error" && isUsageLimit(output)) {
722
- const reason = "Usage limit exceeded";
723
- if (logDir) {
724
- await markAdapterUnhealthy(logDir, adapter.name, reason);
725
- log.debug(
726
- `Adapter ${adapter.name} marked unhealthy for 1 hour: ${reason}`,
727
- );
728
- await mainLogger(
729
- `${adapter.name} marked unhealthy for 1 hour: ${reason}\n`,
730
- );
731
- }
732
- return {
733
- adapter: adapter.name,
734
- reviewIndex,
735
- duration: Date.now() - reviewStartTime,
736
- evaluation: {
737
- status: "error",
738
- message: reason,
739
- },
740
- };
741
- }
742
-
743
- // Rerun Filtering: If we have previous failures, filter new violations by threshold
744
- if (
745
- adapterPreviousViolations.length > 0 &&
746
- evaluation.json?.violations &&
747
- evaluation.status === "fail"
748
- ) {
749
- const priorities = ["critical", "high", "medium", "low"];
750
- const thresholdIndex = priorities.indexOf(rerunThreshold);
751
-
752
- const originalCount = evaluation.json.violations.length;
753
-
754
- evaluation.json.violations = evaluation.json.violations.filter((v) => {
755
- const priority = v.priority || "low";
756
- const priorityIndex = priorities.indexOf(priority);
757
-
758
- if (priorityIndex === -1) return true;
759
-
760
- return priorityIndex <= thresholdIndex;
761
- });
762
-
763
- const filteredByThreshold =
764
- originalCount - evaluation.json.violations.length;
765
-
766
- if (filteredByThreshold > 0) {
767
- await adapterLogger(
768
- `Note: ${filteredByThreshold} new violations filtered due to rerun threshold (${rerunThreshold})\n`,
769
- );
770
- evaluation.filteredCount =
771
- (evaluation.filteredCount || 0) + filteredByThreshold;
772
-
773
- if (evaluation.json.violations.length === 0) {
774
- evaluation.status = "pass";
775
- evaluation.message = `Passed (${filteredByThreshold} below-threshold violations filtered)`;
776
- evaluation.json.status = "pass";
777
- }
778
- }
779
- }
780
-
781
- if (evaluation.status === "error") {
782
- await adapterLogger(`Error: ${evaluation.message}\n`);
783
- await mainLogger(
784
- `Error parsing review from ${adapter.name}: ${evaluation.message}\n`,
785
- );
786
- }
787
-
788
- if (evaluation.filteredCount && evaluation.filteredCount > 0) {
789
- await adapterLogger(
790
- `Note: ${evaluation.filteredCount} out-of-scope violations filtered\n`,
791
- );
792
- }
793
-
794
- let skipped: Array<{
795
- file: string;
796
- line: number | string;
797
- issue: string;
798
- result?: string | null;
799
- }> = [];
800
-
801
- if (evaluation.json) {
802
- if (evaluation.json.status === "fail") {
803
- if (!Array.isArray(evaluation.json.violations)) {
804
- await adapterLogger(
805
- "Warning: Missing 'violations' array in failure response\n",
806
- );
807
- } else {
808
- for (const v of evaluation.json.violations) {
809
- if (
810
- !v.file ||
811
- v.line === undefined ||
812
- v.line === null ||
813
- !v.issue ||
814
- !v.priority ||
815
- !v.status
816
- ) {
817
- await adapterLogger(
818
- `Warning: Violation missing required fields: ${JSON.stringify(v)}\n`,
819
- );
820
- }
821
- }
822
- }
823
- }
824
-
825
- const jsonPath = await this.writeJsonResult(
826
- logPath,
827
- adapter.name,
828
- evaluation.status,
829
- output,
830
- evaluation.json,
831
- );
832
-
833
- skipped = (evaluation.json.violations || [])
834
- .filter((v) => v.status === "skipped")
835
- .map((v) => ({
836
- file: v.file,
837
- line: v.line,
838
- issue: v.issue,
839
- result: v.result,
840
- }));
841
-
842
- await adapterLogger(`\n--- Parsed Result (${adapter.name}) ---\n`);
843
- if (
844
- evaluation.json.status === "fail" &&
845
- Array.isArray(evaluation.json.violations)
846
- ) {
847
- await adapterLogger(`Status: FAIL\n`);
848
- await adapterLogger(`Review: ${jsonPath}\n`);
849
- await adapterLogger(`Violations:\n`);
850
- for (const [i, v] of evaluation.json.violations.entries()) {
851
- await adapterLogger(
852
- `${i + 1}. ${v.file}:${v.line || "?"} - ${v.issue}\n`,
853
- );
854
- if (v.fix) await adapterLogger(` Fix: ${v.fix}\n`);
855
- }
856
- } else if (evaluation.json.status === "pass") {
857
- await adapterLogger(`Status: PASS\n`);
858
- if (evaluation.json.message)
859
- await adapterLogger(`Message: ${evaluation.json.message}\n`);
860
- } else {
861
- await adapterLogger(`Status: ${evaluation.json.status}\n`);
862
- await adapterLogger(
863
- `Raw: ${JSON.stringify(evaluation.json, null, 2)}\n`,
864
- );
865
- }
866
- await adapterLogger(`---------------------\n`);
867
- }
868
-
869
- const resultMsg = `Review result (${adapter.name}@${reviewIndex}): ${evaluation.status} - ${evaluation.message}`;
870
- await adapterLogger(`${resultMsg}\n`);
871
-
872
- return {
873
- adapter: adapter.name,
874
- reviewIndex,
875
- duration: Date.now() - reviewStartTime,
876
- evaluation: {
877
- status: evaluation.status,
878
- message: evaluation.message,
879
- json: evaluation.json,
880
- skipped,
881
- },
882
- };
883
- } catch (error: unknown) {
884
- const err = error as { message?: string };
885
- const errorMsg = `Error running ${adapter.name}@${reviewIndex}: ${err.message}`;
886
- log.error(errorMsg);
887
- await adapterLogger(`${errorMsg}\n`);
888
- await mainLogger(`${errorMsg}\n`);
889
-
890
- // Check if the error is a usage limit
891
- if (err.message && isUsageLimit(err.message)) {
892
- const reason = "Usage limit exceeded";
893
- if (logDir) {
894
- await markAdapterUnhealthy(logDir, adapter.name, reason);
895
- log.debug(
896
- `Adapter ${adapter.name} marked unhealthy for 1 hour: ${reason}`,
897
- );
898
- await mainLogger(
899
- `${adapter.name} marked unhealthy for 1 hour: ${reason}\n`,
900
- );
901
- }
902
- return {
903
- adapter: adapter.name,
904
- reviewIndex,
905
- duration: Date.now() - reviewStartTime,
906
- evaluation: {
907
- status: "error",
908
- message: reason,
909
- },
910
- };
911
- }
912
-
913
- return null;
914
- }
915
- }
916
-
917
- private async getDiff(
918
- entryPointPath: string,
919
- baseBranch: string,
920
- options?: { commit?: string; uncommitted?: boolean; fixBase?: string },
921
- ): Promise<string> {
922
- // Debug: log which diff mode is active
923
- log.debug(
924
- `getDiff: entryPoint=${entryPointPath}, fixBase=${options?.fixBase ?? "none"}, uncommitted=${options?.uncommitted ?? false}, commit=${options?.commit ?? "none"}`,
925
- );
926
-
927
- // If fixBase is provided (rerun mode)
928
- if (options?.fixBase) {
929
- // Validate fixBase to prevent command injection
930
- if (!/^[a-f0-9]+$/.test(options.fixBase)) {
931
- throw new Error(`Invalid session ref: ${options.fixBase}`);
932
- }
933
-
934
- const pathArg = this.pathArg(entryPointPath);
935
- try {
936
- const diff = await this.execDiff(
937
- `git diff ${options.fixBase}${pathArg}`,
938
- );
939
-
940
- const { stdout: untrackedStdout } = await execAsync(
941
- `git ls-files --others --exclude-standard${pathArg}`,
942
- { maxBuffer: MAX_BUFFER_BYTES },
943
- );
944
- const currentUntracked = new Set(this.parseLines(untrackedStdout));
945
-
946
- const { stdout: snapshotFilesStdout } = await execAsync(
947
- `git ls-tree -r --name-only ${options.fixBase}${pathArg}`,
948
- { maxBuffer: MAX_BUFFER_BYTES },
949
- );
950
- const snapshotFiles = new Set(this.parseLines(snapshotFilesStdout));
951
-
952
- const newUntracked = [...currentUntracked].filter(
953
- (f) => !snapshotFiles.has(f),
954
- );
955
- const newUntrackedDiffs: string[] = [];
956
-
957
- for (const file of newUntracked) {
958
- try {
959
- const d = await this.execDiff(
960
- `git diff --no-index -- /dev/null ${this.quoteArg(file)}`,
961
- );
962
- if (d.trim()) newUntrackedDiffs.push(d);
963
- } catch (error: unknown) {
964
- const err = error as { message?: string; stderr?: string };
965
- const msg = [err.message, err.stderr].filter(Boolean).join("\n");
966
- if (
967
- !msg.includes("Could not access") &&
968
- !msg.includes("ENOENT") &&
969
- !msg.includes("No such file")
970
- ) {
971
- throw error;
972
- }
973
- }
974
- }
975
-
976
- const scopedDiff = [diff, ...newUntrackedDiffs]
977
- .filter(Boolean)
978
- .join("\n");
979
- log.debug(
980
- `Scoped diff via fixBase: ${scopedDiff.split("\n").length} lines`,
981
- );
982
- return scopedDiff;
983
- } catch (error) {
984
- log.warn(
985
- `Failed to compute diff against fixBase ${options.fixBase}, falling back to full uncommitted diff. ${error instanceof Error ? error.message : error}`,
986
- );
987
- }
988
- }
989
-
990
- if (options?.uncommitted) {
991
- log.debug(`Using full uncommitted diff (no fixBase)`);
992
- const pathArg = this.pathArg(entryPointPath);
993
- const staged = await this.execDiff(`git diff --cached${pathArg}`);
994
- const unstaged = await this.execDiff(`git diff${pathArg}`);
995
- const untracked = await this.untrackedDiff(entryPointPath);
996
- return [staged, unstaged, untracked].filter(Boolean).join("\n");
997
- }
998
-
999
- if (options?.commit) {
1000
- const pathArg = this.pathArg(entryPointPath);
1001
- try {
1002
- return await this.execDiff(
1003
- `git diff ${options.commit}^..${options.commit}${pathArg}`,
1004
- );
1005
- } catch (error: unknown) {
1006
- const err = error as { message?: string; stderr?: string };
1007
- if (
1008
- err.message?.includes("unknown revision") ||
1009
- err.stderr?.includes("unknown revision")
1010
- ) {
1011
- return await this.execDiff(
1012
- `git diff --root ${options.commit}${pathArg}`,
1013
- );
1014
- }
1015
- throw error;
1016
- }
1017
- }
1018
-
1019
- const isCI =
1020
- process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
1021
- return isCI
1022
- ? this.getCIDiff(entryPointPath, baseBranch)
1023
- : this.getLocalDiff(entryPointPath, baseBranch);
1024
- }
1025
-
1026
- private async getCIDiff(
1027
- entryPointPath: string,
1028
- baseBranch: string,
1029
- ): Promise<string> {
1030
- const baseRef = baseBranch;
1031
- const headRef = process.env.GITHUB_SHA || "HEAD";
1032
- const pathArg = this.pathArg(entryPointPath);
1033
-
1034
- try {
1035
- return await this.execDiff(`git diff ${baseRef}...${headRef}${pathArg}`);
1036
- } catch (_error) {
1037
- const fallback = await this.execDiff(`git diff HEAD^...HEAD${pathArg}`);
1038
- return fallback;
1039
- }
1040
- }
1041
-
1042
- private async getLocalDiff(
1043
- entryPointPath: string,
1044
- baseBranch: string,
1045
- ): Promise<string> {
1046
- const pathArg = this.pathArg(entryPointPath);
1047
- const committed = await this.execDiff(
1048
- `git diff ${baseBranch}...HEAD${pathArg}`,
1049
- );
1050
- const uncommitted = await this.execDiff(`git diff HEAD${pathArg}`);
1051
- const untracked = await this.untrackedDiff(entryPointPath);
1052
-
1053
- return [committed, uncommitted, untracked].filter(Boolean).join("\n");
1054
- }
1055
-
1056
- private async untrackedDiff(entryPointPath: string): Promise<string> {
1057
- const pathArg = this.pathArg(entryPointPath);
1058
- const { stdout } = await execAsync(
1059
- `git ls-files --others --exclude-standard${pathArg}`,
1060
- {
1061
- maxBuffer: MAX_BUFFER_BYTES,
1062
- },
1063
- );
1064
- const files = this.parseLines(stdout);
1065
- const diffs: string[] = [];
1066
-
1067
- for (const file of files) {
1068
- try {
1069
- const diff = await this.execDiff(
1070
- `git diff --no-index -- /dev/null ${this.quoteArg(file)}`,
1071
- );
1072
- if (diff.trim()) diffs.push(diff);
1073
- } catch (error: unknown) {
1074
- const err = error as { message?: string; stderr?: string };
1075
- const msg = [err.message, err.stderr].filter(Boolean).join("\n");
1076
- if (
1077
- msg.includes("Could not access") ||
1078
- msg.includes("ENOENT") ||
1079
- msg.includes("No such file")
1080
- ) {
1081
- continue;
1082
- }
1083
- throw error;
1084
- }
1085
- }
1086
-
1087
- return diffs.join("\n");
1088
- }
1089
-
1090
- private async execDiff(command: string): Promise<string> {
1091
- try {
1092
- const { stdout } = await execAsync(command, {
1093
- maxBuffer: MAX_BUFFER_BYTES,
1094
- });
1095
- return stdout;
1096
- } catch (error: unknown) {
1097
- const err = error as { code?: number; stdout?: string };
1098
- if (typeof err.code === "number" && err.stdout) {
1099
- return err.stdout;
1100
- }
1101
- throw error;
1102
- }
1103
- }
1104
-
1105
- private buildPreviousFailuresSection(
1106
- violations: PreviousViolation[],
1107
- ): string {
1108
- const toVerify = violations.filter((v) => v.status === "fixed");
1109
- const unaddressed = violations.filter(
1110
- (v) => v.status === "new" || !v.status,
1111
- );
1112
-
1113
- const affectedFiles = [...new Set(violations.map((v) => v.file))];
1114
-
1115
- const lines: string[] = [];
1116
-
1117
- lines.push(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1118
- RERUN MODE: VERIFY PREVIOUS FIXES ONLY
1119
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1120
-
1121
- This is a RERUN review. The agent attempted to fix some of the violations listed below.
1122
- Your task is STRICTLY LIMITED to verifying the fixes for violations marked as FIXED.
1123
-
1124
- PREVIOUS VIOLATIONS TO VERIFY:
1125
- `);
1126
-
1127
- if (toVerify.length === 0) {
1128
- lines.push("(No violations were marked as FIXED for verification)\n");
1129
- } else {
1130
- toVerify.forEach((v, i) => {
1131
- lines.push(`${i + 1}. ${v.file}:${v.line} - ${v.issue}`);
1132
- if (v.fix) {
1133
- lines.push(` Suggested fix: ${v.fix}`);
1134
- }
1135
- if (v.result) {
1136
- lines.push(` Agent result: ${v.result}`);
1137
- }
1138
- lines.push("");
1139
- });
1140
- }
1141
-
1142
- if (unaddressed.length > 0) {
1143
- lines.push(`UNADDRESSED VIOLATIONS (STILL FAILING):
1144
- The following violations were NOT marked as fixed or skipped and are still active failures:
1145
- `);
1146
- unaddressed.forEach((v, i) => {
1147
- lines.push(`${i + 1}. ${v.file}:${v.line} - ${v.issue}`);
1148
- });
1149
- lines.push("");
1150
- }
1151
-
1152
- lines.push(`STRICT INSTRUCTIONS FOR RERUN MODE:
1153
-
1154
- 1. VERIFY FIXES: Check if each violation marked as FIXED above has been addressed
1155
- - For violations that are fixed, confirm they no longer appear
1156
- - For violations that remain unfixed, include them in your violations array (status: "new")
1157
-
1158
- 2. UNADDRESSED VIOLATIONS: You MUST include all UNADDRESSED violations listed above in your output array if they still exist.
1159
-
1160
- 3. CHECK FOR REGRESSIONS ONLY: You may ONLY report NEW violations if they:
1161
- - Are in FILES that were modified to fix the above violations: ${affectedFiles.join(", ")}
1162
- - Are DIRECTLY caused by the fix changes (e.g., a fix introduced a new bug)
1163
- - Are in the same function/region that was modified to address a previous violation
1164
-
1165
- 4. Return status "pass" ONLY if ALL previous violations (including unaddressed ones) are now fixed AND no regressions were introduced.
1166
- Otherwise, return status "fail" and list all remaining violations.
1167
-
1168
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
1169
-
1170
- return lines.join("\n");
1171
- }
1172
-
1173
- public evaluateOutput(
1174
- output: string,
1175
- diff?: string,
1176
- ): {
1177
- status: "pass" | "fail" | "error";
1178
- message: string;
1179
- json?: ReviewJsonOutput;
1180
- filteredCount?: number;
1181
- } {
1182
- const diffRanges = diff ? parseDiff(diff) : undefined;
1183
-
1184
- try {
1185
- const jsonBlockMatch = output.match(/```json\s*([\s\S]*?)\s*```/);
1186
- if (jsonBlockMatch?.[1]) {
1187
- try {
1188
- const json = JSON.parse(jsonBlockMatch[1]);
1189
- return this.validateAndReturn(json, diffRanges);
1190
- } catch {
1191
- // Fall through
1192
- }
1193
- }
1194
-
1195
- const end = output.lastIndexOf("}");
1196
- if (end !== -1) {
1197
- let start = output.lastIndexOf("{", end);
1198
- while (start !== -1) {
1199
- const candidate = output.substring(start, end + 1);
1200
- try {
1201
- const json = JSON.parse(candidate);
1202
- if (json.status) {
1203
- return this.validateAndReturn(json, diffRanges);
1204
- }
1205
- } catch {
1206
- // Not valid JSON, keep searching
1207
- }
1208
- start = output.lastIndexOf("{", start - 1);
1209
- }
1210
- }
1211
-
1212
- const firstStart = output.indexOf("{");
1213
- if (firstStart !== -1 && end !== -1 && end > firstStart) {
1214
- try {
1215
- const candidate = output.substring(firstStart, end + 1);
1216
- const json = JSON.parse(candidate);
1217
- return this.validateAndReturn(json, diffRanges);
1218
- } catch {
1219
- // Ignore
1220
- }
1221
- }
1222
-
1223
- return {
1224
- status: "error",
1225
- message: "No valid JSON object found in output",
1226
- };
1227
- } catch (error: unknown) {
1228
- const err = error as { message?: string };
1229
- return {
1230
- status: "error",
1231
- message: `Failed to parse JSON output: ${err.message}`,
1232
- };
1233
- }
1234
- }
1235
-
1236
- private validateAndReturn(
1237
- json: ReviewJsonOutput,
1238
- diffRanges?: Map<string, DiffFileRange>,
1239
- ): {
1240
- status: "pass" | "fail" | "error";
1241
- message: string;
1242
- json?: ReviewJsonOutput;
1243
- filteredCount?: number;
1244
- } {
1245
- if (!json.status || (json.status !== "pass" && json.status !== "fail")) {
1246
- return {
1247
- status: "error",
1248
- message: 'Invalid JSON: missing or invalid "status" field',
1249
- json,
1250
- };
1251
- }
1252
-
1253
- if (json.status === "pass") {
1254
- return { status: "pass", message: json.message || "Passed", json };
1255
- }
1256
-
1257
- let filteredCount = 0;
1258
-
1259
- if (Array.isArray(json.violations) && diffRanges?.size) {
1260
- const originalCount = json.violations.length;
1261
-
1262
- json.violations = json.violations.filter(
1263
- (v: { file: string; line: number | string }) => {
1264
- // Coerce string line numbers to numbers for validation
1265
- const lineStr =
1266
- typeof v.line === "string" ? v.line.trim() : undefined;
1267
- const lineNum =
1268
- typeof v.line === "number"
1269
- ? v.line
1270
- : lineStr && /^\d+$/.test(lineStr)
1271
- ? Number(lineStr)
1272
- : undefined;
1273
- const isValid = isValidViolationLocation(v.file, lineNum, diffRanges);
1274
- return isValid;
1275
- },
1276
- );
1277
-
1278
- filteredCount = originalCount - json.violations.length;
1279
-
1280
- if (json.violations.length === 0) {
1281
- return {
1282
- status: "pass",
1283
- message: `Passed (${filteredCount} out-of-scope violations filtered)`,
1284
- json: { status: "pass" },
1285
- filteredCount,
1286
- };
1287
- }
1288
- }
1289
-
1290
- const violationCount = Array.isArray(json.violations)
1291
- ? json.violations.length
1292
- : "some";
1293
-
1294
- const msg = `Found ${violationCount} violations`;
1295
-
1296
- return { status: "fail", message: msg, json, filteredCount };
1297
- }
1298
-
1299
- private async writeJsonResult(
1300
- logPath: string,
1301
- adapter: string,
1302
- status: "pass" | "fail" | "error",
1303
- rawOutput: string,
1304
- json: ReviewJsonOutput,
1305
- ): Promise<string> {
1306
- const jsonPath = logPath.replace(/\.log$/, ".json");
1307
- const fullOutput: ReviewFullJsonOutput = {
1308
- adapter,
1309
- timestamp: new Date().toISOString(),
1310
- status,
1311
- rawOutput,
1312
- violations: json.violations || [],
1313
- };
1314
-
1315
- await fs.writeFile(jsonPath, JSON.stringify(fullOutput, null, 2));
1316
- return jsonPath;
1317
- }
1318
-
1319
- private parseLines(stdout: string): string[] {
1320
- return stdout
1321
- .split("\n")
1322
- .map((line) => line.trim())
1323
- .filter((line) => line.length > 0);
1324
- }
1325
-
1326
- private pathArg(entryPointPath: string): string {
1327
- return ` -- ${this.quoteArg(entryPointPath)}`;
1328
- }
1329
-
1330
- private quoteArg(value: string): string {
1331
- return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
1332
- }
1333
- }