agent-roast 0.1.4 → 0.2.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.
@@ -0,0 +1,1298 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import path from "path";
5
+
6
+ // src/git.ts
7
+ import { spawn, spawnSync } from "child_process";
8
+ import { PassThrough } from "stream";
9
+ import { StringDecoder } from "string_decoder";
10
+
11
+ // src/detector.ts
12
+ var TRAILER_AI_PATTERNS = [
13
+ { regex: /Co-Authored-By:.*(?:Claude|Anthropic)/i, name: "trailer:claude" },
14
+ { regex: /Co-Authored-By:.*(?:Cursor|Anysphere)/i, name: "trailer:cursor" },
15
+ { regex: /Co-Authored-By:.*(?:Copilot|GitHub Copilot)/i, name: "trailer:copilot" },
16
+ { regex: /Co-Authored-By:.*(?:Aider|aider)/i, name: "trailer:aider" },
17
+ { regex: /Co-Authored-By:.*(?:Devin|cognition)/i, name: "trailer:devin" },
18
+ { regex: /Co-Authored-By:.*(?:Windsurf|Codeium)/i, name: "trailer:windsurf" },
19
+ { regex: /Co-Authored-By:.*(?:ChatGPT|OpenAI|GPT)/i, name: "trailer:gpt" }
20
+ ];
21
+ var MESSAGE_AI_PATTERNS = [
22
+ { regex: /(?:Generated|Assisted|Created|Written) by Cursor/i, name: "message:cursor" },
23
+ { regex: /(?:Generated|Assisted|Created|Written) by Claude/i, name: "message:claude" },
24
+ { regex: /(?:Generated|Assisted|Created|Written) by Aider/i, name: "message:aider" },
25
+ { regex: /(?:Generated|Assisted|Created|Written) by Copilot/i, name: "message:copilot" },
26
+ { regex: /(?:Generated|Assisted|Created|Written) by Devin/i, name: "message:devin" }
27
+ ];
28
+ var AUTHOR_AI_PATTERNS = [
29
+ { regex: /(?:claude|anthropic)/i, name: "author:claude" },
30
+ { regex: /(?:cursor|anysphere)/i, name: "author:cursor" },
31
+ { regex: /(?:copilot)/i, name: "author:copilot" },
32
+ { regex: /(?:aider)/i, name: "author:aider" },
33
+ { regex: /(?:devin)/i, name: "author:devin" },
34
+ { regex: /(?:\[bot\]|bot@|automation)/i, name: "author:bot" }
35
+ ];
36
+ function detectAiAttribution(authorName, authorEmail, message) {
37
+ const signatures = [];
38
+ for (const { regex, name } of TRAILER_AI_PATTERNS) {
39
+ if (regex.test(message)) {
40
+ signatures.push(name);
41
+ }
42
+ }
43
+ for (const { regex, name } of MESSAGE_AI_PATTERNS) {
44
+ if (regex.test(message)) {
45
+ signatures.push(name);
46
+ }
47
+ }
48
+ const authorIdentity = `${authorName} <${authorEmail}>`;
49
+ for (const { regex, name } of AUTHOR_AI_PATTERNS) {
50
+ if (regex.test(authorIdentity)) {
51
+ signatures.push(name);
52
+ }
53
+ }
54
+ return {
55
+ isAiAttributed: signatures.length > 0,
56
+ signatures
57
+ };
58
+ }
59
+
60
+ // src/git.ts
61
+ var RECORD_SEPARATOR = "__ROAST_REC__";
62
+ var FIELD_SEPARATOR = "__ROAST_FIELD__";
63
+ var GitError = class extends Error {
64
+ constructor(stderr, code) {
65
+ super(`git exited with code ${code}: ${stderr.trim() || "unknown error"}`);
66
+ this.stderr = stderr;
67
+ this.code = code;
68
+ this.name = "GitError";
69
+ }
70
+ stderr;
71
+ code;
72
+ };
73
+ var HERMETIC_GIT_FLAGS = [
74
+ "-c",
75
+ "core.quotepath=false",
76
+ "-c",
77
+ "diff.noprefix=false",
78
+ "-c",
79
+ "diff.mnemonicPrefix=false",
80
+ "--no-pager"
81
+ ];
82
+ function getHermeticGitEnv(extraEnv) {
83
+ return {
84
+ ...process.env,
85
+ ...extraEnv,
86
+ GIT_PAGER: "cat",
87
+ GIT_CONFIG_NOSYSTEM: "1",
88
+ GIT_CONFIG_GLOBAL: extraEnv?.GIT_CONFIG_GLOBAL || process.env.GIT_CONFIG_GLOBAL || "/dev/null"
89
+ };
90
+ }
91
+ var MAX_RECORD_BUFFER_SIZE = 50 * 1024 * 1024;
92
+ async function parseGitLogStream(stream, onCommit) {
93
+ let buffer = "";
94
+ let totalSkippedLongLines = 0;
95
+ let totalSkippedUnknownFiles = 0;
96
+ let scanFrom = 0;
97
+ let activeRecSep = null;
98
+ let activeFieldSep = null;
99
+ const processBlock = (block) => {
100
+ if (!block.trim()) return;
101
+ const fieldSep = activeFieldSep || (block.includes(FIELD_SEPARATOR) ? FIELD_SEPARATOR : "");
102
+ const parts = block.split(fieldSep);
103
+ if (parts.length < 5) return;
104
+ const hash = parts[0].trim();
105
+ const date = parts[1].trim();
106
+ const authorName = parts[2].trim();
107
+ const authorEmail = parts[3].trim();
108
+ const message = parts[4].trim();
109
+ const rawDiff = parts.slice(5).join(fieldSep);
110
+ const attribution = detectAiAttribution(authorName, authorEmail, message);
111
+ const { files, totalAdded, skippedLongLines, skippedUnknownFiles } = parseDiff(rawDiff);
112
+ totalSkippedLongLines += skippedLongLines;
113
+ totalSkippedUnknownFiles += skippedUnknownFiles;
114
+ onCommit(
115
+ {
116
+ hash,
117
+ date,
118
+ authorName,
119
+ authorEmail,
120
+ message,
121
+ isAiAttributed: attribution.isAiAttributed,
122
+ aiSignatures: attribution.signatures,
123
+ files,
124
+ totalAddedCount: totalAdded
125
+ },
126
+ { skippedLongLines, skippedUnknownFiles }
127
+ );
128
+ };
129
+ return new Promise((resolve, reject) => {
130
+ let settled = false;
131
+ const safeResolve = (val) => {
132
+ if (!settled) {
133
+ settled = true;
134
+ resolve(val);
135
+ }
136
+ };
137
+ const safeReject = (err) => {
138
+ if (!settled) {
139
+ settled = true;
140
+ reject(err);
141
+ }
142
+ };
143
+ const decoder = new StringDecoder("utf8");
144
+ stream.on("data", (chunk) => {
145
+ const chunkStr = typeof chunk === "string" ? chunk : decoder.write(chunk);
146
+ buffer += chunkStr;
147
+ if (buffer.length > MAX_RECORD_BUFFER_SIZE) {
148
+ const oomErr = new Error(`Git log record exceeded maximum buffer limit (${MAX_RECORD_BUFFER_SIZE} bytes).`);
149
+ stream.destroy(oomErr);
150
+ safeReject(oomErr);
151
+ return;
152
+ }
153
+ if (activeRecSep === null) {
154
+ if (buffer.includes(RECORD_SEPARATOR)) {
155
+ activeRecSep = RECORD_SEPARATOR;
156
+ activeFieldSep = FIELD_SEPARATOR;
157
+ scanFrom = 0;
158
+ } else if (buffer.includes("")) {
159
+ const first1e = buffer.indexOf("");
160
+ const remaining = buffer.slice(first1e);
161
+ if (remaining.length >= RECORD_SEPARATOR.length || !RECORD_SEPARATOR.startsWith(remaining)) {
162
+ activeRecSep = "";
163
+ activeFieldSep = "";
164
+ scanFrom = 0;
165
+ }
166
+ }
167
+ }
168
+ if (activeRecSep === null) {
169
+ return;
170
+ }
171
+ const recSep = activeRecSep;
172
+ let idx;
173
+ while ((idx = buffer.indexOf(recSep, scanFrom)) !== -1) {
174
+ const completeBlock = buffer.slice(0, idx);
175
+ buffer = buffer.slice(idx + recSep.length);
176
+ scanFrom = 0;
177
+ try {
178
+ processBlock(completeBlock);
179
+ } catch (err) {
180
+ const errObj = err instanceof Error ? err : new Error(String(err));
181
+ stream.destroy(errObj);
182
+ safeReject(errObj);
183
+ return;
184
+ }
185
+ }
186
+ scanFrom = buffer.length;
187
+ });
188
+ stream.on("end", () => {
189
+ buffer += decoder.end();
190
+ if (buffer.trim()) {
191
+ try {
192
+ processBlock(buffer);
193
+ } catch (err) {
194
+ safeReject(err);
195
+ return;
196
+ }
197
+ }
198
+ safeResolve({ totalSkippedLongLines, totalSkippedUnknownFiles });
199
+ });
200
+ stream.on("error", safeReject);
201
+ stream.on("close", () => {
202
+ if (!settled) {
203
+ safeReject(new Error("Git log stream was closed prematurely before completion."));
204
+ }
205
+ });
206
+ });
207
+ }
208
+ var HUNK_REGEX = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
209
+ var MAX_LINE_LENGTH = 4096;
210
+ function unquoteGitPath(pathStr) {
211
+ let s = pathStr;
212
+ if (s.startsWith('"') && s.endsWith('"')) {
213
+ s = s.slice(1, -1);
214
+ const bytes = [];
215
+ let i = 0;
216
+ while (i < s.length) {
217
+ if (s[i] === "\\" && i + 1 < s.length) {
218
+ const next = s[i + 1];
219
+ if (next >= "0" && next <= "7") {
220
+ let octal = next;
221
+ let j = i + 2;
222
+ while (j < s.length && j < i + 4 && s[j] >= "0" && s[j] <= "7") {
223
+ octal += s[j];
224
+ j++;
225
+ }
226
+ bytes.push(parseInt(octal, 8));
227
+ i = j;
228
+ } else if (next === "a") {
229
+ bytes.push(7);
230
+ i += 2;
231
+ } else if (next === "b") {
232
+ bytes.push(8);
233
+ i += 2;
234
+ } else if (next === "f") {
235
+ bytes.push(12);
236
+ i += 2;
237
+ } else if (next === "n") {
238
+ bytes.push(10);
239
+ i += 2;
240
+ } else if (next === "r") {
241
+ bytes.push(13);
242
+ i += 2;
243
+ } else if (next === "t") {
244
+ bytes.push(9);
245
+ i += 2;
246
+ } else if (next === "v") {
247
+ bytes.push(11);
248
+ i += 2;
249
+ } else if (next === "\\") {
250
+ bytes.push(92);
251
+ i += 2;
252
+ } else if (next === '"') {
253
+ bytes.push(34);
254
+ i += 2;
255
+ } else {
256
+ const codePoint = s.codePointAt(i + 1);
257
+ const charStr = String.fromCodePoint(codePoint);
258
+ const buf = Buffer.from(charStr, "utf8");
259
+ for (const b of buf) bytes.push(b);
260
+ i += 1 + charStr.length;
261
+ }
262
+ } else {
263
+ const codePoint = s.codePointAt(i);
264
+ const charStr = String.fromCodePoint(codePoint);
265
+ const buf = Buffer.from(charStr, "utf8");
266
+ for (const b of buf) bytes.push(b);
267
+ i += charStr.length;
268
+ }
269
+ }
270
+ s = Buffer.from(bytes).toString("utf8");
271
+ }
272
+ if (s.startsWith("b/")) return s.slice(2);
273
+ if (s.startsWith("a/")) return s.slice(2);
274
+ return s;
275
+ }
276
+ function parseDiff(rawDiff) {
277
+ const files = [];
278
+ let currentFile = null;
279
+ let lineNo = 0;
280
+ let oldLeft = 0;
281
+ let newLeft = 0;
282
+ let totalAdded = 0;
283
+ let skippedLongLines = 0;
284
+ const diffLines = rawDiff.split("\n");
285
+ for (const rawLine of diffLines) {
286
+ const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
287
+ if (oldLeft > 0 || newLeft > 0) {
288
+ const char = line[0];
289
+ if (char === "\\") {
290
+ continue;
291
+ }
292
+ if (char === "+") {
293
+ if (currentFile && !currentFile.deleted && currentFile.path !== "unknown") {
294
+ const content = line.slice(1);
295
+ if (content.length <= MAX_LINE_LENGTH) {
296
+ currentFile.addedLines.push({
297
+ line: content,
298
+ lineNumber: lineNo
299
+ });
300
+ totalAdded++;
301
+ } else {
302
+ skippedLongLines++;
303
+ }
304
+ }
305
+ lineNo++;
306
+ newLeft--;
307
+ continue;
308
+ }
309
+ if (char === "-") {
310
+ oldLeft--;
311
+ continue;
312
+ }
313
+ if (char === " ") {
314
+ oldLeft--;
315
+ newLeft--;
316
+ lineNo++;
317
+ continue;
318
+ }
319
+ oldLeft = 0;
320
+ newLeft = 0;
321
+ }
322
+ const hunkMatch = HUNK_REGEX.exec(line);
323
+ if (hunkMatch) {
324
+ oldLeft = hunkMatch[1] === void 0 ? 1 : parseInt(hunkMatch[1], 10);
325
+ lineNo = parseInt(hunkMatch[2], 10);
326
+ newLeft = hunkMatch[3] === void 0 ? 1 : parseInt(hunkMatch[3], 10);
327
+ continue;
328
+ }
329
+ if (line.startsWith("diff --git ")) {
330
+ const quotedMatch = line.match(/^diff --git ("(?:\\.|[^"])+") ("(?:\\.|[^"])+")$/);
331
+ let filePath = "unknown";
332
+ if (quotedMatch) {
333
+ filePath = unquoteGitPath(quotedMatch[2]);
334
+ } else {
335
+ const plainMatch = line.match(/^diff --git a\/(.+) b\/(.+)$/);
336
+ if (plainMatch) {
337
+ filePath = plainMatch[2];
338
+ }
339
+ }
340
+ currentFile = { path: filePath, addedLines: [] };
341
+ files.push(currentFile);
342
+ continue;
343
+ }
344
+ if (line.startsWith("+++ /dev/null")) {
345
+ if (currentFile) {
346
+ currentFile.deleted = true;
347
+ }
348
+ continue;
349
+ }
350
+ if (line.startsWith("+++ ")) {
351
+ const rawPath = line.trimEnd().slice(4).trim();
352
+ const filePath = unquoteGitPath(rawPath);
353
+ if (currentFile) {
354
+ currentFile.path = filePath;
355
+ } else {
356
+ currentFile = { path: filePath, addedLines: [] };
357
+ files.push(currentFile);
358
+ }
359
+ continue;
360
+ }
361
+ }
362
+ let skippedUnknownFiles = 0;
363
+ for (const file of files) {
364
+ if (file.path === "unknown" && !file.deleted) {
365
+ skippedUnknownFiles++;
366
+ }
367
+ }
368
+ return { files, totalAdded, skippedLongLines, skippedUnknownFiles };
369
+ }
370
+ var COMMON_EXCLUDE_SPECS = [
371
+ "--",
372
+ ".",
373
+ ":(exclude,glob)**/*.lock",
374
+ ":(exclude,glob)**/*-lock.json",
375
+ ":(exclude,glob)**/*.lockb",
376
+ ":(exclude,glob)**/dist/**",
377
+ ":(exclude,glob)**/build/**",
378
+ ":(exclude,glob)**/node_modules/**",
379
+ ":(exclude,glob)**/*.min.js",
380
+ ":(exclude,glob)**/*.map"
381
+ ];
382
+ function streamGitLog(cwd = process.cwd(), since = "90 days ago") {
383
+ const gitArgs = [
384
+ ...HERMETIC_GIT_FLAGS,
385
+ "log",
386
+ "-p",
387
+ "-U0",
388
+ "--no-color",
389
+ "--no-merges",
390
+ "--no-show-signature",
391
+ "--no-ext-diff",
392
+ "--no-textconv",
393
+ "--encoding=UTF-8",
394
+ "--src-prefix=a/",
395
+ "--dst-prefix=b/",
396
+ `--since=${since}`,
397
+ `--format=${RECORD_SEPARATOR}%H${FIELD_SEPARATOR}%aI${FIELD_SEPARATOR}%an${FIELD_SEPARATOR}%ae${FIELD_SEPARATOR}%B${FIELD_SEPARATOR}`,
398
+ ...COMMON_EXCLUDE_SPECS
399
+ ];
400
+ const output = new PassThrough();
401
+ const child = spawn("git", gitArgs, {
402
+ cwd,
403
+ env: getHermeticGitEnv(),
404
+ stdio: ["ignore", "pipe", "pipe"],
405
+ windowsHide: true
406
+ });
407
+ child.stdout.on("data", (chunk) => {
408
+ output.write(chunk);
409
+ });
410
+ let stderrBuffer = "";
411
+ child.stderr.on("data", (chunk) => {
412
+ stderrBuffer += chunk.toString();
413
+ });
414
+ child.on("close", (code) => {
415
+ if (code !== 0) {
416
+ output.destroy(new GitError(stderrBuffer, code));
417
+ } else {
418
+ output.end();
419
+ }
420
+ });
421
+ child.on("error", (err) => {
422
+ output.destroy(err);
423
+ });
424
+ return output;
425
+ }
426
+ function streamGitDiff(cwd, baseSha, headSha) {
427
+ const gitArgs = [
428
+ ...HERMETIC_GIT_FLAGS,
429
+ "diff",
430
+ "-U0",
431
+ "--no-color",
432
+ "--no-ext-diff",
433
+ "--no-textconv",
434
+ "--src-prefix=a/",
435
+ "--dst-prefix=b/",
436
+ "--end-of-options",
437
+ baseSha,
438
+ headSha,
439
+ ...COMMON_EXCLUDE_SPECS
440
+ ];
441
+ const output = new PassThrough();
442
+ const child = spawn("git", gitArgs, {
443
+ cwd,
444
+ env: getHermeticGitEnv(),
445
+ stdio: ["ignore", "pipe", "pipe"],
446
+ windowsHide: true
447
+ });
448
+ child.stdout.on("data", (chunk) => {
449
+ output.write(chunk);
450
+ });
451
+ let stderrBuffer = "";
452
+ child.stderr.on("data", (chunk) => {
453
+ stderrBuffer += chunk.toString();
454
+ });
455
+ child.on("close", (code) => {
456
+ if (code !== 0) {
457
+ output.destroy(new GitError(stderrBuffer, code));
458
+ } else {
459
+ output.end();
460
+ }
461
+ });
462
+ child.on("error", (err) => {
463
+ output.destroy(err);
464
+ });
465
+ return output;
466
+ }
467
+ function resolveCommitSha(cwd, ref, roleName = "ref") {
468
+ const result = spawnSync(
469
+ "git",
470
+ [...HERMETIC_GIT_FLAGS, "rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`],
471
+ {
472
+ cwd,
473
+ env: getHermeticGitEnv(),
474
+ encoding: "utf8",
475
+ windowsHide: true
476
+ }
477
+ );
478
+ if (result.error) {
479
+ throw new GitResolutionError(`Git command execution failed: ${result.error.message}. Ensure git is installed and in your PATH.`);
480
+ }
481
+ if (result.status !== 0) {
482
+ const isShallow = checkIfShallow(cwd);
483
+ const shallowHint = isShallow ? "\nShallow clone detected: please configure actions/checkout with `fetch-depth: 0`." : "";
484
+ throw new GitResolutionError(
485
+ `${roleName} '${ref}' could not be resolved to a valid commit object.${shallowHint}
486
+ Git output: ${result.stderr.trim() || result.stdout.trim()}`
487
+ );
488
+ }
489
+ return result.stdout.trim();
490
+ }
491
+ function findMergeBase(cwd, baseSha, headSha) {
492
+ const result = spawnSync(
493
+ "git",
494
+ [...HERMETIC_GIT_FLAGS, "merge-base", "--end-of-options", baseSha, headSha],
495
+ {
496
+ cwd,
497
+ env: getHermeticGitEnv(),
498
+ encoding: "utf8",
499
+ windowsHide: true
500
+ }
501
+ );
502
+ if (result.error) {
503
+ throw new GitResolutionError(`Git command execution failed: ${result.error.message}. Ensure git is installed and in your PATH.`);
504
+ }
505
+ if (result.status !== 0) {
506
+ const isShallow = checkIfShallow(cwd);
507
+ const shallowHint = isShallow ? "\nShallow clone detected: repository history is truncated. Configure `fetch-depth: 0` in your workflow." : "";
508
+ throw new GitResolutionError(
509
+ `Could not find a common ancestor (merge-base) between base (${baseSha.slice(0, 7)}) and head (${headSha.slice(0, 7)}).${shallowHint}
510
+ Git output: ${result.stderr.trim() || result.stdout.trim()}`
511
+ );
512
+ }
513
+ return result.stdout.trim();
514
+ }
515
+ function checkIfShallow(cwd) {
516
+ try {
517
+ const res = spawnSync("git", [...HERMETIC_GIT_FLAGS, "rev-parse", "--is-shallow-repository"], {
518
+ cwd,
519
+ env: getHermeticGitEnv(),
520
+ encoding: "utf8",
521
+ windowsHide: true
522
+ });
523
+ return res.stdout.trim() === "true";
524
+ } catch {
525
+ return false;
526
+ }
527
+ }
528
+ var GitResolutionError = class extends Error {
529
+ constructor(message) {
530
+ super(message);
531
+ this.name = "GitResolutionError";
532
+ }
533
+ };
534
+
535
+ // src/metrics.ts
536
+ var TEST_SKIP_PATTERNS = [
537
+ { regex: /(?:it|test|describe)\.skip\b/, name: "skipped test (it.skip / describe.skip)" },
538
+ { regex: /\b(?:xit|xtest)\(/, name: "disabled test (xit / xtest)" },
539
+ { regex: /@pytest\.mark\.skip\b/, name: "skipped pytest (@pytest.mark.skip)" },
540
+ { regex: /\bpytest\.skip\(/, name: "imperative pytest skip (pytest.skip)" },
541
+ { regex: /\btest\.todo\(/, name: "todo placeholder test" }
542
+ ];
543
+ var COMMENT_DIRECTIVE_PATTERNS = [
544
+ { regex: /^\s*\/\/\s*@ts-ignore\b/, name: "compiler ignore (@ts-ignore)" },
545
+ { regex: /^\s*\/\/\s*@ts-expect-error\b/, name: "compiler suppression (@ts-expect-error)" },
546
+ { regex: /(?:^\s*#\s*type:\s*ignore\b|#\s*type:\s*ignore\s*$)/, name: "python type ignore" },
547
+ { regex: /^\s*\/\/\s*eslint-disable(?:-next-line)?\b/, name: "linter suppression (eslint-disable)" }
548
+ ];
549
+ var CODE_TYPE_ESCAPE_PATTERNS = [
550
+ { regex: /\bas\s+any\b/, name: "type bypass (as any)" }
551
+ ];
552
+ var SWALLOWED_ERROR_PATTERNS = [
553
+ { regex: /catch\s*(?:\([^)]*\))?\s*\{\s*\}/, name: "empty catch block (catch {})" },
554
+ { regex: /except(?:\s+[^:]*)?:\s*pass\b/, name: "silent python error swallow (except: pass)" }
555
+ ];
556
+ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
557
+ ".ts",
558
+ ".tsx",
559
+ ".js",
560
+ ".jsx",
561
+ ".mjs",
562
+ ".cjs",
563
+ ".py",
564
+ ".go",
565
+ ".rs",
566
+ ".java",
567
+ ".kt",
568
+ ".vue",
569
+ ".svelte",
570
+ ".php",
571
+ ".rb",
572
+ ".cs",
573
+ ".cpp",
574
+ ".c"
575
+ ]);
576
+ var IGNORED_PATH_REGEX = /(?:^|\/)(?:fixtures|__fixtures__|testdata|mocks|test_data|__snapshots__|sample[s]?)\//i;
577
+ function isCodeFile(filePath) {
578
+ if (IGNORED_PATH_REGEX.test(filePath)) {
579
+ return false;
580
+ }
581
+ const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
582
+ return CODE_EXTENSIONS.has(ext);
583
+ }
584
+ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ */ new Date()).toISOString()) {
585
+ const infractions = [];
586
+ let hasSupportedChanges = false;
587
+ let filesInspected = 0;
588
+ let linesAdded = 0;
589
+ for (const file of files) {
590
+ if (file.deleted || file.path === "unknown") continue;
591
+ if (!isCodeFile(file.path)) continue;
592
+ hasSupportedChanges = true;
593
+ filesInspected++;
594
+ linesAdded += file.addedLines.length;
595
+ for (const added of file.addedLines) {
596
+ const rawLine = added.line;
597
+ const cleanedLine = stripStringAndRegexLiterals(rawLine);
598
+ const trimmedCleaned = cleanedLine.trim();
599
+ const isComment = isCommentOnlyLine(trimmedCleaned);
600
+ if (!isComment) {
601
+ for (const rule of TEST_SKIP_PATTERNS) {
602
+ if (rule.regex.test(cleanedLine)) {
603
+ infractions.push({
604
+ type: "test-skip",
605
+ file: file.path,
606
+ lineNumber: added.lineNumber,
607
+ lineSnippet: rawLine.trim(),
608
+ commitHash,
609
+ commitDate,
610
+ reason: rule.name
611
+ });
612
+ break;
613
+ }
614
+ }
615
+ }
616
+ let foundEscape = false;
617
+ for (const rule of COMMENT_DIRECTIVE_PATTERNS) {
618
+ if (rule.regex.test(cleanedLine)) {
619
+ infractions.push({
620
+ type: "type-escape",
621
+ file: file.path,
622
+ lineNumber: added.lineNumber,
623
+ lineSnippet: rawLine.trim(),
624
+ commitHash,
625
+ commitDate,
626
+ reason: rule.name
627
+ });
628
+ foundEscape = true;
629
+ break;
630
+ }
631
+ }
632
+ if (!foundEscape && !isComment) {
633
+ for (const rule of CODE_TYPE_ESCAPE_PATTERNS) {
634
+ if (rule.regex.test(cleanedLine)) {
635
+ infractions.push({
636
+ type: "type-escape",
637
+ file: file.path,
638
+ lineNumber: added.lineNumber,
639
+ lineSnippet: rawLine.trim(),
640
+ commitHash,
641
+ commitDate,
642
+ reason: rule.name
643
+ });
644
+ break;
645
+ }
646
+ }
647
+ }
648
+ if (!isComment) {
649
+ for (const rule of SWALLOWED_ERROR_PATTERNS) {
650
+ if (rule.regex.test(cleanedLine)) {
651
+ infractions.push({
652
+ type: "swallowed-error",
653
+ file: file.path,
654
+ lineNumber: added.lineNumber,
655
+ lineSnippet: rawLine.trim(),
656
+ commitHash,
657
+ commitDate,
658
+ reason: rule.name
659
+ });
660
+ break;
661
+ }
662
+ }
663
+ }
664
+ }
665
+ }
666
+ return { infractions, hasSupportedChanges, filesInspected, linesAdded };
667
+ }
668
+ function stripStringAndRegexLiterals(line) {
669
+ return line.replace(/(["'`])(?:\\.|(?!\1)[^\\\r\n])*\1/g, '""').replace(/\/(?=[^\/\s])(?:\\\/|[^\/\r\n])+\/[gimsuy]*/g, '""');
670
+ }
671
+ function isCommentOnlyLine(trimmedLine) {
672
+ return trimmedLine.startsWith("//") || trimmedLine.startsWith("/*") || trimmedLine.startsWith("*") || trimmedLine.startsWith("#");
673
+ }
674
+ var PANIC_COMMIT_REGEX = /\b(?:fix|quickfix|revert|hotfix|retry|patch|undo|workaround)\b/i;
675
+ var MAX_INFRACTIONS_PER_COMMIT = 10;
676
+ var MIN_SAMPLE_LOC = 200;
677
+ var MetricsCollector = class {
678
+ totalLinesAdded = 0;
679
+ aiLinesAdded = 0;
680
+ totalCommits = 0;
681
+ aiCommits = 0;
682
+ testSkips = [];
683
+ typeEscapes = [];
684
+ swallowedErrors = [];
685
+ panicLoops = [];
686
+ skippedLongLines = 0;
687
+ skippedUnknownFiles = 0;
688
+ activePanicChain = null;
689
+ includeAll;
690
+ constructor(options = {}) {
691
+ this.includeAll = options.all ?? false;
692
+ }
693
+ recordSkipped(stats) {
694
+ if (stats.skippedLongLines) this.skippedLongLines += stats.skippedLongLines;
695
+ if (stats.skippedUnknownFiles) this.skippedUnknownFiles += stats.skippedUnknownFiles;
696
+ }
697
+ flushPanicChain() {
698
+ if (this.activePanicChain && this.activePanicChain.commitCount >= 2) {
699
+ const chain = this.activePanicChain;
700
+ const durationMinutes = Math.max(
701
+ 1,
702
+ Math.round(Math.abs(chain.latestDate - chain.firstDate) / (1e3 * 60))
703
+ );
704
+ const attributionTag = chain.isAiAttributed ? "AI panic loop" : "Panic loop (unverified / --all mode)";
705
+ this.panicLoops.push({
706
+ type: "panic-loop",
707
+ commitHash: chain.latestHash,
708
+ commitDate: new Date(chain.latestDate).toISOString(),
709
+ reason: `${attributionTag}: chain of ${chain.commitCount} quick fixes across ${durationMinutes}m (${chain.firstHash.slice(0, 7)} -> ${chain.latestHash.slice(0, 7)})`
710
+ });
711
+ }
712
+ this.activePanicChain = null;
713
+ }
714
+ processCommit(commit) {
715
+ this.totalCommits++;
716
+ this.totalLinesAdded += commit.totalAddedCount;
717
+ const isTarget = this.includeAll || commit.isAiAttributed;
718
+ if (commit.isAiAttributed) {
719
+ this.aiCommits++;
720
+ this.aiLinesAdded += commit.totalAddedCount;
721
+ }
722
+ if (!isTarget) {
723
+ return;
724
+ }
725
+ const subject = commit.message.split("\n")[0];
726
+ const commitDateMs = Date.parse(commit.date);
727
+ const isFixCommit = PANIC_COMMIT_REGEX.test(subject);
728
+ const currentActiveFiles = new Set(
729
+ commit.files.filter((f) => !f.deleted && f.path !== "unknown").map((f) => f.path)
730
+ );
731
+ if (isFixCommit && !isNaN(commitDateMs)) {
732
+ if (this.activePanicChain && this.activePanicChain.author.toLowerCase() === commit.authorEmail.toLowerCase()) {
733
+ const diffMinutes = Math.abs(commitDateMs - this.activePanicChain.latestDate) / (1e3 * 60);
734
+ const touchedSameFiles = currentActiveFiles.size > 0 && [...currentActiveFiles].some((p) => this.activePanicChain.activeFiles.has(p));
735
+ if (diffMinutes > 0 && diffMinutes <= 15 && touchedSameFiles) {
736
+ this.activePanicChain.commitCount++;
737
+ this.activePanicChain.latestHash = commit.hash;
738
+ this.activePanicChain.latestDate = commitDateMs;
739
+ for (const p of currentActiveFiles) {
740
+ this.activePanicChain.activeFiles.add(p);
741
+ }
742
+ if (commit.isAiAttributed) {
743
+ this.activePanicChain.isAiAttributed = true;
744
+ }
745
+ } else {
746
+ this.flushPanicChain();
747
+ this.activePanicChain = {
748
+ firstHash: commit.hash,
749
+ latestHash: commit.hash,
750
+ firstDate: commitDateMs,
751
+ latestDate: commitDateMs,
752
+ commitCount: 1,
753
+ author: commit.authorEmail,
754
+ activeFiles: currentActiveFiles,
755
+ isAiAttributed: commit.isAiAttributed
756
+ };
757
+ }
758
+ } else {
759
+ this.flushPanicChain();
760
+ this.activePanicChain = {
761
+ firstHash: commit.hash,
762
+ latestHash: commit.hash,
763
+ firstDate: commitDateMs,
764
+ latestDate: commitDateMs,
765
+ commitCount: 1,
766
+ author: commit.authorEmail,
767
+ activeFiles: currentActiveFiles,
768
+ isAiAttributed: commit.isAiAttributed
769
+ };
770
+ }
771
+ } else {
772
+ this.flushPanicChain();
773
+ }
774
+ let commitSkips = 0;
775
+ let commitEscapes = 0;
776
+ let commitSwallowed = 0;
777
+ for (const file of commit.files) {
778
+ if (!isCodeFile(file.path)) {
779
+ continue;
780
+ }
781
+ for (const added of file.addedLines) {
782
+ const rawLine = added.line;
783
+ const cleanedLine = stripStringAndRegexLiterals(rawLine);
784
+ const trimmedCleaned = cleanedLine.trim();
785
+ const isComment = isCommentOnlyLine(trimmedCleaned);
786
+ if (!isComment && commitSkips < MAX_INFRACTIONS_PER_COMMIT) {
787
+ for (const rule of TEST_SKIP_PATTERNS) {
788
+ if (rule.regex.test(cleanedLine)) {
789
+ this.testSkips.push({
790
+ type: "test-skip",
791
+ file: file.path,
792
+ lineNumber: added.lineNumber,
793
+ lineSnippet: rawLine.trim(),
794
+ commitHash: commit.hash,
795
+ commitDate: commit.date,
796
+ reason: rule.name
797
+ });
798
+ commitSkips++;
799
+ break;
800
+ }
801
+ }
802
+ }
803
+ if (commitEscapes < MAX_INFRACTIONS_PER_COMMIT) {
804
+ for (const rule of COMMENT_DIRECTIVE_PATTERNS) {
805
+ if (rule.regex.test(cleanedLine)) {
806
+ this.typeEscapes.push({
807
+ type: "type-escape",
808
+ file: file.path,
809
+ lineNumber: added.lineNumber,
810
+ lineSnippet: rawLine.trim(),
811
+ commitHash: commit.hash,
812
+ commitDate: commit.date,
813
+ reason: rule.name
814
+ });
815
+ commitEscapes++;
816
+ break;
817
+ }
818
+ }
819
+ if (!isComment && commitEscapes < MAX_INFRACTIONS_PER_COMMIT) {
820
+ for (const rule of CODE_TYPE_ESCAPE_PATTERNS) {
821
+ if (rule.regex.test(cleanedLine)) {
822
+ this.typeEscapes.push({
823
+ type: "type-escape",
824
+ file: file.path,
825
+ lineNumber: added.lineNumber,
826
+ lineSnippet: rawLine.trim(),
827
+ commitHash: commit.hash,
828
+ commitDate: commit.date,
829
+ reason: rule.name
830
+ });
831
+ commitEscapes++;
832
+ break;
833
+ }
834
+ }
835
+ }
836
+ }
837
+ if (!isComment && commitSwallowed < MAX_INFRACTIONS_PER_COMMIT) {
838
+ for (const rule of SWALLOWED_ERROR_PATTERNS) {
839
+ if (rule.regex.test(cleanedLine)) {
840
+ this.swallowedErrors.push({
841
+ type: "swallowed-error",
842
+ file: file.path,
843
+ lineNumber: added.lineNumber,
844
+ lineSnippet: rawLine.trim(),
845
+ commitHash: commit.hash,
846
+ commitDate: commit.date,
847
+ reason: rule.name
848
+ });
849
+ commitSwallowed++;
850
+ break;
851
+ }
852
+ }
853
+ }
854
+ }
855
+ }
856
+ }
857
+ getSummary() {
858
+ this.flushPanicChain();
859
+ const linesSample = this.includeAll ? this.totalLinesAdded : this.aiLinesAdded;
860
+ const isSufficientData = linesSample >= MIN_SAMPLE_LOC;
861
+ const codeInfractionPoints = this.testSkips.length * 15 + this.swallowedErrors.length * 10 + this.typeEscapes.length * 3;
862
+ let panicInfractionPoints = 0;
863
+ for (const loop of this.panicLoops) {
864
+ const countMatch = loop.reason.match(/chain of (\d+) quick fixes/);
865
+ const chainLen = countMatch ? parseInt(countMatch[1], 10) : 2;
866
+ panicInfractionPoints += Math.round(8 * (1 + Math.log(chainLen)));
867
+ }
868
+ panicInfractionPoints = Math.min(panicInfractionPoints, 40);
869
+ const totalInfractionPoints = codeInfractionPoints + panicInfractionPoints;
870
+ const kLoc = linesSample / 1e3;
871
+ const effectiveKLoc = kLoc + 0.5;
872
+ const codeDensity = codeInfractionPoints / effectiveKLoc;
873
+ const panicDensity = panicInfractionPoints / effectiveKLoc;
874
+ const R0 = 15;
875
+ const R1 = 10;
876
+ const score = isSufficientData ? Math.max(0, Math.min(100, Math.round(100 * Math.exp(-(codeDensity / R0 + panicDensity / R1))))) : null;
877
+ let archetype = "The Clean Coder";
878
+ let archetypeDescription = "Spotless discipline. No skipped tests, zero panic loops, and clean type safety.";
879
+ if (score !== null) {
880
+ if (score >= 90) {
881
+ if (totalInfractionPoints === 0) {
882
+ archetype = "The Clean Coder";
883
+ archetypeDescription = "Spotless discipline. No skipped tests, zero panic loops, and clean type safety.";
884
+ } else {
885
+ archetype = "The Pragmatic Builder";
886
+ archetypeDescription = "Overwhelmingly disciplined. Only rare, isolated shortcuts across thousands of lines.";
887
+ }
888
+ } else {
889
+ const testSkipPoints = this.testSkips.length * 15;
890
+ const panicPoints = this.panicLoops.length * 8;
891
+ const escapePoints = this.typeEscapes.length * 3;
892
+ const swallowPoints = this.swallowedErrors.length * 10;
893
+ const maxPoints = Math.max(testSkipPoints, panicPoints, escapePoints, swallowPoints);
894
+ if (maxPoints === panicPoints && this.panicLoops.length >= 2) {
895
+ archetype = "The Panic Looper";
896
+ archetypeDescription = "Prone to rapid-fire fix and revert thrashing cycles when wrestling stubborn bugs.";
897
+ } else if (maxPoints === testSkipPoints && this.testSkips.length >= 3) {
898
+ archetype = "The Silent Vandal";
899
+ archetypeDescription = "When test suites push back, tends to disable or skip tests to keep pipelines green.";
900
+ } else if (maxPoints === escapePoints && this.typeEscapes.length >= 5) {
901
+ archetype = "The Any Architect";
902
+ archetypeDescription = "Leans on compiler bypasses (`as any`, `@ts-ignore`) rather than strict type modeling.";
903
+ } else if (maxPoints === swallowPoints && this.swallowedErrors.length >= 2) {
904
+ archetype = "The Secret Keeper";
905
+ archetypeDescription = "Tends to silence errors with empty catch blocks, obscuring runtime failures.";
906
+ } else if (score < 50) {
907
+ archetype = "The Chaos Gremlin";
908
+ archetypeDescription = "Mixes multiple shortcuts: skipped tests, type bypasses, and quick patches under pressure.";
909
+ } else {
910
+ archetype = "The Shortcut Specialist";
911
+ archetypeDescription = "Frequently cuts corners on edge cases, prioritizing delivery speed over rigour.";
912
+ }
913
+ }
914
+ }
915
+ return {
916
+ schemaVersion: "1.0.0",
917
+ totalLinesAdded: this.totalLinesAdded,
918
+ aiLinesAdded: this.aiLinesAdded,
919
+ totalCommits: this.totalCommits,
920
+ aiCommits: this.aiCommits,
921
+ testSkips: this.testSkips,
922
+ typeEscapes: this.typeEscapes,
923
+ swallowedErrors: this.swallowedErrors,
924
+ panicLoops: this.panicLoops,
925
+ rawScore: totalInfractionPoints,
926
+ score,
927
+ isSufficientData,
928
+ archetype,
929
+ archetypeDescription,
930
+ skippedLongLines: this.skippedLongLines,
931
+ skippedUnknownFiles: this.skippedUnknownFiles
932
+ };
933
+ }
934
+ };
935
+
936
+ // src/renderer.ts
937
+ import pc from "picocolors";
938
+ function generateShareUrl(summary) {
939
+ const scoreDisplay = summary.score !== null ? `${summary.score}/100` : "N/A";
940
+ const text = `My AI coding agent scored ${scoreDisplay} on agent-roast.
941
+
942
+ Archetype: ${summary.archetype}
943
+ Infractions:
944
+ \u2022 ${summary.testSkips.length} skipped tests
945
+ \u2022 ${summary.typeEscapes.length} type escapes
946
+ \u2022 ${summary.panicLoops.length} panic revert loops
947
+
948
+ Audit your repo: npx agent-roast`;
949
+ return `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}`;
950
+ }
951
+ function formatTerminalCard(summary, options) {
952
+ const lines = [];
953
+ lines.push("");
954
+ lines.push(
955
+ `${pc.bold(pc.cyan("agent-roast"))} ${pc.dim("\u2014 git discipline audit")} ${pc.white(options.repoName)}`
956
+ );
957
+ lines.push(
958
+ ` ${pc.dim("commits:")} ${pc.yellow(summary.aiCommits)} AI / ${summary.totalCommits} total ${pc.dim("lines added:")} ${pc.green(summary.aiLinesAdded)} AI / ${summary.totalLinesAdded} total`
959
+ );
960
+ lines.push("");
961
+ if (!summary.isSufficientData || summary.score === null) {
962
+ lines.push(pc.yellow(` insufficient data (< 200 lines added by AI)`));
963
+ lines.push(pc.dim(" commit more code and run `npx agent-roast` again."));
964
+ lines.push("");
965
+ return lines.join("\n");
966
+ }
967
+ let scoreColor = pc.green;
968
+ if (summary.score < 60) scoreColor = pc.red;
969
+ else if (summary.score < 80) scoreColor = pc.yellow;
970
+ lines.push(
971
+ ` ${pc.bold("SCORE:")} ${scoreColor(pc.bold(`${summary.score} / 100`))} ${pc.dim("\xB7")} ${pc.magenta(pc.bold(summary.archetype))}`
972
+ );
973
+ lines.push(` ${pc.dim(summary.archetypeDescription)}`);
974
+ lines.push("");
975
+ const totalInfractions = summary.testSkips.length + summary.typeEscapes.length + summary.swallowedErrors.length + summary.panicLoops.length;
976
+ lines.push(pc.bold(` INFRACTIONS (${totalInfractions})`));
977
+ lines.push(pc.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
978
+ if (summary.panicLoops.length > 0) {
979
+ lines.push(
980
+ ` ${pc.magenta("\u25CF")} ${pc.bold("panic-loop")} (${summary.panicLoops.length}) ${pc.dim("consecutive quick fixes < 15m apart")}`
981
+ );
982
+ const sample = summary.panicLoops[0];
983
+ lines.push(` ${pc.dim(sample.reason)}`);
984
+ }
985
+ if (summary.typeEscapes.length > 0) {
986
+ lines.push(
987
+ ` ${pc.yellow("\u25CF")} ${pc.bold("type-escape")} (${summary.typeEscapes.length}) ${pc.dim("compiler bypasses (as any, @ts-ignore)")}`
988
+ );
989
+ const sample = summary.typeEscapes[0];
990
+ if (sample.file) {
991
+ lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
992
+ }
993
+ }
994
+ if (summary.testSkips.length > 0) {
995
+ lines.push(
996
+ ` ${pc.red("\u25CF")} ${pc.bold("test-skip")} (${summary.testSkips.length}) ${pc.dim("skipped or disabled tests")}`
997
+ );
998
+ const sample = summary.testSkips[0];
999
+ if (sample.file) {
1000
+ lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1001
+ }
1002
+ }
1003
+ if (summary.swallowedErrors.length > 0) {
1004
+ lines.push(
1005
+ ` ${pc.red("\u25CF")} ${pc.bold("swallowed-error")} (${summary.swallowedErrors.length}) ${pc.dim("empty catch blocks or except: pass")}`
1006
+ );
1007
+ const sample = summary.swallowedErrors[0];
1008
+ if (sample.file) {
1009
+ lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1010
+ }
1011
+ }
1012
+ if (totalInfractions === 0) {
1013
+ lines.push(` ${pc.green("\u25CF")} ${pc.dim("zero infractions detected across all inspected lines.")}`);
1014
+ }
1015
+ lines.push("");
1016
+ if (options.verbose) {
1017
+ if (summary.skippedLongLines || summary.skippedUnknownFiles) {
1018
+ lines.push(pc.bold(" PARSER AUDIT STATS:"));
1019
+ lines.push(pc.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1020
+ if (summary.skippedLongLines) {
1021
+ lines.push(` ${pc.dim("skipped long lines (> 4096 chars):")} ${pc.yellow(summary.skippedLongLines)}`);
1022
+ }
1023
+ if (summary.skippedUnknownFiles) {
1024
+ lines.push(` ${pc.dim("skipped files with unparseable path:")} ${pc.yellow(summary.skippedUnknownFiles)}`);
1025
+ }
1026
+ lines.push("");
1027
+ }
1028
+ const allInfractions = [
1029
+ ...summary.testSkips,
1030
+ ...summary.typeEscapes,
1031
+ ...summary.swallowedErrors,
1032
+ ...summary.panicLoops
1033
+ ];
1034
+ if (allInfractions.length > 0) {
1035
+ lines.push(pc.bold(" EVIDENCE (First 10 occurrences):"));
1036
+ lines.push(pc.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1037
+ for (const inf of allInfractions.slice(0, 10)) {
1038
+ const fileLoc = inf.file ? `${inf.file}${inf.lineNumber ? `:${inf.lineNumber}` : ""}` : "commit history";
1039
+ lines.push(
1040
+ ` \u25CF ${pc.cyan(inf.commitHash.slice(0, 7))} [${inf.type}] ${pc.white(fileLoc)}`
1041
+ );
1042
+ if (inf.lineSnippet) {
1043
+ lines.push(` ${pc.dim(`\u2514\u2500 ${inf.lineSnippet}`)}`);
1044
+ }
1045
+ }
1046
+ lines.push("");
1047
+ }
1048
+ }
1049
+ lines.push(
1050
+ ` ${pc.dim("Share score:")} ${pc.cyan(generateShareUrl(summary))}`
1051
+ );
1052
+ lines.push("");
1053
+ return lines.join("\n");
1054
+ }
1055
+ function formatPrTerminalCard(result) {
1056
+ const lines = [];
1057
+ lines.push("");
1058
+ lines.push(
1059
+ `${pc.bold(pc.cyan("agent-roast"))} ${pc.dim("\u2014 pull request audit")} ${pc.white(`${result.baseSha.slice(0, 7)}...${result.headSha.slice(0, 7)}`)}`
1060
+ );
1061
+ if (!result.hasSupportedChanges) {
1062
+ lines.push(` ${pc.dim("no supported source changes detected in pull request.")}`);
1063
+ lines.push("");
1064
+ return lines.join("\n");
1065
+ }
1066
+ lines.push(
1067
+ ` ${pc.dim("files inspected:")} ${result.filesInspected} ${pc.dim("lines added:")} ${result.linesAdded}`
1068
+ );
1069
+ lines.push("");
1070
+ if (result.infractions.length === 0) {
1071
+ lines.push(` ${pc.green("PASS")} ${pc.dim("zero infractions detected across all pull request changes.")}`);
1072
+ } else {
1073
+ lines.push(pc.bold(` INFRACTIONS DETECTED (${result.infractions.length})`));
1074
+ lines.push(pc.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
1075
+ for (const inf of result.infractions) {
1076
+ const isFailedRule = result.failedRules.includes(inf.type);
1077
+ const badge = isFailedRule ? pc.red("FAIL") : pc.yellow("WARN");
1078
+ const loc = `${inf.file || "unknown"}:${inf.lineNumber || 1}`;
1079
+ lines.push(` ${badge} [${inf.type}] ${pc.white(loc)} \u2014 ${pc.dim(inf.reason)}`);
1080
+ if (inf.lineSnippet) {
1081
+ lines.push(` ${pc.dim(`\u2514\u2500 ${inf.lineSnippet}`)}`);
1082
+ }
1083
+ }
1084
+ }
1085
+ lines.push("");
1086
+ if (result.failedRules.length > 0) {
1087
+ lines.push(
1088
+ ` ${pc.red(pc.bold("BLOCKING FAILURE:"))} ${result.failedRules.length} rule(s) violated in --fail-on: ${result.failedRules.join(", ")}`
1089
+ );
1090
+ } else {
1091
+ lines.push(` ${pc.green(pc.bold("PASSED:"))} no blocking infractions.`);
1092
+ }
1093
+ lines.push("");
1094
+ return lines.join("\n");
1095
+ }
1096
+ function sortInfractionsDeterministic(infractions) {
1097
+ const getLevelRank = (type) => {
1098
+ return type === "test-skip" || type === "swallowed-error" ? 0 : 1;
1099
+ };
1100
+ return [...infractions].sort((a, b) => {
1101
+ const rankDiff = getLevelRank(a.type) - getLevelRank(b.type);
1102
+ if (rankDiff !== 0) return rankDiff;
1103
+ const fileA = a.file || "";
1104
+ const fileB = b.file || "";
1105
+ if (fileA !== fileB) return fileA.localeCompare(fileB);
1106
+ return (a.lineNumber || 0) - (b.lineNumber || 0);
1107
+ });
1108
+ }
1109
+ function formatGithubAnnotations(result) {
1110
+ const outputLines = [];
1111
+ const sorted = sortInfractionsDeterministic(result.infractions);
1112
+ const MAX_PER_LEVEL = 10;
1113
+ let errorCount = 0;
1114
+ let warnCount = 0;
1115
+ let omittedCount = 0;
1116
+ for (const inf of sorted) {
1117
+ if (!inf.file || inf.file === "unknown") continue;
1118
+ const isError = result.failedRules.includes(inf.type) || inf.type === "test-skip" || inf.type === "swallowed-error";
1119
+ const line = inf.lineNumber || 1;
1120
+ const title = inf.reason.replace(/[\r\n]/g, " ");
1121
+ const msg = (inf.lineSnippet || inf.reason).replace(/[\r\n]/g, " ");
1122
+ if (isError) {
1123
+ if (errorCount < MAX_PER_LEVEL) {
1124
+ outputLines.push(`::error file=${inf.file},line=${line},title=${title}::${msg}`);
1125
+ errorCount++;
1126
+ } else {
1127
+ omittedCount++;
1128
+ }
1129
+ } else {
1130
+ if (warnCount < MAX_PER_LEVEL) {
1131
+ outputLines.push(`::warning file=${inf.file},line=${line},title=${title}::${msg}`);
1132
+ warnCount++;
1133
+ } else {
1134
+ omittedCount++;
1135
+ }
1136
+ }
1137
+ }
1138
+ if (omittedCount > 0) {
1139
+ outputLines.push(
1140
+ `::notice::And ${omittedCount} more infractions omitted from annotations. See step summary for full report.`
1141
+ );
1142
+ }
1143
+ return outputLines.join("\n");
1144
+ }
1145
+ function formatGithubStepSummary(result) {
1146
+ const lines = [];
1147
+ lines.push("### agent-roast Pull Request Audit");
1148
+ lines.push("");
1149
+ if (!result.hasSupportedChanges) {
1150
+ lines.push("No supported source file changes detected in this pull request.");
1151
+ return lines.join("\n");
1152
+ }
1153
+ const statusBadge = result.passed ? "Passed" : `Failed (${result.failedRules.join(", ")})`;
1154
+ lines.push(`| Status | Base | Head | Files | Lines Added | Infractions |`);
1155
+ lines.push(`| --- | --- | --- | --- | --- | --- |`);
1156
+ lines.push(
1157
+ `| **${statusBadge}** | \`${result.baseSha.slice(0, 7)}\` | \`${result.headSha.slice(0, 7)}\` | ${result.filesInspected} | ${result.linesAdded} | ${result.infractions.length} |`
1158
+ );
1159
+ lines.push("");
1160
+ if (result.infractions.length > 0) {
1161
+ lines.push("#### Infractions");
1162
+ lines.push("");
1163
+ lines.push("| Type | File:Line | Description | Snippet |");
1164
+ lines.push("| --- | --- | --- | --- |");
1165
+ const sorted = sortInfractionsDeterministic(result.infractions);
1166
+ for (const inf of sorted) {
1167
+ const loc = inf.file ? `\`${inf.file}:${inf.lineNumber || 1}\`` : "*history*";
1168
+ const snippet = inf.lineSnippet ? `\`${inf.lineSnippet.replace(/\|/g, "\\|")}\`` : "-";
1169
+ lines.push(`| \`${inf.type}\` | ${loc} | ${inf.reason} | ${snippet} |`);
1170
+ }
1171
+ lines.push("");
1172
+ } else {
1173
+ lines.push("Clean pull request: zero shortcuts or bypassed checks detected.");
1174
+ lines.push("");
1175
+ }
1176
+ return lines.join("\n");
1177
+ }
1178
+
1179
+ // src/types.ts
1180
+ var VALID_DETECTORS = [
1181
+ "test-skip",
1182
+ "type-escape",
1183
+ "swallowed-error",
1184
+ "panic-loop"
1185
+ ];
1186
+
1187
+ // src/index.ts
1188
+ var InvalidDetectorError = class extends Error {
1189
+ constructor(message) {
1190
+ super(message);
1191
+ this.name = "InvalidDetectorError";
1192
+ }
1193
+ };
1194
+ async function roastRepository(options = {}) {
1195
+ const cwd = path.resolve(options.cwd || process.cwd());
1196
+ const since = options.since || "90 days ago";
1197
+ const repoName = path.basename(cwd);
1198
+ const collector = new MetricsCollector({ all: options.all });
1199
+ const gitStream = streamGitLog(cwd, since);
1200
+ await parseGitLogStream(gitStream, (commit, stats) => {
1201
+ collector.processCommit(commit);
1202
+ collector.recordSkipped(stats);
1203
+ });
1204
+ const summary = collector.getSummary();
1205
+ const output = formatTerminalCard(summary, {
1206
+ repoName,
1207
+ verbose: options.verbose
1208
+ });
1209
+ return { summary, output };
1210
+ }
1211
+ async function auditPullRequest(options) {
1212
+ const cwd = path.resolve(options.cwd || process.cwd());
1213
+ const format = options.format || "terminal";
1214
+ const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
1215
+ for (const det of failOn) {
1216
+ if (!VALID_DETECTORS.includes(det)) {
1217
+ throw new InvalidDetectorError(
1218
+ `Unknown detector '${det}' in --fail-on. Valid options: ${VALID_DETECTORS.join(", ")}`
1219
+ );
1220
+ }
1221
+ }
1222
+ const baseSha = resolveCommitSha(cwd, options.base, "Base ref");
1223
+ const headSha = resolveCommitSha(cwd, options.head, "Head ref");
1224
+ const mergeBaseSha = findMergeBase(cwd, baseSha, headSha);
1225
+ const diffStream = streamGitDiff(cwd, mergeBaseSha, headSha);
1226
+ let rawDiff = "";
1227
+ await new Promise((resolve, reject) => {
1228
+ diffStream.on("data", (chunk) => {
1229
+ rawDiff += chunk.toString();
1230
+ });
1231
+ diffStream.on("end", () => resolve());
1232
+ diffStream.on("error", reject);
1233
+ });
1234
+ const { files, skippedLongLines, skippedUnknownFiles } = parseDiff(rawDiff);
1235
+ const audit = auditFileDiffs(files, headSha);
1236
+ const infractions = audit.infractions;
1237
+ const defectDensityPer100Lines = audit.linesAdded > 0 ? Math.round(infractions.length / audit.linesAdded * 100 * 100) / 100 : 0;
1238
+ const failedRules = [...new Set(
1239
+ infractions.filter((inf) => failOn.includes(inf.type)).map((inf) => inf.type)
1240
+ )];
1241
+ const passed = failedRules.length === 0;
1242
+ const exitCode = passed ? 0 : 1;
1243
+ const result = {
1244
+ schemaVersion: "1.0.0",
1245
+ baseSha,
1246
+ headSha,
1247
+ mergeBaseSha,
1248
+ hasSupportedChanges: audit.hasSupportedChanges,
1249
+ filesInspected: audit.filesInspected,
1250
+ linesAdded: audit.linesAdded,
1251
+ defectDensityPer100Lines,
1252
+ infractions,
1253
+ failedRules,
1254
+ passed,
1255
+ skippedLongLines,
1256
+ skippedUnknownFiles,
1257
+ output: "",
1258
+ exitCode
1259
+ };
1260
+ if (format === "json") {
1261
+ result.output = JSON.stringify(result, null, 2);
1262
+ } else if (format === "github") {
1263
+ result.output = formatGithubAnnotations(result);
1264
+ } else {
1265
+ result.output = formatPrTerminalCard(result);
1266
+ }
1267
+ return result;
1268
+ }
1269
+
1270
+ export {
1271
+ detectAiAttribution,
1272
+ RECORD_SEPARATOR,
1273
+ FIELD_SEPARATOR,
1274
+ GitError,
1275
+ HERMETIC_GIT_FLAGS,
1276
+ getHermeticGitEnv,
1277
+ parseGitLogStream,
1278
+ unquoteGitPath,
1279
+ parseDiff,
1280
+ streamGitLog,
1281
+ streamGitDiff,
1282
+ resolveCommitSha,
1283
+ findMergeBase,
1284
+ GitResolutionError,
1285
+ isCodeFile,
1286
+ auditFileDiffs,
1287
+ stripStringAndRegexLiterals,
1288
+ MetricsCollector,
1289
+ generateShareUrl,
1290
+ formatTerminalCard,
1291
+ formatPrTerminalCard,
1292
+ formatGithubAnnotations,
1293
+ formatGithubStepSummary,
1294
+ VALID_DETECTORS,
1295
+ InvalidDetectorError,
1296
+ roastRepository,
1297
+ auditPullRequest
1298
+ };