agent-roast 0.7.0 → 0.8.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.
@@ -1,75 +1,53 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import path6 from "path";
4
+ import path7 from "path";
5
5
 
6
6
  // src/git.ts
7
7
  import { spawn, spawnSync } from "child_process";
8
+ import { randomUUID } from "crypto";
8
9
  import { PassThrough } from "stream";
9
- import { StringDecoder } from "string_decoder";
10
10
 
11
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
- { regex: /Co-Authored-By:.*(?:Antigravity|Deepmind|Google)/i, name: "trailer:antigravity" },
21
- { regex: /Co-Authored-By:.*(?:Codex)/i, name: "trailer:codex" },
22
- { regex: /Co-Authored-By:.*(?:Gemini)/i, name: "trailer:gemini" },
23
- { regex: /Co-Authored-By:.*(?:OpenClaw|Claw)/i, name: "trailer:openclaw" }
24
- ];
25
- var MESSAGE_AI_PATTERNS = [
26
- { regex: /(?:Generated|Assisted|Created|Written) by Cursor/i, name: "message:cursor" },
27
- { regex: /(?:Generated|Assisted|Created|Written) by Claude/i, name: "message:claude" },
28
- { regex: /(?:Generated|Assisted|Created|Written) by Aider/i, name: "message:aider" },
29
- { regex: /(?:Generated|Assisted|Created|Written) by Copilot/i, name: "message:copilot" },
30
- { regex: /(?:Generated|Assisted|Created|Written) by Devin/i, name: "message:devin" },
31
- { regex: /(?:Generated|Assisted|Created|Written) by (?:Antigravity|AGY)/i, name: "message:antigravity" },
32
- { regex: /(?:Generated|Assisted|Created|Written) by Codex/i, name: "message:codex" },
33
- { regex: /(?:Generated|Assisted|Created|Written) by Gemini/i, name: "message:gemini" }
34
- ];
35
- var AUTHOR_AI_PATTERNS = [
36
- { regex: /(?:claude|anthropic)/i, name: "author:claude" },
37
- { regex: /(?:cursor|anysphere)/i, name: "author:cursor" },
38
- { regex: /(?:copilot)/i, name: "author:copilot" },
39
- { regex: /(?:aider)/i, name: "author:aider" },
40
- { regex: /(?:devin)/i, name: "author:devin" },
41
- { regex: /(?:antigravity|deepmind)/i, name: "author:antigravity" },
42
- { regex: /(?:codex)/i, name: "author:codex" },
43
- { regex: /(?:gemini)/i, name: "author:gemini" },
44
- { regex: /(?:\[bot\]|bot@|automation)/i, name: "author:bot" }
12
+ var AGENT_IDENTITIES = [
13
+ { name: "claude", label: /^(?:Claude(?: Code| Agent| AI)?|Anthropic Agent)(?:\s*\((?:Anthropic|v?[\d.]+)\))?$/i, email: /^(?:noreply|claude)@anthropic\.com$/i },
14
+ { name: "cursor", label: /^(?:Cursor(?: Agent| AI)?|Anysphere Agent)$/i, email: /^(?:cursor|noreply)@(?:cursor\.com|cursor\.sh|anysphere\.co)$/i },
15
+ { name: "copilot", label: /^(?:GitHub )?Copilot(?: Agent| AI|\[bot\])?$/i, email: /^(?:\d+\+)?copilot(?:\[bot\])?@users\.noreply\.github\.com$/i },
16
+ { name: "aider", label: /^Aider(?:\s*\(v?[\d.]+\))?$/i, email: /^aider@(?:local|aider\.chat)$/i },
17
+ { name: "devin", label: /^Devin(?: AI| Agent|\[bot\])?$/i, email: /^devin@(?:cognition\.ai|users\.noreply\.github\.com)$/i },
18
+ { name: "windsurf", label: /^(?:Windsurf|Codeium)(?: Agent| AI)?$/i, email: /^(?:windsurf|codeium)@(?:windsurf\.com|codeium\.com)$/i },
19
+ { name: "gpt", label: /^(?:ChatGPT|OpenAI Agent|GPT Agent)$/i, email: /^(?:chatgpt|gpt)@openai\.com$/i },
20
+ { name: "antigravity", label: /^(?:Antigravity(?: Agent| AI)?|DeepMind Agent)$/i, email: /^antigravity@(?:google\.com|deepmind\.com)$/i },
21
+ { name: "codex", label: /^Codex(?: Agent| AI)?$/i, email: /^codex@openai\.com$/i },
22
+ { name: "gemini", label: /^Gemini(?: CLI| Agent| AI)?$/i, email: /^gemini@google\.com$/i },
23
+ { name: "openclaw", label: /^OpenClaw(?: Agent| AI)?$/i, email: /^openclaw@(?:local|openclaw\.ai)$/i }
45
24
  ];
25
+ function identitySignals(name, email) {
26
+ return AGENT_IDENTITIES.filter((agent) => agent.label.test(name.trim()) || agent.email.test(email.trim())).map((agent) => agent.name);
27
+ }
46
28
  function detectAiAttribution(authorName, authorEmail, message) {
47
- const signatures = [];
48
- for (const { regex, name } of TRAILER_AI_PATTERNS) {
49
- if (regex.test(message)) {
50
- signatures.push(name);
51
- }
52
- }
53
- for (const { regex, name } of MESSAGE_AI_PATTERNS) {
54
- if (regex.test(message)) {
55
- signatures.push(name);
56
- }
57
- }
58
- const authorIdentity = `${authorName} <${authorEmail}>`;
59
- for (const { regex, name } of AUTHOR_AI_PATTERNS) {
60
- if (regex.test(authorIdentity)) {
61
- signatures.push(name);
62
- }
63
- }
64
- return {
65
- isAiAttributed: signatures.length > 0,
66
- signatures
67
- };
29
+ const signatures = /* @__PURE__ */ new Set();
30
+ const lines = message.trimEnd().split(/\r?\n/);
31
+ let trailerStart = lines.length;
32
+ while (trailerStart > 0 && /^[A-Za-z][A-Za-z-]*:\s*.+$/.test(lines[trailerStart - 1])) trailerStart--;
33
+ for (const line of lines.slice(trailerStart)) {
34
+ const trailer = /^Co-Authored-By:\s*(.+?)\s*<([^<>]+)>\s*$/i.exec(line);
35
+ if (!trailer) continue;
36
+ for (const name of identitySignals(trailer[1], trailer[2])) signatures.add(`trailer:${name}`);
37
+ }
38
+ for (const line of lines) {
39
+ const attribution = /^\s*(?:Generated|Assisted|Created|Written) by (.+?)\.?\s*$/i.exec(line);
40
+ if (!attribution) continue;
41
+ for (const name of identitySignals(attribution[1], "")) signatures.add(`message:${name}`);
42
+ }
43
+ for (const name of identitySignals(authorName, authorEmail)) signatures.add(`author:${name}`);
44
+ return { isAiAttributed: signatures.size > 0, signatures: [...signatures] };
68
45
  }
69
46
 
70
47
  // src/git.ts
71
48
  var RECORD_SEPARATOR = "__ROAST_REC__";
72
49
  var FIELD_SEPARATOR = "__ROAST_FIELD__";
50
+ var LOG_FRAMING = /* @__PURE__ */ Symbol("agent-roast Git log framing");
73
51
  var GitError = class extends Error {
74
52
  constructor(stderr, code) {
75
53
  super(`git exited with code ${code}: ${stderr.trim() || "unknown error"}`);
@@ -92,33 +70,46 @@ var HERMETIC_GIT_FLAGS = [
92
70
  "--no-pager"
93
71
  ];
94
72
  function getHermeticGitEnv(extraEnv) {
73
+ const inherited = { ...process.env };
74
+ for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_NAMESPACE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_DIFF_OPTS"]) delete inherited[key];
95
75
  return {
96
- ...process.env,
76
+ ...inherited,
97
77
  ...extraEnv,
98
78
  GIT_PAGER: "cat",
99
79
  GIT_CONFIG_NOSYSTEM: "1",
100
- GIT_CONFIG_GLOBAL: extraEnv?.GIT_CONFIG_GLOBAL || process.env.GIT_CONFIG_GLOBAL || "/dev/null"
80
+ GIT_CONFIG_GLOBAL: extraEnv?.GIT_CONFIG_GLOBAL || process.env.GIT_CONFIG_GLOBAL || "/dev/null",
81
+ GIT_NO_REPLACE_OBJECTS: "1",
82
+ GIT_NO_LAZY_FETCH: "1",
83
+ GIT_TERMINAL_PROMPT: "0"
101
84
  };
102
85
  }
86
+ function gitOutputPath(value) {
87
+ return value.endsWith("\n") ? value.slice(0, -1) : value;
88
+ }
103
89
  var MAX_RECORD_BUFFER_SIZE = 50 * 1024 * 1024;
104
90
  async function parseGitLogStream(stream, onCommit) {
105
91
  let buffer = "";
92
+ let bufferedBytes = 0;
106
93
  let totalSkippedLongLines = 0;
107
94
  let totalSkippedUnknownFiles = 0;
108
95
  let scanFrom = 0;
109
- let activeRecSep = null;
110
- let activeFieldSep = null;
96
+ const framing = stream[LOG_FRAMING];
97
+ let activeRecSep = framing?.record ?? null;
98
+ let activeFieldSep = framing?.field ?? null;
111
99
  const processBlock = (block) => {
112
100
  if (!block.trim()) return;
113
101
  const fieldSep = activeFieldSep || (block.includes(FIELD_SEPARATOR) ? FIELD_SEPARATOR : "");
114
102
  const parts = block.split(fieldSep);
115
- if (parts.length < 5) return;
103
+ if (parts.length !== 6) throw new GitError("Malformed or ambiguous Git log record; no complete history result was produced.", 2);
116
104
  const hash = parts[0].trim();
117
105
  const date = parts[1].trim();
118
106
  const authorName = parts[2].trim();
119
107
  const authorEmail = parts[3].trim();
120
108
  const message = parts[4].trim();
121
109
  const rawDiff = parts.slice(5).join(fieldSep);
110
+ if (framing && (!/^[a-f0-9]{40,64}$/.test(hash) || !Number.isFinite(Date.parse(date)))) {
111
+ throw new GitError("Invalid Git log header; no complete history result was produced.", 2);
112
+ }
122
113
  const attribution = detectAiAttribution(authorName, authorEmail, message);
123
114
  const { files, totalAdded, skippedLongLines, skippedUnknownFiles } = parseDiff(rawDiff);
124
115
  totalSkippedLongLines += skippedLongLines;
@@ -152,12 +143,21 @@ async function parseGitLogStream(stream, onCommit) {
152
143
  reject(err);
153
144
  }
154
145
  };
155
- const decoder = new StringDecoder("utf8");
146
+ const decoder = new TextDecoder("utf-8", { fatal: true });
156
147
  stream.on("data", (chunk) => {
157
- const chunkStr = typeof chunk === "string" ? chunk : decoder.write(chunk);
148
+ let chunkStr;
149
+ try {
150
+ chunkStr = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
151
+ } catch {
152
+ const error = new GitError("Git history contains invalid UTF-8; no complete result was produced.", 2);
153
+ stream.destroy(error);
154
+ safeReject(error);
155
+ return;
156
+ }
158
157
  buffer += chunkStr;
159
- if (buffer.length > MAX_RECORD_BUFFER_SIZE) {
160
- const oomErr = new Error(`Git log record exceeded maximum buffer limit (${MAX_RECORD_BUFFER_SIZE} bytes).`);
158
+ bufferedBytes += Buffer.byteLength(chunkStr, "utf8");
159
+ if (bufferedBytes > MAX_RECORD_BUFFER_SIZE) {
160
+ const oomErr = new GitError(`Git log record exceeded maximum buffer limit (${MAX_RECORD_BUFFER_SIZE} bytes).`, 2);
161
161
  stream.destroy(oomErr);
162
162
  safeReject(oomErr);
163
163
  return;
@@ -185,6 +185,7 @@ async function parseGitLogStream(stream, onCommit) {
185
185
  while ((idx = buffer.indexOf(recSep, scanFrom)) !== -1) {
186
186
  const completeBlock = buffer.slice(0, idx);
187
187
  buffer = buffer.slice(idx + recSep.length);
188
+ bufferedBytes -= Buffer.byteLength(completeBlock, "utf8") + Buffer.byteLength(recSep, "utf8");
188
189
  scanFrom = 0;
189
190
  try {
190
191
  processBlock(completeBlock);
@@ -195,17 +196,17 @@ async function parseGitLogStream(stream, onCommit) {
195
196
  return;
196
197
  }
197
198
  }
198
- scanFrom = buffer.length;
199
+ scanFrom = Math.max(0, buffer.length - recSep.length + 1);
199
200
  });
200
201
  stream.on("end", () => {
201
- buffer += decoder.end();
202
- if (buffer.trim()) {
203
- try {
202
+ try {
203
+ buffer += decoder.decode();
204
+ if (buffer.trim()) {
204
205
  processBlock(buffer);
205
- } catch (err) {
206
- safeReject(err);
207
- return;
208
206
  }
207
+ } catch (err) {
208
+ safeReject(err);
209
+ return;
209
210
  }
210
211
  safeResolve({ totalSkippedLongLines, totalSkippedUnknownFiles });
211
212
  });
@@ -302,6 +303,7 @@ function parseDiff(rawDiff) {
302
303
  continue;
303
304
  }
304
305
  if (char === "+") {
306
+ if (newLeft <= 0) throw new GitError("Malformed diff hunk adds more lines than declared.", 2);
305
307
  if (currentFile && !currentFile.deleted && currentFile.path !== "unknown") {
306
308
  const content = line.slice(1);
307
309
  if (content.length <= MAX_LINE_LENGTH) {
@@ -319,23 +321,25 @@ function parseDiff(rawDiff) {
319
321
  continue;
320
322
  }
321
323
  if (char === "-") {
324
+ if (oldLeft <= 0) throw new GitError("Malformed diff hunk removes more lines than declared.", 2);
322
325
  oldLeft--;
323
326
  continue;
324
327
  }
325
328
  if (char === " ") {
329
+ if (oldLeft <= 0 || newLeft <= 0) throw new GitError("Malformed diff hunk has invalid context counts.", 2);
326
330
  oldLeft--;
327
331
  newLeft--;
328
332
  lineNo++;
329
333
  continue;
330
334
  }
331
- oldLeft = 0;
332
- newLeft = 0;
335
+ throw new GitError("Truncated or malformed diff hunk; no complete audit result was produced.", 2);
333
336
  }
334
337
  const hunkMatch = HUNK_REGEX.exec(line);
335
338
  if (hunkMatch) {
336
339
  oldLeft = hunkMatch[1] === void 0 ? 1 : parseInt(hunkMatch[1], 10);
337
340
  lineNo = parseInt(hunkMatch[2], 10);
338
341
  newLeft = hunkMatch[3] === void 0 ? 1 : parseInt(hunkMatch[3], 10);
342
+ if (![oldLeft, newLeft, lineNo].every(Number.isSafeInteger)) throw new GitError("Invalid diff hunk line counts.", 2);
339
343
  continue;
340
344
  }
341
345
  if (line.startsWith("diff --git ")) {
@@ -353,6 +357,10 @@ function parseDiff(rawDiff) {
353
357
  files.push(currentFile);
354
358
  continue;
355
359
  }
360
+ if (currentFile && (line.startsWith("Binary files ") || line === "GIT binary patch")) {
361
+ currentFile.binary = true;
362
+ continue;
363
+ }
356
364
  if (line.startsWith("+++ /dev/null")) {
357
365
  if (currentFile) {
358
366
  currentFile.deleted = true;
@@ -371,6 +379,7 @@ function parseDiff(rawDiff) {
371
379
  continue;
372
380
  }
373
381
  }
382
+ if (oldLeft > 0 || newLeft > 0) throw new GitError("Truncated diff hunk; no complete audit result was produced.", 2);
374
383
  let skippedUnknownFiles = 0;
375
384
  for (const file of files) {
376
385
  if (file.path === "unknown" && !file.deleted) {
@@ -381,17 +390,69 @@ function parseDiff(rawDiff) {
381
390
  }
382
391
  var COMMON_EXCLUDE_SPECS = [
383
392
  "--",
384
- ".",
385
- ":(exclude,glob)**/*.lock",
386
- ":(exclude,glob)**/*-lock.json",
387
- ":(exclude,glob)**/*.lockb",
388
- ":(exclude,glob)**/dist/**",
389
- ":(exclude,glob)**/build/**",
390
- ":(exclude,glob)**/node_modules/**",
391
- ":(exclude,glob)**/*.min.js",
392
- ":(exclude,glob)**/*.map"
393
+ ":(top,glob)**",
394
+ ":(top,exclude,glob)**/*.lock",
395
+ ":(top,exclude,glob)**/*-lock.json",
396
+ ":(top,exclude,glob)**/*.lockb",
397
+ ":(top,exclude,glob)**/dist/**",
398
+ ":(top,exclude,glob)**/build/**",
399
+ ":(top,exclude,glob)**/node_modules/**",
400
+ ":(top,exclude,glob)**/*.min.js",
401
+ ":(top,exclude,glob)**/*.map"
393
402
  ];
403
+ function requireLocalObjects(cwd) {
404
+ const result = spawnSync("git", [...HERMETIC_GIT_FLAGS, "config", "--type=bool", "--get-regexp", "^remote\\..*\\.promisor$"], {
405
+ cwd,
406
+ env: getHermeticGitEnv(),
407
+ encoding: "utf8",
408
+ timeout: 3e4,
409
+ maxBuffer: 64 * 1024,
410
+ windowsHide: true
411
+ });
412
+ if (result.error) throw new GitError(result.error.message, result.status);
413
+ if (result.status !== 0 && result.status !== 1) throw new GitError(result.stderr, result.status);
414
+ if (result.stdout.split("\n").some((line) => /\s+(?:true|yes|on|1)\s*$/i.test(line))) {
415
+ throw new GitError("Offline audits require a non-partial clone with local objects; promisor remotes are unsupported.", 2);
416
+ }
417
+ }
418
+ function streamGit(cwd, gitArgs) {
419
+ const output = new PassThrough();
420
+ try {
421
+ requireLocalObjects(cwd);
422
+ } catch (error) {
423
+ queueMicrotask(() => output.destroy(error instanceof Error ? error : new Error(String(error))));
424
+ return output;
425
+ }
426
+ const child = spawn("git", gitArgs, {
427
+ cwd,
428
+ env: getHermeticGitEnv(),
429
+ stdio: ["ignore", "pipe", "pipe"],
430
+ windowsHide: true
431
+ });
432
+ let stderr = "";
433
+ const timer = setTimeout(() => output.destroy(new GitError("Git audit timed out after 30 seconds; no complete result was produced.", 2)), 3e4);
434
+ child.stderr.on("data", (chunk) => {
435
+ if (stderr.length < 64 * 1024) stderr += chunk.toString().slice(0, 64 * 1024 - stderr.length);
436
+ });
437
+ child.stdout.pipe(output, { end: false });
438
+ output.on("close", () => {
439
+ clearTimeout(timer);
440
+ if (child.exitCode === null) child.kill();
441
+ });
442
+ child.stdout.on("error", (error) => output.destroy(error));
443
+ child.on("error", (error) => output.destroy(error));
444
+ child.on("close", (code) => {
445
+ clearTimeout(timer);
446
+ if (output.destroyed) return;
447
+ if (code !== 0) output.destroy(new GitError(stderr, code));
448
+ else output.end();
449
+ });
450
+ return output;
451
+ }
394
452
  function streamGitLog(cwd = process.cwd(), since = "90 days ago") {
453
+ const nonce = randomUUID();
454
+ const record = `__ROAST_REC_${nonce}__`;
455
+ const field = `__ROAST_FIELD_${nonce}__`;
395
456
  const gitArgs = [
396
457
  ...HERMETIC_GIT_FLAGS,
397
458
  "log",
@@ -402,37 +463,16 @@ function streamGitLog(cwd = process.cwd(), since = "90 days ago") {
402
463
  "--no-show-signature",
403
464
  "--no-ext-diff",
404
465
  "--no-textconv",
466
+ "--no-relative",
405
467
  "--encoding=UTF-8",
406
468
  "--src-prefix=a/",
407
469
  "--dst-prefix=b/",
408
470
  `--since=${since}`,
409
- `--format=${RECORD_SEPARATOR}%H${FIELD_SEPARATOR}%aI${FIELD_SEPARATOR}%an${FIELD_SEPARATOR}%ae${FIELD_SEPARATOR}%B${FIELD_SEPARATOR}`,
471
+ `--format=${record}%H${field}%aI${field}%an${field}%ae${field}%B${field}`,
410
472
  ...COMMON_EXCLUDE_SPECS
411
473
  ];
412
- const output = new PassThrough();
413
- const child = spawn("git", gitArgs, {
414
- cwd,
415
- env: getHermeticGitEnv(),
416
- stdio: ["ignore", "pipe", "pipe"],
417
- windowsHide: true
418
- });
419
- child.stdout.on("data", (chunk) => {
420
- output.write(chunk);
421
- });
422
- let stderrBuffer = "";
423
- child.stderr.on("data", (chunk) => {
424
- stderrBuffer += chunk.toString();
425
- });
426
- child.on("close", (code) => {
427
- if (code !== 0) {
428
- output.destroy(new GitError(stderrBuffer, code));
429
- } else {
430
- output.end();
431
- }
432
- });
433
- child.on("error", (err) => {
434
- output.destroy(err);
435
- });
474
+ const output = streamGit(cwd, gitArgs);
475
+ output[LOG_FRAMING] = { record, field };
436
476
  return output;
437
477
  }
438
478
  function streamGitDiff(cwd, baseSha, headSha, staged = false) {
@@ -443,6 +483,7 @@ function streamGitDiff(cwd, baseSha, headSha, staged = false) {
443
483
  "--no-color",
444
484
  "--no-ext-diff",
445
485
  "--no-textconv",
486
+ "--no-relative",
446
487
  "--src-prefix=a/",
447
488
  "--dst-prefix=b/",
448
489
  ...staged ? ["--cached"] : [],
@@ -450,33 +491,10 @@ function streamGitDiff(cwd, baseSha, headSha, staged = false) {
450
491
  ...staged ? [] : [baseSha, headSha],
451
492
  ...COMMON_EXCLUDE_SPECS
452
493
  ];
453
- const output = new PassThrough();
454
- const child = spawn("git", gitArgs, {
455
- cwd,
456
- env: getHermeticGitEnv(),
457
- stdio: ["ignore", "pipe", "pipe"],
458
- windowsHide: true
459
- });
460
- child.stdout.on("data", (chunk) => {
461
- output.write(chunk);
462
- });
463
- let stderrBuffer = "";
464
- child.stderr.on("data", (chunk) => {
465
- stderrBuffer += chunk.toString();
466
- });
467
- child.on("close", (code) => {
468
- if (code !== 0) {
469
- output.destroy(new GitError(stderrBuffer, code));
470
- } else {
471
- output.end();
472
- }
473
- });
474
- child.on("error", (err) => {
475
- output.destroy(err);
476
- });
477
- return output;
494
+ return streamGit(cwd, gitArgs);
478
495
  }
479
496
  function resolveCommitSha(cwd, ref, roleName = "ref") {
497
+ requireLocalObjects(cwd);
480
498
  const result = spawnSync(
481
499
  "git",
482
500
  [...HERMETIC_GIT_FLAGS, "rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`],
@@ -484,6 +502,8 @@ function resolveCommitSha(cwd, ref, roleName = "ref") {
484
502
  cwd,
485
503
  env: getHermeticGitEnv(),
486
504
  encoding: "utf8",
505
+ timeout: 3e4,
506
+ maxBuffer: 64 * 1024,
487
507
  windowsHide: true
488
508
  }
489
509
  );
@@ -508,6 +528,8 @@ function findMergeBase(cwd, baseSha, headSha) {
508
528
  cwd,
509
529
  env: getHermeticGitEnv(),
510
530
  encoding: "utf8",
531
+ timeout: 3e4,
532
+ maxBuffer: 64 * 1024,
511
533
  windowsHide: true
512
534
  }
513
535
  );
@@ -530,6 +552,8 @@ function checkIfShallow(cwd) {
530
552
  cwd,
531
553
  env: getHermeticGitEnv(),
532
554
  encoding: "utf8",
555
+ timeout: 3e4,
556
+ maxBuffer: 64 * 1024,
533
557
  windowsHide: true
534
558
  });
535
559
  return res.stdout.trim() === "true";
@@ -546,41 +570,79 @@ var GitResolutionError = class extends Error {
546
570
 
547
571
  // src/source.ts
548
572
  var SourceMasker = class {
549
- blockComment = false;
573
+ blockDepth = 0;
550
574
  quote = "";
575
+ rawEnd = "";
551
576
  triple = false;
577
+ verbatim = false;
552
578
  previousLine = 0;
553
579
  python;
554
580
  javascript;
581
+ rust;
555
582
  hashComments;
556
583
  backticks;
584
+ textBlocks;
585
+ csharp;
586
+ cpp;
557
587
  constructor(filename) {
558
- this.python = filename.endsWith(".py");
559
- this.hashComments = this.python || filename.endsWith(".rb");
560
- this.backticks = /\.(?:go|[cm]?[jt]sx?|vue|svelte)$/.test(filename);
561
- this.javascript = /\.(?:[cm]?[jt]sx?|vue|svelte)$/.test(filename);
588
+ const name = filename.toLowerCase();
589
+ this.python = name.endsWith(".py");
590
+ this.rust = name.endsWith(".rs");
591
+ this.hashComments = this.python || /\.(?:rb|php)$/.test(name);
592
+ this.backticks = /\.(?:go|[cm]?[jt]sx?|vue|svelte)$/.test(name);
593
+ this.javascript = /\.(?:[cm]?[jt]sx?|vue|svelte)$/.test(name);
594
+ this.textBlocks = /\.(?:java|kt|cs)$/.test(name);
595
+ this.csharp = name.endsWith(".cs");
596
+ this.cpp = /\.(?:cpp|c)$/.test(name);
562
597
  }
563
598
  mask(line, number) {
564
599
  if (number !== this.previousLine + 1) {
565
- this.blockComment = false;
600
+ this.blockDepth = 0;
566
601
  this.quote = "";
602
+ this.rawEnd = "";
567
603
  this.triple = false;
604
+ this.verbatim = false;
568
605
  }
569
606
  this.previousLine = number;
570
607
  const code = Array(line.length).fill(" ");
571
608
  const comments = Array(line.length).fill(" ");
572
609
  let i = 0;
573
610
  let prefix = "";
611
+ const appendPrefix = (text) => {
612
+ for (const char of text) {
613
+ if (/\s/.test(char)) {
614
+ if (!prefix.endsWith(" ")) prefix = (prefix + " ").slice(-64);
615
+ } else prefix = (prefix + char).slice(-64);
616
+ }
617
+ };
618
+ let continued = false;
619
+ const templateStarts = [];
620
+ const blockStarts = [];
574
621
  while (i < line.length) {
575
- if (this.blockComment) {
576
- if (line.startsWith("*/", i)) {
577
- this.blockComment = false;
622
+ if (this.blockDepth) {
623
+ if (this.rust && line.startsWith("/*", i)) {
624
+ this.blockDepth++;
625
+ i += 2;
626
+ } else if (line.startsWith("*/", i)) {
627
+ this.blockDepth--;
578
628
  i += 2;
579
629
  } else i++;
580
630
  continue;
581
631
  }
632
+ if (this.rawEnd) {
633
+ if (line.startsWith(this.rawEnd, i)) {
634
+ i += this.rawEnd.length;
635
+ this.rawEnd = "";
636
+ } else i++;
637
+ continue;
638
+ }
582
639
  if (this.quote) {
583
- if (line[i] === "\\") {
640
+ if (this.verbatim && line.startsWith('""', i)) {
641
+ i += 2;
642
+ continue;
643
+ }
644
+ if (!this.verbatim && !(this.quote === "`" && !this.javascript) && line[i] === "\\") {
645
+ if (i === line.length - 1) continued = true;
584
646
  i += 2;
585
647
  continue;
586
648
  }
@@ -589,6 +651,7 @@ var SourceMasker = class {
589
651
  i += delimiter.length;
590
652
  this.quote = "";
591
653
  this.triple = false;
654
+ this.verbatim = false;
592
655
  } else i++;
593
656
  continue;
594
657
  }
@@ -597,16 +660,50 @@ var SourceMasker = class {
597
660
  break;
598
661
  }
599
662
  if (!this.python && line.startsWith("/*", i)) {
600
- this.blockComment = true;
663
+ blockStarts.push(i);
664
+ this.blockDepth = 1;
665
+ i += 2;
666
+ continue;
667
+ }
668
+ if (this.rust && (i === 0 || !/[\w]/.test(line[i - 1]))) {
669
+ const raw = /^(?:br|cr|r)(#{0,255})"/.exec(line.slice(i));
670
+ if (raw) {
671
+ this.rawEnd = '"' + raw[1];
672
+ i += raw[0].length;
673
+ appendPrefix(" literal ");
674
+ continue;
675
+ }
676
+ }
677
+ if (this.cpp && (i === 0 || !/[\w]/.test(line[i - 1]))) {
678
+ const raw = /^(?:u8|u|U|L)?R"([^\s()\\]{0,16})\(/.exec(line.slice(i));
679
+ if (raw) {
680
+ this.rawEnd = ")" + raw[1] + '"';
681
+ i += raw[0].length;
682
+ appendPrefix(" literal ");
683
+ continue;
684
+ }
685
+ }
686
+ if (this.csharp && line.startsWith('@"', i)) {
687
+ this.quote = '"';
688
+ this.verbatim = true;
601
689
  i += 2;
690
+ appendPrefix(" literal ");
602
691
  continue;
603
692
  }
604
693
  const char = line[i];
694
+ const lifetime = this.rust && char === "'" && /^'[A-Za-z_][A-Za-z0-9_]*(?![A-Za-z0-9_])/.exec(line.slice(i));
695
+ if (lifetime && line[i + lifetime[0].length] !== "'") {
696
+ for (let j = 0; j < lifetime[0].length; j++) code[i + j] = line[i + j];
697
+ appendPrefix(lifetime[0]);
698
+ i += lifetime[0].length;
699
+ continue;
700
+ }
605
701
  if (char === '"' || char === "'" || this.backticks && char === "`") {
606
702
  this.quote = char;
607
- this.triple = this.python && line.startsWith(char.repeat(3), i);
703
+ if (this.javascript && char === "`") templateStarts.push(i);
704
+ this.triple = (this.python || this.textBlocks && char === '"') && line.startsWith(char.repeat(3), i);
608
705
  i += this.triple ? 3 : 1;
609
- prefix += " literal ";
706
+ appendPrefix(" literal ");
610
707
  continue;
611
708
  }
612
709
  if (this.javascript && char === "/" && (/(?:[=(:,[!&|?{;]|\breturn|\byield)\s*$/.test(prefix) || !prefix.trim())) {
@@ -623,18 +720,18 @@ var SourceMasker = class {
623
720
  }
624
721
  if (j < line.length) {
625
722
  j++;
626
- while (/[a-z]/i.test(line[j] || "") && j < line.length) j++;
723
+ while (j < line.length && /[a-z]/i.test(line[j])) j++;
627
724
  i = j;
628
- prefix += " literal ";
725
+ appendPrefix(" literal ");
629
726
  continue;
630
727
  }
631
728
  }
632
729
  code[i] = char;
633
- prefix += char;
730
+ appendPrefix(char);
634
731
  i++;
635
732
  }
636
- if (this.quote !== "`" && !this.triple) this.quote = "";
637
- return { code: code.join(""), comments: comments.join("") };
733
+ if (this.quote !== "`" && !this.triple && !this.verbatim && !continued) this.quote = "";
734
+ return { code: code.join(""), comments: comments.join(""), templateStarts, blockStarts };
638
735
  }
639
736
  };
640
737
 
@@ -685,8 +782,8 @@ var RULE_GUIDES = {
685
782
  };
686
783
 
687
784
  // src/config.ts
688
- import * as fs from "fs";
689
- import * as path from "path";
785
+ import * as fs3 from "fs";
786
+ import * as path2 from "path";
690
787
 
691
788
  // src/types.ts
692
789
  var VALID_DETECTORS = [
@@ -699,7 +796,146 @@ var VALID_DETECTORS = [
699
796
  "test-focus"
700
797
  ];
701
798
 
799
+ // src/file-read.ts
800
+ import * as fs from "fs";
801
+ function readRegularFile(filename, limit) {
802
+ const stat = fs.lstatSync(filename);
803
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > limit) throw new Error(`Expected a regular file no larger than ${limit} bytes.`);
804
+ const fd = fs.openSync(filename, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
805
+ try {
806
+ const opened = fs.fstatSync(fd);
807
+ if (!opened.isFile() || opened.ino !== stat.ino || opened.dev !== stat.dev) throw new Error("File changed while opening.");
808
+ const buffer = Buffer.alloc(limit + 1);
809
+ let size = 0;
810
+ while (size < buffer.length) {
811
+ const read = fs.readSync(fd, buffer, size, buffer.length - size, null);
812
+ if (!read) break;
813
+ size += read;
814
+ }
815
+ const after = fs.fstatSync(fd);
816
+ if (size > limit || size !== after.size || opened.size !== after.size || opened.mtimeMs !== after.mtimeMs || opened.ctimeMs !== after.ctimeMs) {
817
+ throw new Error("File changed or exceeded the read limit.");
818
+ }
819
+ return buffer.subarray(0, size);
820
+ } finally {
821
+ fs.closeSync(fd);
822
+ }
823
+ }
824
+
825
+ // src/baseline.ts
826
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
827
+ import * as fs2 from "fs";
828
+ import path from "path";
829
+ function findingFingerprint(finding) {
830
+ return createHash("sha256").update(JSON.stringify([finding.type, finding.file, finding.lineSnippet?.trim()])).digest("hex");
831
+ }
832
+ function createBaseline(findings) {
833
+ const counts = /* @__PURE__ */ new Map();
834
+ for (const finding of findings) {
835
+ const fingerprint = findingFingerprint(finding);
836
+ counts.set(fingerprint, (counts.get(fingerprint) || 0) + 1);
837
+ }
838
+ return {
839
+ schemaVersion: "1.0.0",
840
+ fingerprintVersion: 1,
841
+ entries: [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([fingerprint, count]) => ({ fingerprint, count }))
842
+ };
843
+ }
844
+ function loadBaseline(filename) {
845
+ try {
846
+ const content = new TextDecoder("utf-8", { fatal: true }).decode(readRegularFile(filename, 5 * 1024 * 1024));
847
+ const parsed = JSON.parse(content);
848
+ if (!parsed || parsed.schemaVersion !== "1.0.0" || parsed.fingerprintVersion !== 1 || !Array.isArray(parsed.entries) || parsed.entries.length > 1e5) {
849
+ throw new Error("Unsupported baseline schema.");
850
+ }
851
+ const seen = /* @__PURE__ */ new Set();
852
+ for (const entry of parsed.entries) {
853
+ if (!entry || typeof entry.fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(entry.fingerprint) || !Number.isSafeInteger(entry.count) || entry.count <= 0 || entry.count > 1e6 || seen.has(entry.fingerprint)) {
854
+ throw new Error("Invalid or duplicate baseline entry.");
855
+ }
856
+ seen.add(entry.fingerprint);
857
+ }
858
+ return parsed;
859
+ } catch (error) {
860
+ throw new ConfigError(`Cannot load baseline '${filename}': ${error instanceof Error ? error.message : String(error)}`);
861
+ }
862
+ }
863
+ function applyBaseline(findings, baseline) {
864
+ const remaining = new Map(baseline.entries.map((entry) => [entry.fingerprint, entry.count]));
865
+ const fresh = [];
866
+ let baselineCount = 0;
867
+ for (const finding of findings) {
868
+ const fingerprint = findingFingerprint(finding);
869
+ const count = remaining.get(fingerprint) || 0;
870
+ if (count > 0) {
871
+ remaining.set(fingerprint, count - 1);
872
+ baselineCount++;
873
+ } else {
874
+ fresh.push(finding);
875
+ }
876
+ }
877
+ return { findings: fresh, baselineCount, staleBaselineCount: [...remaining.values()].reduce((a, b) => a + b, 0) };
878
+ }
879
+ function writeBaseline(filename, findings) {
880
+ const destination = path.resolve(filename);
881
+ const temporary = path.join(path.dirname(destination), `.agent-roast-${randomUUID2()}.tmp`);
882
+ let fd;
883
+ try {
884
+ const baseline = createBaseline(findings);
885
+ const serialized = JSON.stringify(baseline, null, 2) + "\n";
886
+ if (Buffer.byteLength(serialized, "utf8") > 5 * 1024 * 1024 || baseline.entries.length > 1e5 || baseline.entries.some((entry) => entry.count > 1e6)) {
887
+ throw new Error("Baseline exceeds the supported read limits; narrow the adopted scope.");
888
+ }
889
+ fd = fs2.openSync(temporary, "wx", 384);
890
+ fs2.writeFileSync(fd, serialized);
891
+ fs2.fsyncSync(fd);
892
+ fs2.closeSync(fd);
893
+ fd = void 0;
894
+ fs2.linkSync(temporary, destination);
895
+ } catch (error) {
896
+ throw new ConfigError(`Cannot write baseline '${filename}' (existing files are preserved): ${error instanceof Error ? error.message : String(error)}`);
897
+ } finally {
898
+ if (fd !== void 0) fs2.closeSync(fd);
899
+ fs2.rmSync(temporary, { force: true });
900
+ }
901
+ }
902
+
903
+ // src/exceptions.ts
904
+ function validateExceptions(value) {
905
+ if (value === void 0) return;
906
+ if (!Array.isArray(value) || value.length > 1e3) throw new ConfigError("exceptions must be an array with at most 1000 entries.");
907
+ const seen = /* @__PURE__ */ new Set();
908
+ for (const entry of value) {
909
+ if (!entry || typeof entry !== "object" || Array.isArray(entry) || Object.keys(entry).some((key) => !["fingerprint", "count", "reason", "expires"].includes(key)) || typeof entry.fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(entry.fingerprint) || seen.has(entry.fingerprint) || !Number.isSafeInteger(entry.count) || entry.count < 1 || entry.count > 1e5 || typeof entry.reason !== "string" || !entry.reason.trim() || entry.reason.length > 1e3 || /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u.test(entry.reason) || typeof entry.expires !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(entry.expires) || !Number.isFinite(Date.parse(entry.expires)) || new Date(entry.expires).toISOString().slice(0, 10) !== entry.expires) {
910
+ throw new ConfigError("Each exception needs a unique SHA-256 fingerprint, positive count, non-empty single-line reason and real expires date (YYYY-MM-DD).");
911
+ }
912
+ seen.add(entry.fingerprint);
913
+ }
914
+ }
915
+ function applyExceptions(findings, exceptions = [], now = /* @__PURE__ */ new Date(), priorOccurrences = /* @__PURE__ */ new Map()) {
916
+ validateExceptions(exceptions);
917
+ if (!Number.isFinite(now.getTime())) throw new ConfigError("Invalid exception evaluation time.");
918
+ const today = now.toISOString().slice(0, 10);
919
+ const active = new Map(exceptions.filter((entry) => entry.expires >= today).map((entry) => [entry.fingerprint, { ...entry, remaining: Math.max(0, entry.count - (priorOccurrences.get(entry.fingerprint) ?? 0)) }]));
920
+ const occurrences = new Map(priorOccurrences);
921
+ const actionable = [];
922
+ const suppressedFindings = [];
923
+ for (const finding of findings) {
924
+ const fingerprint = findingFingerprint(finding);
925
+ const occurrence = (occurrences.get(fingerprint) ?? 0) + 1;
926
+ occurrences.set(fingerprint, occurrence);
927
+ const enriched = { ...finding, fingerprint, occurrence };
928
+ const allowance = active.get(fingerprint);
929
+ if (allowance && allowance.remaining > 0) {
930
+ allowance.remaining--;
931
+ suppressedFindings.push({ ...enriched, suppression: { reason: allowance.reason, expires: allowance.expires } });
932
+ } else actionable.push(enriched);
933
+ }
934
+ return { findings: actionable, suppressedFindings, expiredExceptionCount: exceptions.filter((entry) => entry.expires < today).length };
935
+ }
936
+
702
937
  // src/config.ts
938
+ var MAX_CONFIG_BYTES = 64 * 1024;
703
939
  var DEFAULT_CONFIG = {
704
940
  failOn: ["test-skip", "trivial-assert", "test-focus"],
705
941
  ignorePaths: [
@@ -738,12 +974,16 @@ function validateConfig(value) {
738
974
  throw new ConfigError("Configuration must be a JSON object.");
739
975
  }
740
976
  const config = value;
977
+ const supported = /* @__PURE__ */ new Set(["$schema", "failOn", "ignorePaths", "scoreThreshold", "rules", "panicLoopWindowMinutes", "exceptions"]);
978
+ for (const key of Object.keys(config)) if (!supported.has(key)) throw new ConfigError(`Unknown configuration setting '${key}'.`);
979
+ if (config.$schema !== void 0 && typeof config.$schema !== "string") throw new ConfigError("$schema must be a string.");
980
+ validateExceptions(config.exceptions);
741
981
  const knownRules = new Set(VALID_DETECTORS);
742
982
  if (config.failOn !== void 0 && (!Array.isArray(config.failOn) || config.failOn.some((rule) => typeof rule !== "string" || !knownRules.has(rule)))) {
743
983
  throw new ConfigError(`failOn must contain known rules: ${VALID_DETECTORS.join(", ")}`);
744
984
  }
745
- if (config.ignorePaths !== void 0 && (!Array.isArray(config.ignorePaths) || config.ignorePaths.some((pattern) => typeof pattern !== "string"))) {
746
- throw new ConfigError("ignorePaths must be an array of strings.");
985
+ if (config.ignorePaths !== void 0 && (!Array.isArray(config.ignorePaths) || config.ignorePaths.length > 1e3 || config.ignorePaths.some((pattern) => typeof pattern !== "string" || pattern.length > 1024))) {
986
+ throw new ConfigError("ignorePaths must contain at most 1000 strings, each no longer than 1024 characters.");
747
987
  }
748
988
  if (config.scoreThreshold !== void 0 && (typeof config.scoreThreshold !== "number" || !Number.isFinite(config.scoreThreshold) || config.scoreThreshold < 0 || config.scoreThreshold > 100)) {
749
989
  throw new ConfigError("scoreThreshold must be a number between 0 and 100.");
@@ -756,7 +996,7 @@ function validateConfig(value) {
756
996
  throw new ConfigError("rules must be an object.");
757
997
  }
758
998
  for (const [rule, level] of Object.entries(config.rules)) {
759
- if (!knownRules.has(rule) || !["error", "warn", "off"].includes(String(level))) {
999
+ if (!knownRules.has(rule) || typeof level !== "string" || !["error", "warn", "off"].includes(level)) {
760
1000
  throw new ConfigError(`Invalid rule '${rule}' or level '${level}'. Use known rules with error, warn, or off.`);
761
1001
  }
762
1002
  }
@@ -764,6 +1004,7 @@ function validateConfig(value) {
764
1004
  }
765
1005
  function configFromText(text, source) {
766
1006
  try {
1007
+ if (Buffer.byteLength(text, "utf8") > MAX_CONFIG_BYTES) throw new ConfigError("Configuration exceeds the 64 KiB limit.");
767
1008
  const parsed = JSON.parse(text);
768
1009
  validateConfig(parsed);
769
1010
  const rules = { ...DEFAULT_CONFIG.rules, ...parsed.rules };
@@ -781,10 +1022,16 @@ function configFromText(text, source) {
781
1022
  }
782
1023
  function loadConfig(cwd = process.cwd()) {
783
1024
  for (const filename of CONFIG_FILENAMES) {
784
- const filePath = path.join(cwd, filename);
785
- if (fs.existsSync(filePath)) {
1025
+ const filePath = path2.join(cwd, filename);
1026
+ try {
1027
+ fs3.lstatSync(filePath);
1028
+ } catch (error) {
1029
+ if (error.code === "ENOENT") continue;
1030
+ throw new ConfigError(`Cannot inspect ${filename}: ${error instanceof Error ? error.message : String(error)}`);
1031
+ }
1032
+ {
786
1033
  try {
787
- return configFromText(fs.readFileSync(filePath, "utf8"), filename);
1034
+ return configFromText(new TextDecoder("utf-8", { fatal: true }).decode(readRegularFile(filePath, MAX_CONFIG_BYTES)), filename);
788
1035
  } catch (error) {
789
1036
  if (error instanceof ConfigError) throw error;
790
1037
  throw new ConfigError(`Cannot read ${filename}: ${error instanceof Error ? error.message : String(error)}`);
@@ -793,93 +1040,114 @@ function loadConfig(cwd = process.cwd()) {
793
1040
  }
794
1041
  return configFromText("{}", "default configuration");
795
1042
  }
796
- function matchIgnorePath(filePath, ignorePatterns) {
797
- if (!ignorePatterns || ignorePatterns.length === 0) {
798
- return false;
799
- }
800
- const normalized = filePath.replace(/\\/g, "/");
801
- for (const pattern of ignorePatterns) {
802
- if (!pattern) continue;
803
- const cleanPattern = pattern.replace(/\\/g, "/");
804
- let regexPattern = "";
805
- for (let i = 0; i < cleanPattern.length; i++) {
806
- const char = cleanPattern[i];
807
- if (char === "*" && cleanPattern[i + 1] === "*") {
1043
+ function matchesGlob(filename, pattern) {
1044
+ const tokens = [];
1045
+ for (let i = 0; i < pattern.length; i++) {
1046
+ if (pattern[i] === "*" && pattern[i + 1] === "*") {
1047
+ i++;
1048
+ if (pattern[i + 1] === "/") {
1049
+ tokens.push({ kind: "directoryStart" }, { kind: "directoryBody" });
808
1050
  i++;
809
- if (cleanPattern[i + 1] === "/") {
810
- regexPattern += "(?:.*/)?";
811
- i++;
812
- } else {
813
- regexPattern += ".*";
1051
+ } else tokens.push({ kind: "globstar" });
1052
+ } else if (pattern[i] === "*") tokens.push({ kind: "star" });
1053
+ else if (pattern[i] === "?") tokens.push({ kind: "question" });
1054
+ else tokens.push({ kind: "literal", value: pattern[i].toLowerCase() });
1055
+ }
1056
+ const closure = (states2) => {
1057
+ const queue = [...states2];
1058
+ for (let i = 0; i < queue.length; i++) {
1059
+ const state = queue[i];
1060
+ const kind = tokens[state]?.kind;
1061
+ const add = (next) => {
1062
+ if (!states2.has(next)) {
1063
+ states2.add(next);
1064
+ queue.push(next);
814
1065
  }
815
- } else if (char === "*") {
816
- regexPattern += "[^/]*";
817
- } else if (char === "?") {
818
- regexPattern += "[^/]";
819
- } else {
820
- regexPattern += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
1066
+ };
1067
+ if (kind === "star" || kind === "globstar") add(state + 1);
1068
+ else if (kind === "directoryStart") {
1069
+ add(state + 1);
1070
+ add(state + 2);
821
1071
  }
822
1072
  }
823
- if (!regexPattern.startsWith("^") && !regexPattern.startsWith("(?:^")) {
824
- regexPattern = "(?:^|/)" + regexPattern;
825
- }
826
- if (!regexPattern.endsWith("$")) {
827
- regexPattern = regexPattern + "(?:$|/)";
828
- }
829
- try {
830
- const rx = new RegExp(regexPattern, "i");
831
- if (rx.test(normalized)) {
832
- return true;
833
- }
834
- } catch {
835
- if (normalized.includes(cleanPattern.replace(/\*/g, ""))) {
836
- return true;
1073
+ return states2;
1074
+ };
1075
+ let states = /* @__PURE__ */ new Set();
1076
+ for (let offset = 0; offset <= filename.length; offset++) {
1077
+ if (!offset || filename[offset - 1] === "/") states.add(0);
1078
+ closure(states);
1079
+ if (states.has(tokens.length) && (offset === filename.length || filename[offset] === "/")) return true;
1080
+ if (offset === filename.length) break;
1081
+ const char = filename[offset];
1082
+ const next = /* @__PURE__ */ new Set();
1083
+ for (const state of states) {
1084
+ const token = tokens[state];
1085
+ if (!token) continue;
1086
+ if (token.kind === "literal") {
1087
+ if (token.value === char.toLowerCase()) next.add(state + 1);
1088
+ } else if (token.kind === "question") {
1089
+ if (char !== "/") next.add(state + 1);
1090
+ } else if (token.kind === "star") {
1091
+ if (char !== "/") next.add(state);
1092
+ } else if (token.kind === "globstar") next.add(state);
1093
+ else if (token.kind === "directoryBody") {
1094
+ next.add(state);
1095
+ if (char === "/") next.add(state + 1);
837
1096
  }
838
1097
  }
1098
+ states = next;
839
1099
  }
840
1100
  return false;
841
1101
  }
1102
+ function matchIgnorePath(filePath, ignorePatterns) {
1103
+ if (!ignorePatterns?.length) return false;
1104
+ const normalized = filePath.replace(/\\/g, "/");
1105
+ return ignorePatterns.some((pattern) => Boolean(pattern) && matchesGlob(normalized, pattern.replace(/\\/g, "/")));
1106
+ }
842
1107
 
843
1108
  // src/metrics.ts
844
- var TEST_SKIP_PATTERNS = [
845
- { regex: /\b(?:it|test|describe)\.skip\b/, name: "skipped test (it.skip / describe.skip)" },
846
- { regex: /\b(?:xit|xtest)\(/, name: "disabled test (xit / xtest)" },
847
- { regex: /@pytest\.mark\.skip\b/, name: "skipped pytest (@pytest.mark.skip)" },
848
- { regex: /\bpytest\.skip\(/, name: "imperative pytest skip (pytest.skip)" },
849
- { regex: /\btest\.todo\(/, name: "todo placeholder test" }
1109
+ var PYTHON_TEST_SKIP_PATTERNS = [
1110
+ { regex: /@pytest\s*\.\s*mark\s*\.\s*skip\b/, name: "skipped pytest (@pytest.mark.skip)" },
1111
+ { regex: /(?<![\w.])pytest\s*\.\s*skip\s*\(/, name: "imperative pytest skip (pytest.skip)" },
1112
+ { regex: /@unittest\s*\.\s*skip\s*\(/, name: "skipped unittest (@unittest.skip)" },
1113
+ { regex: /\bself\s*\.\s*skipTest\s*\(/, name: "imperative unittest skip (self.skipTest)" }
850
1114
  ];
851
- var FOCUSED_TEST = /(?<![\w$.])(?:it|test|describe|suite)(?:\s*\.\s*(?:concurrent|serial|parallel|describe))*\s*\.\s*only\b(?=\s*(?:\(|\.\s*(?:each|failing|concurrent|serial|parallel)\b))|(?<![\w$.])(?:fit|fdescribe)\b(?=\s*(?:\(|\.\s*each\b))/g;
852
- var COMMENT_DIRECTIVE_PATTERNS = [
1115
+ var JS_TEST_API = /(?<![\w$.])(?:(?:it|test|describe|suite)(?:\s*\.\s*(?:concurrent|serial|parallel|describe|failing|sequential))*\s*\.\s*(skip|todo|only)|((?:xit|xtest|xdescribe|fit|fdescribe)))\b((?:\s*\.\s*(?:each|failing|concurrent|serial|parallel|sequential))*)\s*(?=\(|\u0001)/g;
1116
+ var JS_COMMENT_DIRECTIVE_PATTERNS = [
853
1117
  { regex: /^\s*\/\/\s*@ts-ignore\b/, name: "compiler ignore (@ts-ignore)" },
854
1118
  { regex: /^\s*\/\/\s*@ts-expect-error\b/, name: "compiler suppression (@ts-expect-error)" },
855
1119
  { regex: /^\s*\/\/\s*@ts-nocheck\b/, name: "compiler check disable (@ts-nocheck)" },
856
- { regex: /(?:^\s*#\s*type:\s*ignore\b|#\s*type:\s*ignore\s*$)/, name: "python type ignore" },
857
- { regex: /(?:^\s*#\s*noqa\b|#\s*noqa\s*$)/, name: "python linter suppression (# noqa)" },
858
- { regex: /^\s*\/\/\s*eslint-disable(?:-next-line)?\b/, name: "linter suppression (eslint-disable)" }
1120
+ { regex: /^\s*\/\/\s*eslint-disable(?:-next-line|-line)?\b/, name: "linter suppression (eslint-disable)" }
1121
+ ];
1122
+ var PYTHON_COMMENT_DIRECTIVE_PATTERNS = [
1123
+ { regex: /#\s*type:\s*ignore\b/, name: "python type ignore" },
1124
+ { regex: /#\s*noqa\b/, name: "python linter suppression (# noqa)" }
859
1125
  ];
860
1126
  var CODE_TYPE_ESCAPE_PATTERNS = [
861
1127
  { regex: /\bas\s+any\b/, name: "type bypass (as any)" },
862
1128
  { regex: /\bas\s+unknown\s+as\b/, name: "double assertion bypass (as unknown as)" },
863
1129
  { regex: /\bas\s+never\b/, name: "type escape (as never)" }
864
1130
  ];
865
- var SWALLOWED_ERROR_PATTERNS = [
866
- { regex: /catch\s*(?:\([^)]*\))?\s*\{\s*\}/, name: "empty catch block (catch {})" },
867
- { regex: /except(?:\s+[^:]*)?:\s*pass\b/, name: "silent python error swallow (except: pass)" }
1131
+ var JS_TRIVIAL_ASSERT_PATTERNS = [
1132
+ { regex: /(?<![\w$.])expect\s*\(\s*(true|false|1|0|null|undefined)\s*\)\s*\.\s*toBe\s*\(\s*\1\s*\)/, name: "trivial assertion (expect(val).toBe(val))" },
1133
+ { regex: /(?<![\w$.])expect\s*\(\s*(true|false|1|0|null|undefined)\s*\)\s*\.\s*toEqual\s*\(\s*\1\s*\)/, name: "trivial assertion (expect(val).toEqual(val))" },
1134
+ { regex: /(?<![\w$.])assert\s*\.\s*(?:strictEqual|equal)\s*\(\s*(?:true\s*,\s*true|1\s*,\s*1)\s*\)/, name: "trivial assert (assert.equal(1, 1))" }
868
1135
  ];
869
- var TRIVIAL_ASSERT_PATTERNS = [
870
- { regex: /expect\s*\(\s*(true|false|1|0|null|undefined)\s*\)\.toBe\s*\(\s*\1\s*\)/, name: "trivial assertion (expect(val).toBe(val))" },
871
- { regex: /expect\s*\(\s*(true|false|1|0|null|undefined)\s*\)\.toEqual\s*\(\s*\1\s*\)/, name: "trivial assertion (expect(val).toEqual(val))" },
1136
+ var PYTHON_TRIVIAL_ASSERT_PATTERNS = [
872
1137
  { regex: /\bassert\s+(?:True|1\s*==\s*1)\s*(?=$|#|,)/, name: "trivial python assert (assert True / 1 == 1)" },
873
- { regex: /self\.assertTrue\s*\(\s*(?:True|1)\s*\)/, name: "trivial assert (self.assertTrue(True))" },
874
- { regex: /self\.assertEqual\s*\(\s*(?:1\s*,\s*1|True\s*,\s*True)\s*\)/, name: "trivial assert (self.assertEqual(1, 1))" },
875
- { regex: /assert\.(?:strictEqual|equal)\s*\(\s*(?:true\s*,\s*true|1\s*,\s*1)\s*\)/, name: "trivial assert (assert.equal(1, 1))" }
1138
+ { regex: /\bself\s*\.\s*assertTrue\s*\(\s*(?:True|1)\s*\)/, name: "trivial assert (self.assertTrue(True))" },
1139
+ { regex: /\bself\s*\.\s*assertEqual\s*\(\s*(?:1\s*,\s*1|True\s*,\s*True)\s*\)/, name: "trivial assert (self.assertEqual(1, 1))" }
876
1140
  ];
877
- var STUB_ABANDONMENT_PATTERNS = [
878
- { regex: /\bthrow\s+new\s+Error\s*\(\s*["'](?:TODO|Not implemented|to be implemented|stub|implement me)/i, name: "unimplemented stub (throw new Error('Not implemented'))" },
1141
+ var JS_STUB_PATTERNS = [
1142
+ { regex: /\bthrow\s+new\s+Error\s*\(\s*["'](?:TODO\b|Not implemented\b|to be implemented\b|stub\b|implement me\b)/i, name: "unimplemented stub (throw new Error('Not implemented'))" }
1143
+ ];
1144
+ var PYTHON_STUB_PATTERNS = [
879
1145
  { regex: /\braise\s+NotImplementedError\b/, name: "unimplemented stub (raise NotImplementedError)" },
880
- { regex: /\braise\s+NotImplemented\b/, name: "unimplemented stub (raise NotImplemented)" },
881
- { regex: /\btodo!\s*\(\s*\)/, name: "unimplemented rust stub (todo!())" },
882
- { regex: /\bunimplemented!\s*\(\s*\)/, name: "unimplemented rust stub (unimplemented!())" }
1146
+ { regex: /\braise\s+NotImplemented\b/, name: "unimplemented stub (raise NotImplemented)" }
1147
+ ];
1148
+ var RUST_STUB_PATTERNS = [
1149
+ { regex: /\btodo!\s*\(/, name: "unimplemented rust stub (todo!())" },
1150
+ { regex: /\bunimplemented!\s*\(/, name: "unimplemented rust stub (unimplemented!())" }
883
1151
  ];
884
1152
  var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
885
1153
  ".ts",
@@ -929,16 +1197,26 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
929
1197
  hasSupportedChanges = true;
930
1198
  filesInspected++;
931
1199
  linesAdded += file.addedLines.length;
1200
+ const javascript = /\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file.path);
1201
+ const typescript = /\.(?:[cm]?tsx?|vue|svelte)$/i.test(file.path);
1202
+ const python = /\.py$/i.test(file.path);
1203
+ const rust = /\.rs$/i.test(file.path);
1204
+ const braceCatches = javascript || /\.(?:java|kt|php|cs|cpp)$/i.test(file.path);
1205
+ const directives = javascript ? JS_COMMENT_DIRECTIVE_PATTERNS : python ? PYTHON_COMMENT_DIRECTIVE_PATTERNS : [];
1206
+ const stubs = javascript ? JS_STUB_PATTERNS : python ? PYTHON_STUB_PATTERNS : rust ? RUST_STUB_PATTERNS : [];
1207
+ const firstFinding = infractions.length;
932
1208
  const masker = new SourceMasker(file.path);
933
1209
  const maskedLines = [];
934
1210
  for (const added of file.addedLines) {
935
1211
  const rawLine = added.line;
936
1212
  const masked = masker.mask(rawLine, added.lineNumber);
937
1213
  const cleanedLine = masked.code;
938
- maskedLines.push({ code: cleanedLine, raw: rawLine, number: added.lineNumber });
1214
+ const testCode = cleanedLine.split("");
1215
+ for (const start of masked.templateStarts) testCode[start] = "";
1216
+ maskedLines.push({ code: cleanedLine, testCode: testCode.join(""), raw: rawLine, number: added.lineNumber });
939
1217
  const isComment = !cleanedLine.trim();
940
- if (!isComment) {
941
- for (const rule of TEST_SKIP_PATTERNS) {
1218
+ if (python && !isComment) {
1219
+ for (const rule of PYTHON_TEST_SKIP_PATTERNS) {
942
1220
  if (rule.regex.test(cleanedLine)) {
943
1221
  infractions.push({
944
1222
  type: "test-skip",
@@ -954,7 +1232,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
954
1232
  }
955
1233
  }
956
1234
  let foundEscape = false;
957
- for (const rule of COMMENT_DIRECTIVE_PATTERNS) {
1235
+ for (const rule of directives) {
958
1236
  if (rule.regex.test(masked.comments)) {
959
1237
  infractions.push({
960
1238
  type: "type-escape",
@@ -969,7 +1247,11 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
969
1247
  break;
970
1248
  }
971
1249
  }
972
- if (!foundEscape && !isComment) {
1250
+ if (javascript && !foundEscape && masked.blockStarts.some((start) => /^\/\*\s*eslint-disable(?:-next-line|-line)?\b/.test(rawLine.slice(start)))) {
1251
+ infractions.push({ type: "type-escape", file: file.path, lineNumber: added.lineNumber, lineSnippet: rawLine.trim(), commitHash, commitDate, reason: "linter suppression (eslint-disable)" });
1252
+ foundEscape = true;
1253
+ }
1254
+ if (typescript && !foundEscape && !isComment) {
973
1255
  for (const rule of CODE_TYPE_ESCAPE_PATTERNS) {
974
1256
  if (rule.regex.test(cleanedLine)) {
975
1257
  infractions.push({
@@ -985,24 +1267,8 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
985
1267
  }
986
1268
  }
987
1269
  }
988
- if (!isComment) {
989
- for (const rule of SWALLOWED_ERROR_PATTERNS) {
990
- if (rule.regex.test(cleanedLine)) {
991
- infractions.push({
992
- type: "swallowed-error",
993
- file: file.path,
994
- lineNumber: added.lineNumber,
995
- lineSnippet: rawLine.trim(),
996
- commitHash,
997
- commitDate,
998
- reason: rule.name
999
- });
1000
- break;
1001
- }
1002
- }
1003
- }
1004
- if (!isComment && isTestFile(file.path)) {
1005
- for (const rule of TRIVIAL_ASSERT_PATTERNS) {
1270
+ if (python && !isComment && isTestFile(file.path)) {
1271
+ for (const rule of PYTHON_TRIVIAL_ASSERT_PATTERNS) {
1006
1272
  if (rule.regex.test(cleanedLine)) {
1007
1273
  infractions.push({
1008
1274
  type: "trivial-assert",
@@ -1018,9 +1284,10 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
1018
1284
  }
1019
1285
  }
1020
1286
  if (!isComment) {
1021
- for (const rule of STUB_ABANDONMENT_PATTERNS) {
1022
- const match = rule.regex.exec(rawLine);
1023
- if (match && cleanedLine[match.index] === rawLine[match.index]) {
1287
+ for (const rule of stubs) {
1288
+ const matches = rawLine.matchAll(new RegExp(rule.regex.source, rule.regex.flags + "g"));
1289
+ const match = [...matches].find((candidate) => cleanedLine[candidate.index] === rawLine[candidate.index]);
1290
+ if (match) {
1024
1291
  infractions.push({
1025
1292
  type: "stub-abandonment",
1026
1293
  file: file.path,
@@ -1035,46 +1302,68 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
1035
1302
  }
1036
1303
  }
1037
1304
  }
1038
- if (/\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file.path)) {
1305
+ if (javascript) {
1039
1306
  let source = "";
1040
1307
  const offsets = [];
1041
1308
  for (let i = 0; i < maskedLines.length; i++) {
1042
1309
  if (i) source += maskedLines[i].number === maskedLines[i - 1].number + 1 ? "\n" : "\0";
1043
1310
  offsets.push(source.length);
1044
- source += maskedLines[i].code;
1311
+ source += maskedLines[i].testCode;
1045
1312
  }
1046
1313
  let row = 0;
1047
- for (const match of source.matchAll(FOCUSED_TEST)) {
1314
+ const evidence = (index, length) => {
1315
+ while (row + 1 < offsets.length && offsets[row + 1] <= index) row++;
1316
+ let end = row;
1317
+ while (end + 1 < offsets.length && offsets[end + 1] < index + length) end++;
1318
+ return { lineNumber: maskedLines[row].number, lineSnippet: maskedLines.slice(row, end + 1).map((line) => line.raw.trim()).join(" ") };
1319
+ };
1320
+ for (const match of source.matchAll(JS_TEST_API)) {
1048
1321
  let previous = match.index - 1;
1049
1322
  while (previous >= 0 && /\s/.test(source[previous])) previous--;
1050
1323
  if (source[previous] === ".") continue;
1051
- if (/^(?:fit|fdescribe)$/.test(match[0]) && !isTestFile(file.path)) continue;
1324
+ if (match[2] && !isTestFile(file.path)) continue;
1052
1325
  if (/\bfunction$/.test(source.slice(Math.max(0, previous - 8), previous + 1))) continue;
1053
- while (row + 1 < offsets.length && offsets[row + 1] <= match.index) row++;
1054
- let end = row;
1055
- while (end + 1 < offsets.length && offsets[end + 1] < match.index + match[0].length) end++;
1326
+ if (source[match.index + match[0].length] === "" && !/\.\s*each\s*$/.test(match[3])) continue;
1327
+ const focus = match[1] === "only" || match[2] === "fit" || match[2] === "fdescribe";
1056
1328
  infractions.push({
1057
- type: "test-focus",
1329
+ type: focus ? "test-focus" : "test-skip",
1058
1330
  file: file.path,
1059
- lineNumber: maskedLines[row].number,
1060
- lineSnippet: maskedLines.slice(row, end + 1).map((line) => line.raw.trim()).join(" "),
1331
+ ...evidence(match.index, match[0].length),
1061
1332
  commitHash,
1062
1333
  commitDate,
1063
- reason: "focused test or suite excludes other tests from the run"
1334
+ reason: focus ? "focused test or suite excludes other tests from the run" : `skipped, disabled or placeholder test registration (${match[0].replace(/\s/g, "")})`
1064
1335
  });
1065
1336
  }
1337
+ if (isTestFile(file.path)) {
1338
+ row = 0;
1339
+ for (const rule of JS_TRIVIAL_ASSERT_PATTERNS) {
1340
+ for (const match of source.matchAll(new RegExp(rule.regex.source, "g"))) {
1341
+ infractions.push({ type: "trivial-assert", file: file.path, ...evidence(match.index, match[0].length), commitHash, commitDate, reason: rule.name });
1342
+ }
1343
+ row = 0;
1344
+ }
1345
+ }
1066
1346
  }
1067
1347
  for (let index = 0; index < maskedLines.length; index++) {
1068
1348
  const start = maskedLines[index];
1069
- const isCatch = /\bcatch\s*(?:\([^)]*\))?\s*\{\s*$/.test(start.code);
1070
- const isExcept = /\bexcept(?:\s+[^:]*)?:\s*$/.test(start.code);
1349
+ const catches = braceCatches ? [...start.code.matchAll(/(?<![\w$.])catch\s*(?:\(\s*[^)\s][^)]*\))?\s*\{/g)] : [];
1350
+ for (const match of catches) {
1351
+ if (/^\s*}/.test(start.code.slice(match.index + match[0].length))) {
1352
+ infractions.push({ type: "swallowed-error", file: file.path, lineNumber: start.number, lineSnippet: start.raw.trim(), commitHash, commitDate, reason: "empty catch block (catch {})" });
1353
+ }
1354
+ }
1355
+ const lastCatch = catches.at(-1);
1356
+ const isCatch = lastCatch && /^\s*$/.test(start.code.slice(lastCatch.index + lastCatch[0].length));
1357
+ const inlineExcept = python && /^\s*except(?:\*?\s+[^:]+)?:\s*pass\s*;?\s*$/.test(start.code);
1358
+ if (inlineExcept) infractions.push({ type: "swallowed-error", file: file.path, lineNumber: start.number, lineSnippet: start.raw.trim(), commitHash, commitDate, reason: "silent python error swallow (except: pass)" });
1359
+ const isExcept = python && /^\s*except(?:\*?\s+[^:]+)?:\s*$/.test(start.code);
1071
1360
  if (!isCatch && !isExcept) continue;
1072
1361
  let next = index + 1;
1073
1362
  while (next < maskedLines.length && maskedLines[next].number === maskedLines[next - 1].number + 1 && !maskedLines[next].code.trim()) next++;
1074
1363
  const end = maskedLines[next];
1075
1364
  if (!end || end.number !== maskedLines[next - 1].number + 1) continue;
1076
1365
  const closesCatch = isCatch && /^\s*}/.test(end.code);
1077
- let closesExcept = isExcept && /^\s*pass\s*$/.test(end.code);
1366
+ let closesExcept = isExcept && /^\s*pass\s*;?\s*$/.test(end.code);
1078
1367
  if (closesExcept) {
1079
1368
  const indent = start.raw.search(/\S/);
1080
1369
  if (end.raw.search(/\S/) <= indent) closesExcept = false;
@@ -1092,6 +1381,8 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
1092
1381
  reason: closesCatch ? "empty multi-line catch block" : "silent multi-line python error swallow"
1093
1382
  });
1094
1383
  }
1384
+ const ordered = infractions.splice(firstFinding).sort((left, right) => (left.lineNumber || 0) - (right.lineNumber || 0));
1385
+ for (const finding of ordered) infractions.push(finding);
1095
1386
  }
1096
1387
  return { infractions: infractions.filter((inf) => options.rules?.[inf.type] !== "off").map((inf) => ({ ...inf, suggestion: RULE_GUIDES[inf.type].suggestion })), hasSupportedChanges, filesInspected, linesAdded };
1097
1388
  }
@@ -1115,6 +1406,7 @@ var MetricsCollector = class {
1115
1406
  stubAbandonments = [];
1116
1407
  skippedLongLines = 0;
1117
1408
  skippedUnknownFiles = 0;
1409
+ skippedBinaryFiles = 0;
1118
1410
  activePanicChain = null;
1119
1411
  includeAll;
1120
1412
  ignorePaths;
@@ -1159,6 +1451,7 @@ var MetricsCollector = class {
1159
1451
  this.flushPanicChain();
1160
1452
  return;
1161
1453
  }
1454
+ this.skippedBinaryFiles += commit.files.filter((file) => file.binary && isCodeFile(file.path, this.ignorePaths)).length;
1162
1455
  const subject = commit.message.split("\n")[0];
1163
1456
  const commitDateMs = Date.parse(commit.date);
1164
1457
  const isFixCommit = PANIC_COMMIT_REGEX.test(subject);
@@ -1231,7 +1524,8 @@ var MetricsCollector = class {
1231
1524
  getSummary() {
1232
1525
  this.flushPanicChain();
1233
1526
  const linesSample = this.includeAll ? this.totalLinesAdded : this.aiLinesAdded;
1234
- const isSufficientData = linesSample >= MIN_SAMPLE_LOC;
1527
+ const complete = !this.skippedLongLines && !this.skippedUnknownFiles && !this.skippedBinaryFiles;
1528
+ const isSufficientData = complete && linesSample >= MIN_SAMPLE_LOC;
1235
1529
  const codeInfractionPoints = this.testSkips.length * 15 + this.focusedTests.length * 15 + this.trivialAsserts.length * 15 + this.swallowedErrors.length * 10 + this.stubAbandonments.length * 8 + this.typeEscapes.length * 3;
1236
1530
  let panicInfractionPoints = 0;
1237
1531
  for (const loop of this.panicLoops) {
@@ -1294,7 +1588,12 @@ var MetricsCollector = class {
1294
1588
  }
1295
1589
  }
1296
1590
  }
1591
+ if (!complete) {
1592
+ archetype = "Incomplete audit";
1593
+ archetypeDescription = "Source was skipped; findings are partial and no complete score can be issued.";
1594
+ }
1297
1595
  return {
1596
+ complete,
1298
1597
  schemaVersion: "1.0.0",
1299
1598
  totalLinesAdded: this.totalLinesAdded,
1300
1599
  aiLinesAdded: this.aiLinesAdded,
@@ -1313,14 +1612,22 @@ var MetricsCollector = class {
1313
1612
  archetype,
1314
1613
  archetypeDescription,
1315
1614
  skippedLongLines: this.skippedLongLines,
1316
- skippedUnknownFiles: this.skippedUnknownFiles
1615
+ skippedUnknownFiles: this.skippedUnknownFiles,
1616
+ skippedBinaryFiles: this.skippedBinaryFiles
1317
1617
  };
1318
1618
  }
1319
1619
  };
1320
1620
 
1321
1621
  // src/renderer.ts
1322
1622
  import pc from "picocolors";
1623
+ function historyIncomplete(summary) {
1624
+ return Boolean(summary.skippedLongLines || summary.skippedUnknownFiles || summary.skippedBinaryFiles);
1625
+ }
1323
1626
  function generateShareUrl(summary) {
1627
+ if (historyIncomplete(summary)) {
1628
+ const text2 = "My agent-roast history audit was incomplete: some long lines, unparsed paths or binary source were skipped. No complete score was issued.\n\nAudit your repo: npx agent-roast";
1629
+ return `https://twitter.com/intent/tweet?text=${encodeURIComponent(text2)}`;
1630
+ }
1324
1631
  const scoreDisplay = summary.score !== null ? `${summary.score}/100` : "N/A";
1325
1632
  const text = `My AI coding agent scored ${scoreDisplay} on agent-roast.
1326
1633
 
@@ -1335,27 +1642,32 @@ Audit your repo: npx agent-roast`;
1335
1642
  }
1336
1643
  function formatTerminalCard(summary, options) {
1337
1644
  const lines = [];
1645
+ const incomplete = historyIncomplete(summary);
1338
1646
  lines.push("");
1339
1647
  lines.push(
1340
- `${pc.bold(pc.cyan("agent-roast"))} ${pc.dim("\u2014 git discipline audit")} ${pc.white(options.repoName)}`
1648
+ `${pc.bold(pc.cyan("agent-roast"))} ${pc.dim("\u2014 git discipline audit")} ${pc.white(terminalText(options.repoName))}`
1341
1649
  );
1342
1650
  lines.push(
1343
1651
  ` ${pc.dim("commits:")} ${pc.yellow(summary.aiCommits)} AI / ${summary.totalCommits} total ${pc.dim("lines added:")} ${pc.green(summary.aiLinesAdded)} AI / ${summary.totalLinesAdded} total`
1344
1652
  );
1345
1653
  lines.push("");
1346
- if (!summary.isSufficientData || summary.score === null) {
1654
+ if (incomplete) {
1655
+ lines.push(pc.yellow(" INCOMPLETE: long lines, unparsed paths or binary source were skipped; no complete score issued."));
1656
+ lines.push(pc.dim(` Skipped: ${summary.skippedLongLines || 0} long lines \xB7 ${summary.skippedUnknownFiles || 0} unparsed files \xB7 ${summary.skippedBinaryFiles || 0} binary source files.`));
1657
+ lines.push("");
1658
+ } else if (!summary.isSufficientData || summary.score === null) {
1347
1659
  lines.push(pc.yellow(` insufficient data (< 200 analyzed lines added)`));
1348
1660
  lines.push(pc.dim(" Findings below remain actionable; a score needs a larger sample."));
1349
1661
  lines.push("");
1350
1662
  }
1351
- if (summary.isSufficientData && summary.score !== null) {
1663
+ if (!incomplete && summary.isSufficientData && summary.score !== null) {
1352
1664
  let scoreColor = pc.green;
1353
1665
  if (summary.score < 60) scoreColor = pc.red;
1354
1666
  else if (summary.score < 80) scoreColor = pc.yellow;
1355
1667
  lines.push(
1356
- ` ${pc.bold("SCORE:")} ${scoreColor(pc.bold(`${summary.score} / 100`))} ${pc.dim("\xB7")} ${pc.magenta(pc.bold(summary.archetype))}`
1668
+ ` ${pc.bold("SCORE:")} ${scoreColor(pc.bold(`${summary.score} / 100`))} ${pc.dim("\xB7")} ${pc.magenta(pc.bold(terminalText(summary.archetype)))}`
1357
1669
  );
1358
- lines.push(` ${pc.dim(summary.archetypeDescription)}`);
1670
+ lines.push(` ${pc.dim(terminalText(summary.archetypeDescription))}`);
1359
1671
  lines.push("");
1360
1672
  }
1361
1673
  const totalInfractions = (summary.testSkips?.length || 0) + (summary.focusedTests?.length || 0) + (summary.typeEscapes?.length || 0) + (summary.swallowedErrors?.length || 0) + (summary.panicLoops?.length || 0) + (summary.trivialAsserts?.length || 0) + (summary.stubAbandonments?.length || 0);
@@ -1366,7 +1678,7 @@ function formatTerminalCard(summary, options) {
1366
1678
  ` ${pc.magenta("\u25CF")} ${pc.bold("panic-loop")} (${summary.panicLoops.length}) ${pc.dim("rapid consecutive fixes touching overlapping files")}`
1367
1679
  );
1368
1680
  const sample = summary.panicLoops[0];
1369
- lines.push(` ${pc.dim(sample.reason)}`);
1681
+ lines.push(` ${pc.dim(terminalText(sample.reason))}`);
1370
1682
  }
1371
1683
  if (summary.trivialAsserts && summary.trivialAsserts.length > 0) {
1372
1684
  lines.push(
@@ -1374,7 +1686,7 @@ function formatTerminalCard(summary, options) {
1374
1686
  );
1375
1687
  const sample = summary.trivialAsserts[0];
1376
1688
  if (sample.file) {
1377
- lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1689
+ lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
1378
1690
  }
1379
1691
  }
1380
1692
  if (summary.focusedTests?.length) {
@@ -1388,7 +1700,7 @@ function formatTerminalCard(summary, options) {
1388
1700
  );
1389
1701
  const sample = summary.testSkips[0];
1390
1702
  if (sample.file) {
1391
- lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1703
+ lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
1392
1704
  }
1393
1705
  }
1394
1706
  if (summary.swallowedErrors && summary.swallowedErrors.length > 0) {
@@ -1397,7 +1709,7 @@ function formatTerminalCard(summary, options) {
1397
1709
  );
1398
1710
  const sample = summary.swallowedErrors[0];
1399
1711
  if (sample.file) {
1400
- lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1712
+ lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
1401
1713
  }
1402
1714
  }
1403
1715
  if (summary.stubAbandonments && summary.stubAbandonments.length > 0) {
@@ -1406,7 +1718,7 @@ function formatTerminalCard(summary, options) {
1406
1718
  );
1407
1719
  const sample = summary.stubAbandonments[0];
1408
1720
  if (sample.file) {
1409
- lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1721
+ lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
1410
1722
  }
1411
1723
  }
1412
1724
  if (summary.typeEscapes && summary.typeEscapes.length > 0) {
@@ -1415,15 +1727,15 @@ function formatTerminalCard(summary, options) {
1415
1727
  );
1416
1728
  const sample = summary.typeEscapes[0];
1417
1729
  if (sample.file) {
1418
- lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1730
+ lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
1419
1731
  }
1420
1732
  }
1421
1733
  if (totalInfractions === 0) {
1422
- lines.push(` ${pc.green("\u25CF")} ${pc.dim("zero infractions detected across all inspected lines.")}`);
1734
+ lines.push(incomplete ? " No findings in the inspected portion; skipped source remains unreviewed." : ` ${pc.green("\u25CF")} ${pc.dim("zero infractions detected across all inspected lines.")}`);
1423
1735
  }
1424
1736
  lines.push("");
1425
1737
  if (options.verbose) {
1426
- if (summary.skippedLongLines || summary.skippedUnknownFiles) {
1738
+ if (incomplete) {
1427
1739
  lines.push(pc.bold(" PARSER AUDIT STATS:"));
1428
1740
  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"));
1429
1741
  if (summary.skippedLongLines) {
@@ -1432,6 +1744,9 @@ function formatTerminalCard(summary, options) {
1432
1744
  if (summary.skippedUnknownFiles) {
1433
1745
  lines.push(` ${pc.dim("skipped files with unparseable path:")} ${pc.yellow(summary.skippedUnknownFiles)}`);
1434
1746
  }
1747
+ if (summary.skippedBinaryFiles) {
1748
+ lines.push(` ${pc.dim("skipped binary source files:")} ${pc.yellow(summary.skippedBinaryFiles)}`);
1749
+ }
1435
1750
  lines.push("");
1436
1751
  }
1437
1752
  const allInfractions = [
@@ -1449,16 +1764,16 @@ function formatTerminalCard(summary, options) {
1449
1764
  for (const inf of allInfractions.slice(0, 10)) {
1450
1765
  const fileLoc = inf.file ? `${inf.file}${inf.lineNumber ? `:${inf.lineNumber}` : ""}` : "commit history";
1451
1766
  lines.push(
1452
- ` \u25CF ${pc.cyan(inf.commitHash.slice(0, 7))} [${inf.type}] ${pc.white(fileLoc)}`
1767
+ ` \u25CF ${pc.cyan(terminalText(inf.commitHash.slice(0, 7)))} [${inf.type}] ${pc.white(terminalText(fileLoc))}`
1453
1768
  );
1454
1769
  if (inf.lineSnippet) {
1455
- lines.push(` ${pc.dim(`\u2514\u2500 ${inf.lineSnippet}`)}`);
1770
+ lines.push(` ${pc.dim(`\u2514\u2500 ${terminalText(inf.lineSnippet)}`)}`);
1456
1771
  }
1457
1772
  }
1458
1773
  lines.push("");
1459
1774
  }
1460
1775
  }
1461
- lines.push(
1776
+ if (!incomplete) lines.push(
1462
1777
  ` ${pc.dim("Share score:")} ${pc.cyan(generateShareUrl(summary))}`
1463
1778
  );
1464
1779
  lines.push("");
@@ -1471,6 +1786,7 @@ function formatPrTerminalCard(result) {
1471
1786
  `${pc.bold(pc.cyan("agent-roast"))} ${pc.dim(result.mode === "staged" ? "\u2014 staged changes audit" : "\u2014 pull request audit")} ${pc.white(`${result.baseSha.slice(0, 7)}...${result.headSha.slice(0, 7)}`)}`
1472
1787
  );
1473
1788
  if (!result.hasSupportedChanges) {
1789
+ if (result.complete === false) lines.push(" INCOMPLETE: some long lines, unparsed paths or binary source could not be inspected; exit 2.");
1474
1790
  lines.push(` ${pc.dim("no supported source changes detected in the audited diff.")}`);
1475
1791
  lines.push("");
1476
1792
  return lines.join("\n");
@@ -1479,7 +1795,7 @@ function formatPrTerminalCard(result) {
1479
1795
  ` ${pc.dim("files inspected:")} ${result.filesInspected} ${pc.dim("lines added:")} ${result.linesAdded}`
1480
1796
  );
1481
1797
  lines.push("");
1482
- if (result.infractions.length === 0) {
1798
+ if (result.infractions.length === 0 && result.complete !== false) {
1483
1799
  lines.push(` ${pc.green("PASS")} ${pc.dim("zero infractions detected across all audited changes.")}`);
1484
1800
  } else {
1485
1801
  lines.push(pc.bold(` INFRACTIONS DETECTED (${result.infractions.length})`));
@@ -1488,15 +1804,17 @@ function formatPrTerminalCard(result) {
1488
1804
  const isFailedRule = result.failedRules.includes(inf.type);
1489
1805
  const badge = isFailedRule ? pc.red("FAIL") : pc.yellow("WARN");
1490
1806
  const loc = `${inf.file || "unknown"}:${inf.lineNumber || 1}`;
1491
- lines.push(` ${badge} [${inf.type}] ${pc.white(loc)} \u2014 ${pc.dim(inf.reason)}`);
1807
+ lines.push(` ${badge} [${inf.type}] ${pc.white(terminalText(loc))} \u2014 ${pc.dim(terminalText(inf.reason))}`);
1492
1808
  if (inf.lineSnippet) {
1493
- lines.push(` ${pc.dim(`\u2514\u2500 ${inf.lineSnippet}`)}`);
1809
+ lines.push(` ${pc.dim(`\u2514\u2500 ${terminalText(inf.lineSnippet)}`)}`);
1494
1810
  }
1495
- if (inf.suggestion) lines.push(` ${pc.dim(`Next: ${inf.suggestion}`)}`);
1811
+ if (inf.suggestion) lines.push(` ${pc.dim(`Next: ${terminalText(inf.suggestion)}`)}`);
1496
1812
  }
1497
1813
  }
1498
1814
  lines.push("");
1499
- if (result.failedRules.length > 0) {
1815
+ if (result.complete === false) {
1816
+ lines.push(" INCOMPLETE: some long lines, unparsed paths or binary source could not be inspected; exit 2.");
1817
+ } else if (result.failedRules.length > 0) {
1500
1818
  lines.push(
1501
1819
  ` ${pc.red(pc.bold("BLOCKING FAILURE:"))} ${result.failedRules.length} rule(s) violated in --fail-on: ${result.failedRules.join(", ")}`
1502
1820
  );
@@ -1564,10 +1882,11 @@ function formatGithubStepSummary(result) {
1564
1882
  lines.push(result.mode === "staged" ? "### agent-roast Staged Changes Audit" : "### agent-roast Pull Request Audit");
1565
1883
  lines.push("");
1566
1884
  if (!result.hasSupportedChanges) {
1885
+ if (result.complete === false) lines.push("INCOMPLETE: some long lines, unparsed paths or binary source could not be inspected; exit 2.");
1567
1886
  lines.push("No supported source file changes detected in the audited diff.");
1568
1887
  return lines.join("\n");
1569
1888
  }
1570
- const statusBadge = result.passed ? "Passed" : `Failed (${result.failedRules.join(", ")})`;
1889
+ const statusBadge = result.complete === false ? "INCOMPLETE" : result.passed ? "Passed" : `Failed (${result.failedRules.join(", ")})`;
1571
1890
  lines.push(`| Status | Base | Head | Files | Lines Added | Infractions |`);
1572
1891
  lines.push(`| --- | --- | --- | --- | --- | --- |`);
1573
1892
  lines.push(
@@ -1581,31 +1900,32 @@ function formatGithubStepSummary(result) {
1581
1900
  lines.push("| --- | --- | --- | --- |");
1582
1901
  const sorted = sortInfractionsDeterministic(result.infractions);
1583
1902
  for (const inf of sorted) {
1584
- const loc = inf.file ? `\`${inf.file}:${inf.lineNumber || 1}\`` : "*history*";
1585
- const snippet = inf.lineSnippet ? `\`${inf.lineSnippet.replace(/\|/g, "\\|")}\`` : "-";
1586
- lines.push(`| \`${inf.type}\` | ${loc} | ${inf.reason} | ${snippet} |`);
1903
+ const loc = inf.file ? markdownCell(`${inf.file}:${inf.lineNumber || 1}`) : "history";
1904
+ const snippet = inf.lineSnippet ? markdownCell(inf.lineSnippet) : "-";
1905
+ lines.push(`| ${inf.type} | ${loc} | ${markdownCell(inf.reason)} | ${snippet} |`);
1587
1906
  }
1588
1907
  lines.push("");
1589
1908
  } else {
1590
- lines.push("Clean pull request: zero shortcuts or bypassed checks detected.");
1909
+ lines.push(result.complete === false ? "INCOMPLETE: skipped long lines, unparsed paths or binary source prevent a clean result." : "Clean pull request: zero shortcuts or bypassed checks detected.");
1591
1910
  lines.push("");
1592
1911
  }
1593
1912
  return lines.join("\n");
1594
1913
  }
1595
1914
  function formatPrMarkdownComment(result) {
1596
1915
  const lines = [];
1597
- const titleEmoji = result.passed ? "\u2705" : "\u274C";
1598
- const titleStatus = result.passed ? "PASSED" : "BLOCKED";
1916
+ const titleEmoji = result.complete === false ? "\u26A0\uFE0F" : result.passed ? "\u2705" : "\u274C";
1917
+ const titleStatus = result.complete === false ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
1599
1918
  lines.push(`## \u{1F6E1}\uFE0F agent-roast audit: ${titleStatus} ${titleEmoji}`);
1600
1919
  lines.push("");
1601
1920
  if (!result.hasSupportedChanges) {
1921
+ if (result.complete === false) lines.push("INCOMPLETE: some long lines, unparsed paths or binary source could not be inspected; exit 2.");
1602
1922
  lines.push("No supported source file changes detected in the audited diff.");
1603
1923
  lines.push("");
1604
1924
  lines.push("---");
1605
1925
  lines.push("*Audited 100% offline with [agent-roast](https://github.com/Nari-Ab/agent-roast)*");
1606
1926
  return lines.join("\n");
1607
1927
  }
1608
- const statusText = result.passed ? "\u2705 Passed" : `\u274C Blocked on ${result.failedRules.join(", ")}`;
1928
+ const statusText = result.complete === false ? "\u26A0\uFE0F INCOMPLETE" : result.passed ? "\u2705 Passed" : `\u274C Blocked on ${result.failedRules.join(", ")}`;
1609
1929
  lines.push("| Status | Defect Density | Files Inspected | Lines Added | Infractions |");
1610
1930
  lines.push("| :--- | :--- | :--- | :--- | :--- |");
1611
1931
  lines.push(
@@ -1620,15 +1940,15 @@ function formatPrMarkdownComment(result) {
1620
1940
  lines.push("| :--- | :--- | :--- | :--- |");
1621
1941
  const sorted = sortInfractionsDeterministic(result.infractions);
1622
1942
  for (const inf of sorted) {
1623
- const loc = inf.file ? `\`${inf.file}:${inf.lineNumber || 1}\`` : "*history*";
1624
- const snippet = inf.lineSnippet ? `\`${inf.lineSnippet.replace(/\|/g, "\\|")}\`` : "-";
1625
- lines.push(`| \`${inf.type}\` | ${loc} | ${inf.reason} | ${snippet} |`);
1943
+ const loc = inf.file ? markdownCell(`${inf.file}:${inf.lineNumber || 1}`) : "history";
1944
+ const snippet = inf.lineSnippet ? markdownCell(inf.lineSnippet) : "-";
1945
+ lines.push(`| ${inf.type} | ${loc} | ${markdownCell(inf.reason)} | ${snippet} |`);
1626
1946
  }
1627
1947
  lines.push("");
1628
1948
  lines.push("</details>");
1629
1949
  lines.push("");
1630
1950
  } else {
1631
- lines.push("\u2728 **Clean pull request**: Zero AI shortcuts, bypassed tests, or compiler type escapes detected.");
1951
+ lines.push(result.complete === false ? "INCOMPLETE: skipped long lines, unparsed paths or binary source prevent a clean result." : "\u2728 **Clean pull request**: Zero AI shortcuts, bypassed tests, or compiler type escapes detected.");
1632
1952
  lines.push("");
1633
1953
  }
1634
1954
  lines.push("---");
@@ -1636,7 +1956,38 @@ function formatPrMarkdownComment(result) {
1636
1956
  return lines.join("\n");
1637
1957
  }
1638
1958
  function terminalText(value) {
1639
- return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
1959
+ return value.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
1960
+ }
1961
+ function exceptionTerminal(result) {
1962
+ const lines = [];
1963
+ const accepted = result.suppressedFindings || [];
1964
+ if (accepted.length) {
1965
+ lines.push(` ${accepted.length} findings accepted by explicit exceptions (not fixed)`);
1966
+ for (const finding of accepted.slice(0, 20)) lines.push(
1967
+ ` ACCEPTED [${finding.type}] ${terminalText(finding.file || "unknown")}:${finding.lineNumber} \u2014 ${terminalText(finding.suppression.reason)} (expires ${terminalText(finding.suppression.expires)})`
1968
+ );
1969
+ if (accepted.length > 20) lines.push(` ${accepted.length - 20} more accepted findings; use --format json.`);
1970
+ }
1971
+ if (result.expiredExceptionCount) lines.push(` ${result.expiredExceptionCount} expired exceptions were not applied; review their findings.`);
1972
+ return lines;
1973
+ }
1974
+ function exceptionMarkdown(result) {
1975
+ const lines = [];
1976
+ const accepted = result.suppressedFindings || [];
1977
+ if (accepted.length) {
1978
+ lines.push(
1979
+ "",
1980
+ `### Accepted exceptions (${accepted.length})`,
1981
+ "",
1982
+ "These findings remain in the source and were accepted by explicit policy; they were not fixed.",
1983
+ "",
1984
+ "| Rule | Location | Reason | Expires |",
1985
+ "| --- | --- | --- | --- |"
1986
+ );
1987
+ for (const finding of accepted) lines.push(`| ${finding.type} | ${markdownCell(`${finding.file}:${finding.lineNumber}`)} | ${markdownCell(finding.suppression.reason)} | ${markdownCell(finding.suppression.expires)} |`);
1988
+ }
1989
+ if (result.expiredExceptionCount) lines.push("", `${result.expiredExceptionCount} expired exceptions were not applied; review their findings.`);
1990
+ return lines;
1640
1991
  }
1641
1992
  function formatCheckTerminal(result) {
1642
1993
  const lines = [
@@ -1645,10 +1996,11 @@ function formatCheckTerminal(result) {
1645
1996
  ` ${result.filesInspected} files \xB7 ${result.linesInspected} lines inspected`,
1646
1997
  ` ${result.infractions.length} findings to review \xB7 ${result.baselineCount} accepted baseline findings`
1647
1998
  ];
1999
+ lines.push(...exceptionTerminal(result));
1648
2000
  if (result.staleBaselineCount) lines.push(` ${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
1649
2001
  for (const inf of result.infractions.slice(0, 20)) {
1650
2002
  lines.push("", ` ${result.failedRules.includes(inf.type) ? pc.red("FAIL") : pc.yellow("WARN")} [${inf.type}] ${terminalText(inf.file || "unknown")}:${inf.lineNumber}`, ` ${terminalText(inf.lineSnippet || inf.reason)}`);
1651
- if (inf.suggestion) lines.push(` Next: ${inf.suggestion}`);
2003
+ if (inf.suggestion) lines.push(` Next: ${terminalText(inf.suggestion)}`);
1652
2004
  }
1653
2005
  if (result.infractions.length > 20) lines.push(`
1654
2006
  ${result.infractions.length - 20} more findings. Use --format json for the complete report.`);
@@ -1659,7 +2011,7 @@ function formatCheckTerminal(result) {
1659
2011
  return lines.join("\n");
1660
2012
  }
1661
2013
  function markdownCell(value) {
1662
- return value.replace(/[\\[\]()*_!]/g, "\\$&").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\|/g, "\\|").replace(/`/g, "&#96;").replace(/[\r\n]/g, " ");
2014
+ return terminalText(value).replace(/[\\[\]*_!]/g, "\\$&").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\|/g, "\\|").replace(/`/g, "&#96;").replace(/@/g, "&#64;");
1663
2015
  }
1664
2016
  function formatCheckMarkdown(result) {
1665
2017
  const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
@@ -1672,6 +2024,7 @@ function formatCheckMarkdown(result) {
1672
2024
  "| --- | --- | --- | --- |"
1673
2025
  ];
1674
2026
  for (const inf of result.infractions) lines.push(`| ${inf.type} | ${markdownCell(`${inf.file}:${inf.lineNumber}`)} | ${markdownCell(inf.lineSnippet || inf.reason)} | ${markdownCell(inf.suggestion || "Review this occurrence.")} |`);
2027
+ lines.push(...exceptionMarkdown(result));
1675
2028
  if (result.staleBaselineCount) lines.push("", `${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
1676
2029
  for (const skipped of result.skippedFiles) lines.push("", `Not scanned: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
1677
2030
  return lines.join("\n");
@@ -1685,6 +2038,7 @@ function formatReviewTerminal(result) {
1685
2038
  ` ${result.filesInspected} changed files compared \xB7 ${result.linesInspected} source lines inspected across both versions`,
1686
2039
  ` ${result.infractions.length} new \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected`
1687
2040
  ];
2041
+ lines.push(...exceptionTerminal(result));
1688
2042
  if (result.policyChanges.length) lines.push(" Candidate policy changes were ignored; adopt them separately after review.");
1689
2043
  if (result.excludedFiles || result.unsupportedFiles) lines.push(` ${result.excludedFiles} excluded \xB7 ${result.unsupportedFiles} unsupported/non-source changes`);
1690
2044
  for (const finding of result.infractions.slice(0, 20)) {
@@ -1692,7 +2046,7 @@ function formatReviewTerminal(result) {
1692
2046
  "",
1693
2047
  ` ${result.failedRules.includes(finding.type) ? "FAIL" : "WARN"} [${finding.type}] ${terminalText(finding.file || "unknown")}:${finding.lineNumber}`,
1694
2048
  ` ${terminalText(finding.lineSnippet || finding.reason)}`,
1695
- ` Next: ${finding.suggestion || "Review this occurrence."}`
2049
+ ` Next: ${terminalText(finding.suggestion || "Review this occurrence.")}`
1696
2050
  );
1697
2051
  }
1698
2052
  if (result.infractions.length > 20) lines.push(` ${result.infractions.length - 20} more new findings; use --format json for all results.`);
@@ -1714,6 +2068,7 @@ function formatReviewMarkdown(result) {
1714
2068
  if (result.policyChanges.length) lines.push("Candidate policy changes were ignored. This review uses the supplied base commit's policy.", "");
1715
2069
  lines.push("| New finding | Head location | Evidence | Next step |", "| --- | --- | --- | --- |");
1716
2070
  for (const finding of result.infractions) lines.push(`| ${finding.type} | ${markdownCell(`${finding.file}:${finding.lineNumber}`)} | ${markdownCell(finding.lineSnippet || finding.reason)} | ${markdownCell(finding.suggestion || "Review this occurrence.")} |`);
2071
+ lines.push(...exceptionMarkdown(result));
1717
2072
  if (result.removedFindings.length) {
1718
2073
  lines.push(
1719
2074
  "",
@@ -1732,8 +2087,10 @@ function formatReviewMarkdown(result) {
1732
2087
  }
1733
2088
 
1734
2089
  // src/init.ts
1735
- import * as fs2 from "fs";
1736
- import * as path2 from "path";
2090
+ import * as fs4 from "fs";
2091
+ import * as path3 from "path";
2092
+ import { randomUUID as randomUUID3 } from "crypto";
2093
+ import { spawnSync as spawnSync2 } from "child_process";
1737
2094
  var DEFAULT_CONFIG_TEMPLATE = {
1738
2095
  ignorePaths: [
1739
2096
  "**/*.min.js",
@@ -1776,58 +2133,112 @@ var PRE_COMMIT_HOOK_TEMPLATE = `#!/bin/sh
1776
2133
  # Run agent-roast pre-commit verification
1777
2134
  npx --no-install agent-roast --staged
1778
2135
  `;
1779
- async function initProject(options = {}) {
1780
- const cwd = path2.resolve(options.cwd || process.cwd());
1781
- const created = [];
1782
- const skipped = [];
1783
- const configPath = path2.join(cwd, ".agentroastrc.json");
1784
- const altConfigPath = path2.join(cwd, "agent-roast.config.json");
1785
- if (fs2.existsSync(configPath) || fs2.existsSync(altConfigPath) || fs2.existsSync(path2.join(cwd, ".agentroastrc"))) {
1786
- skipped.push(".agentroastrc.json");
1787
- } else {
1788
- fs2.writeFileSync(
1789
- configPath,
1790
- JSON.stringify(DEFAULT_CONFIG_TEMPLATE, null, 2) + "\n",
1791
- "utf-8"
1792
- );
1793
- created.push(".agentroastrc.json");
2136
+ function createNewFile(root, relative2, content, mode = 420) {
2137
+ const destination = path3.resolve(root, relative2);
2138
+ const inside = path3.relative(root, destination);
2139
+ if (!inside || inside === ".." || inside.startsWith(".." + path3.sep) || path3.isAbsolute(inside)) throw new ConfigError("Initialization target escapes its allowed directory.");
2140
+ let directory = root;
2141
+ for (const segment of path3.dirname(inside).split(path3.sep).filter((part) => part !== ".")) {
2142
+ directory = path3.join(directory, segment);
2143
+ let stat;
2144
+ try {
2145
+ stat = fs4.lstatSync(directory);
2146
+ } catch (error) {
2147
+ if (error.code !== "ENOENT") throw error;
2148
+ try {
2149
+ fs4.mkdirSync(directory);
2150
+ } catch (mkdirError) {
2151
+ if (mkdirError.code !== "EEXIST") throw mkdirError;
2152
+ }
2153
+ stat = fs4.lstatSync(directory);
2154
+ }
2155
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new ConfigError("Initialization refuses symlinked or non-directory parent paths.");
1794
2156
  }
1795
- if (options.ci) {
1796
- const workflowDir = path2.join(cwd, ".github", "workflows");
1797
- const workflowPath = path2.join(workflowDir, "agent-roast.yml");
1798
- if (fs2.existsSync(workflowPath)) {
1799
- skipped.push(".github/workflows/agent-roast.yml");
1800
- } else {
1801
- fs2.mkdirSync(workflowDir, { recursive: true });
1802
- fs2.writeFileSync(workflowPath, GITHUB_WORKFLOW_TEMPLATE, "utf-8");
1803
- created.push(".github/workflows/agent-roast.yml");
2157
+ const temporary = path3.join(directory, `.agent-roast-init-${randomUUID3()}.tmp`);
2158
+ let fd;
2159
+ try {
2160
+ fd = fs4.openSync(temporary, "wx", mode);
2161
+ fs4.writeFileSync(fd, content);
2162
+ fs4.fsyncSync(fd);
2163
+ fs4.closeSync(fd);
2164
+ fd = void 0;
2165
+ try {
2166
+ fs4.linkSync(temporary, destination);
2167
+ } catch (error) {
2168
+ if (error.code === "EEXIST") return false;
2169
+ throw error;
1804
2170
  }
2171
+ return true;
2172
+ } finally {
2173
+ if (fd !== void 0) fs4.closeSync(fd);
2174
+ fs4.rmSync(temporary, { force: true });
2175
+ }
2176
+ }
2177
+ var hasEntry = (filename) => {
2178
+ try {
2179
+ fs4.lstatSync(filename);
2180
+ return true;
2181
+ } catch (error) {
2182
+ if (error.code === "ENOENT") return false;
2183
+ throw error;
1805
2184
  }
2185
+ };
2186
+ async function initProject(options = {}) {
2187
+ const cwd = fs4.realpathSync(path3.resolve(options.cwd || process.cwd()));
2188
+ const created = [];
2189
+ const skipped = [];
2190
+ const publish = (root, relative2, label, content, mode) => {
2191
+ (createNewFile(root, relative2, content, mode) ? created : skipped).push(label);
2192
+ };
2193
+ if ([".agentroastrc.json", "agent-roast.config.json", ".agentroastrc"].some((name) => hasEntry(path3.join(cwd, name)))) {
2194
+ skipped.push(".agentroastrc.json");
2195
+ } else publish(cwd, ".agentroastrc.json", ".agentroastrc.json", JSON.stringify(DEFAULT_CONFIG_TEMPLATE, null, 2) + "\n");
2196
+ if (options.ci) publish(cwd, ".github/workflows/agent-roast.yml", ".github/workflows/agent-roast.yml", GITHUB_WORKFLOW_TEMPLATE);
1806
2197
  if (options.hook) {
1807
- const gitDir = path2.join(cwd, ".git");
1808
- if (!fs2.existsSync(gitDir) || !fs2.statSync(gitDir).isDirectory()) {
1809
- skipped.push(".git/hooks/pre-commit (no .git directory found)");
2198
+ const run = (...args) => spawnSync2("git", [...HERMETIC_GIT_FLAGS, ...args], {
2199
+ cwd,
2200
+ env: getHermeticGitEnv(),
2201
+ encoding: "utf8",
2202
+ timeout: 3e4,
2203
+ maxBuffer: 8192,
2204
+ windowsHide: true
2205
+ });
2206
+ const common = run("rev-parse", "--git-common-dir");
2207
+ if (common.error || common.status !== 0) {
2208
+ const plainGit = path3.join(cwd, ".git");
2209
+ if (hasEntry(plainGit) && fs4.lstatSync(plainGit).isDirectory() && !fs4.lstatSync(plainGit).isSymbolicLink()) {
2210
+ publish(plainGit, "hooks/pre-commit", ".git/hooks/pre-commit", PRE_COMMIT_HOOK_TEMPLATE, 493);
2211
+ } else skipped.push(".git/hooks/pre-commit (no .git directory found)");
1810
2212
  } else {
1811
- const hooksDir = path2.join(gitDir, "hooks");
1812
- const hookPath = path2.join(hooksDir, "pre-commit");
1813
- if (fs2.existsSync(hookPath)) {
1814
- skipped.push(".git/hooks/pre-commit");
1815
- } else {
1816
- fs2.mkdirSync(hooksDir, { recursive: true });
1817
- fs2.writeFileSync(hookPath, PRE_COMMIT_HOOK_TEMPLATE, {
1818
- encoding: "utf-8",
1819
- mode: 493
1820
- });
1821
- created.push(".git/hooks/pre-commit");
1822
- }
2213
+ const commonDir = fs4.realpathSync(path3.resolve(cwd, gitOutputPath(common.stdout)));
2214
+ const hooks = run("rev-parse", "--git-path", "hooks");
2215
+ if (hooks.error || hooks.status !== 0) throw new ConfigError("Cannot resolve this repository's hooks directory.");
2216
+ const hookDir = path3.resolve(cwd, gitOutputPath(hooks.stdout));
2217
+ const relative2 = path3.relative(commonDir, path3.join(hookDir, "pre-commit"));
2218
+ if (!relative2 || relative2 === ".." || relative2.startsWith(".." + path3.sep) || path3.isAbsolute(relative2)) {
2219
+ skipped.push(".git/hooks/pre-commit (custom hooks path outside repository metadata; install manually)");
2220
+ } else publish(commonDir, relative2, ".git/hooks/pre-commit", PRE_COMMIT_HOOK_TEMPLATE, 493);
1823
2221
  }
1824
2222
  }
1825
2223
  return { created, skipped };
1826
2224
  }
1827
2225
 
1828
2226
  // src/badge.ts
2227
+ function displayText(value) {
2228
+ return value.replace(
2229
+ /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/g,
2230
+ (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`
2231
+ );
2232
+ }
2233
+ function xmlText(value) {
2234
+ return displayText(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
2235
+ }
2236
+ function badgeScore(score) {
2237
+ return score !== null && Number.isFinite(score) && score >= 0 && score <= 100 ? score : null;
2238
+ }
1829
2239
  function getScoreColor(score) {
1830
- if (score === null || isNaN(score)) {
2240
+ score = badgeScore(score);
2241
+ if (score === null) {
1831
2242
  return { name: "lightgrey", hex: "#9f9f9f" };
1832
2243
  }
1833
2244
  if (score >= 90) return { name: "brightgreen", hex: "#4c1" };
@@ -1837,14 +2248,16 @@ function getScoreColor(score) {
1837
2248
  return { name: "red", hex: "#e05d44" };
1838
2249
  }
1839
2250
  function generateSvgBadge(score, label = "AI Discipline") {
1840
- const value = score !== null && !isNaN(score) ? `${score}/100` : "N/A";
2251
+ score = badgeScore(score);
2252
+ const value = score !== null ? `${score}/100` : "N/A";
1841
2253
  const color = getScoreColor(score);
2254
+ const safeLabel = xmlText(label);
1842
2255
  const labelWidth = Math.round(label.length * 6.8 + 14);
1843
2256
  const valueWidth = Math.round(value.length * 7.2 + 14);
1844
2257
  const totalWidth = labelWidth + valueWidth;
1845
2258
  const labelX = Math.round(labelWidth / 2);
1846
2259
  const valueX = Math.round(labelWidth + valueWidth / 2);
1847
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${label}: ${value}">
2260
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${safeLabel}: ${value}">
1848
2261
  <linearGradient id="s" x2="0" y2="100%">
1849
2262
  <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
1850
2263
  <stop offset="1" stop-opacity=".1"/>
@@ -1858,22 +2271,31 @@ function generateSvgBadge(score, label = "AI Discipline") {
1858
2271
  <rect width="${totalWidth}" height="20" fill="url(#s)"/>
1859
2272
  </g>
1860
2273
  <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
1861
- <text aria-hidden="true" x="${labelX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(labelWidth - 14) * 10}">${label}</text>
1862
- <text x="${labelX * 10}" y="140" transform="scale(.1)" fill="#fff" textLength="${(labelWidth - 14) * 10}">${label}</text>
2274
+ <text aria-hidden="true" x="${labelX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(labelWidth - 14) * 10}">${safeLabel}</text>
2275
+ <text x="${labelX * 10}" y="140" transform="scale(.1)" fill="#fff" textLength="${(labelWidth - 14) * 10}">${safeLabel}</text>
1863
2276
  <text aria-hidden="true" x="${valueX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(valueWidth - 14) * 10}">${value}</text>
1864
2277
  <text x="${valueX * 10}" y="140" transform="scale(.1)" fill="#fff" textLength="${(valueWidth - 14) * 10}">${value}</text>
1865
2278
  </g>
1866
2279
  </svg>`;
1867
2280
  }
1868
2281
  function generateShieldsMarkdown(score, label = "AI Discipline", repoUrl = "https://github.com/Nari-Ab/agent-roast") {
1869
- const value = score !== null && !isNaN(score) ? `${score}%2F100` : "N%2FA";
2282
+ score = badgeScore(score);
2283
+ const value = score !== null ? `${score}%2F100` : "N%2FA";
1870
2284
  const color = getScoreColor(score);
1871
- const encodedLabel = encodeURIComponent(label);
2285
+ const shownLabel = displayText(label);
2286
+ const encodedLabel = encodeURIComponent(shownLabel.replace(/-/g, "--").replace(/_/g, "__"));
2287
+ const alt = shownLabel.replace(/[\\[\]*_!`]/g, "\\$&").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2288
+ const destination = new URL(repoUrl);
2289
+ if (!["https:", "http:"].includes(destination.protocol) || destination.username || destination.password) {
2290
+ throw new Error("Badge destination must be an HTTP(S) URL without credentials.");
2291
+ }
2292
+ const safeDestination = destination.href.replace(/[()\\]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
1872
2293
  const badgeUrl = `https://img.shields.io/badge/${encodedLabel}-${value}-${color.name}?style=flat-square`;
1873
- return `[![${label}](${badgeUrl})](${repoUrl})`;
2294
+ return `[![${alt}](${badgeUrl})](${safeDestination})`;
1874
2295
  }
1875
2296
  function generateShieldsJson(score, label = "AI Discipline") {
1876
- const value = score !== null && !isNaN(score) ? `${score}/100` : "N/A";
2297
+ score = badgeScore(score);
2298
+ const value = score !== null ? `${score}/100` : "N/A";
1877
2299
  const color = getScoreColor(score);
1878
2300
  return {
1879
2301
  schemaVersion: 1,
@@ -1885,111 +2307,22 @@ function generateShieldsJson(score, label = "AI Discipline") {
1885
2307
 
1886
2308
  // src/sarif.ts
1887
2309
  import { createRequire } from "module";
1888
-
1889
- // src/baseline.ts
1890
- import { createHash, randomUUID } from "crypto";
1891
- import * as fs3 from "fs";
1892
- import path3 from "path";
1893
- function findingFingerprint(finding) {
1894
- return createHash("sha256").update(JSON.stringify([finding.type, finding.file, finding.lineSnippet?.trim()])).digest("hex");
1895
- }
1896
- function createBaseline(findings) {
1897
- const counts = /* @__PURE__ */ new Map();
1898
- for (const finding of findings) {
1899
- const fingerprint = findingFingerprint(finding);
1900
- counts.set(fingerprint, (counts.get(fingerprint) || 0) + 1);
1901
- }
1902
- return {
1903
- schemaVersion: "1.0.0",
1904
- fingerprintVersion: 1,
1905
- entries: [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([fingerprint, count]) => ({ fingerprint, count }))
1906
- };
1907
- }
1908
- function loadBaseline(filename) {
1909
- try {
1910
- const stat = fs3.lstatSync(filename);
1911
- if (!stat.isFile() || stat.size > 5 * 1024 * 1024) throw new Error("Expected a regular baseline file under 5 MiB.");
1912
- const fd = fs3.openSync(filename, fs3.constants.O_RDONLY | (fs3.constants.O_NOFOLLOW || 0));
1913
- let content;
1914
- try {
1915
- const opened = fs3.fstatSync(fd);
1916
- if (opened.ino !== stat.ino || opened.dev !== stat.dev) throw new Error("Baseline changed while opening.");
1917
- const buffer = Buffer.alloc(5 * 1024 * 1024 + 1);
1918
- let size = 0;
1919
- while (size < buffer.length) {
1920
- const read = fs3.readSync(fd, buffer, size, buffer.length - size, null);
1921
- if (!read) break;
1922
- size += read;
1923
- }
1924
- const after = fs3.fstatSync(fd);
1925
- if (size > 5 * 1024 * 1024 || size !== after.size || opened.mtimeMs !== after.mtimeMs) throw new Error("Baseline changed or exceeded the size limit.");
1926
- content = buffer.subarray(0, size).toString("utf8");
1927
- } finally {
1928
- fs3.closeSync(fd);
1929
- }
1930
- const parsed = JSON.parse(content);
1931
- if (!parsed || parsed.schemaVersion !== "1.0.0" || parsed.fingerprintVersion !== 1 || !Array.isArray(parsed.entries) || parsed.entries.length > 1e5) {
1932
- throw new Error("Unsupported baseline schema.");
1933
- }
1934
- const seen = /* @__PURE__ */ new Set();
1935
- for (const entry of parsed.entries) {
1936
- if (!entry || typeof entry.fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(entry.fingerprint) || !Number.isSafeInteger(entry.count) || entry.count <= 0 || entry.count > 1e6 || seen.has(entry.fingerprint)) {
1937
- throw new Error("Invalid or duplicate baseline entry.");
1938
- }
1939
- seen.add(entry.fingerprint);
1940
- }
1941
- return parsed;
1942
- } catch (error) {
1943
- throw new ConfigError(`Cannot load baseline '${filename}': ${error instanceof Error ? error.message : String(error)}`);
1944
- }
1945
- }
1946
- function applyBaseline(findings, baseline) {
1947
- const remaining = new Map(baseline.entries.map((entry) => [entry.fingerprint, entry.count]));
1948
- const fresh = [];
1949
- let baselineCount = 0;
1950
- for (const finding of findings) {
1951
- const fingerprint = findingFingerprint(finding);
1952
- const count = remaining.get(fingerprint) || 0;
1953
- if (count > 0) {
1954
- remaining.set(fingerprint, count - 1);
1955
- baselineCount++;
1956
- } else {
1957
- fresh.push(finding);
1958
- }
1959
- }
1960
- return { findings: fresh, baselineCount, staleBaselineCount: [...remaining.values()].reduce((a, b) => a + b, 0) };
1961
- }
1962
- function writeBaseline(filename, findings) {
1963
- const destination = path3.resolve(filename);
1964
- const temporary = path3.join(path3.dirname(destination), `.agent-roast-${randomUUID()}.tmp`);
1965
- let fd;
1966
- try {
1967
- fd = fs3.openSync(temporary, "wx", 384);
1968
- fs3.writeFileSync(fd, JSON.stringify(createBaseline(findings), null, 2) + "\n");
1969
- fs3.fsyncSync(fd);
1970
- fs3.closeSync(fd);
1971
- fd = void 0;
1972
- fs3.linkSync(temporary, destination);
1973
- } catch (error) {
1974
- throw new ConfigError(`Cannot write baseline '${filename}' (existing files are preserved): ${error instanceof Error ? error.message : String(error)}`);
1975
- } finally {
1976
- if (fd !== void 0) fs3.closeSync(fd);
1977
- fs3.rmSync(temporary, { force: true });
1978
- }
1979
- }
1980
-
1981
- // src/sarif.ts
1982
2310
  var require2 = createRequire(import.meta.url);
1983
2311
  var version = require2("../package.json").version;
1984
2312
  function formatSarif(report) {
1985
2313
  const occurrences = /* @__PURE__ */ new Map();
1986
- const results = report.infractions.map((finding) => {
2314
+ const allFindings = [
2315
+ ...report.infractions.map((finding) => ({ finding, suppression: void 0 })),
2316
+ ...(report.suppressedFindings ?? []).map((finding) => ({ finding, suppression: finding.suppression }))
2317
+ ];
2318
+ allFindings.sort((a, b) => (a.finding.file ?? "").localeCompare(b.finding.file ?? "") || (a.finding.lineNumber ?? 1) - (b.finding.lineNumber ?? 1) || a.finding.type.localeCompare(b.finding.type));
2319
+ const results = allFindings.map(({ finding, suppression }) => {
1987
2320
  const filename = finding.file;
1988
2321
  if (!filename || filename.startsWith("/") || filename.split("/").includes("..") || /^[a-z]:/i.test(filename)) {
1989
2322
  throw new ConfigError("SARIF needs repository-relative finding paths.");
1990
2323
  }
1991
2324
  const fingerprint = findingFingerprint(finding);
1992
- const occurrence = (occurrences.get(fingerprint) || 0) + 1;
2325
+ const occurrence = finding.occurrence ?? (occurrences.get(fingerprint) || 0) + 1;
1993
2326
  occurrences.set(fingerprint, occurrence);
1994
2327
  return {
1995
2328
  ruleId: finding.type,
@@ -2001,6 +2334,11 @@ function formatSarif(report) {
2001
2334
  region: { startLine: Math.max(1, finding.lineNumber || 1) }
2002
2335
  } }],
2003
2336
  partialFingerprints: { "agentRoastFinding/v1": `${fingerprint}:${occurrence}` },
2337
+ ...suppression ? { suppressions: [{
2338
+ kind: "external",
2339
+ status: "accepted",
2340
+ justification: `${suppression.reason} (expires ${suppression.expires}, UTC)`
2341
+ }] } : {},
2004
2342
  ...report.mode === "review" ? { baselineState: "new" } : {}
2005
2343
  };
2006
2344
  });
@@ -2040,156 +2378,31 @@ Example: ${RULE_GUIDES[rule].example}` },
2040
2378
  mergeBaseSha: report.mergeBaseSha,
2041
2379
  policySha: report.policySha,
2042
2380
  unchangedFindings: report.unchangedCount,
2043
- removedFindings: report.removedFindings.length
2044
- } : { scope: "current source", acceptedBaselineFindings: report.baselineCount }
2381
+ removedFindings: report.removedFindings.length,
2382
+ acceptedExceptions: report.suppressedFindings?.length ?? 0,
2383
+ expiredExceptions: report.expiredExceptionCount ?? 0
2384
+ } : { scope: "current source", acceptedBaselineFindings: report.baselineCount, acceptedExceptions: report.suppressedFindings?.length ?? 0, expiredExceptions: report.expiredExceptionCount ?? 0 }
2045
2385
  }]
2046
2386
  }, null, 2);
2047
2387
  }
2048
2388
 
2049
2389
  // src/check.ts
2050
- import { spawnSync as spawnSync2 } from "child_process";
2051
- import * as fs4 from "fs/promises";
2390
+ import * as fs6 from "fs/promises";
2052
2391
  import { constants as constants2 } from "fs";
2053
- import path4 from "path";
2054
- var MAX_FILE_BYTES = 2 * 1024 * 1024;
2055
- var MAX_SCAN_BYTES = 50 * 1024 * 1024;
2056
- var GENERATED_PATHS = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
2057
- function git(cwd, args) {
2058
- const result = spawnSync2("git", [...HERMETIC_GIT_FLAGS, ...args], {
2059
- cwd,
2060
- env: getHermeticGitEnv(),
2061
- encoding: "utf8",
2062
- maxBuffer: 16 * 1024 * 1024,
2063
- windowsHide: true
2064
- });
2065
- if (result.error || result.status !== 0) throw new GitError(result.error?.message || result.stderr, result.status);
2066
- return result.stdout;
2067
- }
2068
- function checkAborted(signal) {
2069
- if (signal?.aborted) throw new ConfigError("Project check cancelled; no complete result was produced.");
2070
- }
2071
- function insideRoot(root, filename) {
2072
- const relative = path4.relative(root, filename);
2073
- return relative.length > 0 && relative !== ".." && !relative.startsWith(".." + path4.sep) && !path4.isAbsolute(relative);
2074
- }
2075
- async function readSource(root, file, signal) {
2076
- const filename = path4.resolve(root, file);
2077
- if (!insideRoot(root, filename)) throw new Error("File escapes the repository.");
2078
- let stat;
2079
- try {
2080
- stat = await fs4.lstat(filename);
2081
- } catch (error) {
2082
- if (error.code === "ENOENT") return null;
2083
- throw error;
2084
- }
2085
- if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Symlinks and special files are not scanned.");
2086
- const real = await fs4.realpath(filename);
2087
- if (!insideRoot(root, real)) throw new Error("File resolves outside the repository.");
2088
- if (stat.size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
2089
- const handle = await fs4.open(filename, constants2.O_RDONLY | (constants2.O_NOFOLLOW || 0));
2090
- try {
2091
- const opened = await handle.stat();
2092
- if (opened.ino !== stat.ino || opened.dev !== stat.dev || !opened.isFile()) throw new Error("File changed while opening.");
2093
- const chunks = [];
2094
- let size = 0;
2095
- while (true) {
2096
- checkAborted(signal);
2097
- const chunk = Buffer.alloc(Math.min(64 * 1024, MAX_FILE_BYTES + 1 - size));
2098
- const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
2099
- if (!bytesRead) break;
2100
- size += bytesRead;
2101
- if (size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
2102
- chunks.push(chunk.subarray(0, bytesRead));
2103
- }
2104
- const after = await handle.stat();
2105
- if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || size !== after.size) throw new Error("File changed while reading; rerun the check.");
2106
- return Buffer.concat(chunks);
2107
- } finally {
2108
- await handle.close();
2109
- }
2110
- }
2111
- async function checkProject(options = {}) {
2112
- checkAborted(options.signal);
2113
- const cwd = path4.resolve(options.cwd || process.cwd());
2114
- const root = await fs4.realpath(git(cwd, ["rev-parse", "--show-toplevel"]).trim());
2115
- const config = loadConfig(root);
2116
- const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert", "test-focus"];
2117
- for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown rule '${rule}'.`);
2118
- const format = options.format || "terminal";
2119
- if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported check output format.");
2120
- const baseline = options.baseline ? loadBaseline(path4.resolve(cwd, options.baseline)) : void 0;
2121
- const filenames = [...new Set(git(root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]).split("\0").filter(Boolean))].sort();
2122
- const infractions = [];
2123
- const skippedFiles = [];
2124
- let filesInspected = 0;
2125
- let linesInspected = 0;
2126
- let totalBytes = 0;
2127
- for (const file of filenames) {
2128
- checkAborted(options.signal);
2129
- if (!isCodeFile(file, [...GENERATED_PATHS, ...config.ignorePaths || []])) continue;
2130
- try {
2131
- const content = await readSource(root, file, options.signal);
2132
- if (content === null) continue;
2133
- totalBytes += content.length;
2134
- if (totalBytes > MAX_SCAN_BYTES) throw new Error("Project exceeds the 50 MiB scan limit.");
2135
- if (content.includes(0)) throw new Error("Binary content in a source file.");
2136
- const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
2137
- const lines = text.split(/\r?\n/);
2138
- if (lines[lines.length - 1] === "") lines.pop();
2139
- const audit = auditFileDiffs([{ path: file, addedLines: lines.map((line, index) => ({ line, lineNumber: index + 1 })) }], "WORKTREE", "", { rules: config.rules });
2140
- infractions.push(...audit.infractions);
2141
- filesInspected++;
2142
- linesInspected += lines.length;
2143
- } catch (error) {
2144
- checkAborted(options.signal);
2145
- skippedFiles.push({ file, reason: error instanceof Error ? error.message : String(error) });
2146
- if (totalBytes > MAX_SCAN_BYTES) break;
2147
- }
2148
- }
2149
- checkAborted(options.signal);
2150
- const applied = baseline ? applyBaseline(infractions, baseline) : { findings: infractions, baselineCount: 0, staleBaselineCount: 0 };
2151
- const findings = applied.findings;
2152
- const failedRules = [...new Set(findings.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
2153
- const complete = skippedFiles.length === 0;
2154
- const passed = complete && failedRules.length === 0;
2155
- const result = {
2156
- schemaVersion: "1.0.0",
2157
- mode: "check",
2158
- filesInspected,
2159
- linesInspected,
2160
- totalFindings: infractions.length,
2161
- infractions: findings,
2162
- baselineCount: applied.baselineCount,
2163
- staleBaselineCount: applied.staleBaselineCount,
2164
- skippedFiles,
2165
- complete,
2166
- failedRules,
2167
- passed,
2168
- exitCode: complete ? passed ? 0 : 1 : 2,
2169
- output: ""
2170
- };
2171
- if (format === "json") result.output = JSON.stringify(result, null, 2);
2172
- else if (format === "sarif") result.output = formatSarif(result);
2173
- else if (format === "markdown") result.output = formatCheckMarkdown(result);
2174
- else if (format === "github") {
2175
- result.output = formatGithubAnnotations(result) + (complete ? "" : "\n::error::Project scan incomplete. See the JSON or terminal report for skipped files.");
2176
- } else result.output = formatCheckTerminal(result);
2177
- return result;
2178
- }
2179
-
2180
- // src/review.ts
2181
2392
  import path5 from "path";
2182
2393
 
2183
2394
  // src/snapshot.ts
2184
2395
  import { createHash as createHash2 } from "crypto";
2396
+ import * as fs5 from "fs/promises";
2397
+ import path4 from "path";
2185
2398
  import { spawn as spawn2 } from "child_process";
2186
2399
  function assertNotAborted(signal) {
2187
2400
  if (signal?.aborted) throw new ConfigError("Review cancelled; no complete result was produced.");
2188
2401
  }
2189
- async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024) {
2402
+ async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024, indexFile) {
2190
2403
  assertNotAborted(signal);
2191
2404
  const env = getHermeticGitEnv({ GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0" });
2192
- for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_NAMESPACE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"]) delete env[key];
2405
+ if (indexFile) env.GIT_INDEX_FILE = indexFile;
2193
2406
  return new Promise((resolve2, reject) => {
2194
2407
  const child = spawn2("git", [...HERMETIC_GIT_FLAGS, ...args], {
2195
2408
  cwd,
@@ -2228,7 +2441,7 @@ async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024) {
2228
2441
  if (signal?.aborted) abort();
2229
2442
  });
2230
2443
  }
2231
- async function requireLocalObjects(cwd, signal) {
2444
+ async function requireLocalObjects2(cwd, signal) {
2232
2445
  try {
2233
2446
  const promises = (await readGit(cwd, ["config", "--type=bool", "--get-regexp", "^remote\\..*\\.promisor$"], signal, 64 * 1024)).toString();
2234
2447
  if (promises.split("\n").some((line) => /\s+(?:true|yes|on|1)\s*$/i.test(line))) {
@@ -2248,7 +2461,7 @@ async function reviewRefs(cwd, base, head, signal) {
2248
2461
  throw new ConfigError(`Cannot resolve review ref '${ref}'. Use valid commits and fetch complete history (actions/checkout fetch-depth: 0). ${error instanceof Error ? error.message : String(error)}`);
2249
2462
  }
2250
2463
  };
2251
- await requireLocalObjects(cwd, signal);
2464
+ await requireLocalObjects2(cwd, signal);
2252
2465
  const baseSha = await resolve2(base);
2253
2466
  const headSha = await resolve2(head);
2254
2467
  let ancestors;
@@ -2303,7 +2516,19 @@ async function reviewPolicy(cwd, baseSha, signal) {
2303
2516
  return { config: configFromText("{}", "default review policy"), filename: null };
2304
2517
  }
2305
2518
  async function stagedSnapshot(cwd, signal) {
2306
- await requireLocalObjects(cwd, signal);
2519
+ await requireLocalObjects2(cwd, signal);
2520
+ let indexFile;
2521
+ if (process.env.GIT_INDEX_FILE) {
2522
+ const gitDirectory = await fs5.realpath(gitOutputPath((await readGit(cwd, ["rev-parse", "--absolute-git-dir"], signal, 8192)).toString()));
2523
+ const requested = path4.resolve(cwd, process.env.GIT_INDEX_FILE);
2524
+ const stat = await fs5.lstat(requested);
2525
+ const resolved = await fs5.realpath(requested);
2526
+ const relative2 = path4.relative(gitDirectory, resolved);
2527
+ if (!stat.isFile() || stat.isSymbolicLink() || !relative2 || relative2 === ".." || relative2.startsWith(".." + path4.sep) || path4.isAbsolute(relative2)) {
2528
+ throw new ConfigError("GIT_INDEX_FILE must be a regular candidate index inside this repository's Git directory; refusing to review another index.");
2529
+ }
2530
+ indexFile = resolved;
2531
+ }
2307
2532
  const head = async () => {
2308
2533
  try {
2309
2534
  return (await readGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], signal, 1024)).toString().trim();
@@ -2336,8 +2561,8 @@ async function stagedSnapshot(cwd, signal) {
2336
2561
  "--end-of-options",
2337
2562
  ...baseSha === "EMPTY" ? [] : [baseSha],
2338
2563
  "--"
2339
- ], signal);
2340
- const conflicts = await readGit(cwd, ["ls-files", "--unmerged", "-z", "--full-name"], signal);
2564
+ ], signal, 8 * 1024 * 1024, indexFile);
2565
+ const conflicts = await readGit(cwd, ["ls-files", "--unmerged", "-z", "--full-name"], signal, 8 * 1024 * 1024, indexFile);
2341
2566
  if (conflicts.length) throw new ConfigError("Resolve unmerged index entries before reviewing staged changes.");
2342
2567
  const pinned = await inventory();
2343
2568
  const changes = parseTreeChanges(pinned);
@@ -2356,7 +2581,137 @@ async function stagedSnapshot(cwd, signal) {
2356
2581
  };
2357
2582
  }
2358
2583
 
2584
+ // src/check.ts
2585
+ var MAX_FILE_BYTES = 2 * 1024 * 1024;
2586
+ var MAX_SCAN_BYTES = 50 * 1024 * 1024;
2587
+ var GENERATED_PATHS = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
2588
+ async function git(cwd, args, signal) {
2589
+ return new TextDecoder("utf-8", { fatal: true }).decode(await readGit(cwd, args, signal, 16 * 1024 * 1024));
2590
+ }
2591
+ function checkAborted(signal) {
2592
+ if (signal?.aborted) throw new ConfigError("Project check cancelled; no complete result was produced.");
2593
+ }
2594
+ function insideRoot(root, filename) {
2595
+ const relative2 = path5.relative(root, filename);
2596
+ return relative2.length > 0 && relative2 !== ".." && !relative2.startsWith(".." + path5.sep) && !path5.isAbsolute(relative2);
2597
+ }
2598
+ async function readSource(root, file, signal) {
2599
+ const filename = path5.resolve(root, file);
2600
+ if (!insideRoot(root, filename)) throw new Error("File escapes the repository.");
2601
+ let stat;
2602
+ try {
2603
+ stat = await fs6.lstat(filename);
2604
+ } catch (error) {
2605
+ if (error.code === "ENOENT") return null;
2606
+ throw error;
2607
+ }
2608
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Symlinks and special files are not scanned.");
2609
+ const real = await fs6.realpath(filename);
2610
+ if (!insideRoot(root, real)) throw new Error("File resolves outside the repository.");
2611
+ if (stat.size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
2612
+ const handle = await fs6.open(filename, constants2.O_RDONLY | (constants2.O_NOFOLLOW || 0) | (constants2.O_NONBLOCK || 0));
2613
+ try {
2614
+ const opened = await handle.stat();
2615
+ if (opened.ino !== stat.ino || opened.dev !== stat.dev || !opened.isFile()) throw new Error("File changed while opening.");
2616
+ const chunks = [];
2617
+ let size = 0;
2618
+ while (true) {
2619
+ checkAborted(signal);
2620
+ const chunk = Buffer.alloc(Math.min(64 * 1024, MAX_FILE_BYTES + 1 - size));
2621
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
2622
+ if (!bytesRead) break;
2623
+ size += bytesRead;
2624
+ if (size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
2625
+ chunks.push(chunk.subarray(0, bytesRead));
2626
+ }
2627
+ const after = await handle.stat();
2628
+ if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs || size !== after.size) throw new Error("File changed while reading; rerun the check.");
2629
+ return Buffer.concat(chunks);
2630
+ } finally {
2631
+ await handle.close();
2632
+ }
2633
+ }
2634
+ async function checkProject(options = {}) {
2635
+ checkAborted(options.signal);
2636
+ const cwd = path5.resolve(options.cwd || process.cwd());
2637
+ const root = await fs6.realpath(gitOutputPath(await git(cwd, ["rev-parse", "--show-toplevel"], options.signal)));
2638
+ const config = loadConfig(root);
2639
+ const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert", "test-focus"];
2640
+ for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown rule '${rule}'.`);
2641
+ const format = options.format || "terminal";
2642
+ if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported check output format.");
2643
+ const baseline = options.baseline ? loadBaseline(path5.resolve(cwd, options.baseline)) : void 0;
2644
+ const filenames = [...new Set((await git(root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], options.signal)).split("\0").filter(Boolean))].sort();
2645
+ const infractions = [];
2646
+ const skippedFiles = [];
2647
+ let filesInspected = 0;
2648
+ let linesInspected = 0;
2649
+ let totalBytes = 0;
2650
+ for (const file of filenames) {
2651
+ checkAborted(options.signal);
2652
+ if (!isCodeFile(file, [...GENERATED_PATHS, ...config.ignorePaths || []])) continue;
2653
+ try {
2654
+ const content = await readSource(root, file, options.signal);
2655
+ if (content === null) continue;
2656
+ totalBytes += content.length;
2657
+ if (totalBytes > MAX_SCAN_BYTES) throw new Error("Project exceeds the 50 MiB scan limit.");
2658
+ if (content.includes(0)) throw new Error("Binary content in a source file.");
2659
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
2660
+ const lines = text.split(/\r?\n/);
2661
+ if (lines[lines.length - 1] === "") lines.pop();
2662
+ const audit = auditFileDiffs([{ path: file, addedLines: lines.map((line, index) => ({ line, lineNumber: index + 1 })) }], "WORKTREE", "", { rules: config.rules });
2663
+ infractions.push(...audit.infractions);
2664
+ filesInspected++;
2665
+ linesInspected += lines.length;
2666
+ } catch (error) {
2667
+ checkAborted(options.signal);
2668
+ skippedFiles.push({ file, reason: error instanceof Error ? error.message : String(error) });
2669
+ if (totalBytes > MAX_SCAN_BYTES) break;
2670
+ }
2671
+ }
2672
+ checkAborted(options.signal);
2673
+ const applied = baseline ? applyBaseline(infractions, baseline) : { findings: infractions, baselineCount: 0, staleBaselineCount: 0 };
2674
+ const notInBaseline = new Set(applied.findings);
2675
+ const acceptedCounts = /* @__PURE__ */ new Map();
2676
+ for (const finding of infractions) {
2677
+ if (notInBaseline.has(finding)) continue;
2678
+ const fingerprint = findingFingerprint(finding);
2679
+ acceptedCounts.set(fingerprint, (acceptedCounts.get(fingerprint) ?? 0) + 1);
2680
+ }
2681
+ const exceptions = applyExceptions(applied.findings, options.ignoreExceptions ? [] : config.exceptions, /* @__PURE__ */ new Date(), acceptedCounts);
2682
+ const findings = exceptions.findings;
2683
+ const failedRules = [...new Set(findings.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
2684
+ const complete = skippedFiles.length === 0;
2685
+ const passed = complete && failedRules.length === 0;
2686
+ const result = {
2687
+ schemaVersion: "1.0.0",
2688
+ mode: "check",
2689
+ filesInspected,
2690
+ linesInspected,
2691
+ totalFindings: infractions.length,
2692
+ infractions: findings,
2693
+ baselineCount: applied.baselineCount,
2694
+ staleBaselineCount: applied.staleBaselineCount,
2695
+ suppressedFindings: exceptions.suppressedFindings,
2696
+ expiredExceptionCount: exceptions.expiredExceptionCount,
2697
+ skippedFiles,
2698
+ complete,
2699
+ failedRules,
2700
+ passed,
2701
+ exitCode: complete ? passed ? 0 : 1 : 2,
2702
+ output: ""
2703
+ };
2704
+ if (format === "json") result.output = JSON.stringify(result, null, 2);
2705
+ else if (format === "sarif") result.output = formatSarif(result);
2706
+ else if (format === "markdown") result.output = formatCheckMarkdown(result);
2707
+ else if (format === "github") {
2708
+ result.output = formatGithubAnnotations(result) + (complete ? "" : "\n::error::Project scan incomplete. See the JSON or terminal report for skipped files.");
2709
+ } else result.output = formatCheckTerminal(result);
2710
+ return result;
2711
+ }
2712
+
2359
2713
  // src/review.ts
2714
+ import path6 from "path";
2360
2715
  var GENERATED_PATHS2 = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
2361
2716
  var MAX_SCAN_BYTES2 = 50 * 1024 * 1024;
2362
2717
  function compareFindings(before, after, newPath) {
@@ -2386,7 +2741,7 @@ function reviewStagedChanges(options = {}) {
2386
2741
  }
2387
2742
  async function compareSourceChanges(options) {
2388
2743
  assertNotAborted(options.signal);
2389
- const cwd = path5.resolve(options.cwd || process.cwd());
2744
+ const cwd = path6.resolve(options.cwd || process.cwd());
2390
2745
  const staged = options.staged ? await stagedSnapshot(cwd, options.signal) : void 0;
2391
2746
  const refs = options.staged ? { baseSha: staged.baseSha, headSha: staged.headSha, mergeBaseSha: staged.mergeBaseSha } : await reviewRefs(cwd, options.base, options.head ?? "HEAD", options.signal);
2392
2747
  const policy = refs.baseSha === "EMPTY" ? { config: configFromText("{}", "default first-commit policy"), filename: null } : await reviewPolicy(cwd, refs.baseSha, options.signal);
@@ -2424,6 +2779,7 @@ async function compareSourceChanges(options) {
2424
2779
  output: ""
2425
2780
  };
2426
2781
  let totalBytes = 0;
2782
+ const existingCounts = /* @__PURE__ */ new Map();
2427
2783
  for (const change of changes) {
2428
2784
  assertNotAborted(options.signal);
2429
2785
  const oldSupported = change.oldMode !== "000000" && isCodeFile(change.oldPath);
@@ -2459,6 +2815,12 @@ async function compareSourceChanges(options) {
2459
2815
  const before = await scan(change.oldMode, change.oldOid, change.oldPath, refs.mergeBaseSha);
2460
2816
  const after = await scan(change.newMode, change.newOid, change.newPath, refs.headSha);
2461
2817
  const compared = compareFindings(before, after, change.newPath);
2818
+ const introduced = new Set(compared.introduced);
2819
+ for (const finding of after) {
2820
+ if (introduced.has(finding)) continue;
2821
+ const fingerprint = findingFingerprint(finding);
2822
+ existingCounts.set(fingerprint, (existingCounts.get(fingerprint) ?? 0) + 1);
2823
+ }
2462
2824
  result.infractions.push(...compared.introduced);
2463
2825
  result.removedFindings.push(...compared.removed);
2464
2826
  result.unchangedCount += compared.unchanged;
@@ -2475,6 +2837,10 @@ async function compareSourceChanges(options) {
2475
2837
  assertNotAborted(options.signal);
2476
2838
  await staged?.verify();
2477
2839
  result.complete = result.skippedFiles.length === 0;
2840
+ const exceptions = applyExceptions(result.infractions, options.ignoreExceptions ? [] : policy.config.exceptions, /* @__PURE__ */ new Date(), existingCounts);
2841
+ result.infractions = exceptions.findings;
2842
+ result.suppressedFindings = exceptions.suppressedFindings;
2843
+ result.expiredExceptionCount = exceptions.expiredExceptionCount;
2478
2844
  result.failedRules = [...new Set(result.infractions.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
2479
2845
  result.passed = result.complete && result.failedRules.length === 0;
2480
2846
  result.exitCode = !result.complete ? 2 : result.passed ? 0 : 1;
@@ -2494,9 +2860,9 @@ var InvalidDetectorError = class extends Error {
2494
2860
  }
2495
2861
  };
2496
2862
  async function roastRepository(options = {}) {
2497
- const cwd = path6.resolve(options.cwd || process.cwd());
2863
+ const cwd = path7.resolve(options.cwd || process.cwd());
2498
2864
  const since = options.since || "90 days ago";
2499
- const repoName = path6.basename(cwd);
2865
+ const repoName = path7.basename(cwd);
2500
2866
  const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths, rules: options.rules, panicLoopWindowMinutes: options.panicLoopWindowMinutes });
2501
2867
  const gitStream = streamGitLog(cwd, since);
2502
2868
  await parseGitLogStream(gitStream, (commit, stats) => {
@@ -2511,7 +2877,7 @@ async function roastRepository(options = {}) {
2511
2877
  return { summary, output };
2512
2878
  }
2513
2879
  async function auditPullRequest(options) {
2514
- const cwd = path6.resolve(options.cwd || process.cwd());
2880
+ const cwd = path7.resolve(options.cwd || process.cwd());
2515
2881
  const format = options.format || "terminal";
2516
2882
  const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
2517
2883
  for (const det of failOn) {
@@ -2530,15 +2896,26 @@ async function auditPullRequest(options) {
2530
2896
  const baseSha = options.staged ? "INDEX" : resolveCommitSha(cwd, options.base, "Base ref");
2531
2897
  const headSha = options.staged ? "INDEX" : resolveCommitSha(cwd, options.head || "HEAD", "Head ref");
2532
2898
  const mergeBaseSha = options.staged ? "INDEX" : findMergeBase(cwd, baseSha, headSha);
2899
+ if (!["terminal", "json", "github", "markdown"].includes(format)) throw new InvalidDetectorError("Unsupported legacy audit output format.");
2533
2900
  const diffStream = streamGitDiff(cwd, mergeBaseSha, headSha, options.staged);
2534
- let rawDiff = "";
2901
+ const chunks = [];
2902
+ let bytes = 0;
2535
2903
  await new Promise((resolve2, reject) => {
2536
2904
  diffStream.on("data", (chunk) => {
2537
- rawDiff += chunk.toString();
2905
+ bytes += chunk.length;
2906
+ if (bytes > 50 * 1024 * 1024) {
2907
+ diffStream.destroy(new GitResolutionError("Legacy diff exceeds the 50 MiB limit; use a smaller context review."));
2908
+ } else chunks.push(chunk);
2538
2909
  });
2539
- diffStream.on("end", () => resolve2());
2910
+ diffStream.on("end", resolve2);
2540
2911
  diffStream.on("error", reject);
2541
2912
  });
2913
+ let rawDiff;
2914
+ try {
2915
+ rawDiff = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
2916
+ } catch {
2917
+ throw new GitResolutionError("Legacy diff contains invalid UTF-8; no complete result was produced.");
2918
+ }
2542
2919
  const { files, skippedLongLines, skippedUnknownFiles } = parseDiff(rawDiff);
2543
2920
  const audit = auditFileDiffs(files, headSha, (/* @__PURE__ */ new Date()).toISOString(), { ignorePaths: options.ignorePaths, rules: options.rules });
2544
2921
  const infractions = audit.infractions;
@@ -2546,8 +2923,10 @@ async function auditPullRequest(options) {
2546
2923
  const failedRules = [...new Set(
2547
2924
  infractions.filter((inf) => failOn.includes(inf.type)).map((inf) => inf.type)
2548
2925
  )];
2549
- const passed = failedRules.length === 0;
2550
- const exitCode = passed ? 0 : 1;
2926
+ const skippedBinaryFiles = files.filter((file) => file.binary && isCodeFile(file.path, options.ignorePaths)).length;
2927
+ const complete = skippedLongLines === 0 && skippedUnknownFiles === 0 && skippedBinaryFiles === 0;
2928
+ const passed = complete && failedRules.length === 0;
2929
+ const exitCode = !complete ? 2 : passed ? 0 : 1;
2551
2930
  const result = {
2552
2931
  schemaVersion: "1.0.0",
2553
2932
  mode: options.staged ? "staged" : "pr",
@@ -2561,15 +2940,17 @@ async function auditPullRequest(options) {
2561
2940
  infractions,
2562
2941
  failedRules,
2563
2942
  passed,
2943
+ complete,
2564
2944
  skippedLongLines,
2565
2945
  skippedUnknownFiles,
2946
+ skippedBinaryFiles,
2566
2947
  output: "",
2567
2948
  exitCode
2568
2949
  };
2569
2950
  if (format === "json") {
2570
2951
  result.output = JSON.stringify(result, null, 2);
2571
2952
  } else if (format === "github") {
2572
- result.output = formatGithubAnnotations(result);
2953
+ result.output = formatGithubAnnotations(result) + (complete ? "" : "\n::error::Legacy audit incomplete: long lines, binary-marked or unparsed source were skipped. Use context review.");
2573
2954
  } else if (format === "markdown") {
2574
2955
  result.output = formatPrMarkdownComment(result);
2575
2956
  } else {
@@ -2579,12 +2960,28 @@ async function auditPullRequest(options) {
2579
2960
  }
2580
2961
 
2581
2962
  export {
2963
+ VALID_DETECTORS,
2964
+ findingFingerprint,
2965
+ createBaseline,
2966
+ loadBaseline,
2967
+ applyBaseline,
2968
+ writeBaseline,
2969
+ validateExceptions,
2970
+ applyExceptions,
2971
+ MAX_CONFIG_BYTES,
2972
+ DEFAULT_CONFIG,
2973
+ CONFIG_FILENAMES,
2974
+ ConfigError,
2975
+ configFromText,
2976
+ loadConfig,
2977
+ matchIgnorePath,
2582
2978
  detectAiAttribution,
2583
2979
  RECORD_SEPARATOR,
2584
2980
  FIELD_SEPARATOR,
2585
2981
  GitError,
2586
2982
  HERMETIC_GIT_FLAGS,
2587
2983
  getHermeticGitEnv,
2984
+ gitOutputPath,
2588
2985
  parseGitLogStream,
2589
2986
  unquoteGitPath,
2590
2987
  parseDiff,
@@ -2593,14 +2990,8 @@ export {
2593
2990
  resolveCommitSha,
2594
2991
  findMergeBase,
2595
2992
  GitResolutionError,
2993
+ readGit,
2596
2994
  RULE_GUIDES,
2597
- VALID_DETECTORS,
2598
- DEFAULT_CONFIG,
2599
- CONFIG_FILENAMES,
2600
- ConfigError,
2601
- configFromText,
2602
- loadConfig,
2603
- matchIgnorePath,
2604
2995
  isCodeFile,
2605
2996
  isTestFile,
2606
2997
  auditFileDiffs,
@@ -2612,6 +3003,7 @@ export {
2612
3003
  formatGithubAnnotations,
2613
3004
  formatGithubStepSummary,
2614
3005
  formatPrMarkdownComment,
3006
+ terminalText,
2615
3007
  formatCheckTerminal,
2616
3008
  formatCheckMarkdown,
2617
3009
  formatReviewTerminal,
@@ -2624,11 +3016,6 @@ export {
2624
3016
  generateSvgBadge,
2625
3017
  generateShieldsMarkdown,
2626
3018
  generateShieldsJson,
2627
- findingFingerprint,
2628
- createBaseline,
2629
- loadBaseline,
2630
- applyBaseline,
2631
- writeBaseline,
2632
3019
  formatSarif,
2633
3020
  checkProject,
2634
3021
  compareFindings,