agent-roast 0.6.0 → 0.8.0

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