agent-roast 0.4.0 → 0.6.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,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import path3 from "path";
4
+ import path6 from "path";
5
5
 
6
6
  // src/git.ts
7
7
  import { spawn, spawnSync } from "child_process";
@@ -84,6 +84,8 @@ var HERMETIC_GIT_FLAGS = [
84
84
  "-c",
85
85
  "core.quotepath=false",
86
86
  "-c",
87
+ "core.fsmonitor=false",
88
+ "-c",
87
89
  "diff.noprefix=false",
88
90
  "-c",
89
91
  "diff.mnemonicPrefix=false",
@@ -433,7 +435,7 @@ function streamGitLog(cwd = process.cwd(), since = "90 days ago") {
433
435
  });
434
436
  return output;
435
437
  }
436
- function streamGitDiff(cwd, baseSha, headSha) {
438
+ function streamGitDiff(cwd, baseSha, headSha, staged = false) {
437
439
  const gitArgs = [
438
440
  ...HERMETIC_GIT_FLAGS,
439
441
  "diff",
@@ -443,9 +445,9 @@ function streamGitDiff(cwd, baseSha, headSha) {
443
445
  "--no-textconv",
444
446
  "--src-prefix=a/",
445
447
  "--dst-prefix=b/",
448
+ ...staged ? ["--cached"] : [],
446
449
  "--end-of-options",
447
- baseSha,
448
- headSha,
450
+ ...staged ? [] : [baseSha, headSha],
449
451
  ...COMMON_EXCLUDE_SPECS
450
452
  ];
451
453
  const output = new PassThrough();
@@ -542,9 +544,155 @@ var GitResolutionError = class extends Error {
542
544
  }
543
545
  };
544
546
 
547
+ // src/source.ts
548
+ var SourceMasker = class {
549
+ blockComment = false;
550
+ quote = "";
551
+ triple = false;
552
+ previousLine = 0;
553
+ python;
554
+ javascript;
555
+ hashComments;
556
+ backticks;
557
+ 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);
562
+ }
563
+ mask(line, number) {
564
+ if (number !== this.previousLine + 1) {
565
+ this.blockComment = false;
566
+ this.quote = "";
567
+ this.triple = false;
568
+ }
569
+ this.previousLine = number;
570
+ const code = Array(line.length).fill(" ");
571
+ const comments = Array(line.length).fill(" ");
572
+ let i = 0;
573
+ let prefix = "";
574
+ while (i < line.length) {
575
+ if (this.blockComment) {
576
+ if (line.startsWith("*/", i)) {
577
+ this.blockComment = false;
578
+ i += 2;
579
+ } else i++;
580
+ continue;
581
+ }
582
+ if (this.quote) {
583
+ if (line[i] === "\\") {
584
+ i += 2;
585
+ continue;
586
+ }
587
+ const delimiter = this.triple ? this.quote.repeat(3) : this.quote;
588
+ if (line.startsWith(delimiter, i)) {
589
+ i += delimiter.length;
590
+ this.quote = "";
591
+ this.triple = false;
592
+ } else i++;
593
+ continue;
594
+ }
595
+ if (!this.python && line.startsWith("//", i) || this.hashComments && line[i] === "#") {
596
+ for (let j = i; j < line.length; j++) comments[j] = line[j];
597
+ break;
598
+ }
599
+ if (!this.python && line.startsWith("/*", i)) {
600
+ this.blockComment = true;
601
+ i += 2;
602
+ continue;
603
+ }
604
+ const char = line[i];
605
+ if (char === '"' || char === "'" || this.backticks && char === "`") {
606
+ this.quote = char;
607
+ this.triple = this.python && line.startsWith(char.repeat(3), i);
608
+ i += this.triple ? 3 : 1;
609
+ prefix += " literal ";
610
+ continue;
611
+ }
612
+ if (this.javascript && char === "/" && (/(?:[=(:,[!&|?{;]|\breturn|\byield)\s*$/.test(prefix) || !prefix.trim())) {
613
+ let j = i + 1;
614
+ let inClass = false;
615
+ for (; j < line.length; j++) {
616
+ if (line[j] === "\\") {
617
+ j++;
618
+ continue;
619
+ }
620
+ if (line[j] === "[") inClass = true;
621
+ if (line[j] === "]") inClass = false;
622
+ if (line[j] === "/" && !inClass) break;
623
+ }
624
+ if (j < line.length) {
625
+ j++;
626
+ while (/[a-z]/i.test(line[j] || "") && j < line.length) j++;
627
+ i = j;
628
+ prefix += " literal ";
629
+ continue;
630
+ }
631
+ }
632
+ code[i] = char;
633
+ prefix += char;
634
+ i++;
635
+ }
636
+ if (this.quote !== "`" && !this.triple) this.quote = "";
637
+ return { code: code.join(""), comments: comments.join("") };
638
+ }
639
+ };
640
+
641
+ // src/rules.ts
642
+ var RULE_GUIDES = {
643
+ "test-skip": {
644
+ title: "Skipped or disabled test",
645
+ why: "The test runner may report success without executing this behavior check.",
646
+ suggestion: "Re-enable the test and fix its failure. If a skip is intentional, document its reason and track when to restore it.",
647
+ example: 'it.skip("auth", ...) \u2192 it("auth", ...) after fixing the failing behavior'
648
+ },
649
+ "trivial-assert": {
650
+ title: "Assertion that always passes",
651
+ why: "Comparing a constant to itself does not check the implementation and cannot catch regressions.",
652
+ suggestion: "Call the real behavior and assert its output or observable side effect instead of comparing constants.",
653
+ example: "expect(true).toBe(true) \u2192 expect(canSignIn(validUser)).toBe(true)"
654
+ },
655
+ "type-escape": {
656
+ title: "Type or linter check bypass",
657
+ why: "A suppression can hide incompatible values or a disabled correctness check. Some suppressions are justified.",
658
+ suggestion: "Narrow or validate the value at the boundary. Keep a suppression only with a specific reason and the smallest possible scope.",
659
+ example: "response as any \u2192 parse and validate response before using it"
660
+ },
661
+ "swallowed-error": {
662
+ title: "Error discarded without handling",
663
+ why: "An empty handler makes a failed operation look successful to callers and hides diagnostics.",
664
+ suggestion: "Handle the expected error explicitly, return an observable failure, or rethrow it. Document an intentional best-effort operation.",
665
+ example: "catch (error) {} \u2192 catch (error) { throw new Error('Save failed', { cause: error }); }"
666
+ },
667
+ "stub-abandonment": {
668
+ title: "Unimplemented code path",
669
+ why: "A placeholder throws at runtime when a caller reaches the unfinished path. Abstract methods may be intentional.",
670
+ suggestion: "Implement the path or make its unsupported state explicit to callers. Check whether this is an intentional abstract contract.",
671
+ example: "throw new Error('Not implemented') \u2192 implement the operation and add a behavior test"
672
+ },
673
+ "panic-loop": {
674
+ title: "Rapid overlapping fix commits",
675
+ why: "Repeated short fix cycles can suggest a missing reproduction or feedback loop; timing alone does not prove poor work.",
676
+ suggestion: "Find a minimal reproduction, add a regression test, and verify the complete fix before the next patch.",
677
+ example: "fix \u2192 revert \u2192 fix \u2192 add a reproducer and validate the final behavior"
678
+ }
679
+ };
680
+
545
681
  // src/config.ts
546
682
  import * as fs from "fs";
547
683
  import * as path from "path";
684
+
685
+ // src/types.ts
686
+ var VALID_DETECTORS = [
687
+ "test-skip",
688
+ "type-escape",
689
+ "swallowed-error",
690
+ "panic-loop",
691
+ "trivial-assert",
692
+ "stub-abandonment"
693
+ ];
694
+
695
+ // src/config.ts
548
696
  var DEFAULT_CONFIG = {
549
697
  failOn: ["test-skip", "trivial-assert"],
550
698
  ignorePaths: [
@@ -571,31 +719,71 @@ var CONFIG_FILENAMES = [
571
719
  ".agentroastrc",
572
720
  "agent-roast.config.json"
573
721
  ];
722
+ var ConfigError = class extends Error {
723
+ constructor(message) {
724
+ super(message);
725
+ this.name = "ConfigError";
726
+ }
727
+ };
728
+ function validateConfig(value) {
729
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
730
+ throw new ConfigError("Configuration must be a JSON object.");
731
+ }
732
+ const config = value;
733
+ const knownRules = new Set(VALID_DETECTORS);
734
+ if (config.failOn !== void 0 && (!Array.isArray(config.failOn) || config.failOn.some((rule) => typeof rule !== "string" || !knownRules.has(rule)))) {
735
+ throw new ConfigError(`failOn must contain known rules: ${VALID_DETECTORS.join(", ")}`);
736
+ }
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.");
739
+ }
740
+ if (config.scoreThreshold !== void 0 && (typeof config.scoreThreshold !== "number" || !Number.isFinite(config.scoreThreshold) || config.scoreThreshold < 0 || config.scoreThreshold > 100)) {
741
+ throw new ConfigError("scoreThreshold must be a number between 0 and 100.");
742
+ }
743
+ if (config.panicLoopWindowMinutes !== void 0 && (typeof config.panicLoopWindowMinutes !== "number" || !Number.isFinite(config.panicLoopWindowMinutes) || config.panicLoopWindowMinutes <= 0)) {
744
+ throw new ConfigError("panicLoopWindowMinutes must be a positive number.");
745
+ }
746
+ if (config.rules !== void 0) {
747
+ if (!config.rules || typeof config.rules !== "object" || Array.isArray(config.rules)) {
748
+ throw new ConfigError("rules must be an object.");
749
+ }
750
+ for (const [rule, level] of Object.entries(config.rules)) {
751
+ if (!knownRules.has(rule) || !["error", "warn", "off"].includes(String(level))) {
752
+ throw new ConfigError(`Invalid rule '${rule}' or level '${level}'. Use known rules with error, warn, or off.`);
753
+ }
754
+ }
755
+ }
756
+ }
757
+ function configFromText(text, source) {
758
+ try {
759
+ const parsed = JSON.parse(text);
760
+ validateConfig(parsed);
761
+ const rules = { ...DEFAULT_CONFIG.rules, ...parsed.rules };
762
+ const failOn = parsed.failOn ?? Object.entries(rules).filter(([, level]) => level === "error").map(([rule]) => rule);
763
+ return {
764
+ ...DEFAULT_CONFIG,
765
+ ...parsed,
766
+ failOn,
767
+ ignorePaths: [.../* @__PURE__ */ new Set([...DEFAULT_CONFIG.ignorePaths || [], ...parsed.ignorePaths || []])],
768
+ rules
769
+ };
770
+ } catch (error) {
771
+ throw new ConfigError(`Invalid ${source}: ${error instanceof Error ? error.message : String(error)}`);
772
+ }
773
+ }
574
774
  function loadConfig(cwd = process.cwd()) {
575
775
  for (const filename of CONFIG_FILENAMES) {
576
776
  const filePath = path.join(cwd, filename);
577
777
  if (fs.existsSync(filePath)) {
578
778
  try {
579
- const raw = fs.readFileSync(filePath, "utf8");
580
- const parsed = JSON.parse(raw);
581
- if (typeof parsed === "object" && parsed !== null) {
582
- return {
583
- ...DEFAULT_CONFIG,
584
- ...parsed,
585
- failOn: Array.isArray(parsed.failOn) ? parsed.failOn : DEFAULT_CONFIG.failOn,
586
- ignorePaths: Array.isArray(parsed.ignorePaths) ? Array.from(/* @__PURE__ */ new Set([...DEFAULT_CONFIG.ignorePaths || [], ...parsed.ignorePaths])) : DEFAULT_CONFIG.ignorePaths,
587
- rules: {
588
- ...DEFAULT_CONFIG.rules,
589
- ...parsed.rules || {}
590
- }
591
- };
592
- }
593
- } catch (err) {
594
- console.warn(`Warning: Failed to parse ${filename}: ${err.message}. Using default configuration.`);
779
+ return configFromText(fs.readFileSync(filePath, "utf8"), filename);
780
+ } catch (error) {
781
+ if (error instanceof ConfigError) throw error;
782
+ throw new ConfigError(`Cannot read ${filename}: ${error instanceof Error ? error.message : String(error)}`);
595
783
  }
596
784
  }
597
785
  }
598
- return { ...DEFAULT_CONFIG };
786
+ return configFromText("{}", "default configuration");
599
787
  }
600
788
  function matchIgnorePath(filePath, ignorePatterns) {
601
789
  if (!ignorePatterns || ignorePatterns.length === 0) {
@@ -605,7 +793,25 @@ function matchIgnorePath(filePath, ignorePatterns) {
605
793
  for (const pattern of ignorePatterns) {
606
794
  if (!pattern) continue;
607
795
  const cleanPattern = pattern.replace(/\\/g, "/");
608
- let regexPattern = cleanPattern.replace(/[.+^${}()|[\]]/g, "\\$&").replace(/\*\*\//g, "(?:^|.*?/)").replace(/\/\*\*/g, "(?:/.*)?").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] === "*") {
800
+ i++;
801
+ if (cleanPattern[i + 1] === "/") {
802
+ regexPattern += "(?:.*/)?";
803
+ i++;
804
+ } else {
805
+ regexPattern += ".*";
806
+ }
807
+ } else if (char === "*") {
808
+ regexPattern += "[^/]*";
809
+ } else if (char === "?") {
810
+ regexPattern += "[^/]";
811
+ } else {
812
+ regexPattern += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
813
+ }
814
+ }
609
815
  if (!regexPattern.startsWith("^") && !regexPattern.startsWith("(?:^")) {
610
816
  regexPattern = "(?:^|/)" + regexPattern;
611
817
  }
@@ -628,7 +834,7 @@ function matchIgnorePath(filePath, ignorePatterns) {
628
834
 
629
835
  // src/metrics.ts
630
836
  var TEST_SKIP_PATTERNS = [
631
- { regex: /(?:it|test|describe)\.skip\b/, name: "skipped test (it.skip / describe.skip)" },
837
+ { regex: /\b(?:it|test|describe)\.skip\b/, name: "skipped test (it.skip / describe.skip)" },
632
838
  { regex: /\b(?:xit|xtest)\(/, name: "disabled test (xit / xtest)" },
633
839
  { regex: /@pytest\.mark\.skip\b/, name: "skipped pytest (@pytest.mark.skip)" },
634
840
  { regex: /\bpytest\.skip\(/, name: "imperative pytest skip (pytest.skip)" },
@@ -652,9 +858,9 @@ var SWALLOWED_ERROR_PATTERNS = [
652
858
  { regex: /except(?:\s+[^:]*)?:\s*pass\b/, name: "silent python error swallow (except: pass)" }
653
859
  ];
654
860
  var TRIVIAL_ASSERT_PATTERNS = [
655
- { regex: /expect\s*\(\s*(?:true|false|1|0|null|undefined)\s*\)\.toBe\s*\(\s*(?:true|false|1|0|null|undefined)\s*\)/, name: "trivial assertion (expect(val).toBe(val))" },
656
- { regex: /expect\s*\(\s*(?:true|false|1|0|null|undefined)\s*\)\.toEqual\s*\(\s*(?:true|false|1|0|null|undefined)\s*\)/, name: "trivial assertion (expect(val).toEqual(val))" },
657
- { regex: /\bassert\s+(?:True|False|1\s*==\s*1)\b/, name: "trivial python assert (assert True / 1 == 1)" },
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))" },
863
+ { regex: /\bassert\s+(?:True|1\s*==\s*1)\s*(?=$|#|,)/, name: "trivial python assert (assert True / 1 == 1)" },
658
864
  { regex: /self\.assertTrue\s*\(\s*(?:True|1)\s*\)/, name: "trivial assert (self.assertTrue(True))" },
659
865
  { regex: /self\.assertEqual\s*\(\s*(?:1\s*,\s*1|True\s*,\s*True)\s*\)/, name: "trivial assert (self.assertEqual(1, 1))" },
660
866
  { regex: /assert\.(?:strictEqual|equal)\s*\(\s*(?:true\s*,\s*true|1\s*,\s*1)\s*\)/, name: "trivial assert (assert.equal(1, 1))" }
@@ -687,7 +893,7 @@ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
687
893
  ".c"
688
894
  ]);
689
895
  var IGNORED_PATH_REGEX = /(?:^|\/)(?:fixtures|__fixtures__|testdata|mocks|test_data|__snapshots__|sample[s]?)\//i;
690
- var TEST_PATH_REGEX = /(?:^|\/)(?:tests?|__tests__|specs?)\/|[._-](?:test|spec)\.[a-z0-9]+$/i;
896
+ var TEST_PATH_REGEX = /(?:^|\/)test_[^/]+\.py$|(?:^|\/)(?:tests?|__tests__|specs?)\/|[._-](?:test|spec)\.[a-z0-9]+$/i;
691
897
  function isCodeFile(filePath, customIgnorePaths) {
692
898
  if (IGNORED_PATH_REGEX.test(filePath)) {
693
899
  return false;
@@ -712,11 +918,14 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
712
918
  hasSupportedChanges = true;
713
919
  filesInspected++;
714
920
  linesAdded += file.addedLines.length;
921
+ const masker = new SourceMasker(file.path);
922
+ const maskedLines = [];
715
923
  for (const added of file.addedLines) {
716
924
  const rawLine = added.line;
717
- const cleanedLine = stripStringAndRegexLiterals(rawLine);
718
- const trimmedCleaned = cleanedLine.trim();
719
- const isComment = isCommentOnlyLine(trimmedCleaned);
925
+ const masked = masker.mask(rawLine, added.lineNumber);
926
+ const cleanedLine = masked.code;
927
+ maskedLines.push({ code: cleanedLine, raw: rawLine, number: added.lineNumber });
928
+ const isComment = !cleanedLine.trim();
720
929
  if (!isComment) {
721
930
  for (const rule of TEST_SKIP_PATTERNS) {
722
931
  if (rule.regex.test(cleanedLine)) {
@@ -735,7 +944,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
735
944
  }
736
945
  let foundEscape = false;
737
946
  for (const rule of COMMENT_DIRECTIVE_PATTERNS) {
738
- if (rule.regex.test(cleanedLine)) {
947
+ if (rule.regex.test(masked.comments)) {
739
948
  infractions.push({
740
949
  type: "type-escape",
741
950
  file: file.path,
@@ -799,7 +1008,8 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
799
1008
  }
800
1009
  if (!isComment) {
801
1010
  for (const rule of STUB_ABANDONMENT_PATTERNS) {
802
- if (rule.regex.test(rawLine)) {
1011
+ const match = rule.regex.exec(rawLine);
1012
+ if (match && cleanedLine[match.index] === rawLine[match.index]) {
803
1013
  infractions.push({
804
1014
  type: "stub-abandonment",
805
1015
  file: file.path,
@@ -814,14 +1024,39 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
814
1024
  }
815
1025
  }
816
1026
  }
1027
+ for (let index = 0; index < maskedLines.length; index++) {
1028
+ const start = maskedLines[index];
1029
+ const isCatch = /\bcatch\s*(?:\([^)]*\))?\s*\{\s*$/.test(start.code);
1030
+ const isExcept = /\bexcept(?:\s+[^:]*)?:\s*$/.test(start.code);
1031
+ if (!isCatch && !isExcept) continue;
1032
+ let next = index + 1;
1033
+ while (next < maskedLines.length && maskedLines[next].number === maskedLines[next - 1].number + 1 && !maskedLines[next].code.trim()) next++;
1034
+ const end = maskedLines[next];
1035
+ if (!end || end.number !== maskedLines[next - 1].number + 1) continue;
1036
+ const closesCatch = isCatch && /^\s*}/.test(end.code);
1037
+ let closesExcept = isExcept && /^\s*pass\s*$/.test(end.code);
1038
+ if (closesExcept) {
1039
+ const indent = start.raw.search(/\S/);
1040
+ if (end.raw.search(/\S/) <= indent) closesExcept = false;
1041
+ let after = next + 1;
1042
+ while (after < maskedLines.length && maskedLines[after].number === maskedLines[after - 1].number + 1 && !maskedLines[after].code.trim()) after++;
1043
+ if (after < maskedLines.length && maskedLines[after].number === maskedLines[after - 1].number + 1 && maskedLines[after].raw.search(/\S/) > indent) closesExcept = false;
1044
+ }
1045
+ if (closesCatch || closesExcept) infractions.push({
1046
+ type: "swallowed-error",
1047
+ file: file.path,
1048
+ lineNumber: start.number,
1049
+ lineSnippet: maskedLines.slice(index, next + 1).map((line) => line.raw.trim()).join(" "),
1050
+ commitHash,
1051
+ commitDate,
1052
+ reason: closesCatch ? "empty multi-line catch block" : "silent multi-line python error swallow"
1053
+ });
1054
+ }
817
1055
  }
818
- return { infractions, hasSupportedChanges, filesInspected, linesAdded };
1056
+ return { infractions: infractions.filter((inf) => options.rules?.[inf.type] !== "off").map((inf) => ({ ...inf, suggestion: RULE_GUIDES[inf.type].suggestion })), hasSupportedChanges, filesInspected, linesAdded };
819
1057
  }
820
1058
  function stripStringAndRegexLiterals(line) {
821
- return line.replace(/(["'`])(?:\\.|(?!\1)[^\\\r\n])*\1/g, '""').replace(/\/(?=[^\/\s])(?:\\\/|[^\/\r\n])+\/[gimsuy]*/g, '""');
822
- }
823
- function isCommentOnlyLine(trimmedLine) {
824
- return trimmedLine.startsWith("//") || trimmedLine.startsWith("/*") || trimmedLine.startsWith("*") || trimmedLine.startsWith("#");
1059
+ return new SourceMasker("source.ts").mask(line, 1).code;
825
1060
  }
826
1061
  var PANIC_COMMIT_REGEX = /\b(?:fix|quickfix|revert|hotfix|retry|patch|undo|workaround)\b/i;
827
1062
  var MAX_INFRACTIONS_PER_COMMIT = 10;
@@ -842,16 +1077,20 @@ var MetricsCollector = class {
842
1077
  activePanicChain = null;
843
1078
  includeAll;
844
1079
  ignorePaths;
1080
+ rules;
1081
+ panicLoopWindowMinutes;
845
1082
  constructor(options = {}) {
846
1083
  this.includeAll = options.all ?? false;
847
1084
  this.ignorePaths = options.ignorePaths;
1085
+ this.rules = options.rules;
1086
+ this.panicLoopWindowMinutes = options.panicLoopWindowMinutes ?? 15;
848
1087
  }
849
1088
  recordSkipped(stats) {
850
1089
  if (stats.skippedLongLines) this.skippedLongLines += stats.skippedLongLines;
851
1090
  if (stats.skippedUnknownFiles) this.skippedUnknownFiles += stats.skippedUnknownFiles;
852
1091
  }
853
1092
  flushPanicChain() {
854
- if (this.activePanicChain && this.activePanicChain.commitCount >= 2) {
1093
+ if (this.rules?.["panic-loop"] !== "off" && this.activePanicChain && this.activePanicChain.commitCount >= 2) {
855
1094
  const chain = this.activePanicChain;
856
1095
  const durationMinutes = Math.max(
857
1096
  1,
@@ -876,6 +1115,7 @@ var MetricsCollector = class {
876
1115
  this.aiLinesAdded += commit.totalAddedCount;
877
1116
  }
878
1117
  if (!isTarget) {
1118
+ this.flushPanicChain();
879
1119
  return;
880
1120
  }
881
1121
  const subject = commit.message.split("\n")[0];
@@ -888,7 +1128,7 @@ var MetricsCollector = class {
888
1128
  if (this.activePanicChain && this.activePanicChain.author.toLowerCase() === commit.authorEmail.toLowerCase()) {
889
1129
  const diffMinutes = Math.abs(commitDateMs - this.activePanicChain.latestDate) / (1e3 * 60);
890
1130
  const touchedSameFiles = currentActiveFiles.size > 0 && [...currentActiveFiles].some((p) => this.activePanicChain.activeFiles.has(p));
891
- if (diffMinutes > 0 && diffMinutes <= 15 && touchedSameFiles) {
1131
+ if (diffMinutes > 0 && diffMinutes <= this.panicLoopWindowMinutes && touchedSameFiles) {
892
1132
  this.activePanicChain.commitCount++;
893
1133
  this.activePanicChain.latestHash = commit.hash;
894
1134
  this.activePanicChain.latestDate = commitDateMs;
@@ -927,123 +1167,23 @@ var MetricsCollector = class {
927
1167
  } else {
928
1168
  this.flushPanicChain();
929
1169
  }
930
- let commitSkips = 0;
931
- let commitEscapes = 0;
932
- let commitSwallowed = 0;
933
- let commitTrivial = 0;
934
- let commitStubs = 0;
935
- for (const file of commit.files) {
936
- if (!isCodeFile(file.path, this.ignorePaths)) {
937
- continue;
938
- }
939
- for (const added of file.addedLines) {
940
- const rawLine = added.line;
941
- const cleanedLine = stripStringAndRegexLiterals(rawLine);
942
- const trimmedCleaned = cleanedLine.trim();
943
- const isComment = isCommentOnlyLine(trimmedCleaned);
944
- if (!isComment && commitSkips < MAX_INFRACTIONS_PER_COMMIT) {
945
- for (const rule of TEST_SKIP_PATTERNS) {
946
- if (rule.regex.test(cleanedLine)) {
947
- this.testSkips.push({
948
- type: "test-skip",
949
- file: file.path,
950
- lineNumber: added.lineNumber,
951
- lineSnippet: rawLine.trim(),
952
- commitHash: commit.hash,
953
- commitDate: commit.date,
954
- reason: rule.name
955
- });
956
- commitSkips++;
957
- break;
958
- }
959
- }
960
- }
961
- if (commitEscapes < MAX_INFRACTIONS_PER_COMMIT) {
962
- for (const rule of COMMENT_DIRECTIVE_PATTERNS) {
963
- if (rule.regex.test(cleanedLine)) {
964
- this.typeEscapes.push({
965
- type: "type-escape",
966
- file: file.path,
967
- lineNumber: added.lineNumber,
968
- lineSnippet: rawLine.trim(),
969
- commitHash: commit.hash,
970
- commitDate: commit.date,
971
- reason: rule.name
972
- });
973
- commitEscapes++;
974
- break;
975
- }
976
- }
977
- if (!isComment && commitEscapes < MAX_INFRACTIONS_PER_COMMIT) {
978
- for (const rule of CODE_TYPE_ESCAPE_PATTERNS) {
979
- if (rule.regex.test(cleanedLine)) {
980
- this.typeEscapes.push({
981
- type: "type-escape",
982
- file: file.path,
983
- lineNumber: added.lineNumber,
984
- lineSnippet: rawLine.trim(),
985
- commitHash: commit.hash,
986
- commitDate: commit.date,
987
- reason: rule.name
988
- });
989
- commitEscapes++;
990
- break;
991
- }
992
- }
993
- }
994
- }
995
- if (!isComment && commitSwallowed < MAX_INFRACTIONS_PER_COMMIT) {
996
- for (const rule of SWALLOWED_ERROR_PATTERNS) {
997
- if (rule.regex.test(cleanedLine)) {
998
- this.swallowedErrors.push({
999
- type: "swallowed-error",
1000
- file: file.path,
1001
- lineNumber: added.lineNumber,
1002
- lineSnippet: rawLine.trim(),
1003
- commitHash: commit.hash,
1004
- commitDate: commit.date,
1005
- reason: rule.name
1006
- });
1007
- commitSwallowed++;
1008
- break;
1009
- }
1010
- }
1011
- }
1012
- if (!isComment && isTestFile(file.path) && commitTrivial < MAX_INFRACTIONS_PER_COMMIT) {
1013
- for (const rule of TRIVIAL_ASSERT_PATTERNS) {
1014
- if (rule.regex.test(cleanedLine)) {
1015
- this.trivialAsserts.push({
1016
- type: "trivial-assert",
1017
- file: file.path,
1018
- lineNumber: added.lineNumber,
1019
- lineSnippet: rawLine.trim(),
1020
- commitHash: commit.hash,
1021
- commitDate: commit.date,
1022
- reason: rule.name
1023
- });
1024
- commitTrivial++;
1025
- break;
1026
- }
1027
- }
1028
- }
1029
- if (!isComment && commitStubs < MAX_INFRACTIONS_PER_COMMIT) {
1030
- for (const rule of STUB_ABANDONMENT_PATTERNS) {
1031
- if (rule.regex.test(rawLine)) {
1032
- this.stubAbandonments.push({
1033
- type: "stub-abandonment",
1034
- file: file.path,
1035
- lineNumber: added.lineNumber,
1036
- lineSnippet: rawLine.trim(),
1037
- commitHash: commit.hash,
1038
- commitDate: commit.date,
1039
- reason: rule.name
1040
- });
1041
- commitStubs++;
1042
- break;
1043
- }
1044
- }
1045
- }
1046
- }
1170
+ const audit = auditFileDiffs(commit.files, commit.hash, commit.date, {
1171
+ ignorePaths: this.ignorePaths,
1172
+ rules: this.rules
1173
+ });
1174
+ const buckets = {
1175
+ "test-skip": this.testSkips,
1176
+ "type-escape": this.typeEscapes,
1177
+ "swallowed-error": this.swallowedErrors,
1178
+ "trivial-assert": this.trivialAsserts,
1179
+ "stub-abandonment": this.stubAbandonments,
1180
+ "panic-loop": this.panicLoops
1181
+ };
1182
+ const counts = {};
1183
+ for (const infraction of audit.infractions) {
1184
+ const count = counts[infraction.type] || 0;
1185
+ if (count < MAX_INFRACTIONS_PER_COMMIT) buckets[infraction.type].push(infraction);
1186
+ counts[infraction.type] = count + 1;
1047
1187
  }
1048
1188
  }
1049
1189
  getSummary() {
@@ -1161,25 +1301,26 @@ function formatTerminalCard(summary, options) {
1161
1301
  );
1162
1302
  lines.push("");
1163
1303
  if (!summary.isSufficientData || summary.score === null) {
1164
- lines.push(pc.yellow(` insufficient data (< 200 lines added by AI)`));
1165
- lines.push(pc.dim(" commit more code and run `npx agent-roast` again."));
1304
+ lines.push(pc.yellow(` insufficient data (< 200 analyzed lines added)`));
1305
+ lines.push(pc.dim(" Findings below remain actionable; a score needs a larger sample."));
1306
+ lines.push("");
1307
+ }
1308
+ if (summary.isSufficientData && summary.score !== null) {
1309
+ let scoreColor = pc.green;
1310
+ if (summary.score < 60) scoreColor = pc.red;
1311
+ else if (summary.score < 80) scoreColor = pc.yellow;
1312
+ lines.push(
1313
+ ` ${pc.bold("SCORE:")} ${scoreColor(pc.bold(`${summary.score} / 100`))} ${pc.dim("\xB7")} ${pc.magenta(pc.bold(summary.archetype))}`
1314
+ );
1315
+ lines.push(` ${pc.dim(summary.archetypeDescription)}`);
1166
1316
  lines.push("");
1167
- return lines.join("\n");
1168
1317
  }
1169
- let scoreColor = pc.green;
1170
- if (summary.score < 60) scoreColor = pc.red;
1171
- else if (summary.score < 80) scoreColor = pc.yellow;
1172
- lines.push(
1173
- ` ${pc.bold("SCORE:")} ${scoreColor(pc.bold(`${summary.score} / 100`))} ${pc.dim("\xB7")} ${pc.magenta(pc.bold(summary.archetype))}`
1174
- );
1175
- lines.push(` ${pc.dim(summary.archetypeDescription)}`);
1176
- lines.push("");
1177
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);
1178
1319
  lines.push(pc.bold(` INFRACTIONS (${totalInfractions})`));
1179
1320
  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"));
1180
1321
  if (summary.panicLoops && summary.panicLoops.length > 0) {
1181
1322
  lines.push(
1182
- ` ${pc.magenta("\u25CF")} ${pc.bold("panic-loop")} (${summary.panicLoops.length}) ${pc.dim("consecutive quick fixes < 15m apart")}`
1323
+ ` ${pc.magenta("\u25CF")} ${pc.bold("panic-loop")} (${summary.panicLoops.length}) ${pc.dim("rapid consecutive fixes touching overlapping files")}`
1183
1324
  );
1184
1325
  const sample = summary.panicLoops[0];
1185
1326
  lines.push(` ${pc.dim(sample.reason)}`);
@@ -1278,10 +1419,10 @@ function formatPrTerminalCard(result) {
1278
1419
  const lines = [];
1279
1420
  lines.push("");
1280
1421
  lines.push(
1281
- `${pc.bold(pc.cyan("agent-roast"))} ${pc.dim("\u2014 pull request audit")} ${pc.white(`${result.baseSha.slice(0, 7)}...${result.headSha.slice(0, 7)}`)}`
1422
+ `${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)}`)}`
1282
1423
  );
1283
1424
  if (!result.hasSupportedChanges) {
1284
- lines.push(` ${pc.dim("no supported source changes detected in pull request.")}`);
1425
+ lines.push(` ${pc.dim("no supported source changes detected in the audited diff.")}`);
1285
1426
  lines.push("");
1286
1427
  return lines.join("\n");
1287
1428
  }
@@ -1290,7 +1431,7 @@ function formatPrTerminalCard(result) {
1290
1431
  );
1291
1432
  lines.push("");
1292
1433
  if (result.infractions.length === 0) {
1293
- lines.push(` ${pc.green("PASS")} ${pc.dim("zero infractions detected across all pull request changes.")}`);
1434
+ lines.push(` ${pc.green("PASS")} ${pc.dim("zero infractions detected across all audited changes.")}`);
1294
1435
  } else {
1295
1436
  lines.push(pc.bold(` INFRACTIONS DETECTED (${result.infractions.length})`));
1296
1437
  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"));
@@ -1302,6 +1443,7 @@ function formatPrTerminalCard(result) {
1302
1443
  if (inf.lineSnippet) {
1303
1444
  lines.push(` ${pc.dim(`\u2514\u2500 ${inf.lineSnippet}`)}`);
1304
1445
  }
1446
+ if (inf.suggestion) lines.push(` ${pc.dim(`Next: ${inf.suggestion}`)}`);
1305
1447
  }
1306
1448
  }
1307
1449
  lines.push("");
@@ -1328,6 +1470,10 @@ function sortInfractionsDeterministic(infractions) {
1328
1470
  return (a.lineNumber || 0) - (b.lineNumber || 0);
1329
1471
  });
1330
1472
  }
1473
+ function escapeCommand(value, property = false) {
1474
+ const escaped = value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
1475
+ return property ? escaped.replace(/:/g, "%3A").replace(/,/g, "%2C") : escaped;
1476
+ }
1331
1477
  function formatGithubAnnotations(result) {
1332
1478
  const outputLines = [];
1333
1479
  const sorted = sortInfractionsDeterministic(result.infractions);
@@ -1337,20 +1483,20 @@ function formatGithubAnnotations(result) {
1337
1483
  let omittedCount = 0;
1338
1484
  for (const inf of sorted) {
1339
1485
  if (!inf.file || inf.file === "unknown") continue;
1340
- const isError = result.failedRules.includes(inf.type) || inf.type === "test-skip" || inf.type === "trivial-assert" || inf.type === "swallowed-error";
1486
+ const isError = result.failedRules.includes(inf.type);
1341
1487
  const line = inf.lineNumber || 1;
1342
- const title = inf.reason.replace(/[\r\n]/g, " ");
1343
- const msg = (inf.lineSnippet || inf.reason).replace(/[\r\n]/g, " ");
1488
+ const title = escapeCommand(inf.reason, true);
1489
+ const msg = escapeCommand(inf.lineSnippet || inf.reason);
1344
1490
  if (isError) {
1345
1491
  if (errorCount < MAX_PER_LEVEL) {
1346
- outputLines.push(`::error file=${inf.file},line=${line},title=${title}::${msg}`);
1492
+ outputLines.push(`::error file=${escapeCommand(inf.file, true)},line=${line},title=${title}::${msg}`);
1347
1493
  errorCount++;
1348
1494
  } else {
1349
1495
  omittedCount++;
1350
1496
  }
1351
1497
  } else {
1352
1498
  if (warnCount < MAX_PER_LEVEL) {
1353
- outputLines.push(`::warning file=${inf.file},line=${line},title=${title}::${msg}`);
1499
+ outputLines.push(`::warning file=${escapeCommand(inf.file, true)},line=${line},title=${title}::${msg}`);
1354
1500
  warnCount++;
1355
1501
  } else {
1356
1502
  omittedCount++;
@@ -1366,10 +1512,10 @@ function formatGithubAnnotations(result) {
1366
1512
  }
1367
1513
  function formatGithubStepSummary(result) {
1368
1514
  const lines = [];
1369
- lines.push("### agent-roast Pull Request Audit");
1515
+ lines.push(result.mode === "staged" ? "### agent-roast Staged Changes Audit" : "### agent-roast Pull Request Audit");
1370
1516
  lines.push("");
1371
1517
  if (!result.hasSupportedChanges) {
1372
- lines.push("No supported source file changes detected in this pull request.");
1518
+ lines.push("No supported source file changes detected in the audited diff.");
1373
1519
  return lines.join("\n");
1374
1520
  }
1375
1521
  const statusBadge = result.passed ? "Passed" : `Failed (${result.failedRules.join(", ")})`;
@@ -1404,7 +1550,7 @@ function formatPrMarkdownComment(result) {
1404
1550
  lines.push(`## \u{1F6E1}\uFE0F agent-roast audit: ${titleStatus} ${titleEmoji}`);
1405
1551
  lines.push("");
1406
1552
  if (!result.hasSupportedChanges) {
1407
- lines.push("No supported source file changes detected in this pull request.");
1553
+ lines.push("No supported source file changes detected in the audited diff.");
1408
1554
  lines.push("");
1409
1555
  lines.push("---");
1410
1556
  lines.push("*Audited 100% offline with [agent-roast](https://github.com/Nari-Ab/agent-roast)*");
@@ -1440,22 +1586,106 @@ function formatPrMarkdownComment(result) {
1440
1586
  lines.push("*Audited 100% offline with [agent-roast](https://github.com/Nari-Ab/agent-roast)*");
1441
1587
  return lines.join("\n");
1442
1588
  }
1443
-
1444
- // src/types.ts
1445
- var VALID_DETECTORS = [
1446
- "test-skip",
1447
- "type-escape",
1448
- "swallowed-error",
1449
- "panic-loop",
1450
- "trivial-assert",
1451
- "stub-abandonment"
1452
- ];
1589
+ function terminalText(value) {
1590
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
1591
+ }
1592
+ function formatCheckTerminal(result) {
1593
+ const lines = [
1594
+ "",
1595
+ pc.bold("agent-roast \u2014 current code check"),
1596
+ ` ${result.filesInspected} files \xB7 ${result.linesInspected} lines inspected`,
1597
+ ` ${result.infractions.length} findings to review \xB7 ${result.baselineCount} accepted baseline findings`
1598
+ ];
1599
+ if (result.staleBaselineCount) lines.push(` ${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
1600
+ for (const inf of result.infractions.slice(0, 20)) {
1601
+ 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}`);
1603
+ }
1604
+ if (result.infractions.length > 20) lines.push(`
1605
+ ${result.infractions.length - 20} more findings. Use --format json for the complete report.`);
1606
+ for (const skipped of result.skippedFiles) lines.push(`
1607
+ NOT SCANNED ${terminalText(skipped.file)}: ${terminalText(skipped.reason)}`);
1608
+ lines.push("", !result.complete ? pc.red(" INCOMPLETE: some source could not be inspected; exit 2.") : result.passed ? pc.green(" PASSED: no new blocking findings.") : pc.red(` BLOCKED: ${result.failedRules.join(", ")}.`), "");
1609
+ if (!result.filesInspected) lines.push(" No supported source files inspected.", "");
1610
+ return lines.join("\n");
1611
+ }
1612
+ 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, " ");
1614
+ }
1615
+ function formatCheckMarkdown(result) {
1616
+ const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
1617
+ const lines = [
1618
+ `## agent-roast current code check: ${status}`,
1619
+ "",
1620
+ `${result.filesInspected} files, ${result.linesInspected} lines inspected. ${result.infractions.length} findings to review; ${result.baselineCount} accepted baseline findings.`,
1621
+ "",
1622
+ "| Rule | Location | Finding | Next step |",
1623
+ "| --- | --- | --- | --- |"
1624
+ ];
1625
+ 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.")} |`);
1626
+ if (result.staleBaselineCount) lines.push("", `${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
1627
+ for (const skipped of result.skippedFiles) lines.push("", `Not scanned: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
1628
+ return lines.join("\n");
1629
+ }
1630
+ function formatReviewTerminal(result) {
1631
+ const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
1632
+ const lines = [
1633
+ "",
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)}`,
1636
+ ` ${result.filesInspected} changed files compared \xB7 ${result.linesInspected} source lines inspected across both versions`,
1637
+ ` ${result.infractions.length} new \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected`
1638
+ ];
1639
+ if (result.policyChanges.length) lines.push(" Candidate policy changes were ignored; adopt them separately after review.");
1640
+ if (result.excludedFiles || result.unsupportedFiles) lines.push(` ${result.excludedFiles} excluded \xB7 ${result.unsupportedFiles} unsupported/non-source changes`);
1641
+ for (const finding of result.infractions.slice(0, 20)) {
1642
+ lines.push(
1643
+ "",
1644
+ ` ${result.failedRules.includes(finding.type) ? "FAIL" : "WARN"} [${finding.type}] ${terminalText(finding.file || "unknown")}:${finding.lineNumber}`,
1645
+ ` ${terminalText(finding.lineSnippet || finding.reason)}`,
1646
+ ` Next: ${finding.suggestion || "Review this occurrence."}`
1647
+ );
1648
+ }
1649
+ if (result.infractions.length > 20) lines.push(` ${result.infractions.length - 20} more new findings; use --format json for all results.`);
1650
+ for (const skipped of result.skippedFiles) lines.push(` NOT COMPARED ${terminalText(skipped.file)}: ${terminalText(skipped.reason)}`);
1651
+ lines.push("", !result.complete ? " INCOMPLETE: some changed source was not compared (exit 2)." : result.passed ? " PASSED: no new blocking findings." : ` BLOCKED: ${result.failedRules.join(", ")}.`, "");
1652
+ if (!result.filesInspected) lines.push(" No supported changed source files compared.", "");
1653
+ return lines.join("\n");
1654
+ }
1655
+ function formatReviewMarkdown(result) {
1656
+ const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
1657
+ const lines = [
1658
+ `## agent-roast context review: ${status}`,
1659
+ "",
1660
+ `Compared ${result.filesInspected} changed files at \`${result.mergeBaseSha.slice(0, 7)}\` \u2192 \`${result.headSha.slice(0, 7)}\`. Policy: \`${result.policySha.slice(0, 7)}\`.`,
1661
+ "",
1662
+ `**${result.infractions.length} new** \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected.`,
1663
+ ""
1664
+ ];
1665
+ if (result.policyChanges.length) lines.push("Candidate policy changes were ignored. This review uses the supplied base commit's policy.", "");
1666
+ lines.push("| New finding | Head location | Evidence | Next step |", "| --- | --- | --- | --- |");
1667
+ 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.")} |`);
1668
+ if (result.removedFindings.length) {
1669
+ lines.push(
1670
+ "",
1671
+ "### No longer detected",
1672
+ "",
1673
+ "These patterns disappeared from the compared source. Deleting code is not proof of a behavioral fix.",
1674
+ "",
1675
+ "| Rule | Previous location | Previous evidence |",
1676
+ "| --- | --- | --- |"
1677
+ );
1678
+ for (const finding of result.removedFindings) lines.push(`| ${finding.type} | ${markdownCell(`${finding.file}:${finding.lineNumber}`)} | ${markdownCell(finding.lineSnippet || finding.reason)} |`);
1679
+ }
1680
+ for (const skipped of result.skippedFiles) lines.push("", `Not compared: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
1681
+ lines.push("", `Scope: changed supported files only; ${result.excludedFiles} excluded and ${result.unsupportedFiles} unsupported/non-source changes.`);
1682
+ return lines.join("\n");
1683
+ }
1453
1684
 
1454
1685
  // src/init.ts
1455
1686
  import * as fs2 from "fs";
1456
1687
  import * as path2 from "path";
1457
1688
  var DEFAULT_CONFIG_TEMPLATE = {
1458
- $schema: "https://raw.githubusercontent.com/Nari-Ab/agent-roast/main/schema.json",
1459
1689
  ignorePaths: [
1460
1690
  "**/*.min.js",
1461
1691
  "**/vendor/**",
@@ -1463,21 +1693,16 @@ var DEFAULT_CONFIG_TEMPLATE = {
1463
1693
  "**/build/**",
1464
1694
  "**/fixtures/**"
1465
1695
  ],
1466
- rules: {
1467
- "hallucinated-package": "error",
1468
- "empty-catch": "warn",
1469
- "trivial-assert": "warn",
1470
- "stub-abandonment": "warn",
1471
- "mock-leak": "warn"
1472
- },
1696
+ rules: { ...DEFAULT_CONFIG.rules },
1473
1697
  scoreThreshold: 70
1474
1698
  };
1475
1699
  var GITHUB_WORKFLOW_TEMPLATE = `name: Agent Roast
1476
1700
  on:
1477
1701
  pull_request:
1478
1702
  branches: [main, master]
1479
- push:
1480
- branches: [main, master]
1703
+
1704
+ permissions:
1705
+ contents: read
1481
1706
 
1482
1707
  jobs:
1483
1708
  audit:
@@ -1489,22 +1714,18 @@ jobs:
1489
1714
  with:
1490
1715
  fetch-depth: 0
1491
1716
 
1492
- - name: Setup Node.js
1493
- uses: actions/setup-node@v4
1494
- with:
1495
- node-version: 20
1496
-
1497
1717
  - name: Run agent-roast PR check
1498
1718
  if: github.event_name == 'pull_request'
1499
- run: npx agent-roast --base \${{ github.event.pull_request.base.sha }} --head \${{ github.event.pull_request.head.sha }} --format github
1500
-
1501
- - name: Run agent-roast repo audit
1502
- if: github.event_name != 'pull_request'
1503
- run: npx agent-roast --fail-under 70
1719
+ uses: Nari-Ab/agent-roast@v1
1720
+ with:
1721
+ base: \${{ github.event.pull_request.base.sha }}
1722
+ head: \${{ github.event.pull_request.head.sha }}
1723
+ format: github
1724
+ mode: review
1504
1725
  `;
1505
1726
  var PRE_COMMIT_HOOK_TEMPLATE = `#!/bin/sh
1506
1727
  # Run agent-roast pre-commit verification
1507
- npx agent-roast --fail-under 70
1728
+ npx --no-install agent-roast --staged
1508
1729
  `;
1509
1730
  async function initProject(options = {}) {
1510
1731
  const cwd = path2.resolve(options.cwd || process.cwd());
@@ -1512,7 +1733,7 @@ async function initProject(options = {}) {
1512
1733
  const skipped = [];
1513
1734
  const configPath = path2.join(cwd, ".agentroastrc.json");
1514
1735
  const altConfigPath = path2.join(cwd, "agent-roast.config.json");
1515
- if (fs2.existsSync(configPath) || fs2.existsSync(altConfigPath)) {
1736
+ if (fs2.existsSync(configPath) || fs2.existsSync(altConfigPath) || fs2.existsSync(path2.join(cwd, ".agentroastrc"))) {
1516
1737
  skipped.push(".agentroastrc.json");
1517
1738
  } else {
1518
1739
  fs2.writeFileSync(
@@ -1613,6 +1834,539 @@ function generateShieldsJson(score, label = "AI Discipline") {
1613
1834
  };
1614
1835
  }
1615
1836
 
1837
+ // src/sarif.ts
1838
+ 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
+ var require2 = createRequire(import.meta.url);
1934
+ var version = require2("../package.json").version;
1935
+ function formatSarif(report) {
1936
+ const occurrences = /* @__PURE__ */ new Map();
1937
+ const results = report.infractions.map((finding) => {
1938
+ const filename = finding.file;
1939
+ if (!filename || filename.startsWith("/") || filename.split("/").includes("..") || /^[a-z]:/i.test(filename)) {
1940
+ throw new ConfigError("SARIF needs repository-relative finding paths.");
1941
+ }
1942
+ const fingerprint = findingFingerprint(finding);
1943
+ const occurrence = (occurrences.get(fingerprint) || 0) + 1;
1944
+ occurrences.set(fingerprint, occurrence);
1945
+ return {
1946
+ ruleId: finding.type,
1947
+ ruleIndex: VALID_DETECTORS.indexOf(finding.type),
1948
+ level: report.failedRules.includes(finding.type) ? "error" : "warning",
1949
+ message: { text: `${finding.reason}. ${finding.suggestion || RULE_GUIDES[finding.type].suggestion}` },
1950
+ locations: [{ physicalLocation: {
1951
+ artifactLocation: { uri: filename.split("/").map((segment) => encodeURIComponent(segment)).join("/") },
1952
+ region: { startLine: Math.max(1, finding.lineNumber || 1) }
1953
+ } }],
1954
+ partialFingerprints: { "agentRoastFinding/v1": `${fingerprint}:${occurrence}` },
1955
+ ...report.mode === "review" ? { baselineState: "new" } : {}
1956
+ };
1957
+ });
1958
+ return JSON.stringify({
1959
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
1960
+ version: "2.1.0",
1961
+ runs: [{
1962
+ tool: { driver: {
1963
+ name: "agent-roast",
1964
+ semanticVersion: version,
1965
+ informationUri: "https://github.com/Nari-Ab/agent-roast",
1966
+ rules: VALID_DETECTORS.map((rule) => ({
1967
+ id: rule,
1968
+ name: rule,
1969
+ shortDescription: { text: RULE_GUIDES[rule].title },
1970
+ fullDescription: { text: RULE_GUIDES[rule].why },
1971
+ help: { text: `${RULE_GUIDES[rule].suggestion}
1972
+ Example: ${RULE_GUIDES[rule].example}` },
1973
+ defaultConfiguration: { level: "warning" },
1974
+ properties: { tags: ["maintainability", "code-review"] }
1975
+ }))
1976
+ } },
1977
+ invocations: [{
1978
+ executionSuccessful: report.complete,
1979
+ exitCode: report.exitCode,
1980
+ toolExecutionNotifications: report.skippedFiles.map((skipped) => ({
1981
+ level: "error",
1982
+ message: { text: `Not inspected: ${skipped.file}. ${skipped.reason}` }
1983
+ }))
1984
+ }],
1985
+ results,
1986
+ properties: report.mode === "review" ? {
1987
+ scope: "introduced findings in changed files",
1988
+ baseSha: report.baseSha,
1989
+ headSha: report.headSha,
1990
+ mergeBaseSha: report.mergeBaseSha,
1991
+ policySha: report.policySha,
1992
+ unchangedFindings: report.unchangedCount,
1993
+ removedFindings: report.removedFindings.length
1994
+ } : { scope: "current source", acceptedBaselineFindings: report.baselineCount }
1995
+ }]
1996
+ }, null, 2);
1997
+ }
1998
+
1999
+ // src/check.ts
2000
+ import { spawnSync as spawnSync2 } from "child_process";
2001
+ import * as fs4 from "fs/promises";
2002
+ 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
+ import path5 from "path";
2132
+
2133
+ // src/snapshot.ts
2134
+ import { spawn as spawn2 } from "child_process";
2135
+ function assertNotAborted(signal) {
2136
+ if (signal?.aborted) throw new ConfigError("Review cancelled; no complete result was produced.");
2137
+ }
2138
+ async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024) {
2139
+ assertNotAborted(signal);
2140
+ 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];
2142
+ return new Promise((resolve2, reject) => {
2143
+ const child = spawn2("git", [...HERMETIC_GIT_FLAGS, ...args], {
2144
+ cwd,
2145
+ env,
2146
+ stdio: ["ignore", "pipe", "pipe"],
2147
+ windowsHide: true
2148
+ });
2149
+ const chunks = [];
2150
+ let bytes = 0;
2151
+ let stderr = "";
2152
+ let settled = false;
2153
+ const finish = (error) => {
2154
+ if (settled) return;
2155
+ settled = true;
2156
+ clearTimeout(timer);
2157
+ signal?.removeEventListener("abort", abort);
2158
+ if (error) {
2159
+ child.kill();
2160
+ reject(error);
2161
+ } else resolve2(Buffer.concat(chunks));
2162
+ };
2163
+ const abort = () => finish(new ConfigError("Review cancelled; no complete result was produced."));
2164
+ const timer = setTimeout(() => finish(new ConfigError("Git review read timed out after 30 seconds.")), 3e4);
2165
+ signal?.addEventListener("abort", abort, { once: true });
2166
+ child.stdout.on("data", (chunk) => {
2167
+ if (settled) return;
2168
+ bytes += chunk.length;
2169
+ if (bytes > limit) finish(new ConfigError(`Git read exceeds the ${limit} byte limit.`));
2170
+ else chunks.push(chunk);
2171
+ });
2172
+ child.stderr.on("data", (chunk) => {
2173
+ if (stderr.length < 64 * 1024) stderr += chunk.toString("utf8").slice(0, 64 * 1024 - stderr.length);
2174
+ });
2175
+ child.on("error", (error) => finish(error));
2176
+ child.on("close", (code) => finish(code === 0 ? void 0 : new GitError(stderr, code)));
2177
+ if (signal?.aborted) abort();
2178
+ });
2179
+ }
2180
+ async function reviewRefs(cwd, base, head, signal) {
2181
+ const resolve2 = async (ref) => {
2182
+ if (!ref.trim()) throw new ConfigError("Review refs must not be empty.");
2183
+ try {
2184
+ return (await readGit(cwd, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], signal, 1024)).toString().trim();
2185
+ } catch (error) {
2186
+ assertNotAborted(signal);
2187
+ 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
+ }
2189
+ };
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
+ }
2198
+ const baseSha = await resolve2(base);
2199
+ const headSha = await resolve2(head);
2200
+ let ancestors;
2201
+ try {
2202
+ ancestors = (await readGit(cwd, ["merge-base", "--all", "--end-of-options", baseSha, headSha], signal, 8192)).toString().trim().split("\n");
2203
+ } catch (error) {
2204
+ assertNotAborted(signal);
2205
+ throw new ConfigError(`Cannot find review merge-base. Fetch full shared history (fetch-depth: 0). ${error instanceof Error ? error.message : String(error)}`);
2206
+ }
2207
+ if (ancestors.length !== 1 || !/^[a-f0-9]{40,64}$/.test(ancestors[0])) throw new ConfigError("Review needs exactly one merge base; ambiguous merge ancestry is unsupported.");
2208
+ return { baseSha, headSha, mergeBaseSha: ancestors[0] };
2209
+ }
2210
+ async function changedTreeFiles(cwd, before, after, signal) {
2211
+ 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);
2212
+ const tokens = new TextDecoder("utf-8", { fatal: true }).decode(buffer).split("\0");
2213
+ if (tokens.pop() !== "") throw new ConfigError("Truncated Git change inventory.");
2214
+ const changes = [];
2215
+ for (let i = 0; i < tokens.length; ) {
2216
+ const match = /^:(\d{6}) (\d{6}) ([a-f0-9]{40,64}) ([a-f0-9]{40,64}) ([AMDRT]\d*)$/.exec(tokens[i++]);
2217
+ if (!match) throw new ConfigError("Unsupported Git change inventory entry.");
2218
+ const oldPath = tokens[i++];
2219
+ const newPath = match[5].startsWith("R") ? tokens[i++] : oldPath;
2220
+ if (!oldPath || !newPath) throw new ConfigError("Incomplete Git path inventory.");
2221
+ changes.push({ oldMode: match[1], newMode: match[2], oldOid: match[3], newOid: match[4], status: match[5], oldPath, newPath });
2222
+ if (changes.length > 1e3) throw new ConfigError("Review exceeds the 1000 changed-file limit; split the change into smaller reviews.");
2223
+ }
2224
+ return changes;
2225
+ }
2226
+ async function readBlob(cwd, oid, signal, limit = 2 * 1024 * 1024) {
2227
+ if (!/^[a-f0-9]{40,64}$/.test(oid)) throw new ConfigError("Invalid Git blob identity.");
2228
+ const sizeText = (await readGit(cwd, ["cat-file", "-s", oid], signal, 128)).toString().trim();
2229
+ const size = Number(sizeText);
2230
+ if (!Number.isSafeInteger(size) || size < 0 || size > limit) throw new ConfigError(`Source blob exceeds the ${limit} byte limit or has an invalid size.`);
2231
+ const buffer = await readGit(cwd, ["cat-file", "blob", oid], signal, limit);
2232
+ if (buffer.length !== size) throw new ConfigError("Git blob size differs from the recorded object.");
2233
+ return buffer;
2234
+ }
2235
+ async function reviewPolicy(cwd, baseSha, signal) {
2236
+ const raw = await readGit(cwd, ["ls-tree", "--full-tree", "-z", baseSha, "--", ...CONFIG_FILENAMES], signal, 8192);
2237
+ const entries = raw.toString().split("\0").filter(Boolean);
2238
+ for (const filename of CONFIG_FILENAMES) {
2239
+ const entry = entries.find((line) => line.slice(line.indexOf(" ") + 1) === filename);
2240
+ if (!entry) continue;
2241
+ const match = /^(100644|100755) blob ([a-f0-9]{40,64})\t/.exec(entry);
2242
+ if (!match) throw new ConfigError(`Base policy '${filename}' must be a regular Git blob.`);
2243
+ const content = await readBlob(cwd, match[2], signal, 64 * 1024);
2244
+ return { config: configFromText(new TextDecoder("utf-8", { fatal: true }).decode(content), `${filename} in base ${baseSha.slice(0, 7)}`), filename };
2245
+ }
2246
+ return { config: configFromText("{}", "default review policy"), filename: null };
2247
+ }
2248
+
2249
+ // src/review.ts
2250
+ var GENERATED_PATHS2 = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
2251
+ var MAX_SCAN_BYTES2 = 50 * 1024 * 1024;
2252
+ function compareFindings(before, after, newPath) {
2253
+ const old = /* @__PURE__ */ new Map();
2254
+ for (const finding of before) {
2255
+ const key = findingFingerprint({ ...finding, file: newPath });
2256
+ const bucket = old.get(key) || { findings: [], matched: 0 };
2257
+ bucket.findings.push(finding);
2258
+ old.set(key, bucket);
2259
+ }
2260
+ const introduced = [];
2261
+ let unchanged = 0;
2262
+ for (const finding of after) {
2263
+ const bucket = old.get(findingFingerprint(finding));
2264
+ if (bucket && bucket.matched < bucket.findings.length) {
2265
+ bucket.matched++;
2266
+ unchanged++;
2267
+ } else introduced.push(finding);
2268
+ }
2269
+ return { introduced, removed: [...old.values()].flatMap((bucket) => bucket.findings.slice(bucket.matched)), unchanged };
2270
+ }
2271
+ async function reviewChanges(options) {
2272
+ 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);
2276
+ const format = options.format ?? "terminal";
2277
+ 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"];
2279
+ 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);
2281
+ const policyChanges = changes.filter((change) => CONFIG_FILENAMES.includes(change.oldPath) || CONFIG_FILENAMES.includes(change.newPath)).map((change) => change.newPath);
2282
+ const ignores = [...GENERATED_PATHS2, ...policy.config.ignorePaths || []];
2283
+ const result = {
2284
+ schemaVersion: "1.0.0",
2285
+ mode: "review",
2286
+ ...refs,
2287
+ policySha: refs.baseSha,
2288
+ policyFile: policy.filename,
2289
+ policyChanges,
2290
+ filesChanged: changes.length,
2291
+ filesInspected: 0,
2292
+ unsupportedFiles: 0,
2293
+ excludedFiles: 0,
2294
+ renamedFiles: changes.filter((change) => change.status.startsWith("R")).length,
2295
+ linesInspected: 0,
2296
+ beforeFindings: 0,
2297
+ afterFindings: 0,
2298
+ unchangedCount: 0,
2299
+ infractions: [],
2300
+ removedFindings: [],
2301
+ skippedFiles: [],
2302
+ complete: true,
2303
+ failedRules: [],
2304
+ passed: false,
2305
+ exitCode: 2,
2306
+ output: ""
2307
+ };
2308
+ let totalBytes = 0;
2309
+ for (const change of changes) {
2310
+ assertNotAborted(options.signal);
2311
+ const oldSupported = change.oldMode !== "000000" && isCodeFile(change.oldPath);
2312
+ const newSupported = change.newMode !== "000000" && isCodeFile(change.newPath);
2313
+ if (!oldSupported && !newSupported) {
2314
+ result.unsupportedFiles++;
2315
+ continue;
2316
+ }
2317
+ const oldInScope = oldSupported && isCodeFile(change.oldPath, ignores);
2318
+ const newInScope = newSupported && isCodeFile(change.newPath, ignores);
2319
+ if (!oldInScope && !newInScope) {
2320
+ result.excludedFiles++;
2321
+ continue;
2322
+ }
2323
+ try {
2324
+ let inspectedLines = 0;
2325
+ const scan = async (mode, oid, filename, commit) => {
2326
+ if (mode === "000000") return [];
2327
+ if (!["100644", "100755"].includes(mode)) throw new ConfigError("Changed source is a symlink, submodule or unsupported Git mode; comparison is incomplete.");
2328
+ const content = await readBlob(cwd, oid, options.signal);
2329
+ totalBytes += content.length;
2330
+ if (totalBytes > MAX_SCAN_BYTES2) throw new ConfigError("Review exceeds the 50 MiB source comparison limit.");
2331
+ if (content.includes(0)) throw new ConfigError("Binary content in a source comparison.");
2332
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
2333
+ const lines = text.split(/\r?\n/);
2334
+ if (lines.at(-1) === "") lines.pop();
2335
+ inspectedLines += lines.length;
2336
+ return auditFileDiffs([{ path: filename, addedLines: lines.map((line, index) => ({ line, lineNumber: index + 1 })) }], commit, "", { rules: policy.config.rules }).infractions;
2337
+ };
2338
+ if (oldSupported && !newSupported && change.newMode !== "000000") {
2339
+ throw new ConfigError("Source moved to an unsupported extension; cannot claim its findings were resolved.");
2340
+ }
2341
+ const before = await scan(change.oldMode, change.oldOid, change.oldPath, refs.mergeBaseSha);
2342
+ const after = await scan(change.newMode, change.newOid, change.newPath, refs.headSha);
2343
+ const compared = compareFindings(before, after, change.newPath);
2344
+ result.infractions.push(...compared.introduced);
2345
+ result.removedFindings.push(...compared.removed);
2346
+ result.unchangedCount += compared.unchanged;
2347
+ result.beforeFindings += before.length;
2348
+ result.afterFindings += after.length;
2349
+ result.filesInspected++;
2350
+ result.linesInspected += inspectedLines;
2351
+ } catch (error) {
2352
+ assertNotAborted(options.signal);
2353
+ result.skippedFiles.push({ file: change.newPath, reason: error instanceof Error ? error.message : String(error) });
2354
+ if (totalBytes > MAX_SCAN_BYTES2) break;
2355
+ }
2356
+ }
2357
+ assertNotAborted(options.signal);
2358
+ result.complete = result.skippedFiles.length === 0;
2359
+ result.failedRules = [...new Set(result.infractions.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
2360
+ result.passed = result.complete && result.failedRules.length === 0;
2361
+ result.exitCode = !result.complete ? 2 : result.passed ? 0 : 1;
2362
+ if (format === "json") result.output = JSON.stringify(result, null, 2);
2363
+ else if (format === "markdown") result.output = formatReviewMarkdown(result);
2364
+ else if (format === "sarif") result.output = formatSarif(result);
2365
+ else if (format === "github") result.output = formatGithubAnnotations(result) + (!result.complete ? "\n::error::Context review incomplete. Inspect skipped source files in the JSON report." : "") + (policyChanges.length ? "\n::notice::Candidate policy changes were ignored; review uses policy from the supplied base commit." : "");
2366
+ else result.output = formatReviewTerminal(result);
2367
+ return result;
2368
+ }
2369
+
1616
2370
  // src/index.ts
1617
2371
  var InvalidDetectorError = class extends Error {
1618
2372
  constructor(message) {
@@ -1621,10 +2375,10 @@ var InvalidDetectorError = class extends Error {
1621
2375
  }
1622
2376
  };
1623
2377
  async function roastRepository(options = {}) {
1624
- const cwd = path3.resolve(options.cwd || process.cwd());
2378
+ const cwd = path6.resolve(options.cwd || process.cwd());
1625
2379
  const since = options.since || "90 days ago";
1626
- const repoName = path3.basename(cwd);
1627
- const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths });
2380
+ const repoName = path6.basename(cwd);
2381
+ const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths, rules: options.rules, panicLoopWindowMinutes: options.panicLoopWindowMinutes });
1628
2382
  const gitStream = streamGitLog(cwd, since);
1629
2383
  await parseGitLogStream(gitStream, (commit, stats) => {
1630
2384
  collector.processCommit(commit);
@@ -1638,7 +2392,7 @@ async function roastRepository(options = {}) {
1638
2392
  return { summary, output };
1639
2393
  }
1640
2394
  async function auditPullRequest(options) {
1641
- const cwd = path3.resolve(options.cwd || process.cwd());
2395
+ const cwd = path6.resolve(options.cwd || process.cwd());
1642
2396
  const format = options.format || "terminal";
1643
2397
  const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
1644
2398
  for (const det of failOn) {
@@ -1648,10 +2402,16 @@ async function auditPullRequest(options) {
1648
2402
  );
1649
2403
  }
1650
2404
  }
1651
- const baseSha = resolveCommitSha(cwd, options.base, "Base ref");
1652
- const headSha = resolveCommitSha(cwd, options.head, "Head ref");
1653
- const mergeBaseSha = findMergeBase(cwd, baseSha, headSha);
1654
- const diffStream = streamGitDiff(cwd, mergeBaseSha, headSha);
2405
+ if (options.staged && options.base) {
2406
+ throw new GitResolutionError("--staged cannot be combined with --base.");
2407
+ }
2408
+ if (!options.staged && !options.base) {
2409
+ throw new GitResolutionError("A base ref is required for a pull request audit.");
2410
+ }
2411
+ const baseSha = options.staged ? "INDEX" : resolveCommitSha(cwd, options.base, "Base ref");
2412
+ const headSha = options.staged ? "INDEX" : resolveCommitSha(cwd, options.head || "HEAD", "Head ref");
2413
+ const mergeBaseSha = options.staged ? "INDEX" : findMergeBase(cwd, baseSha, headSha);
2414
+ const diffStream = streamGitDiff(cwd, mergeBaseSha, headSha, options.staged);
1655
2415
  let rawDiff = "";
1656
2416
  await new Promise((resolve2, reject) => {
1657
2417
  diffStream.on("data", (chunk) => {
@@ -1661,7 +2421,7 @@ async function auditPullRequest(options) {
1661
2421
  diffStream.on("error", reject);
1662
2422
  });
1663
2423
  const { files, skippedLongLines, skippedUnknownFiles } = parseDiff(rawDiff);
1664
- const audit = auditFileDiffs(files, headSha, (/* @__PURE__ */ new Date()).toISOString(), { ignorePaths: options.ignorePaths });
2424
+ const audit = auditFileDiffs(files, headSha, (/* @__PURE__ */ new Date()).toISOString(), { ignorePaths: options.ignorePaths, rules: options.rules });
1665
2425
  const infractions = audit.infractions;
1666
2426
  const defectDensityPer100Lines = audit.linesAdded > 0 ? Math.round(infractions.length / audit.linesAdded * 100 * 100) / 100 : 0;
1667
2427
  const failedRules = [...new Set(
@@ -1671,6 +2431,7 @@ async function auditPullRequest(options) {
1671
2431
  const exitCode = passed ? 0 : 1;
1672
2432
  const result = {
1673
2433
  schemaVersion: "1.0.0",
2434
+ mode: options.staged ? "staged" : "pr",
1674
2435
  baseSha,
1675
2436
  headSha,
1676
2437
  mergeBaseSha,
@@ -1713,7 +2474,12 @@ export {
1713
2474
  resolveCommitSha,
1714
2475
  findMergeBase,
1715
2476
  GitResolutionError,
2477
+ RULE_GUIDES,
2478
+ VALID_DETECTORS,
1716
2479
  DEFAULT_CONFIG,
2480
+ CONFIG_FILENAMES,
2481
+ ConfigError,
2482
+ configFromText,
1717
2483
  loadConfig,
1718
2484
  matchIgnorePath,
1719
2485
  isCodeFile,
@@ -1727,7 +2493,10 @@ export {
1727
2493
  formatGithubAnnotations,
1728
2494
  formatGithubStepSummary,
1729
2495
  formatPrMarkdownComment,
1730
- VALID_DETECTORS,
2496
+ formatCheckTerminal,
2497
+ formatCheckMarkdown,
2498
+ formatReviewTerminal,
2499
+ formatReviewMarkdown,
1731
2500
  DEFAULT_CONFIG_TEMPLATE,
1732
2501
  GITHUB_WORKFLOW_TEMPLATE,
1733
2502
  PRE_COMMIT_HOOK_TEMPLATE,
@@ -1736,6 +2505,15 @@ export {
1736
2505
  generateSvgBadge,
1737
2506
  generateShieldsMarkdown,
1738
2507
  generateShieldsJson,
2508
+ findingFingerprint,
2509
+ createBaseline,
2510
+ loadBaseline,
2511
+ applyBaseline,
2512
+ writeBaseline,
2513
+ formatSarif,
2514
+ checkProject,
2515
+ compareFindings,
2516
+ reviewChanges,
1739
2517
  InvalidDetectorError,
1740
2518
  roastRepository,
1741
2519
  auditPullRequest