agent-roast 0.7.0 → 0.8.1

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,83 @@ 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;
579
+ tokenPrefix = "";
580
+ parentheses = [];
553
581
  python;
554
582
  javascript;
583
+ rust;
555
584
  hashComments;
556
585
  backticks;
586
+ textBlocks;
587
+ csharp;
588
+ cpp;
557
589
  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);
590
+ const name = filename.toLowerCase();
591
+ this.python = name.endsWith(".py");
592
+ this.rust = name.endsWith(".rs");
593
+ this.hashComments = this.python || /\.(?:rb|php)$/.test(name);
594
+ this.backticks = /\.(?:go|[cm]?[jt]sx?|vue|svelte)$/.test(name);
595
+ this.javascript = /\.(?:[cm]?[jt]sx?|vue|svelte)$/.test(name);
596
+ this.textBlocks = /\.(?:java|kt|cs)$/.test(name);
597
+ this.csharp = name.endsWith(".cs");
598
+ this.cpp = /\.(?:cpp|c)$/.test(name);
562
599
  }
563
600
  mask(line, number) {
564
601
  if (number !== this.previousLine + 1) {
565
- this.blockComment = false;
602
+ this.blockDepth = 0;
566
603
  this.quote = "";
604
+ this.rawEnd = "";
567
605
  this.triple = false;
606
+ this.verbatim = false;
607
+ this.tokenPrefix = "";
608
+ this.parentheses.length = 0;
568
609
  }
569
610
  this.previousLine = number;
570
611
  const code = Array(line.length).fill(" ");
571
612
  const comments = Array(line.length).fill(" ");
572
613
  let i = 0;
573
- let prefix = "";
614
+ let prefix = this.tokenPrefix;
615
+ const appendPrefix = (text) => {
616
+ for (const char of text) {
617
+ if (/\s/.test(char)) {
618
+ if (!prefix.endsWith(" ")) prefix = (prefix + " ").slice(-64);
619
+ } else prefix = (prefix + char).slice(-64);
620
+ }
621
+ };
622
+ let continued = false;
623
+ const templateStarts = [];
624
+ const blockStarts = [];
574
625
  while (i < line.length) {
575
- if (this.blockComment) {
576
- if (line.startsWith("*/", i)) {
577
- this.blockComment = false;
626
+ if (this.blockDepth) {
627
+ if (this.rust && line.startsWith("/*", i)) {
628
+ this.blockDepth++;
629
+ i += 2;
630
+ } else if (line.startsWith("*/", i)) {
631
+ this.blockDepth--;
578
632
  i += 2;
579
633
  } else i++;
580
634
  continue;
581
635
  }
636
+ if (this.rawEnd) {
637
+ if (line.startsWith(this.rawEnd, i)) {
638
+ i += this.rawEnd.length;
639
+ this.rawEnd = "";
640
+ } else i++;
641
+ continue;
642
+ }
582
643
  if (this.quote) {
583
- if (line[i] === "\\") {
644
+ if (this.verbatim && line.startsWith('""', i)) {
645
+ i += 2;
646
+ continue;
647
+ }
648
+ if (!this.verbatim && !(this.quote === "`" && !this.javascript) && line[i] === "\\") {
649
+ if (i === line.length - 1) continued = true;
584
650
  i += 2;
585
651
  continue;
586
652
  }
@@ -589,6 +655,7 @@ var SourceMasker = class {
589
655
  i += delimiter.length;
590
656
  this.quote = "";
591
657
  this.triple = false;
658
+ this.verbatim = false;
592
659
  } else i++;
593
660
  continue;
594
661
  }
@@ -597,19 +664,53 @@ var SourceMasker = class {
597
664
  break;
598
665
  }
599
666
  if (!this.python && line.startsWith("/*", i)) {
600
- this.blockComment = true;
667
+ blockStarts.push(i);
668
+ this.blockDepth = 1;
669
+ i += 2;
670
+ continue;
671
+ }
672
+ if (this.rust && (i === 0 || !/[\w]/.test(line[i - 1]))) {
673
+ const raw = /^(?:br|cr|r)(#{0,255})"/.exec(line.slice(i));
674
+ if (raw) {
675
+ this.rawEnd = '"' + raw[1];
676
+ i += raw[0].length;
677
+ appendPrefix(" literal ");
678
+ continue;
679
+ }
680
+ }
681
+ if (this.cpp && (i === 0 || !/[\w]/.test(line[i - 1]))) {
682
+ const raw = /^(?:u8|u|U|L)?R"([^\s()\\]{0,16})\(/.exec(line.slice(i));
683
+ if (raw) {
684
+ this.rawEnd = ")" + raw[1] + '"';
685
+ i += raw[0].length;
686
+ appendPrefix(" literal ");
687
+ continue;
688
+ }
689
+ }
690
+ if (this.csharp && line.startsWith('@"', i)) {
691
+ this.quote = '"';
692
+ this.verbatim = true;
601
693
  i += 2;
694
+ appendPrefix(" literal ");
602
695
  continue;
603
696
  }
604
697
  const char = line[i];
698
+ const lifetime = this.rust && char === "'" && /^'[A-Za-z_][A-Za-z0-9_]*(?![A-Za-z0-9_])/.exec(line.slice(i));
699
+ if (lifetime && line[i + lifetime[0].length] !== "'") {
700
+ for (let j = 0; j < lifetime[0].length; j++) code[i + j] = line[i + j];
701
+ appendPrefix(lifetime[0]);
702
+ i += lifetime[0].length;
703
+ continue;
704
+ }
605
705
  if (char === '"' || char === "'" || this.backticks && char === "`") {
606
706
  this.quote = char;
607
- this.triple = this.python && line.startsWith(char.repeat(3), i);
707
+ if (this.javascript && char === "`") templateStarts.push(i);
708
+ this.triple = (this.python || this.textBlocks && char === '"') && line.startsWith(char.repeat(3), i);
608
709
  i += this.triple ? 3 : 1;
609
- prefix += " literal ";
710
+ appendPrefix(" literal ");
610
711
  continue;
611
712
  }
612
- if (this.javascript && char === "/" && (/(?:[=(:,[!&|?{;]|\breturn|\byield)\s*$/.test(prefix) || !prefix.trim())) {
713
+ if (this.javascript && char === "/" && (/(?:=>|[=(:,[!&|?{;]|\breturn|\byield|\bthrow)\s*$/.test(prefix) || !prefix.trim())) {
613
714
  let j = i + 1;
614
715
  let inClass = false;
615
716
  for (; j < line.length; j++) {
@@ -623,18 +724,27 @@ var SourceMasker = class {
623
724
  }
624
725
  if (j < line.length) {
625
726
  j++;
626
- while (/[a-z]/i.test(line[j] || "") && j < line.length) j++;
727
+ while (j < line.length && /[a-z]/i.test(line[j])) j++;
627
728
  i = j;
628
- prefix += " literal ";
729
+ appendPrefix(" literal ");
629
730
  continue;
630
731
  }
631
732
  }
632
733
  code[i] = char;
633
- prefix += char;
734
+ if (this.javascript && char === "(") {
735
+ const control = /(?<![\w$])(?:if|while|for|with|switch|catch)\s*$/.exec(prefix);
736
+ this.parentheses.push(Boolean(control && !/\.\s*$/.test(prefix.slice(0, control.index))));
737
+ } else if (this.javascript && char === ")" && this.parentheses.pop()) {
738
+ prefix = "; ";
739
+ i++;
740
+ continue;
741
+ }
742
+ appendPrefix(char);
634
743
  i++;
635
744
  }
636
- if (this.quote !== "`" && !this.triple) this.quote = "";
637
- return { code: code.join(""), comments: comments.join("") };
745
+ if (this.quote !== "`" && !this.triple && !this.verbatim && !continued) this.quote = "";
746
+ this.tokenPrefix = this.javascript ? (prefix + " ").slice(-64) : "";
747
+ return { code: code.join(""), comments: comments.join(""), templateStarts, blockStarts };
638
748
  }
639
749
  };
640
750
 
@@ -685,8 +795,8 @@ var RULE_GUIDES = {
685
795
  };
686
796
 
687
797
  // src/config.ts
688
- import * as fs from "fs";
689
- import * as path from "path";
798
+ import * as fs3 from "fs";
799
+ import * as path2 from "path";
690
800
 
691
801
  // src/types.ts
692
802
  var VALID_DETECTORS = [
@@ -699,7 +809,146 @@ var VALID_DETECTORS = [
699
809
  "test-focus"
700
810
  ];
701
811
 
812
+ // src/file-read.ts
813
+ import * as fs from "fs";
814
+ function readRegularFile(filename, limit) {
815
+ const stat = fs.lstatSync(filename);
816
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > limit) throw new Error(`Expected a regular file no larger than ${limit} bytes.`);
817
+ const fd = fs.openSync(filename, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
818
+ try {
819
+ const opened = fs.fstatSync(fd);
820
+ if (!opened.isFile() || opened.ino !== stat.ino || opened.dev !== stat.dev) throw new Error("File changed while opening.");
821
+ const buffer = Buffer.alloc(limit + 1);
822
+ let size = 0;
823
+ while (size < buffer.length) {
824
+ const read = fs.readSync(fd, buffer, size, buffer.length - size, null);
825
+ if (!read) break;
826
+ size += read;
827
+ }
828
+ const after = fs.fstatSync(fd);
829
+ if (size > limit || size !== after.size || opened.size !== after.size || opened.mtimeMs !== after.mtimeMs || opened.ctimeMs !== after.ctimeMs) {
830
+ throw new Error("File changed or exceeded the read limit.");
831
+ }
832
+ return buffer.subarray(0, size);
833
+ } finally {
834
+ fs.closeSync(fd);
835
+ }
836
+ }
837
+
838
+ // src/baseline.ts
839
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
840
+ import * as fs2 from "fs";
841
+ import path from "path";
842
+ function findingFingerprint(finding) {
843
+ return createHash("sha256").update(JSON.stringify([finding.type, finding.file, finding.lineSnippet?.trim()])).digest("hex");
844
+ }
845
+ function createBaseline(findings) {
846
+ const counts = /* @__PURE__ */ new Map();
847
+ for (const finding of findings) {
848
+ const fingerprint = findingFingerprint(finding);
849
+ counts.set(fingerprint, (counts.get(fingerprint) || 0) + 1);
850
+ }
851
+ return {
852
+ schemaVersion: "1.0.0",
853
+ fingerprintVersion: 1,
854
+ entries: [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([fingerprint, count]) => ({ fingerprint, count }))
855
+ };
856
+ }
857
+ function loadBaseline(filename) {
858
+ try {
859
+ const content = new TextDecoder("utf-8", { fatal: true }).decode(readRegularFile(filename, 5 * 1024 * 1024));
860
+ const parsed = JSON.parse(content);
861
+ if (!parsed || parsed.schemaVersion !== "1.0.0" || parsed.fingerprintVersion !== 1 || !Array.isArray(parsed.entries) || parsed.entries.length > 1e5) {
862
+ throw new Error("Unsupported baseline schema.");
863
+ }
864
+ const seen = /* @__PURE__ */ new Set();
865
+ for (const entry of parsed.entries) {
866
+ 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)) {
867
+ throw new Error("Invalid or duplicate baseline entry.");
868
+ }
869
+ seen.add(entry.fingerprint);
870
+ }
871
+ return parsed;
872
+ } catch (error) {
873
+ throw new ConfigError(`Cannot load baseline '${filename}': ${error instanceof Error ? error.message : String(error)}`);
874
+ }
875
+ }
876
+ function applyBaseline(findings, baseline) {
877
+ const remaining = new Map(baseline.entries.map((entry) => [entry.fingerprint, entry.count]));
878
+ const fresh = [];
879
+ let baselineCount = 0;
880
+ for (const finding of findings) {
881
+ const fingerprint = findingFingerprint(finding);
882
+ const count = remaining.get(fingerprint) || 0;
883
+ if (count > 0) {
884
+ remaining.set(fingerprint, count - 1);
885
+ baselineCount++;
886
+ } else {
887
+ fresh.push(finding);
888
+ }
889
+ }
890
+ return { findings: fresh, baselineCount, staleBaselineCount: [...remaining.values()].reduce((a, b) => a + b, 0) };
891
+ }
892
+ function writeBaseline(filename, findings) {
893
+ const destination = path.resolve(filename);
894
+ const temporary = path.join(path.dirname(destination), `.agent-roast-${randomUUID2()}.tmp`);
895
+ let fd;
896
+ try {
897
+ const baseline = createBaseline(findings);
898
+ const serialized = JSON.stringify(baseline, null, 2) + "\n";
899
+ if (Buffer.byteLength(serialized, "utf8") > 5 * 1024 * 1024 || baseline.entries.length > 1e5 || baseline.entries.some((entry) => entry.count > 1e6)) {
900
+ throw new Error("Baseline exceeds the supported read limits; narrow the adopted scope.");
901
+ }
902
+ fd = fs2.openSync(temporary, "wx", 384);
903
+ fs2.writeFileSync(fd, serialized);
904
+ fs2.fsyncSync(fd);
905
+ fs2.closeSync(fd);
906
+ fd = void 0;
907
+ fs2.linkSync(temporary, destination);
908
+ } catch (error) {
909
+ throw new ConfigError(`Cannot write baseline '${filename}' (existing files are preserved): ${error instanceof Error ? error.message : String(error)}`);
910
+ } finally {
911
+ if (fd !== void 0) fs2.closeSync(fd);
912
+ fs2.rmSync(temporary, { force: true });
913
+ }
914
+ }
915
+
916
+ // src/exceptions.ts
917
+ function validateExceptions(value) {
918
+ if (value === void 0) return;
919
+ if (!Array.isArray(value) || value.length > 1e3) throw new ConfigError("exceptions must be an array with at most 1000 entries.");
920
+ const seen = /* @__PURE__ */ new Set();
921
+ for (const entry of value) {
922
+ 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) {
923
+ 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).");
924
+ }
925
+ seen.add(entry.fingerprint);
926
+ }
927
+ }
928
+ function applyExceptions(findings, exceptions = [], now = /* @__PURE__ */ new Date(), priorOccurrences = /* @__PURE__ */ new Map()) {
929
+ validateExceptions(exceptions);
930
+ if (!Number.isFinite(now.getTime())) throw new ConfigError("Invalid exception evaluation time.");
931
+ const today = now.toISOString().slice(0, 10);
932
+ 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)) }]));
933
+ const occurrences = new Map(priorOccurrences);
934
+ const actionable = [];
935
+ const suppressedFindings = [];
936
+ for (const finding of findings) {
937
+ const fingerprint = findingFingerprint(finding);
938
+ const occurrence = (occurrences.get(fingerprint) ?? 0) + 1;
939
+ occurrences.set(fingerprint, occurrence);
940
+ const enriched = { ...finding, fingerprint, occurrence };
941
+ const allowance = active.get(fingerprint);
942
+ if (allowance && allowance.remaining > 0) {
943
+ allowance.remaining--;
944
+ suppressedFindings.push({ ...enriched, suppression: { reason: allowance.reason, expires: allowance.expires } });
945
+ } else actionable.push(enriched);
946
+ }
947
+ return { findings: actionable, suppressedFindings, expiredExceptionCount: exceptions.filter((entry) => entry.expires < today).length };
948
+ }
949
+
702
950
  // src/config.ts
951
+ var MAX_CONFIG_BYTES = 64 * 1024;
703
952
  var DEFAULT_CONFIG = {
704
953
  failOn: ["test-skip", "trivial-assert", "test-focus"],
705
954
  ignorePaths: [
@@ -738,12 +987,16 @@ function validateConfig(value) {
738
987
  throw new ConfigError("Configuration must be a JSON object.");
739
988
  }
740
989
  const config = value;
990
+ const supported = /* @__PURE__ */ new Set(["$schema", "failOn", "ignorePaths", "scoreThreshold", "rules", "panicLoopWindowMinutes", "exceptions"]);
991
+ for (const key of Object.keys(config)) if (!supported.has(key)) throw new ConfigError(`Unknown configuration setting '${key}'.`);
992
+ if (config.$schema !== void 0 && typeof config.$schema !== "string") throw new ConfigError("$schema must be a string.");
993
+ validateExceptions(config.exceptions);
741
994
  const knownRules = new Set(VALID_DETECTORS);
742
995
  if (config.failOn !== void 0 && (!Array.isArray(config.failOn) || config.failOn.some((rule) => typeof rule !== "string" || !knownRules.has(rule)))) {
743
996
  throw new ConfigError(`failOn must contain known rules: ${VALID_DETECTORS.join(", ")}`);
744
997
  }
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.");
998
+ if (config.ignorePaths !== void 0 && (!Array.isArray(config.ignorePaths) || config.ignorePaths.length > 1e3 || config.ignorePaths.some((pattern) => typeof pattern !== "string" || pattern.length > 1024))) {
999
+ throw new ConfigError("ignorePaths must contain at most 1000 strings, each no longer than 1024 characters.");
747
1000
  }
748
1001
  if (config.scoreThreshold !== void 0 && (typeof config.scoreThreshold !== "number" || !Number.isFinite(config.scoreThreshold) || config.scoreThreshold < 0 || config.scoreThreshold > 100)) {
749
1002
  throw new ConfigError("scoreThreshold must be a number between 0 and 100.");
@@ -756,7 +1009,7 @@ function validateConfig(value) {
756
1009
  throw new ConfigError("rules must be an object.");
757
1010
  }
758
1011
  for (const [rule, level] of Object.entries(config.rules)) {
759
- if (!knownRules.has(rule) || !["error", "warn", "off"].includes(String(level))) {
1012
+ if (!knownRules.has(rule) || typeof level !== "string" || !["error", "warn", "off"].includes(level)) {
760
1013
  throw new ConfigError(`Invalid rule '${rule}' or level '${level}'. Use known rules with error, warn, or off.`);
761
1014
  }
762
1015
  }
@@ -764,6 +1017,7 @@ function validateConfig(value) {
764
1017
  }
765
1018
  function configFromText(text, source) {
766
1019
  try {
1020
+ if (Buffer.byteLength(text, "utf8") > MAX_CONFIG_BYTES) throw new ConfigError("Configuration exceeds the 64 KiB limit.");
767
1021
  const parsed = JSON.parse(text);
768
1022
  validateConfig(parsed);
769
1023
  const rules = { ...DEFAULT_CONFIG.rules, ...parsed.rules };
@@ -781,10 +1035,16 @@ function configFromText(text, source) {
781
1035
  }
782
1036
  function loadConfig(cwd = process.cwd()) {
783
1037
  for (const filename of CONFIG_FILENAMES) {
784
- const filePath = path.join(cwd, filename);
785
- if (fs.existsSync(filePath)) {
1038
+ const filePath = path2.join(cwd, filename);
1039
+ try {
1040
+ fs3.lstatSync(filePath);
1041
+ } catch (error) {
1042
+ if (error.code === "ENOENT") continue;
1043
+ throw new ConfigError(`Cannot inspect ${filename}: ${error instanceof Error ? error.message : String(error)}`);
1044
+ }
1045
+ {
786
1046
  try {
787
- return configFromText(fs.readFileSync(filePath, "utf8"), filename);
1047
+ return configFromText(new TextDecoder("utf-8", { fatal: true }).decode(readRegularFile(filePath, MAX_CONFIG_BYTES)), filename);
788
1048
  } catch (error) {
789
1049
  if (error instanceof ConfigError) throw error;
790
1050
  throw new ConfigError(`Cannot read ${filename}: ${error instanceof Error ? error.message : String(error)}`);
@@ -793,93 +1053,114 @@ function loadConfig(cwd = process.cwd()) {
793
1053
  }
794
1054
  return configFromText("{}", "default configuration");
795
1055
  }
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] === "*") {
1056
+ function matchesGlob(filename, pattern) {
1057
+ const tokens = [];
1058
+ for (let i = 0; i < pattern.length; i++) {
1059
+ if (pattern[i] === "*" && pattern[i + 1] === "*") {
1060
+ i++;
1061
+ if (pattern[i + 1] === "/") {
1062
+ tokens.push({ kind: "directoryStart" }, { kind: "directoryBody" });
808
1063
  i++;
809
- if (cleanPattern[i + 1] === "/") {
810
- regexPattern += "(?:.*/)?";
811
- i++;
812
- } else {
813
- regexPattern += ".*";
1064
+ } else tokens.push({ kind: "globstar" });
1065
+ } else if (pattern[i] === "*") tokens.push({ kind: "star" });
1066
+ else if (pattern[i] === "?") tokens.push({ kind: "question" });
1067
+ else tokens.push({ kind: "literal", value: pattern[i].toLowerCase() });
1068
+ }
1069
+ const closure = (states2) => {
1070
+ const queue = [...states2];
1071
+ for (let i = 0; i < queue.length; i++) {
1072
+ const state = queue[i];
1073
+ const kind = tokens[state]?.kind;
1074
+ const add = (next) => {
1075
+ if (!states2.has(next)) {
1076
+ states2.add(next);
1077
+ queue.push(next);
814
1078
  }
815
- } else if (char === "*") {
816
- regexPattern += "[^/]*";
817
- } else if (char === "?") {
818
- regexPattern += "[^/]";
819
- } else {
820
- regexPattern += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
1079
+ };
1080
+ if (kind === "star" || kind === "globstar") add(state + 1);
1081
+ else if (kind === "directoryStart") {
1082
+ add(state + 1);
1083
+ add(state + 2);
821
1084
  }
822
1085
  }
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;
1086
+ return states2;
1087
+ };
1088
+ let states = /* @__PURE__ */ new Set();
1089
+ for (let offset = 0; offset <= filename.length; offset++) {
1090
+ if (!offset || filename[offset - 1] === "/") states.add(0);
1091
+ closure(states);
1092
+ if (states.has(tokens.length) && (offset === filename.length || filename[offset] === "/")) return true;
1093
+ if (offset === filename.length) break;
1094
+ const char = filename[offset];
1095
+ const next = /* @__PURE__ */ new Set();
1096
+ for (const state of states) {
1097
+ const token = tokens[state];
1098
+ if (!token) continue;
1099
+ if (token.kind === "literal") {
1100
+ if (token.value === char.toLowerCase()) next.add(state + 1);
1101
+ } else if (token.kind === "question") {
1102
+ if (char !== "/") next.add(state + 1);
1103
+ } else if (token.kind === "star") {
1104
+ if (char !== "/") next.add(state);
1105
+ } else if (token.kind === "globstar") next.add(state);
1106
+ else if (token.kind === "directoryBody") {
1107
+ next.add(state);
1108
+ if (char === "/") next.add(state + 1);
837
1109
  }
838
1110
  }
1111
+ states = next;
839
1112
  }
840
1113
  return false;
841
1114
  }
1115
+ function matchIgnorePath(filePath, ignorePatterns) {
1116
+ if (!ignorePatterns?.length) return false;
1117
+ const normalized = filePath.replace(/\\/g, "/");
1118
+ return ignorePatterns.some((pattern) => Boolean(pattern) && matchesGlob(normalized, pattern.replace(/\\/g, "/")));
1119
+ }
842
1120
 
843
1121
  // 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" }
1122
+ var PYTHON_TEST_SKIP_PATTERNS = [
1123
+ { regex: /@pytest\s*\.\s*mark\s*\.\s*skip\b/, name: "skipped pytest (@pytest.mark.skip)" },
1124
+ { regex: /(?<![\w.])pytest\s*\.\s*skip\s*\(/, name: "imperative pytest skip (pytest.skip)" },
1125
+ { regex: /@unittest\s*\.\s*skip\s*\(/, name: "skipped unittest (@unittest.skip)" },
1126
+ { regex: /\bself\s*\.\s*skipTest\s*\(/, name: "imperative unittest skip (self.skipTest)" }
850
1127
  ];
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 = [
1128
+ 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;
1129
+ var JS_COMMENT_DIRECTIVE_PATTERNS = [
853
1130
  { regex: /^\s*\/\/\s*@ts-ignore\b/, name: "compiler ignore (@ts-ignore)" },
854
1131
  { regex: /^\s*\/\/\s*@ts-expect-error\b/, name: "compiler suppression (@ts-expect-error)" },
855
1132
  { 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)" }
1133
+ { regex: /^\s*\/\/\s*eslint-disable(?:-next-line|-line)?\b/, name: "linter suppression (eslint-disable)" }
1134
+ ];
1135
+ var PYTHON_COMMENT_DIRECTIVE_PATTERNS = [
1136
+ { regex: /#\s*type:\s*ignore\b/, name: "python type ignore" },
1137
+ { regex: /#\s*noqa\b/, name: "python linter suppression (# noqa)" }
859
1138
  ];
860
1139
  var CODE_TYPE_ESCAPE_PATTERNS = [
861
1140
  { regex: /\bas\s+any\b/, name: "type bypass (as any)" },
862
1141
  { regex: /\bas\s+unknown\s+as\b/, name: "double assertion bypass (as unknown as)" },
863
1142
  { regex: /\bas\s+never\b/, name: "type escape (as never)" }
864
1143
  ];
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)" }
1144
+ var JS_TRIVIAL_ASSERT_PATTERNS = [
1145
+ { 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))" },
1146
+ { 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))" },
1147
+ { 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
1148
  ];
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))" },
1149
+ var PYTHON_TRIVIAL_ASSERT_PATTERNS = [
872
1150
  { 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))" }
1151
+ { regex: /\bself\s*\.\s*assertTrue\s*\(\s*(?:True|1)\s*\)/, name: "trivial assert (self.assertTrue(True))" },
1152
+ { regex: /\bself\s*\.\s*assertEqual\s*\(\s*(?:1\s*,\s*1|True\s*,\s*True)\s*\)/, name: "trivial assert (self.assertEqual(1, 1))" }
1153
+ ];
1154
+ var JS_STUB_PATTERNS = [
1155
+ { regex: /\bthrow\s+new\s+Error\s*\(\s*["'`](?:TODO(?=\s*(?:["'`:]|implement\b))|Not implemented\b|to be implemented\b|stub\b|implement me\b)/i, name: "unimplemented stub (throw new Error('Not implemented'))" }
876
1156
  ];
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'))" },
1157
+ var PYTHON_STUB_PATTERNS = [
879
1158
  { 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!())" }
1159
+ { regex: /\braise\s+NotImplemented\b/, name: "unimplemented stub (raise NotImplemented)" }
1160
+ ];
1161
+ var RUST_STUB_PATTERNS = [
1162
+ { regex: /\btodo!\s*\(/, name: "unimplemented rust stub (todo!())" },
1163
+ { regex: /\bunimplemented!\s*\(/, name: "unimplemented rust stub (unimplemented!())" }
883
1164
  ];
884
1165
  var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
885
1166
  ".ts",
@@ -929,16 +1210,26 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
929
1210
  hasSupportedChanges = true;
930
1211
  filesInspected++;
931
1212
  linesAdded += file.addedLines.length;
1213
+ const javascript = /\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file.path);
1214
+ const typescript = /\.(?:[cm]?tsx?|vue|svelte)$/i.test(file.path);
1215
+ const python = /\.py$/i.test(file.path);
1216
+ const rust = /\.rs$/i.test(file.path);
1217
+ const braceCatches = javascript || /\.(?:java|kt|php|cs|cpp)$/i.test(file.path);
1218
+ const directives = javascript ? JS_COMMENT_DIRECTIVE_PATTERNS : python ? PYTHON_COMMENT_DIRECTIVE_PATTERNS : [];
1219
+ const stubs = javascript ? JS_STUB_PATTERNS : python ? PYTHON_STUB_PATTERNS : rust ? RUST_STUB_PATTERNS : [];
1220
+ const firstFinding = infractions.length;
932
1221
  const masker = new SourceMasker(file.path);
933
1222
  const maskedLines = [];
934
1223
  for (const added of file.addedLines) {
935
1224
  const rawLine = added.line;
936
1225
  const masked = masker.mask(rawLine, added.lineNumber);
937
1226
  const cleanedLine = masked.code;
938
- maskedLines.push({ code: cleanedLine, raw: rawLine, number: added.lineNumber });
1227
+ const testCode = cleanedLine.split("");
1228
+ for (const start of masked.templateStarts) testCode[start] = "";
1229
+ maskedLines.push({ code: cleanedLine, testCode: testCode.join(""), raw: rawLine, number: added.lineNumber });
939
1230
  const isComment = !cleanedLine.trim();
940
- if (!isComment) {
941
- for (const rule of TEST_SKIP_PATTERNS) {
1231
+ if (python && !isComment) {
1232
+ for (const rule of PYTHON_TEST_SKIP_PATTERNS) {
942
1233
  if (rule.regex.test(cleanedLine)) {
943
1234
  infractions.push({
944
1235
  type: "test-skip",
@@ -954,7 +1245,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
954
1245
  }
955
1246
  }
956
1247
  let foundEscape = false;
957
- for (const rule of COMMENT_DIRECTIVE_PATTERNS) {
1248
+ for (const rule of directives) {
958
1249
  if (rule.regex.test(masked.comments)) {
959
1250
  infractions.push({
960
1251
  type: "type-escape",
@@ -969,40 +1260,12 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
969
1260
  break;
970
1261
  }
971
1262
  }
972
- if (!foundEscape && !isComment) {
973
- for (const rule of CODE_TYPE_ESCAPE_PATTERNS) {
974
- if (rule.regex.test(cleanedLine)) {
975
- infractions.push({
976
- type: "type-escape",
977
- file: file.path,
978
- lineNumber: added.lineNumber,
979
- lineSnippet: rawLine.trim(),
980
- commitHash,
981
- commitDate,
982
- reason: rule.name
983
- });
984
- break;
985
- }
986
- }
987
- }
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
- }
1263
+ if (javascript && !foundEscape && masked.blockStarts.some((start) => /^\/\*\s*eslint-disable(?:-next-line|-line)?\b/.test(rawLine.slice(start)))) {
1264
+ infractions.push({ type: "type-escape", file: file.path, lineNumber: added.lineNumber, lineSnippet: rawLine.trim(), commitHash, commitDate, reason: "linter suppression (eslint-disable)" });
1265
+ foundEscape = true;
1003
1266
  }
1004
- if (!isComment && isTestFile(file.path)) {
1005
- for (const rule of TRIVIAL_ASSERT_PATTERNS) {
1267
+ if (python && !isComment && isTestFile(file.path)) {
1268
+ for (const rule of PYTHON_TRIVIAL_ASSERT_PATTERNS) {
1006
1269
  if (rule.regex.test(cleanedLine)) {
1007
1270
  infractions.push({
1008
1271
  type: "trivial-assert",
@@ -1017,64 +1280,103 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
1017
1280
  }
1018
1281
  }
1019
1282
  }
1020
- 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]) {
1024
- infractions.push({
1025
- type: "stub-abandonment",
1026
- file: file.path,
1027
- lineNumber: added.lineNumber,
1028
- lineSnippet: rawLine.trim(),
1029
- commitHash,
1030
- commitDate,
1031
- reason: rule.name
1032
- });
1033
- break;
1034
- }
1035
- }
1283
+ }
1284
+ let source = "";
1285
+ let code = "";
1286
+ let raw = "";
1287
+ const offsets = [];
1288
+ for (let i = 0; i < maskedLines.length; i++) {
1289
+ const separator = i ? maskedLines[i].number === maskedLines[i - 1].number + 1 ? "\n" : "\0" : "";
1290
+ source += separator;
1291
+ code += separator;
1292
+ raw += separator;
1293
+ offsets.push(source.length);
1294
+ source += maskedLines[i].testCode;
1295
+ code += maskedLines[i].code;
1296
+ raw += maskedLines[i].raw;
1297
+ }
1298
+ const evidence = (index, length) => {
1299
+ let low = 0;
1300
+ let high = offsets.length;
1301
+ while (low + 1 < high) {
1302
+ const middle = low + high >>> 1;
1303
+ if (offsets[middle] <= index) low = middle;
1304
+ else high = middle;
1305
+ }
1306
+ let end = low;
1307
+ while (end + 1 < offsets.length && offsets[end + 1] < index + length) end++;
1308
+ return { lineNumber: maskedLines[low].number, lineSnippet: maskedLines.slice(low, end + 1).map((line) => line.raw.trim()).join(" ") };
1309
+ };
1310
+ const reportedStubs = /* @__PURE__ */ new Set();
1311
+ for (const rule of stubs) {
1312
+ for (const match of raw.matchAll(new RegExp(rule.regex.source, rule.regex.flags + "g"))) {
1313
+ if (code[match.index] !== raw[match.index]) continue;
1314
+ const location = evidence(match.index, match[0].length);
1315
+ if (reportedStubs.has(location.lineNumber)) continue;
1316
+ reportedStubs.add(location.lineNumber);
1317
+ infractions.push({ type: "stub-abandonment", file: file.path, ...location, commitHash, commitDate, reason: rule.name });
1036
1318
  }
1037
1319
  }
1038
- if (/\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file.path)) {
1039
- let source = "";
1040
- const offsets = [];
1041
- for (let i = 0; i < maskedLines.length; i++) {
1042
- if (i) source += maskedLines[i].number === maskedLines[i - 1].number + 1 ? "\n" : "\0";
1043
- offsets.push(source.length);
1044
- source += maskedLines[i].code;
1320
+ if (typescript) {
1321
+ const reported = new Set(infractions.slice(firstFinding).filter((f) => f.type === "type-escape").map((f) => f.lineNumber));
1322
+ for (const rule of CODE_TYPE_ESCAPE_PATTERNS) {
1323
+ for (const match of code.matchAll(new RegExp(rule.regex.source, "g"))) {
1324
+ const location = evidence(match.index, match[0].length);
1325
+ if (reported.has(location.lineNumber)) continue;
1326
+ reported.add(location.lineNumber);
1327
+ infractions.push({ type: "type-escape", file: file.path, ...location, commitHash, commitDate, reason: rule.name });
1328
+ }
1045
1329
  }
1046
- let row = 0;
1047
- for (const match of source.matchAll(FOCUSED_TEST)) {
1330
+ }
1331
+ if (javascript) {
1332
+ for (const match of source.matchAll(JS_TEST_API)) {
1048
1333
  let previous = match.index - 1;
1049
1334
  while (previous >= 0 && /\s/.test(source[previous])) previous--;
1050
1335
  if (source[previous] === ".") continue;
1051
- if (/^(?:fit|fdescribe)$/.test(match[0]) && !isTestFile(file.path)) continue;
1336
+ if (match[2] && !isTestFile(file.path)) continue;
1052
1337
  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++;
1338
+ if (source[match.index + match[0].length] === "" && !/\.\s*each\s*$/.test(match[3])) continue;
1339
+ const focus = match[1] === "only" || match[2] === "fit" || match[2] === "fdescribe";
1056
1340
  infractions.push({
1057
- type: "test-focus",
1341
+ type: focus ? "test-focus" : "test-skip",
1058
1342
  file: file.path,
1059
- lineNumber: maskedLines[row].number,
1060
- lineSnippet: maskedLines.slice(row, end + 1).map((line) => line.raw.trim()).join(" "),
1343
+ ...evidence(match.index, match[0].length),
1061
1344
  commitHash,
1062
1345
  commitDate,
1063
- reason: "focused test or suite excludes other tests from the run"
1346
+ reason: focus ? "focused test or suite excludes other tests from the run" : `skipped, disabled or placeholder test registration (${match[0].replace(/\s/g, "")})`
1064
1347
  });
1065
1348
  }
1349
+ if (isTestFile(file.path)) {
1350
+ for (const rule of JS_TRIVIAL_ASSERT_PATTERNS) {
1351
+ for (const match of source.matchAll(new RegExp(rule.regex.source, "g"))) {
1352
+ infractions.push({ type: "trivial-assert", file: file.path, ...evidence(match.index, match[0].length), commitHash, commitDate, reason: rule.name });
1353
+ }
1354
+ }
1355
+ }
1066
1356
  }
1067
- for (let index = 0; index < maskedLines.length; index++) {
1068
- const start = maskedLines[index];
1069
- const isCatch = /\bcatch\s*(?:\([^)]*\))?\s*\{\s*$/.test(start.code);
1070
- const isExcept = /\bexcept(?:\s+[^:]*)?:\s*$/.test(start.code);
1071
- if (!isCatch && !isExcept) continue;
1357
+ if (braceCatches) {
1358
+ for (const match of code.matchAll(/(?<![\w$.])catch\s*(?:\(\s*[^()\s\0][^()\0]*\))?\s*\{\s*\}/g)) {
1359
+ infractions.push({
1360
+ type: "swallowed-error",
1361
+ file: file.path,
1362
+ ...evidence(match.index, match[0].length),
1363
+ commitHash,
1364
+ commitDate,
1365
+ reason: match[0].includes("\n") ? "empty multi-line catch block" : "empty catch block (catch {})"
1366
+ });
1367
+ }
1368
+ }
1369
+ for (let index = 0; index < maskedLines.length; index++) {
1370
+ const start = maskedLines[index];
1371
+ const inlineExcept = python && /^\s*except(?:\*?\s+[^:]+)?:\s*pass\s*;?\s*$/.test(start.code);
1372
+ 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)" });
1373
+ const isExcept = python && /^\s*except(?:\*?\s+[^:]+)?:\s*$/.test(start.code);
1374
+ if (!isExcept) continue;
1072
1375
  let next = index + 1;
1073
1376
  while (next < maskedLines.length && maskedLines[next].number === maskedLines[next - 1].number + 1 && !maskedLines[next].code.trim()) next++;
1074
1377
  const end = maskedLines[next];
1075
1378
  if (!end || end.number !== maskedLines[next - 1].number + 1) continue;
1076
- const closesCatch = isCatch && /^\s*}/.test(end.code);
1077
- let closesExcept = isExcept && /^\s*pass\s*$/.test(end.code);
1379
+ let closesExcept = isExcept && /^\s*pass\s*;?\s*$/.test(end.code);
1078
1380
  if (closesExcept) {
1079
1381
  const indent = start.raw.search(/\S/);
1080
1382
  if (end.raw.search(/\S/) <= indent) closesExcept = false;
@@ -1082,16 +1384,18 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
1082
1384
  while (after < maskedLines.length && maskedLines[after].number === maskedLines[after - 1].number + 1 && !maskedLines[after].code.trim()) after++;
1083
1385
  if (after < maskedLines.length && maskedLines[after].number === maskedLines[after - 1].number + 1 && maskedLines[after].raw.search(/\S/) > indent) closesExcept = false;
1084
1386
  }
1085
- if (closesCatch || closesExcept) infractions.push({
1387
+ if (closesExcept) infractions.push({
1086
1388
  type: "swallowed-error",
1087
1389
  file: file.path,
1088
1390
  lineNumber: start.number,
1089
1391
  lineSnippet: maskedLines.slice(index, next + 1).map((line) => line.raw.trim()).join(" "),
1090
1392
  commitHash,
1091
1393
  commitDate,
1092
- reason: closesCatch ? "empty multi-line catch block" : "silent multi-line python error swallow"
1394
+ reason: "silent multi-line python error swallow"
1093
1395
  });
1094
1396
  }
1397
+ const ordered = infractions.splice(firstFinding).sort((left, right) => (left.lineNumber || 0) - (right.lineNumber || 0));
1398
+ for (const finding of ordered) infractions.push(finding);
1095
1399
  }
1096
1400
  return { infractions: infractions.filter((inf) => options.rules?.[inf.type] !== "off").map((inf) => ({ ...inf, suggestion: RULE_GUIDES[inf.type].suggestion })), hasSupportedChanges, filesInspected, linesAdded };
1097
1401
  }
@@ -1115,6 +1419,7 @@ var MetricsCollector = class {
1115
1419
  stubAbandonments = [];
1116
1420
  skippedLongLines = 0;
1117
1421
  skippedUnknownFiles = 0;
1422
+ skippedBinaryFiles = 0;
1118
1423
  activePanicChain = null;
1119
1424
  includeAll;
1120
1425
  ignorePaths;
@@ -1159,6 +1464,7 @@ var MetricsCollector = class {
1159
1464
  this.flushPanicChain();
1160
1465
  return;
1161
1466
  }
1467
+ this.skippedBinaryFiles += commit.files.filter((file) => file.binary && isCodeFile(file.path, this.ignorePaths)).length;
1162
1468
  const subject = commit.message.split("\n")[0];
1163
1469
  const commitDateMs = Date.parse(commit.date);
1164
1470
  const isFixCommit = PANIC_COMMIT_REGEX.test(subject);
@@ -1231,7 +1537,8 @@ var MetricsCollector = class {
1231
1537
  getSummary() {
1232
1538
  this.flushPanicChain();
1233
1539
  const linesSample = this.includeAll ? this.totalLinesAdded : this.aiLinesAdded;
1234
- const isSufficientData = linesSample >= MIN_SAMPLE_LOC;
1540
+ const complete = !this.skippedLongLines && !this.skippedUnknownFiles && !this.skippedBinaryFiles;
1541
+ const isSufficientData = complete && linesSample >= MIN_SAMPLE_LOC;
1235
1542
  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
1543
  let panicInfractionPoints = 0;
1237
1544
  for (const loop of this.panicLoops) {
@@ -1294,7 +1601,12 @@ var MetricsCollector = class {
1294
1601
  }
1295
1602
  }
1296
1603
  }
1604
+ if (!complete) {
1605
+ archetype = "Incomplete audit";
1606
+ archetypeDescription = "Source was skipped; findings are partial and no complete score can be issued.";
1607
+ }
1297
1608
  return {
1609
+ complete,
1298
1610
  schemaVersion: "1.0.0",
1299
1611
  totalLinesAdded: this.totalLinesAdded,
1300
1612
  aiLinesAdded: this.aiLinesAdded,
@@ -1313,14 +1625,22 @@ var MetricsCollector = class {
1313
1625
  archetype,
1314
1626
  archetypeDescription,
1315
1627
  skippedLongLines: this.skippedLongLines,
1316
- skippedUnknownFiles: this.skippedUnknownFiles
1628
+ skippedUnknownFiles: this.skippedUnknownFiles,
1629
+ skippedBinaryFiles: this.skippedBinaryFiles
1317
1630
  };
1318
1631
  }
1319
1632
  };
1320
1633
 
1321
1634
  // src/renderer.ts
1322
1635
  import pc from "picocolors";
1636
+ function historyIncomplete(summary) {
1637
+ return Boolean(summary.skippedLongLines || summary.skippedUnknownFiles || summary.skippedBinaryFiles);
1638
+ }
1323
1639
  function generateShareUrl(summary) {
1640
+ if (historyIncomplete(summary)) {
1641
+ 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";
1642
+ return `https://twitter.com/intent/tweet?text=${encodeURIComponent(text2)}`;
1643
+ }
1324
1644
  const scoreDisplay = summary.score !== null ? `${summary.score}/100` : "N/A";
1325
1645
  const text = `My AI coding agent scored ${scoreDisplay} on agent-roast.
1326
1646
 
@@ -1335,27 +1655,32 @@ Audit your repo: npx agent-roast`;
1335
1655
  }
1336
1656
  function formatTerminalCard(summary, options) {
1337
1657
  const lines = [];
1658
+ const incomplete = historyIncomplete(summary);
1338
1659
  lines.push("");
1339
1660
  lines.push(
1340
- `${pc.bold(pc.cyan("agent-roast"))} ${pc.dim("\u2014 git discipline audit")} ${pc.white(options.repoName)}`
1661
+ `${pc.bold(pc.cyan("agent-roast"))} ${pc.dim("\u2014 git discipline audit")} ${pc.white(terminalText(options.repoName))}`
1341
1662
  );
1342
1663
  lines.push(
1343
1664
  ` ${pc.dim("commits:")} ${pc.yellow(summary.aiCommits)} AI / ${summary.totalCommits} total ${pc.dim("lines added:")} ${pc.green(summary.aiLinesAdded)} AI / ${summary.totalLinesAdded} total`
1344
1665
  );
1345
1666
  lines.push("");
1346
- if (!summary.isSufficientData || summary.score === null) {
1667
+ if (incomplete) {
1668
+ lines.push(pc.yellow(" INCOMPLETE: long lines, unparsed paths or binary source were skipped; no complete score issued."));
1669
+ lines.push(pc.dim(` Skipped: ${summary.skippedLongLines || 0} long lines \xB7 ${summary.skippedUnknownFiles || 0} unparsed files \xB7 ${summary.skippedBinaryFiles || 0} binary source files.`));
1670
+ lines.push("");
1671
+ } else if (!summary.isSufficientData || summary.score === null) {
1347
1672
  lines.push(pc.yellow(` insufficient data (< 200 analyzed lines added)`));
1348
1673
  lines.push(pc.dim(" Findings below remain actionable; a score needs a larger sample."));
1349
1674
  lines.push("");
1350
1675
  }
1351
- if (summary.isSufficientData && summary.score !== null) {
1676
+ if (!incomplete && summary.isSufficientData && summary.score !== null) {
1352
1677
  let scoreColor = pc.green;
1353
1678
  if (summary.score < 60) scoreColor = pc.red;
1354
1679
  else if (summary.score < 80) scoreColor = pc.yellow;
1355
1680
  lines.push(
1356
- ` ${pc.bold("SCORE:")} ${scoreColor(pc.bold(`${summary.score} / 100`))} ${pc.dim("\xB7")} ${pc.magenta(pc.bold(summary.archetype))}`
1681
+ ` ${pc.bold("SCORE:")} ${scoreColor(pc.bold(`${summary.score} / 100`))} ${pc.dim("\xB7")} ${pc.magenta(pc.bold(terminalText(summary.archetype)))}`
1357
1682
  );
1358
- lines.push(` ${pc.dim(summary.archetypeDescription)}`);
1683
+ lines.push(` ${pc.dim(terminalText(summary.archetypeDescription))}`);
1359
1684
  lines.push("");
1360
1685
  }
1361
1686
  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 +1691,7 @@ function formatTerminalCard(summary, options) {
1366
1691
  ` ${pc.magenta("\u25CF")} ${pc.bold("panic-loop")} (${summary.panicLoops.length}) ${pc.dim("rapid consecutive fixes touching overlapping files")}`
1367
1692
  );
1368
1693
  const sample = summary.panicLoops[0];
1369
- lines.push(` ${pc.dim(sample.reason)}`);
1694
+ lines.push(` ${pc.dim(terminalText(sample.reason))}`);
1370
1695
  }
1371
1696
  if (summary.trivialAsserts && summary.trivialAsserts.length > 0) {
1372
1697
  lines.push(
@@ -1374,7 +1699,7 @@ function formatTerminalCard(summary, options) {
1374
1699
  );
1375
1700
  const sample = summary.trivialAsserts[0];
1376
1701
  if (sample.file) {
1377
- lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1702
+ lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
1378
1703
  }
1379
1704
  }
1380
1705
  if (summary.focusedTests?.length) {
@@ -1388,7 +1713,7 @@ function formatTerminalCard(summary, options) {
1388
1713
  );
1389
1714
  const sample = summary.testSkips[0];
1390
1715
  if (sample.file) {
1391
- lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1716
+ lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
1392
1717
  }
1393
1718
  }
1394
1719
  if (summary.swallowedErrors && summary.swallowedErrors.length > 0) {
@@ -1397,7 +1722,7 @@ function formatTerminalCard(summary, options) {
1397
1722
  );
1398
1723
  const sample = summary.swallowedErrors[0];
1399
1724
  if (sample.file) {
1400
- lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1725
+ lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
1401
1726
  }
1402
1727
  }
1403
1728
  if (summary.stubAbandonments && summary.stubAbandonments.length > 0) {
@@ -1406,7 +1731,7 @@ function formatTerminalCard(summary, options) {
1406
1731
  );
1407
1732
  const sample = summary.stubAbandonments[0];
1408
1733
  if (sample.file) {
1409
- lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1734
+ lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
1410
1735
  }
1411
1736
  }
1412
1737
  if (summary.typeEscapes && summary.typeEscapes.length > 0) {
@@ -1415,15 +1740,15 @@ function formatTerminalCard(summary, options) {
1415
1740
  );
1416
1741
  const sample = summary.typeEscapes[0];
1417
1742
  if (sample.file) {
1418
- lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1743
+ lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
1419
1744
  }
1420
1745
  }
1421
1746
  if (totalInfractions === 0) {
1422
- lines.push(` ${pc.green("\u25CF")} ${pc.dim("zero infractions detected across all inspected lines.")}`);
1747
+ 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
1748
  }
1424
1749
  lines.push("");
1425
1750
  if (options.verbose) {
1426
- if (summary.skippedLongLines || summary.skippedUnknownFiles) {
1751
+ if (incomplete) {
1427
1752
  lines.push(pc.bold(" PARSER AUDIT STATS:"));
1428
1753
  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
1754
  if (summary.skippedLongLines) {
@@ -1432,6 +1757,9 @@ function formatTerminalCard(summary, options) {
1432
1757
  if (summary.skippedUnknownFiles) {
1433
1758
  lines.push(` ${pc.dim("skipped files with unparseable path:")} ${pc.yellow(summary.skippedUnknownFiles)}`);
1434
1759
  }
1760
+ if (summary.skippedBinaryFiles) {
1761
+ lines.push(` ${pc.dim("skipped binary source files:")} ${pc.yellow(summary.skippedBinaryFiles)}`);
1762
+ }
1435
1763
  lines.push("");
1436
1764
  }
1437
1765
  const allInfractions = [
@@ -1449,16 +1777,16 @@ function formatTerminalCard(summary, options) {
1449
1777
  for (const inf of allInfractions.slice(0, 10)) {
1450
1778
  const fileLoc = inf.file ? `${inf.file}${inf.lineNumber ? `:${inf.lineNumber}` : ""}` : "commit history";
1451
1779
  lines.push(
1452
- ` \u25CF ${pc.cyan(inf.commitHash.slice(0, 7))} [${inf.type}] ${pc.white(fileLoc)}`
1780
+ ` \u25CF ${pc.cyan(terminalText(inf.commitHash.slice(0, 7)))} [${inf.type}] ${pc.white(terminalText(fileLoc))}`
1453
1781
  );
1454
1782
  if (inf.lineSnippet) {
1455
- lines.push(` ${pc.dim(`\u2514\u2500 ${inf.lineSnippet}`)}`);
1783
+ lines.push(` ${pc.dim(`\u2514\u2500 ${terminalText(inf.lineSnippet)}`)}`);
1456
1784
  }
1457
1785
  }
1458
1786
  lines.push("");
1459
1787
  }
1460
1788
  }
1461
- lines.push(
1789
+ if (!incomplete) lines.push(
1462
1790
  ` ${pc.dim("Share score:")} ${pc.cyan(generateShareUrl(summary))}`
1463
1791
  );
1464
1792
  lines.push("");
@@ -1471,6 +1799,7 @@ function formatPrTerminalCard(result) {
1471
1799
  `${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
1800
  );
1473
1801
  if (!result.hasSupportedChanges) {
1802
+ if (result.complete === false) lines.push(" INCOMPLETE: some long lines, unparsed paths or binary source could not be inspected; exit 2.");
1474
1803
  lines.push(` ${pc.dim("no supported source changes detected in the audited diff.")}`);
1475
1804
  lines.push("");
1476
1805
  return lines.join("\n");
@@ -1479,7 +1808,7 @@ function formatPrTerminalCard(result) {
1479
1808
  ` ${pc.dim("files inspected:")} ${result.filesInspected} ${pc.dim("lines added:")} ${result.linesAdded}`
1480
1809
  );
1481
1810
  lines.push("");
1482
- if (result.infractions.length === 0) {
1811
+ if (result.infractions.length === 0 && result.complete !== false) {
1483
1812
  lines.push(` ${pc.green("PASS")} ${pc.dim("zero infractions detected across all audited changes.")}`);
1484
1813
  } else {
1485
1814
  lines.push(pc.bold(` INFRACTIONS DETECTED (${result.infractions.length})`));
@@ -1488,15 +1817,17 @@ function formatPrTerminalCard(result) {
1488
1817
  const isFailedRule = result.failedRules.includes(inf.type);
1489
1818
  const badge = isFailedRule ? pc.red("FAIL") : pc.yellow("WARN");
1490
1819
  const loc = `${inf.file || "unknown"}:${inf.lineNumber || 1}`;
1491
- lines.push(` ${badge} [${inf.type}] ${pc.white(loc)} \u2014 ${pc.dim(inf.reason)}`);
1820
+ lines.push(` ${badge} [${inf.type}] ${pc.white(terminalText(loc))} \u2014 ${pc.dim(terminalText(inf.reason))}`);
1492
1821
  if (inf.lineSnippet) {
1493
- lines.push(` ${pc.dim(`\u2514\u2500 ${inf.lineSnippet}`)}`);
1822
+ lines.push(` ${pc.dim(`\u2514\u2500 ${terminalText(inf.lineSnippet)}`)}`);
1494
1823
  }
1495
- if (inf.suggestion) lines.push(` ${pc.dim(`Next: ${inf.suggestion}`)}`);
1824
+ if (inf.suggestion) lines.push(` ${pc.dim(`Next: ${terminalText(inf.suggestion)}`)}`);
1496
1825
  }
1497
1826
  }
1498
1827
  lines.push("");
1499
- if (result.failedRules.length > 0) {
1828
+ if (result.complete === false) {
1829
+ lines.push(" INCOMPLETE: some long lines, unparsed paths or binary source could not be inspected; exit 2.");
1830
+ } else if (result.failedRules.length > 0) {
1500
1831
  lines.push(
1501
1832
  ` ${pc.red(pc.bold("BLOCKING FAILURE:"))} ${result.failedRules.length} rule(s) violated in --fail-on: ${result.failedRules.join(", ")}`
1502
1833
  );
@@ -1564,10 +1895,11 @@ function formatGithubStepSummary(result) {
1564
1895
  lines.push(result.mode === "staged" ? "### agent-roast Staged Changes Audit" : "### agent-roast Pull Request Audit");
1565
1896
  lines.push("");
1566
1897
  if (!result.hasSupportedChanges) {
1898
+ if (result.complete === false) lines.push("INCOMPLETE: some long lines, unparsed paths or binary source could not be inspected; exit 2.");
1567
1899
  lines.push("No supported source file changes detected in the audited diff.");
1568
1900
  return lines.join("\n");
1569
1901
  }
1570
- const statusBadge = result.passed ? "Passed" : `Failed (${result.failedRules.join(", ")})`;
1902
+ const statusBadge = result.complete === false ? "INCOMPLETE" : result.passed ? "Passed" : `Failed (${result.failedRules.join(", ")})`;
1571
1903
  lines.push(`| Status | Base | Head | Files | Lines Added | Infractions |`);
1572
1904
  lines.push(`| --- | --- | --- | --- | --- | --- |`);
1573
1905
  lines.push(
@@ -1581,31 +1913,32 @@ function formatGithubStepSummary(result) {
1581
1913
  lines.push("| --- | --- | --- | --- |");
1582
1914
  const sorted = sortInfractionsDeterministic(result.infractions);
1583
1915
  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} |`);
1916
+ const loc = inf.file ? markdownCell(`${inf.file}:${inf.lineNumber || 1}`) : "history";
1917
+ const snippet = inf.lineSnippet ? markdownCell(inf.lineSnippet) : "-";
1918
+ lines.push(`| ${inf.type} | ${loc} | ${markdownCell(inf.reason)} | ${snippet} |`);
1587
1919
  }
1588
1920
  lines.push("");
1589
1921
  } else {
1590
- lines.push("Clean pull request: zero shortcuts or bypassed checks detected.");
1922
+ 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
1923
  lines.push("");
1592
1924
  }
1593
1925
  return lines.join("\n");
1594
1926
  }
1595
1927
  function formatPrMarkdownComment(result) {
1596
1928
  const lines = [];
1597
- const titleEmoji = result.passed ? "\u2705" : "\u274C";
1598
- const titleStatus = result.passed ? "PASSED" : "BLOCKED";
1929
+ const titleEmoji = result.complete === false ? "\u26A0\uFE0F" : result.passed ? "\u2705" : "\u274C";
1930
+ const titleStatus = result.complete === false ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
1599
1931
  lines.push(`## \u{1F6E1}\uFE0F agent-roast audit: ${titleStatus} ${titleEmoji}`);
1600
1932
  lines.push("");
1601
1933
  if (!result.hasSupportedChanges) {
1934
+ if (result.complete === false) lines.push("INCOMPLETE: some long lines, unparsed paths or binary source could not be inspected; exit 2.");
1602
1935
  lines.push("No supported source file changes detected in the audited diff.");
1603
1936
  lines.push("");
1604
1937
  lines.push("---");
1605
1938
  lines.push("*Audited 100% offline with [agent-roast](https://github.com/Nari-Ab/agent-roast)*");
1606
1939
  return lines.join("\n");
1607
1940
  }
1608
- const statusText = result.passed ? "\u2705 Passed" : `\u274C Blocked on ${result.failedRules.join(", ")}`;
1941
+ const statusText = result.complete === false ? "\u26A0\uFE0F INCOMPLETE" : result.passed ? "\u2705 Passed" : `\u274C Blocked on ${result.failedRules.join(", ")}`;
1609
1942
  lines.push("| Status | Defect Density | Files Inspected | Lines Added | Infractions |");
1610
1943
  lines.push("| :--- | :--- | :--- | :--- | :--- |");
1611
1944
  lines.push(
@@ -1620,15 +1953,15 @@ function formatPrMarkdownComment(result) {
1620
1953
  lines.push("| :--- | :--- | :--- | :--- |");
1621
1954
  const sorted = sortInfractionsDeterministic(result.infractions);
1622
1955
  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} |`);
1956
+ const loc = inf.file ? markdownCell(`${inf.file}:${inf.lineNumber || 1}`) : "history";
1957
+ const snippet = inf.lineSnippet ? markdownCell(inf.lineSnippet) : "-";
1958
+ lines.push(`| ${inf.type} | ${loc} | ${markdownCell(inf.reason)} | ${snippet} |`);
1626
1959
  }
1627
1960
  lines.push("");
1628
1961
  lines.push("</details>");
1629
1962
  lines.push("");
1630
1963
  } else {
1631
- lines.push("\u2728 **Clean pull request**: Zero AI shortcuts, bypassed tests, or compiler type escapes detected.");
1964
+ 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
1965
  lines.push("");
1633
1966
  }
1634
1967
  lines.push("---");
@@ -1636,7 +1969,38 @@ function formatPrMarkdownComment(result) {
1636
1969
  return lines.join("\n");
1637
1970
  }
1638
1971
  function terminalText(value) {
1639
- return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
1972
+ 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")}`);
1973
+ }
1974
+ function exceptionTerminal(result) {
1975
+ const lines = [];
1976
+ const accepted = result.suppressedFindings || [];
1977
+ if (accepted.length) {
1978
+ lines.push(` ${accepted.length} findings accepted by explicit exceptions (not fixed)`);
1979
+ for (const finding of accepted.slice(0, 20)) lines.push(
1980
+ ` ACCEPTED [${finding.type}] ${terminalText(finding.file || "unknown")}:${finding.lineNumber} \u2014 ${terminalText(finding.suppression.reason)} (expires ${terminalText(finding.suppression.expires)})`
1981
+ );
1982
+ if (accepted.length > 20) lines.push(` ${accepted.length - 20} more accepted findings; use --format json.`);
1983
+ }
1984
+ if (result.expiredExceptionCount) lines.push(` ${result.expiredExceptionCount} expired exceptions were not applied; review their findings.`);
1985
+ return lines;
1986
+ }
1987
+ function exceptionMarkdown(result) {
1988
+ const lines = [];
1989
+ const accepted = result.suppressedFindings || [];
1990
+ if (accepted.length) {
1991
+ lines.push(
1992
+ "",
1993
+ `### Accepted exceptions (${accepted.length})`,
1994
+ "",
1995
+ "These findings remain in the source and were accepted by explicit policy; they were not fixed.",
1996
+ "",
1997
+ "| Rule | Location | Reason | Expires |",
1998
+ "| --- | --- | --- | --- |"
1999
+ );
2000
+ for (const finding of accepted) lines.push(`| ${finding.type} | ${markdownCell(`${finding.file}:${finding.lineNumber}`)} | ${markdownCell(finding.suppression.reason)} | ${markdownCell(finding.suppression.expires)} |`);
2001
+ }
2002
+ if (result.expiredExceptionCount) lines.push("", `${result.expiredExceptionCount} expired exceptions were not applied; review their findings.`);
2003
+ return lines;
1640
2004
  }
1641
2005
  function formatCheckTerminal(result) {
1642
2006
  const lines = [
@@ -1645,10 +2009,11 @@ function formatCheckTerminal(result) {
1645
2009
  ` ${result.filesInspected} files \xB7 ${result.linesInspected} lines inspected`,
1646
2010
  ` ${result.infractions.length} findings to review \xB7 ${result.baselineCount} accepted baseline findings`
1647
2011
  ];
2012
+ lines.push(...exceptionTerminal(result));
1648
2013
  if (result.staleBaselineCount) lines.push(` ${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
1649
2014
  for (const inf of result.infractions.slice(0, 20)) {
1650
2015
  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}`);
2016
+ if (inf.suggestion) lines.push(` Next: ${terminalText(inf.suggestion)}`);
1652
2017
  }
1653
2018
  if (result.infractions.length > 20) lines.push(`
1654
2019
  ${result.infractions.length - 20} more findings. Use --format json for the complete report.`);
@@ -1659,7 +2024,7 @@ function formatCheckTerminal(result) {
1659
2024
  return lines.join("\n");
1660
2025
  }
1661
2026
  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, " ");
2027
+ return terminalText(value).replace(/[\\[\]*_!]/g, "\\$&").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\|/g, "\\|").replace(/`/g, "&#96;").replace(/@/g, "&#64;");
1663
2028
  }
1664
2029
  function formatCheckMarkdown(result) {
1665
2030
  const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
@@ -1672,6 +2037,7 @@ function formatCheckMarkdown(result) {
1672
2037
  "| --- | --- | --- | --- |"
1673
2038
  ];
1674
2039
  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.")} |`);
2040
+ lines.push(...exceptionMarkdown(result));
1675
2041
  if (result.staleBaselineCount) lines.push("", `${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
1676
2042
  for (const skipped of result.skippedFiles) lines.push("", `Not scanned: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
1677
2043
  return lines.join("\n");
@@ -1685,6 +2051,7 @@ function formatReviewTerminal(result) {
1685
2051
  ` ${result.filesInspected} changed files compared \xB7 ${result.linesInspected} source lines inspected across both versions`,
1686
2052
  ` ${result.infractions.length} new \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected`
1687
2053
  ];
2054
+ lines.push(...exceptionTerminal(result));
1688
2055
  if (result.policyChanges.length) lines.push(" Candidate policy changes were ignored; adopt them separately after review.");
1689
2056
  if (result.excludedFiles || result.unsupportedFiles) lines.push(` ${result.excludedFiles} excluded \xB7 ${result.unsupportedFiles} unsupported/non-source changes`);
1690
2057
  for (const finding of result.infractions.slice(0, 20)) {
@@ -1692,7 +2059,7 @@ function formatReviewTerminal(result) {
1692
2059
  "",
1693
2060
  ` ${result.failedRules.includes(finding.type) ? "FAIL" : "WARN"} [${finding.type}] ${terminalText(finding.file || "unknown")}:${finding.lineNumber}`,
1694
2061
  ` ${terminalText(finding.lineSnippet || finding.reason)}`,
1695
- ` Next: ${finding.suggestion || "Review this occurrence."}`
2062
+ ` Next: ${terminalText(finding.suggestion || "Review this occurrence.")}`
1696
2063
  );
1697
2064
  }
1698
2065
  if (result.infractions.length > 20) lines.push(` ${result.infractions.length - 20} more new findings; use --format json for all results.`);
@@ -1714,6 +2081,7 @@ function formatReviewMarkdown(result) {
1714
2081
  if (result.policyChanges.length) lines.push("Candidate policy changes were ignored. This review uses the supplied base commit's policy.", "");
1715
2082
  lines.push("| New finding | Head location | Evidence | Next step |", "| --- | --- | --- | --- |");
1716
2083
  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.")} |`);
2084
+ lines.push(...exceptionMarkdown(result));
1717
2085
  if (result.removedFindings.length) {
1718
2086
  lines.push(
1719
2087
  "",
@@ -1732,8 +2100,10 @@ function formatReviewMarkdown(result) {
1732
2100
  }
1733
2101
 
1734
2102
  // src/init.ts
1735
- import * as fs2 from "fs";
1736
- import * as path2 from "path";
2103
+ import * as fs4 from "fs";
2104
+ import * as path3 from "path";
2105
+ import { randomUUID as randomUUID3 } from "crypto";
2106
+ import { spawnSync as spawnSync2 } from "child_process";
1737
2107
  var DEFAULT_CONFIG_TEMPLATE = {
1738
2108
  ignorePaths: [
1739
2109
  "**/*.min.js",
@@ -1776,58 +2146,125 @@ var PRE_COMMIT_HOOK_TEMPLATE = `#!/bin/sh
1776
2146
  # Run agent-roast pre-commit verification
1777
2147
  npx --no-install agent-roast --staged
1778
2148
  `;
2149
+ function createNewFile(root, relative2, content, mode = 420) {
2150
+ const destination = path3.resolve(root, relative2);
2151
+ const inside = path3.relative(root, destination);
2152
+ if (!inside || inside === ".." || inside.startsWith(".." + path3.sep) || path3.isAbsolute(inside)) throw new ConfigError("Initialization target escapes its allowed directory.");
2153
+ let directory = root;
2154
+ for (const segment of path3.dirname(inside).split(path3.sep).filter((part) => part !== ".")) {
2155
+ directory = path3.join(directory, segment);
2156
+ let stat;
2157
+ try {
2158
+ stat = fs4.lstatSync(directory);
2159
+ } catch (error) {
2160
+ if (error.code !== "ENOENT") throw error;
2161
+ try {
2162
+ fs4.mkdirSync(directory);
2163
+ } catch (mkdirError) {
2164
+ if (mkdirError.code !== "EEXIST") throw mkdirError;
2165
+ }
2166
+ stat = fs4.lstatSync(directory);
2167
+ }
2168
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new ConfigError("Initialization refuses symlinked or non-directory parent paths.");
2169
+ }
2170
+ const temporary = path3.join(directory, `.agent-roast-init-${randomUUID3()}.tmp`);
2171
+ let fd;
2172
+ try {
2173
+ fd = fs4.openSync(temporary, "wx", mode);
2174
+ fs4.writeFileSync(fd, content);
2175
+ fs4.fsyncSync(fd);
2176
+ fs4.closeSync(fd);
2177
+ fd = void 0;
2178
+ try {
2179
+ fs4.linkSync(temporary, destination);
2180
+ } catch (error) {
2181
+ if (error.code === "EEXIST") return false;
2182
+ throw error;
2183
+ }
2184
+ return true;
2185
+ } finally {
2186
+ if (fd !== void 0) fs4.closeSync(fd);
2187
+ fs4.rmSync(temporary, { force: true });
2188
+ }
2189
+ }
2190
+ var hasEntry = (filename) => {
2191
+ try {
2192
+ fs4.lstatSync(filename);
2193
+ return true;
2194
+ } catch (error) {
2195
+ if (error.code === "ENOENT") return false;
2196
+ throw error;
2197
+ }
2198
+ };
1779
2199
  async function initProject(options = {}) {
1780
- const cwd = path2.resolve(options.cwd || process.cwd());
2200
+ let cwd = fs4.realpathSync(path3.resolve(options.cwd || process.cwd()));
2201
+ const root = spawnSync2("git", [...HERMETIC_GIT_FLAGS, "rev-parse", "--show-toplevel"], {
2202
+ cwd,
2203
+ env: { ...getHermeticGitEnv(), LC_ALL: "C" },
2204
+ encoding: "utf8",
2205
+ timeout: 3e4,
2206
+ maxBuffer: 8192,
2207
+ windowsHide: true
2208
+ });
2209
+ if (root.error) throw new ConfigError("Cannot resolve the repository root for initialization.");
2210
+ if (root.status === 0) cwd = fs4.realpathSync(gitOutputPath(root.stdout));
2211
+ else if (root.status !== 128 || !root.stderr.includes("not a git repository")) {
2212
+ throw new ConfigError("Cannot resolve a working repository root for initialization.");
2213
+ }
1781
2214
  const created = [];
1782
2215
  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"))) {
2216
+ const publish = (root2, relative2, label, content, mode) => {
2217
+ (createNewFile(root2, relative2, content, mode) ? created : skipped).push(label);
2218
+ };
2219
+ if ([".agentroastrc.json", "agent-roast.config.json", ".agentroastrc"].some((name) => hasEntry(path3.join(cwd, name)))) {
1786
2220
  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");
1794
- }
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");
1804
- }
1805
- }
2221
+ } else publish(cwd, ".agentroastrc.json", ".agentroastrc.json", JSON.stringify(DEFAULT_CONFIG_TEMPLATE, null, 2) + "\n");
2222
+ if (options.ci) publish(cwd, ".github/workflows/agent-roast.yml", ".github/workflows/agent-roast.yml", GITHUB_WORKFLOW_TEMPLATE);
1806
2223
  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)");
2224
+ const run = (...args) => spawnSync2("git", [...HERMETIC_GIT_FLAGS, ...args], {
2225
+ cwd,
2226
+ env: getHermeticGitEnv(),
2227
+ encoding: "utf8",
2228
+ timeout: 3e4,
2229
+ maxBuffer: 8192,
2230
+ windowsHide: true
2231
+ });
2232
+ const common = run("rev-parse", "--git-common-dir");
2233
+ if (common.error || common.status !== 0) {
2234
+ const plainGit = path3.join(cwd, ".git");
2235
+ if (hasEntry(plainGit) && fs4.lstatSync(plainGit).isDirectory() && !fs4.lstatSync(plainGit).isSymbolicLink()) {
2236
+ publish(plainGit, "hooks/pre-commit", ".git/hooks/pre-commit", PRE_COMMIT_HOOK_TEMPLATE, 493);
2237
+ } else skipped.push(".git/hooks/pre-commit (no .git directory found)");
1810
2238
  } 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
- }
2239
+ const commonDir = fs4.realpathSync(path3.resolve(cwd, gitOutputPath(common.stdout)));
2240
+ const hooks = run("rev-parse", "--git-path", "hooks");
2241
+ if (hooks.error || hooks.status !== 0) throw new ConfigError("Cannot resolve this repository's hooks directory.");
2242
+ const hookDir = path3.resolve(cwd, gitOutputPath(hooks.stdout));
2243
+ const relative2 = path3.relative(commonDir, path3.join(hookDir, "pre-commit"));
2244
+ if (!relative2 || relative2 === ".." || relative2.startsWith(".." + path3.sep) || path3.isAbsolute(relative2)) {
2245
+ skipped.push(".git/hooks/pre-commit (custom hooks path outside repository metadata; install manually)");
2246
+ } else publish(commonDir, relative2, ".git/hooks/pre-commit", PRE_COMMIT_HOOK_TEMPLATE, 493);
1823
2247
  }
1824
2248
  }
1825
- return { created, skipped };
2249
+ return { directory: cwd, created, skipped };
1826
2250
  }
1827
2251
 
1828
2252
  // src/badge.ts
2253
+ function displayText(value) {
2254
+ return value.replace(
2255
+ /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/g,
2256
+ (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`
2257
+ );
2258
+ }
2259
+ function xmlText(value) {
2260
+ return displayText(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
2261
+ }
2262
+ function badgeScore(score) {
2263
+ return score !== null && Number.isFinite(score) && score >= 0 && score <= 100 ? score : null;
2264
+ }
1829
2265
  function getScoreColor(score) {
1830
- if (score === null || isNaN(score)) {
2266
+ score = badgeScore(score);
2267
+ if (score === null) {
1831
2268
  return { name: "lightgrey", hex: "#9f9f9f" };
1832
2269
  }
1833
2270
  if (score >= 90) return { name: "brightgreen", hex: "#4c1" };
@@ -1837,14 +2274,16 @@ function getScoreColor(score) {
1837
2274
  return { name: "red", hex: "#e05d44" };
1838
2275
  }
1839
2276
  function generateSvgBadge(score, label = "AI Discipline") {
1840
- const value = score !== null && !isNaN(score) ? `${score}/100` : "N/A";
2277
+ score = badgeScore(score);
2278
+ const value = score !== null ? `${score}/100` : "N/A";
1841
2279
  const color = getScoreColor(score);
2280
+ const safeLabel = xmlText(label);
1842
2281
  const labelWidth = Math.round(label.length * 6.8 + 14);
1843
2282
  const valueWidth = Math.round(value.length * 7.2 + 14);
1844
2283
  const totalWidth = labelWidth + valueWidth;
1845
2284
  const labelX = Math.round(labelWidth / 2);
1846
2285
  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}">
2286
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${safeLabel}: ${value}">
1848
2287
  <linearGradient id="s" x2="0" y2="100%">
1849
2288
  <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
1850
2289
  <stop offset="1" stop-opacity=".1"/>
@@ -1858,22 +2297,31 @@ function generateSvgBadge(score, label = "AI Discipline") {
1858
2297
  <rect width="${totalWidth}" height="20" fill="url(#s)"/>
1859
2298
  </g>
1860
2299
  <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>
2300
+ <text aria-hidden="true" x="${labelX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(labelWidth - 14) * 10}">${safeLabel}</text>
2301
+ <text x="${labelX * 10}" y="140" transform="scale(.1)" fill="#fff" textLength="${(labelWidth - 14) * 10}">${safeLabel}</text>
1863
2302
  <text aria-hidden="true" x="${valueX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(valueWidth - 14) * 10}">${value}</text>
1864
2303
  <text x="${valueX * 10}" y="140" transform="scale(.1)" fill="#fff" textLength="${(valueWidth - 14) * 10}">${value}</text>
1865
2304
  </g>
1866
2305
  </svg>`;
1867
2306
  }
1868
2307
  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";
2308
+ score = badgeScore(score);
2309
+ const value = score !== null ? `${score}%2F100` : "N%2FA";
1870
2310
  const color = getScoreColor(score);
1871
- const encodedLabel = encodeURIComponent(label);
2311
+ const shownLabel = displayText(label);
2312
+ const encodedLabel = encodeURIComponent(shownLabel.replace(/-/g, "--").replace(/_/g, "__"));
2313
+ const alt = shownLabel.replace(/[\\[\]*_!`]/g, "\\$&").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2314
+ const destination = new URL(repoUrl);
2315
+ if (!["https:", "http:"].includes(destination.protocol) || destination.username || destination.password) {
2316
+ throw new Error("Badge destination must be an HTTP(S) URL without credentials.");
2317
+ }
2318
+ const safeDestination = destination.href.replace(/[()\\]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
1872
2319
  const badgeUrl = `https://img.shields.io/badge/${encodedLabel}-${value}-${color.name}?style=flat-square`;
1873
- return `[![${label}](${badgeUrl})](${repoUrl})`;
2320
+ return `[![${alt}](${badgeUrl})](${safeDestination})`;
1874
2321
  }
1875
2322
  function generateShieldsJson(score, label = "AI Discipline") {
1876
- const value = score !== null && !isNaN(score) ? `${score}/100` : "N/A";
2323
+ score = badgeScore(score);
2324
+ const value = score !== null ? `${score}/100` : "N/A";
1877
2325
  const color = getScoreColor(score);
1878
2326
  return {
1879
2327
  schemaVersion: 1,
@@ -1885,111 +2333,22 @@ function generateShieldsJson(score, label = "AI Discipline") {
1885
2333
 
1886
2334
  // src/sarif.ts
1887
2335
  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
2336
  var require2 = createRequire(import.meta.url);
1983
2337
  var version = require2("../package.json").version;
1984
2338
  function formatSarif(report) {
1985
2339
  const occurrences = /* @__PURE__ */ new Map();
1986
- const results = report.infractions.map((finding) => {
2340
+ const allFindings = [
2341
+ ...report.infractions.map((finding) => ({ finding, suppression: void 0 })),
2342
+ ...(report.suppressedFindings ?? []).map((finding) => ({ finding, suppression: finding.suppression }))
2343
+ ];
2344
+ 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));
2345
+ const results = allFindings.map(({ finding, suppression }) => {
1987
2346
  const filename = finding.file;
1988
2347
  if (!filename || filename.startsWith("/") || filename.split("/").includes("..") || /^[a-z]:/i.test(filename)) {
1989
2348
  throw new ConfigError("SARIF needs repository-relative finding paths.");
1990
2349
  }
1991
2350
  const fingerprint = findingFingerprint(finding);
1992
- const occurrence = (occurrences.get(fingerprint) || 0) + 1;
2351
+ const occurrence = finding.occurrence ?? (occurrences.get(fingerprint) || 0) + 1;
1993
2352
  occurrences.set(fingerprint, occurrence);
1994
2353
  return {
1995
2354
  ruleId: finding.type,
@@ -2001,6 +2360,11 @@ function formatSarif(report) {
2001
2360
  region: { startLine: Math.max(1, finding.lineNumber || 1) }
2002
2361
  } }],
2003
2362
  partialFingerprints: { "agentRoastFinding/v1": `${fingerprint}:${occurrence}` },
2363
+ ...suppression ? { suppressions: [{
2364
+ kind: "external",
2365
+ status: "accepted",
2366
+ justification: `${suppression.reason} (expires ${suppression.expires}, UTC)`
2367
+ }] } : {},
2004
2368
  ...report.mode === "review" ? { baselineState: "new" } : {}
2005
2369
  };
2006
2370
  });
@@ -2040,156 +2404,31 @@ Example: ${RULE_GUIDES[rule].example}` },
2040
2404
  mergeBaseSha: report.mergeBaseSha,
2041
2405
  policySha: report.policySha,
2042
2406
  unchangedFindings: report.unchangedCount,
2043
- removedFindings: report.removedFindings.length
2044
- } : { scope: "current source", acceptedBaselineFindings: report.baselineCount }
2407
+ removedFindings: report.removedFindings.length,
2408
+ acceptedExceptions: report.suppressedFindings?.length ?? 0,
2409
+ expiredExceptions: report.expiredExceptionCount ?? 0
2410
+ } : { scope: "current source", acceptedBaselineFindings: report.baselineCount, acceptedExceptions: report.suppressedFindings?.length ?? 0, expiredExceptions: report.expiredExceptionCount ?? 0 }
2045
2411
  }]
2046
2412
  }, null, 2);
2047
2413
  }
2048
2414
 
2049
2415
  // src/check.ts
2050
- import { spawnSync as spawnSync2 } from "child_process";
2051
- import * as fs4 from "fs/promises";
2416
+ import * as fs6 from "fs/promises";
2052
2417
  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
2418
  import path5 from "path";
2182
2419
 
2183
2420
  // src/snapshot.ts
2184
2421
  import { createHash as createHash2 } from "crypto";
2422
+ import * as fs5 from "fs/promises";
2423
+ import path4 from "path";
2185
2424
  import { spawn as spawn2 } from "child_process";
2186
2425
  function assertNotAborted(signal) {
2187
2426
  if (signal?.aborted) throw new ConfigError("Review cancelled; no complete result was produced.");
2188
2427
  }
2189
- async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024) {
2428
+ async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024, indexFile) {
2190
2429
  assertNotAborted(signal);
2191
2430
  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];
2431
+ if (indexFile) env.GIT_INDEX_FILE = indexFile;
2193
2432
  return new Promise((resolve2, reject) => {
2194
2433
  const child = spawn2("git", [...HERMETIC_GIT_FLAGS, ...args], {
2195
2434
  cwd,
@@ -2228,7 +2467,7 @@ async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024) {
2228
2467
  if (signal?.aborted) abort();
2229
2468
  });
2230
2469
  }
2231
- async function requireLocalObjects(cwd, signal) {
2470
+ async function requireLocalObjects2(cwd, signal) {
2232
2471
  try {
2233
2472
  const promises = (await readGit(cwd, ["config", "--type=bool", "--get-regexp", "^remote\\..*\\.promisor$"], signal, 64 * 1024)).toString();
2234
2473
  if (promises.split("\n").some((line) => /\s+(?:true|yes|on|1)\s*$/i.test(line))) {
@@ -2248,7 +2487,7 @@ async function reviewRefs(cwd, base, head, signal) {
2248
2487
  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
2488
  }
2250
2489
  };
2251
- await requireLocalObjects(cwd, signal);
2490
+ await requireLocalObjects2(cwd, signal);
2252
2491
  const baseSha = await resolve2(base);
2253
2492
  const headSha = await resolve2(head);
2254
2493
  let ancestors;
@@ -2303,7 +2542,19 @@ async function reviewPolicy(cwd, baseSha, signal) {
2303
2542
  return { config: configFromText("{}", "default review policy"), filename: null };
2304
2543
  }
2305
2544
  async function stagedSnapshot(cwd, signal) {
2306
- await requireLocalObjects(cwd, signal);
2545
+ await requireLocalObjects2(cwd, signal);
2546
+ let indexFile;
2547
+ if (process.env.GIT_INDEX_FILE) {
2548
+ const gitDirectory = await fs5.realpath(gitOutputPath((await readGit(cwd, ["rev-parse", "--absolute-git-dir"], signal, 8192)).toString()));
2549
+ const requested = path4.resolve(cwd, process.env.GIT_INDEX_FILE);
2550
+ const stat = await fs5.lstat(requested);
2551
+ const resolved = await fs5.realpath(requested);
2552
+ const relative2 = path4.relative(gitDirectory, resolved);
2553
+ if (!stat.isFile() || stat.isSymbolicLink() || !relative2 || relative2 === ".." || relative2.startsWith(".." + path4.sep) || path4.isAbsolute(relative2)) {
2554
+ throw new ConfigError("GIT_INDEX_FILE must be a regular candidate index inside this repository's Git directory; refusing to review another index.");
2555
+ }
2556
+ indexFile = resolved;
2557
+ }
2307
2558
  const head = async () => {
2308
2559
  try {
2309
2560
  return (await readGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], signal, 1024)).toString().trim();
@@ -2336,8 +2587,8 @@ async function stagedSnapshot(cwd, signal) {
2336
2587
  "--end-of-options",
2337
2588
  ...baseSha === "EMPTY" ? [] : [baseSha],
2338
2589
  "--"
2339
- ], signal);
2340
- const conflicts = await readGit(cwd, ["ls-files", "--unmerged", "-z", "--full-name"], signal);
2590
+ ], signal, 8 * 1024 * 1024, indexFile);
2591
+ const conflicts = await readGit(cwd, ["ls-files", "--unmerged", "-z", "--full-name"], signal, 8 * 1024 * 1024, indexFile);
2341
2592
  if (conflicts.length) throw new ConfigError("Resolve unmerged index entries before reviewing staged changes.");
2342
2593
  const pinned = await inventory();
2343
2594
  const changes = parseTreeChanges(pinned);
@@ -2356,7 +2607,137 @@ async function stagedSnapshot(cwd, signal) {
2356
2607
  };
2357
2608
  }
2358
2609
 
2610
+ // src/check.ts
2611
+ var MAX_FILE_BYTES = 2 * 1024 * 1024;
2612
+ var MAX_SCAN_BYTES = 50 * 1024 * 1024;
2613
+ var GENERATED_PATHS = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
2614
+ async function git(cwd, args, signal) {
2615
+ return new TextDecoder("utf-8", { fatal: true }).decode(await readGit(cwd, args, signal, 16 * 1024 * 1024));
2616
+ }
2617
+ function checkAborted(signal) {
2618
+ if (signal?.aborted) throw new ConfigError("Project check cancelled; no complete result was produced.");
2619
+ }
2620
+ function insideRoot(root, filename) {
2621
+ const relative2 = path5.relative(root, filename);
2622
+ return relative2.length > 0 && relative2 !== ".." && !relative2.startsWith(".." + path5.sep) && !path5.isAbsolute(relative2);
2623
+ }
2624
+ async function readSource(root, file, signal) {
2625
+ const filename = path5.resolve(root, file);
2626
+ if (!insideRoot(root, filename)) throw new Error("File escapes the repository.");
2627
+ let stat;
2628
+ try {
2629
+ stat = await fs6.lstat(filename);
2630
+ } catch (error) {
2631
+ if (error.code === "ENOENT") return null;
2632
+ throw error;
2633
+ }
2634
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Symlinks and special files are not scanned.");
2635
+ const real = await fs6.realpath(filename);
2636
+ if (!insideRoot(root, real)) throw new Error("File resolves outside the repository.");
2637
+ if (stat.size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
2638
+ const handle = await fs6.open(filename, constants2.O_RDONLY | (constants2.O_NOFOLLOW || 0) | (constants2.O_NONBLOCK || 0));
2639
+ try {
2640
+ const opened = await handle.stat();
2641
+ if (opened.ino !== stat.ino || opened.dev !== stat.dev || !opened.isFile()) throw new Error("File changed while opening.");
2642
+ const chunks = [];
2643
+ let size = 0;
2644
+ while (true) {
2645
+ checkAborted(signal);
2646
+ const chunk = Buffer.alloc(Math.min(64 * 1024, MAX_FILE_BYTES + 1 - size));
2647
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
2648
+ if (!bytesRead) break;
2649
+ size += bytesRead;
2650
+ if (size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
2651
+ chunks.push(chunk.subarray(0, bytesRead));
2652
+ }
2653
+ const after = await handle.stat();
2654
+ 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.");
2655
+ return Buffer.concat(chunks);
2656
+ } finally {
2657
+ await handle.close();
2658
+ }
2659
+ }
2660
+ async function checkProject(options = {}) {
2661
+ checkAborted(options.signal);
2662
+ const cwd = path5.resolve(options.cwd || process.cwd());
2663
+ const root = await fs6.realpath(gitOutputPath(await git(cwd, ["rev-parse", "--show-toplevel"], options.signal)));
2664
+ const config = loadConfig(root);
2665
+ const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert", "test-focus"];
2666
+ for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown rule '${rule}'.`);
2667
+ const format = options.format || "terminal";
2668
+ if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported check output format.");
2669
+ const baseline = options.baseline ? loadBaseline(path5.resolve(cwd, options.baseline)) : void 0;
2670
+ const filenames = [...new Set((await git(root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], options.signal)).split("\0").filter(Boolean))].sort();
2671
+ const infractions = [];
2672
+ const skippedFiles = [];
2673
+ let filesInspected = 0;
2674
+ let linesInspected = 0;
2675
+ let totalBytes = 0;
2676
+ for (const file of filenames) {
2677
+ checkAborted(options.signal);
2678
+ if (!isCodeFile(file, [...GENERATED_PATHS, ...config.ignorePaths || []])) continue;
2679
+ try {
2680
+ const content = await readSource(root, file, options.signal);
2681
+ if (content === null) continue;
2682
+ totalBytes += content.length;
2683
+ if (totalBytes > MAX_SCAN_BYTES) throw new Error("Project exceeds the 50 MiB scan limit.");
2684
+ if (content.includes(0)) throw new Error("Binary content in a source file.");
2685
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
2686
+ const lines = text.split(/\r?\n/);
2687
+ if (lines[lines.length - 1] === "") lines.pop();
2688
+ const audit = auditFileDiffs([{ path: file, addedLines: lines.map((line, index) => ({ line, lineNumber: index + 1 })) }], "WORKTREE", "", { rules: config.rules });
2689
+ infractions.push(...audit.infractions);
2690
+ filesInspected++;
2691
+ linesInspected += lines.length;
2692
+ } catch (error) {
2693
+ checkAborted(options.signal);
2694
+ skippedFiles.push({ file, reason: error instanceof Error ? error.message : String(error) });
2695
+ if (totalBytes > MAX_SCAN_BYTES) break;
2696
+ }
2697
+ }
2698
+ checkAborted(options.signal);
2699
+ const applied = baseline ? applyBaseline(infractions, baseline) : { findings: infractions, baselineCount: 0, staleBaselineCount: 0 };
2700
+ const notInBaseline = new Set(applied.findings);
2701
+ const acceptedCounts = /* @__PURE__ */ new Map();
2702
+ for (const finding of infractions) {
2703
+ if (notInBaseline.has(finding)) continue;
2704
+ const fingerprint = findingFingerprint(finding);
2705
+ acceptedCounts.set(fingerprint, (acceptedCounts.get(fingerprint) ?? 0) + 1);
2706
+ }
2707
+ const exceptions = applyExceptions(applied.findings, options.ignoreExceptions ? [] : config.exceptions, /* @__PURE__ */ new Date(), acceptedCounts);
2708
+ const findings = exceptions.findings;
2709
+ const failedRules = [...new Set(findings.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
2710
+ const complete = skippedFiles.length === 0;
2711
+ const passed = complete && failedRules.length === 0;
2712
+ const result = {
2713
+ schemaVersion: "1.0.0",
2714
+ mode: "check",
2715
+ filesInspected,
2716
+ linesInspected,
2717
+ totalFindings: infractions.length,
2718
+ infractions: findings,
2719
+ baselineCount: applied.baselineCount,
2720
+ staleBaselineCount: applied.staleBaselineCount,
2721
+ suppressedFindings: exceptions.suppressedFindings,
2722
+ expiredExceptionCount: exceptions.expiredExceptionCount,
2723
+ skippedFiles,
2724
+ complete,
2725
+ failedRules,
2726
+ passed,
2727
+ exitCode: complete ? passed ? 0 : 1 : 2,
2728
+ output: ""
2729
+ };
2730
+ if (format === "json") result.output = JSON.stringify(result, null, 2);
2731
+ else if (format === "sarif") result.output = formatSarif(result);
2732
+ else if (format === "markdown") result.output = formatCheckMarkdown(result);
2733
+ else if (format === "github") {
2734
+ result.output = formatGithubAnnotations(result) + (complete ? "" : "\n::error::Project scan incomplete. See the JSON or terminal report for skipped files.");
2735
+ } else result.output = formatCheckTerminal(result);
2736
+ return result;
2737
+ }
2738
+
2359
2739
  // src/review.ts
2740
+ import path6 from "path";
2360
2741
  var GENERATED_PATHS2 = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
2361
2742
  var MAX_SCAN_BYTES2 = 50 * 1024 * 1024;
2362
2743
  function compareFindings(before, after, newPath) {
@@ -2386,7 +2767,7 @@ function reviewStagedChanges(options = {}) {
2386
2767
  }
2387
2768
  async function compareSourceChanges(options) {
2388
2769
  assertNotAborted(options.signal);
2389
- const cwd = path5.resolve(options.cwd || process.cwd());
2770
+ const cwd = path6.resolve(options.cwd || process.cwd());
2390
2771
  const staged = options.staged ? await stagedSnapshot(cwd, options.signal) : void 0;
2391
2772
  const refs = options.staged ? { baseSha: staged.baseSha, headSha: staged.headSha, mergeBaseSha: staged.mergeBaseSha } : await reviewRefs(cwd, options.base, options.head ?? "HEAD", options.signal);
2392
2773
  const policy = refs.baseSha === "EMPTY" ? { config: configFromText("{}", "default first-commit policy"), filename: null } : await reviewPolicy(cwd, refs.baseSha, options.signal);
@@ -2424,6 +2805,7 @@ async function compareSourceChanges(options) {
2424
2805
  output: ""
2425
2806
  };
2426
2807
  let totalBytes = 0;
2808
+ const existingCounts = /* @__PURE__ */ new Map();
2427
2809
  for (const change of changes) {
2428
2810
  assertNotAborted(options.signal);
2429
2811
  const oldSupported = change.oldMode !== "000000" && isCodeFile(change.oldPath);
@@ -2459,6 +2841,12 @@ async function compareSourceChanges(options) {
2459
2841
  const before = await scan(change.oldMode, change.oldOid, change.oldPath, refs.mergeBaseSha);
2460
2842
  const after = await scan(change.newMode, change.newOid, change.newPath, refs.headSha);
2461
2843
  const compared = compareFindings(before, after, change.newPath);
2844
+ const introduced = new Set(compared.introduced);
2845
+ for (const finding of after) {
2846
+ if (introduced.has(finding)) continue;
2847
+ const fingerprint = findingFingerprint(finding);
2848
+ existingCounts.set(fingerprint, (existingCounts.get(fingerprint) ?? 0) + 1);
2849
+ }
2462
2850
  result.infractions.push(...compared.introduced);
2463
2851
  result.removedFindings.push(...compared.removed);
2464
2852
  result.unchangedCount += compared.unchanged;
@@ -2475,6 +2863,10 @@ async function compareSourceChanges(options) {
2475
2863
  assertNotAborted(options.signal);
2476
2864
  await staged?.verify();
2477
2865
  result.complete = result.skippedFiles.length === 0;
2866
+ const exceptions = applyExceptions(result.infractions, options.ignoreExceptions ? [] : policy.config.exceptions, /* @__PURE__ */ new Date(), existingCounts);
2867
+ result.infractions = exceptions.findings;
2868
+ result.suppressedFindings = exceptions.suppressedFindings;
2869
+ result.expiredExceptionCount = exceptions.expiredExceptionCount;
2478
2870
  result.failedRules = [...new Set(result.infractions.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
2479
2871
  result.passed = result.complete && result.failedRules.length === 0;
2480
2872
  result.exitCode = !result.complete ? 2 : result.passed ? 0 : 1;
@@ -2494,9 +2886,9 @@ var InvalidDetectorError = class extends Error {
2494
2886
  }
2495
2887
  };
2496
2888
  async function roastRepository(options = {}) {
2497
- const cwd = path6.resolve(options.cwd || process.cwd());
2889
+ const cwd = path7.resolve(options.cwd || process.cwd());
2498
2890
  const since = options.since || "90 days ago";
2499
- const repoName = path6.basename(cwd);
2891
+ const repoName = path7.basename(cwd);
2500
2892
  const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths, rules: options.rules, panicLoopWindowMinutes: options.panicLoopWindowMinutes });
2501
2893
  const gitStream = streamGitLog(cwd, since);
2502
2894
  await parseGitLogStream(gitStream, (commit, stats) => {
@@ -2511,7 +2903,7 @@ async function roastRepository(options = {}) {
2511
2903
  return { summary, output };
2512
2904
  }
2513
2905
  async function auditPullRequest(options) {
2514
- const cwd = path6.resolve(options.cwd || process.cwd());
2906
+ const cwd = path7.resolve(options.cwd || process.cwd());
2515
2907
  const format = options.format || "terminal";
2516
2908
  const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
2517
2909
  for (const det of failOn) {
@@ -2530,15 +2922,26 @@ async function auditPullRequest(options) {
2530
2922
  const baseSha = options.staged ? "INDEX" : resolveCommitSha(cwd, options.base, "Base ref");
2531
2923
  const headSha = options.staged ? "INDEX" : resolveCommitSha(cwd, options.head || "HEAD", "Head ref");
2532
2924
  const mergeBaseSha = options.staged ? "INDEX" : findMergeBase(cwd, baseSha, headSha);
2925
+ if (!["terminal", "json", "github", "markdown"].includes(format)) throw new InvalidDetectorError("Unsupported legacy audit output format.");
2533
2926
  const diffStream = streamGitDiff(cwd, mergeBaseSha, headSha, options.staged);
2534
- let rawDiff = "";
2927
+ const chunks = [];
2928
+ let bytes = 0;
2535
2929
  await new Promise((resolve2, reject) => {
2536
2930
  diffStream.on("data", (chunk) => {
2537
- rawDiff += chunk.toString();
2931
+ bytes += chunk.length;
2932
+ if (bytes > 50 * 1024 * 1024) {
2933
+ diffStream.destroy(new GitResolutionError("Legacy diff exceeds the 50 MiB limit; use a smaller context review."));
2934
+ } else chunks.push(chunk);
2538
2935
  });
2539
- diffStream.on("end", () => resolve2());
2936
+ diffStream.on("end", resolve2);
2540
2937
  diffStream.on("error", reject);
2541
2938
  });
2939
+ let rawDiff;
2940
+ try {
2941
+ rawDiff = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
2942
+ } catch {
2943
+ throw new GitResolutionError("Legacy diff contains invalid UTF-8; no complete result was produced.");
2944
+ }
2542
2945
  const { files, skippedLongLines, skippedUnknownFiles } = parseDiff(rawDiff);
2543
2946
  const audit = auditFileDiffs(files, headSha, (/* @__PURE__ */ new Date()).toISOString(), { ignorePaths: options.ignorePaths, rules: options.rules });
2544
2947
  const infractions = audit.infractions;
@@ -2546,8 +2949,10 @@ async function auditPullRequest(options) {
2546
2949
  const failedRules = [...new Set(
2547
2950
  infractions.filter((inf) => failOn.includes(inf.type)).map((inf) => inf.type)
2548
2951
  )];
2549
- const passed = failedRules.length === 0;
2550
- const exitCode = passed ? 0 : 1;
2952
+ const skippedBinaryFiles = files.filter((file) => file.binary && isCodeFile(file.path, options.ignorePaths)).length;
2953
+ const complete = skippedLongLines === 0 && skippedUnknownFiles === 0 && skippedBinaryFiles === 0;
2954
+ const passed = complete && failedRules.length === 0;
2955
+ const exitCode = !complete ? 2 : passed ? 0 : 1;
2551
2956
  const result = {
2552
2957
  schemaVersion: "1.0.0",
2553
2958
  mode: options.staged ? "staged" : "pr",
@@ -2561,15 +2966,17 @@ async function auditPullRequest(options) {
2561
2966
  infractions,
2562
2967
  failedRules,
2563
2968
  passed,
2969
+ complete,
2564
2970
  skippedLongLines,
2565
2971
  skippedUnknownFiles,
2972
+ skippedBinaryFiles,
2566
2973
  output: "",
2567
2974
  exitCode
2568
2975
  };
2569
2976
  if (format === "json") {
2570
2977
  result.output = JSON.stringify(result, null, 2);
2571
2978
  } else if (format === "github") {
2572
- result.output = formatGithubAnnotations(result);
2979
+ result.output = formatGithubAnnotations(result) + (complete ? "" : "\n::error::Legacy audit incomplete: long lines, binary-marked or unparsed source were skipped. Use context review.");
2573
2980
  } else if (format === "markdown") {
2574
2981
  result.output = formatPrMarkdownComment(result);
2575
2982
  } else {
@@ -2579,12 +2986,28 @@ async function auditPullRequest(options) {
2579
2986
  }
2580
2987
 
2581
2988
  export {
2989
+ VALID_DETECTORS,
2990
+ findingFingerprint,
2991
+ createBaseline,
2992
+ loadBaseline,
2993
+ applyBaseline,
2994
+ writeBaseline,
2995
+ validateExceptions,
2996
+ applyExceptions,
2997
+ MAX_CONFIG_BYTES,
2998
+ DEFAULT_CONFIG,
2999
+ CONFIG_FILENAMES,
3000
+ ConfigError,
3001
+ configFromText,
3002
+ loadConfig,
3003
+ matchIgnorePath,
2582
3004
  detectAiAttribution,
2583
3005
  RECORD_SEPARATOR,
2584
3006
  FIELD_SEPARATOR,
2585
3007
  GitError,
2586
3008
  HERMETIC_GIT_FLAGS,
2587
3009
  getHermeticGitEnv,
3010
+ gitOutputPath,
2588
3011
  parseGitLogStream,
2589
3012
  unquoteGitPath,
2590
3013
  parseDiff,
@@ -2593,14 +3016,8 @@ export {
2593
3016
  resolveCommitSha,
2594
3017
  findMergeBase,
2595
3018
  GitResolutionError,
3019
+ readGit,
2596
3020
  RULE_GUIDES,
2597
- VALID_DETECTORS,
2598
- DEFAULT_CONFIG,
2599
- CONFIG_FILENAMES,
2600
- ConfigError,
2601
- configFromText,
2602
- loadConfig,
2603
- matchIgnorePath,
2604
3021
  isCodeFile,
2605
3022
  isTestFile,
2606
3023
  auditFileDiffs,
@@ -2612,6 +3029,7 @@ export {
2612
3029
  formatGithubAnnotations,
2613
3030
  formatGithubStepSummary,
2614
3031
  formatPrMarkdownComment,
3032
+ terminalText,
2615
3033
  formatCheckTerminal,
2616
3034
  formatCheckMarkdown,
2617
3035
  formatReviewTerminal,
@@ -2624,11 +3042,6 @@ export {
2624
3042
  generateSvgBadge,
2625
3043
  generateShieldsMarkdown,
2626
3044
  generateShieldsJson,
2627
- findingFingerprint,
2628
- createBaseline,
2629
- loadBaseline,
2630
- applyBaseline,
2631
- writeBaseline,
2632
3045
  formatSarif,
2633
3046
  checkProject,
2634
3047
  compareFindings,