agent-roast 0.4.0 → 0.5.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 path5 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,67 @@ 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
+ }
574
757
  function loadConfig(cwd = process.cwd()) {
575
758
  for (const filename of CONFIG_FILENAMES) {
576
759
  const filePath = path.join(cwd, filename);
577
- if (fs.existsSync(filePath)) {
578
- 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.`);
595
- }
760
+ if (!fs.existsSync(filePath)) continue;
761
+ try {
762
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
763
+ validateConfig(parsed);
764
+ const rules = { ...DEFAULT_CONFIG.rules, ...parsed.rules };
765
+ const failOn = parsed.failOn ?? Object.entries(rules).filter(([, level]) => level === "error").map(([rule]) => rule);
766
+ return {
767
+ ...DEFAULT_CONFIG,
768
+ ...parsed,
769
+ failOn,
770
+ ignorePaths: [.../* @__PURE__ */ new Set([...DEFAULT_CONFIG.ignorePaths || [], ...parsed.ignorePaths || []])],
771
+ rules
772
+ };
773
+ } catch (err) {
774
+ throw new ConfigError(`Invalid ${filename}: ${err instanceof Error ? err.message : String(err)}`);
596
775
  }
597
776
  }
598
- return { ...DEFAULT_CONFIG };
777
+ return {
778
+ ...DEFAULT_CONFIG,
779
+ failOn: [...DEFAULT_CONFIG.failOn || []],
780
+ ignorePaths: [...DEFAULT_CONFIG.ignorePaths || []],
781
+ rules: { ...DEFAULT_CONFIG.rules }
782
+ };
599
783
  }
600
784
  function matchIgnorePath(filePath, ignorePatterns) {
601
785
  if (!ignorePatterns || ignorePatterns.length === 0) {
@@ -605,7 +789,25 @@ function matchIgnorePath(filePath, ignorePatterns) {
605
789
  for (const pattern of ignorePatterns) {
606
790
  if (!pattern) continue;
607
791
  const cleanPattern = pattern.replace(/\\/g, "/");
608
- let regexPattern = cleanPattern.replace(/[.+^${}()|[\]]/g, "\\$&").replace(/\*\*\//g, "(?:^|.*?/)").replace(/\/\*\*/g, "(?:/.*)?").replace(/\*/g, "[^/]*");
792
+ let regexPattern = "";
793
+ for (let i = 0; i < cleanPattern.length; i++) {
794
+ const char = cleanPattern[i];
795
+ if (char === "*" && cleanPattern[i + 1] === "*") {
796
+ i++;
797
+ if (cleanPattern[i + 1] === "/") {
798
+ regexPattern += "(?:.*/)?";
799
+ i++;
800
+ } else {
801
+ regexPattern += ".*";
802
+ }
803
+ } else if (char === "*") {
804
+ regexPattern += "[^/]*";
805
+ } else if (char === "?") {
806
+ regexPattern += "[^/]";
807
+ } else {
808
+ regexPattern += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
809
+ }
810
+ }
609
811
  if (!regexPattern.startsWith("^") && !regexPattern.startsWith("(?:^")) {
610
812
  regexPattern = "(?:^|/)" + regexPattern;
611
813
  }
@@ -628,7 +830,7 @@ function matchIgnorePath(filePath, ignorePatterns) {
628
830
 
629
831
  // src/metrics.ts
630
832
  var TEST_SKIP_PATTERNS = [
631
- { regex: /(?:it|test|describe)\.skip\b/, name: "skipped test (it.skip / describe.skip)" },
833
+ { regex: /\b(?:it|test|describe)\.skip\b/, name: "skipped test (it.skip / describe.skip)" },
632
834
  { regex: /\b(?:xit|xtest)\(/, name: "disabled test (xit / xtest)" },
633
835
  { regex: /@pytest\.mark\.skip\b/, name: "skipped pytest (@pytest.mark.skip)" },
634
836
  { regex: /\bpytest\.skip\(/, name: "imperative pytest skip (pytest.skip)" },
@@ -652,9 +854,9 @@ var SWALLOWED_ERROR_PATTERNS = [
652
854
  { regex: /except(?:\s+[^:]*)?:\s*pass\b/, name: "silent python error swallow (except: pass)" }
653
855
  ];
654
856
  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)" },
857
+ { regex: /expect\s*\(\s*(true|false|1|0|null|undefined)\s*\)\.toBe\s*\(\s*\1\s*\)/, name: "trivial assertion (expect(val).toBe(val))" },
858
+ { regex: /expect\s*\(\s*(true|false|1|0|null|undefined)\s*\)\.toEqual\s*\(\s*\1\s*\)/, name: "trivial assertion (expect(val).toEqual(val))" },
859
+ { regex: /\bassert\s+(?:True|1\s*==\s*1)\s*(?=$|#|,)/, name: "trivial python assert (assert True / 1 == 1)" },
658
860
  { regex: /self\.assertTrue\s*\(\s*(?:True|1)\s*\)/, name: "trivial assert (self.assertTrue(True))" },
659
861
  { regex: /self\.assertEqual\s*\(\s*(?:1\s*,\s*1|True\s*,\s*True)\s*\)/, name: "trivial assert (self.assertEqual(1, 1))" },
660
862
  { regex: /assert\.(?:strictEqual|equal)\s*\(\s*(?:true\s*,\s*true|1\s*,\s*1)\s*\)/, name: "trivial assert (assert.equal(1, 1))" }
@@ -687,7 +889,7 @@ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
687
889
  ".c"
688
890
  ]);
689
891
  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;
892
+ var TEST_PATH_REGEX = /(?:^|\/)test_[^/]+\.py$|(?:^|\/)(?:tests?|__tests__|specs?)\/|[._-](?:test|spec)\.[a-z0-9]+$/i;
691
893
  function isCodeFile(filePath, customIgnorePaths) {
692
894
  if (IGNORED_PATH_REGEX.test(filePath)) {
693
895
  return false;
@@ -712,11 +914,14 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
712
914
  hasSupportedChanges = true;
713
915
  filesInspected++;
714
916
  linesAdded += file.addedLines.length;
917
+ const masker = new SourceMasker(file.path);
918
+ const maskedLines = [];
715
919
  for (const added of file.addedLines) {
716
920
  const rawLine = added.line;
717
- const cleanedLine = stripStringAndRegexLiterals(rawLine);
718
- const trimmedCleaned = cleanedLine.trim();
719
- const isComment = isCommentOnlyLine(trimmedCleaned);
921
+ const masked = masker.mask(rawLine, added.lineNumber);
922
+ const cleanedLine = masked.code;
923
+ maskedLines.push({ code: cleanedLine, raw: rawLine, number: added.lineNumber });
924
+ const isComment = !cleanedLine.trim();
720
925
  if (!isComment) {
721
926
  for (const rule of TEST_SKIP_PATTERNS) {
722
927
  if (rule.regex.test(cleanedLine)) {
@@ -735,7 +940,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
735
940
  }
736
941
  let foundEscape = false;
737
942
  for (const rule of COMMENT_DIRECTIVE_PATTERNS) {
738
- if (rule.regex.test(cleanedLine)) {
943
+ if (rule.regex.test(masked.comments)) {
739
944
  infractions.push({
740
945
  type: "type-escape",
741
946
  file: file.path,
@@ -799,7 +1004,8 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
799
1004
  }
800
1005
  if (!isComment) {
801
1006
  for (const rule of STUB_ABANDONMENT_PATTERNS) {
802
- if (rule.regex.test(rawLine)) {
1007
+ const match = rule.regex.exec(rawLine);
1008
+ if (match && cleanedLine[match.index] === rawLine[match.index]) {
803
1009
  infractions.push({
804
1010
  type: "stub-abandonment",
805
1011
  file: file.path,
@@ -814,14 +1020,39 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
814
1020
  }
815
1021
  }
816
1022
  }
1023
+ for (let index = 0; index < maskedLines.length; index++) {
1024
+ const start = maskedLines[index];
1025
+ const isCatch = /\bcatch\s*(?:\([^)]*\))?\s*\{\s*$/.test(start.code);
1026
+ const isExcept = /\bexcept(?:\s+[^:]*)?:\s*$/.test(start.code);
1027
+ if (!isCatch && !isExcept) continue;
1028
+ let next = index + 1;
1029
+ while (next < maskedLines.length && maskedLines[next].number === maskedLines[next - 1].number + 1 && !maskedLines[next].code.trim()) next++;
1030
+ const end = maskedLines[next];
1031
+ if (!end || end.number !== maskedLines[next - 1].number + 1) continue;
1032
+ const closesCatch = isCatch && /^\s*}/.test(end.code);
1033
+ let closesExcept = isExcept && /^\s*pass\s*$/.test(end.code);
1034
+ if (closesExcept) {
1035
+ const indent = start.raw.search(/\S/);
1036
+ if (end.raw.search(/\S/) <= indent) closesExcept = false;
1037
+ let after = next + 1;
1038
+ while (after < maskedLines.length && maskedLines[after].number === maskedLines[after - 1].number + 1 && !maskedLines[after].code.trim()) after++;
1039
+ if (after < maskedLines.length && maskedLines[after].number === maskedLines[after - 1].number + 1 && maskedLines[after].raw.search(/\S/) > indent) closesExcept = false;
1040
+ }
1041
+ if (closesCatch || closesExcept) infractions.push({
1042
+ type: "swallowed-error",
1043
+ file: file.path,
1044
+ lineNumber: start.number,
1045
+ lineSnippet: maskedLines.slice(index, next + 1).map((line) => line.raw.trim()).join(" "),
1046
+ commitHash,
1047
+ commitDate,
1048
+ reason: closesCatch ? "empty multi-line catch block" : "silent multi-line python error swallow"
1049
+ });
1050
+ }
817
1051
  }
818
- return { infractions, hasSupportedChanges, filesInspected, linesAdded };
1052
+ return { infractions: infractions.filter((inf) => options.rules?.[inf.type] !== "off").map((inf) => ({ ...inf, suggestion: RULE_GUIDES[inf.type].suggestion })), hasSupportedChanges, filesInspected, linesAdded };
819
1053
  }
820
1054
  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("#");
1055
+ return new SourceMasker("source.ts").mask(line, 1).code;
825
1056
  }
826
1057
  var PANIC_COMMIT_REGEX = /\b(?:fix|quickfix|revert|hotfix|retry|patch|undo|workaround)\b/i;
827
1058
  var MAX_INFRACTIONS_PER_COMMIT = 10;
@@ -842,16 +1073,20 @@ var MetricsCollector = class {
842
1073
  activePanicChain = null;
843
1074
  includeAll;
844
1075
  ignorePaths;
1076
+ rules;
1077
+ panicLoopWindowMinutes;
845
1078
  constructor(options = {}) {
846
1079
  this.includeAll = options.all ?? false;
847
1080
  this.ignorePaths = options.ignorePaths;
1081
+ this.rules = options.rules;
1082
+ this.panicLoopWindowMinutes = options.panicLoopWindowMinutes ?? 15;
848
1083
  }
849
1084
  recordSkipped(stats) {
850
1085
  if (stats.skippedLongLines) this.skippedLongLines += stats.skippedLongLines;
851
1086
  if (stats.skippedUnknownFiles) this.skippedUnknownFiles += stats.skippedUnknownFiles;
852
1087
  }
853
1088
  flushPanicChain() {
854
- if (this.activePanicChain && this.activePanicChain.commitCount >= 2) {
1089
+ if (this.rules?.["panic-loop"] !== "off" && this.activePanicChain && this.activePanicChain.commitCount >= 2) {
855
1090
  const chain = this.activePanicChain;
856
1091
  const durationMinutes = Math.max(
857
1092
  1,
@@ -876,6 +1111,7 @@ var MetricsCollector = class {
876
1111
  this.aiLinesAdded += commit.totalAddedCount;
877
1112
  }
878
1113
  if (!isTarget) {
1114
+ this.flushPanicChain();
879
1115
  return;
880
1116
  }
881
1117
  const subject = commit.message.split("\n")[0];
@@ -888,7 +1124,7 @@ var MetricsCollector = class {
888
1124
  if (this.activePanicChain && this.activePanicChain.author.toLowerCase() === commit.authorEmail.toLowerCase()) {
889
1125
  const diffMinutes = Math.abs(commitDateMs - this.activePanicChain.latestDate) / (1e3 * 60);
890
1126
  const touchedSameFiles = currentActiveFiles.size > 0 && [...currentActiveFiles].some((p) => this.activePanicChain.activeFiles.has(p));
891
- if (diffMinutes > 0 && diffMinutes <= 15 && touchedSameFiles) {
1127
+ if (diffMinutes > 0 && diffMinutes <= this.panicLoopWindowMinutes && touchedSameFiles) {
892
1128
  this.activePanicChain.commitCount++;
893
1129
  this.activePanicChain.latestHash = commit.hash;
894
1130
  this.activePanicChain.latestDate = commitDateMs;
@@ -927,123 +1163,23 @@ var MetricsCollector = class {
927
1163
  } else {
928
1164
  this.flushPanicChain();
929
1165
  }
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
- }
1166
+ const audit = auditFileDiffs(commit.files, commit.hash, commit.date, {
1167
+ ignorePaths: this.ignorePaths,
1168
+ rules: this.rules
1169
+ });
1170
+ const buckets = {
1171
+ "test-skip": this.testSkips,
1172
+ "type-escape": this.typeEscapes,
1173
+ "swallowed-error": this.swallowedErrors,
1174
+ "trivial-assert": this.trivialAsserts,
1175
+ "stub-abandonment": this.stubAbandonments,
1176
+ "panic-loop": this.panicLoops
1177
+ };
1178
+ const counts = {};
1179
+ for (const infraction of audit.infractions) {
1180
+ const count = counts[infraction.type] || 0;
1181
+ if (count < MAX_INFRACTIONS_PER_COMMIT) buckets[infraction.type].push(infraction);
1182
+ counts[infraction.type] = count + 1;
1047
1183
  }
1048
1184
  }
1049
1185
  getSummary() {
@@ -1161,25 +1297,26 @@ function formatTerminalCard(summary, options) {
1161
1297
  );
1162
1298
  lines.push("");
1163
1299
  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."));
1300
+ lines.push(pc.yellow(` insufficient data (< 200 analyzed lines added)`));
1301
+ lines.push(pc.dim(" Findings below remain actionable; a score needs a larger sample."));
1302
+ lines.push("");
1303
+ }
1304
+ if (summary.isSufficientData && summary.score !== null) {
1305
+ let scoreColor = pc.green;
1306
+ if (summary.score < 60) scoreColor = pc.red;
1307
+ else if (summary.score < 80) scoreColor = pc.yellow;
1308
+ lines.push(
1309
+ ` ${pc.bold("SCORE:")} ${scoreColor(pc.bold(`${summary.score} / 100`))} ${pc.dim("\xB7")} ${pc.magenta(pc.bold(summary.archetype))}`
1310
+ );
1311
+ lines.push(` ${pc.dim(summary.archetypeDescription)}`);
1166
1312
  lines.push("");
1167
- return lines.join("\n");
1168
1313
  }
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
1314
  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
1315
  lines.push(pc.bold(` INFRACTIONS (${totalInfractions})`));
1179
1316
  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
1317
  if (summary.panicLoops && summary.panicLoops.length > 0) {
1181
1318
  lines.push(
1182
- ` ${pc.magenta("\u25CF")} ${pc.bold("panic-loop")} (${summary.panicLoops.length}) ${pc.dim("consecutive quick fixes < 15m apart")}`
1319
+ ` ${pc.magenta("\u25CF")} ${pc.bold("panic-loop")} (${summary.panicLoops.length}) ${pc.dim("rapid consecutive fixes touching overlapping files")}`
1183
1320
  );
1184
1321
  const sample = summary.panicLoops[0];
1185
1322
  lines.push(` ${pc.dim(sample.reason)}`);
@@ -1278,10 +1415,10 @@ function formatPrTerminalCard(result) {
1278
1415
  const lines = [];
1279
1416
  lines.push("");
1280
1417
  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)}`)}`
1418
+ `${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
1419
  );
1283
1420
  if (!result.hasSupportedChanges) {
1284
- lines.push(` ${pc.dim("no supported source changes detected in pull request.")}`);
1421
+ lines.push(` ${pc.dim("no supported source changes detected in the audited diff.")}`);
1285
1422
  lines.push("");
1286
1423
  return lines.join("\n");
1287
1424
  }
@@ -1290,7 +1427,7 @@ function formatPrTerminalCard(result) {
1290
1427
  );
1291
1428
  lines.push("");
1292
1429
  if (result.infractions.length === 0) {
1293
- lines.push(` ${pc.green("PASS")} ${pc.dim("zero infractions detected across all pull request changes.")}`);
1430
+ lines.push(` ${pc.green("PASS")} ${pc.dim("zero infractions detected across all audited changes.")}`);
1294
1431
  } else {
1295
1432
  lines.push(pc.bold(` INFRACTIONS DETECTED (${result.infractions.length})`));
1296
1433
  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 +1439,7 @@ function formatPrTerminalCard(result) {
1302
1439
  if (inf.lineSnippet) {
1303
1440
  lines.push(` ${pc.dim(`\u2514\u2500 ${inf.lineSnippet}`)}`);
1304
1441
  }
1442
+ if (inf.suggestion) lines.push(` ${pc.dim(`Next: ${inf.suggestion}`)}`);
1305
1443
  }
1306
1444
  }
1307
1445
  lines.push("");
@@ -1328,6 +1466,10 @@ function sortInfractionsDeterministic(infractions) {
1328
1466
  return (a.lineNumber || 0) - (b.lineNumber || 0);
1329
1467
  });
1330
1468
  }
1469
+ function escapeCommand(value, property = false) {
1470
+ const escaped = value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
1471
+ return property ? escaped.replace(/:/g, "%3A").replace(/,/g, "%2C") : escaped;
1472
+ }
1331
1473
  function formatGithubAnnotations(result) {
1332
1474
  const outputLines = [];
1333
1475
  const sorted = sortInfractionsDeterministic(result.infractions);
@@ -1337,20 +1479,20 @@ function formatGithubAnnotations(result) {
1337
1479
  let omittedCount = 0;
1338
1480
  for (const inf of sorted) {
1339
1481
  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";
1482
+ const isError = result.failedRules.includes(inf.type);
1341
1483
  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, " ");
1484
+ const title = escapeCommand(inf.reason, true);
1485
+ const msg = escapeCommand(inf.lineSnippet || inf.reason);
1344
1486
  if (isError) {
1345
1487
  if (errorCount < MAX_PER_LEVEL) {
1346
- outputLines.push(`::error file=${inf.file},line=${line},title=${title}::${msg}`);
1488
+ outputLines.push(`::error file=${escapeCommand(inf.file, true)},line=${line},title=${title}::${msg}`);
1347
1489
  errorCount++;
1348
1490
  } else {
1349
1491
  omittedCount++;
1350
1492
  }
1351
1493
  } else {
1352
1494
  if (warnCount < MAX_PER_LEVEL) {
1353
- outputLines.push(`::warning file=${inf.file},line=${line},title=${title}::${msg}`);
1495
+ outputLines.push(`::warning file=${escapeCommand(inf.file, true)},line=${line},title=${title}::${msg}`);
1354
1496
  warnCount++;
1355
1497
  } else {
1356
1498
  omittedCount++;
@@ -1366,10 +1508,10 @@ function formatGithubAnnotations(result) {
1366
1508
  }
1367
1509
  function formatGithubStepSummary(result) {
1368
1510
  const lines = [];
1369
- lines.push("### agent-roast Pull Request Audit");
1511
+ lines.push(result.mode === "staged" ? "### agent-roast Staged Changes Audit" : "### agent-roast Pull Request Audit");
1370
1512
  lines.push("");
1371
1513
  if (!result.hasSupportedChanges) {
1372
- lines.push("No supported source file changes detected in this pull request.");
1514
+ lines.push("No supported source file changes detected in the audited diff.");
1373
1515
  return lines.join("\n");
1374
1516
  }
1375
1517
  const statusBadge = result.passed ? "Passed" : `Failed (${result.failedRules.join(", ")})`;
@@ -1404,7 +1546,7 @@ function formatPrMarkdownComment(result) {
1404
1546
  lines.push(`## \u{1F6E1}\uFE0F agent-roast audit: ${titleStatus} ${titleEmoji}`);
1405
1547
  lines.push("");
1406
1548
  if (!result.hasSupportedChanges) {
1407
- lines.push("No supported source file changes detected in this pull request.");
1549
+ lines.push("No supported source file changes detected in the audited diff.");
1408
1550
  lines.push("");
1409
1551
  lines.push("---");
1410
1552
  lines.push("*Audited 100% offline with [agent-roast](https://github.com/Nari-Ab/agent-roast)*");
@@ -1440,22 +1582,52 @@ function formatPrMarkdownComment(result) {
1440
1582
  lines.push("*Audited 100% offline with [agent-roast](https://github.com/Nari-Ab/agent-roast)*");
1441
1583
  return lines.join("\n");
1442
1584
  }
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
- ];
1585
+ function terminalText(value) {
1586
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
1587
+ }
1588
+ function formatCheckTerminal(result) {
1589
+ const lines = [
1590
+ "",
1591
+ pc.bold("agent-roast \u2014 current code check"),
1592
+ ` ${result.filesInspected} files \xB7 ${result.linesInspected} lines inspected`,
1593
+ ` ${result.infractions.length} findings to review \xB7 ${result.baselineCount} accepted baseline findings`
1594
+ ];
1595
+ if (result.staleBaselineCount) lines.push(` ${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
1596
+ for (const inf of result.infractions.slice(0, 20)) {
1597
+ 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)}`);
1598
+ if (inf.suggestion) lines.push(` Next: ${inf.suggestion}`);
1599
+ }
1600
+ if (result.infractions.length > 20) lines.push(`
1601
+ ${result.infractions.length - 20} more findings. Use --format json for the complete report.`);
1602
+ for (const skipped of result.skippedFiles) lines.push(`
1603
+ NOT SCANNED ${terminalText(skipped.file)}: ${terminalText(skipped.reason)}`);
1604
+ 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(", ")}.`), "");
1605
+ if (!result.filesInspected) lines.push(" No supported source files inspected.", "");
1606
+ return lines.join("\n");
1607
+ }
1608
+ function markdownCell(value) {
1609
+ return value.replace(/[\\[\]()*_!]/g, "\\$&").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\|/g, "\\|").replace(/`/g, "&#96;").replace(/[\r\n]/g, " ");
1610
+ }
1611
+ function formatCheckMarkdown(result) {
1612
+ const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
1613
+ const lines = [
1614
+ `## agent-roast current code check: ${status}`,
1615
+ "",
1616
+ `${result.filesInspected} files, ${result.linesInspected} lines inspected. ${result.infractions.length} findings to review; ${result.baselineCount} accepted baseline findings.`,
1617
+ "",
1618
+ "| Rule | Location | Finding | Next step |",
1619
+ "| --- | --- | --- | --- |"
1620
+ ];
1621
+ 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.")} |`);
1622
+ if (result.staleBaselineCount) lines.push("", `${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
1623
+ for (const skipped of result.skippedFiles) lines.push("", `Not scanned: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
1624
+ return lines.join("\n");
1625
+ }
1453
1626
 
1454
1627
  // src/init.ts
1455
1628
  import * as fs2 from "fs";
1456
1629
  import * as path2 from "path";
1457
1630
  var DEFAULT_CONFIG_TEMPLATE = {
1458
- $schema: "https://raw.githubusercontent.com/Nari-Ab/agent-roast/main/schema.json",
1459
1631
  ignorePaths: [
1460
1632
  "**/*.min.js",
1461
1633
  "**/vendor/**",
@@ -1463,21 +1635,16 @@ var DEFAULT_CONFIG_TEMPLATE = {
1463
1635
  "**/build/**",
1464
1636
  "**/fixtures/**"
1465
1637
  ],
1466
- rules: {
1467
- "hallucinated-package": "error",
1468
- "empty-catch": "warn",
1469
- "trivial-assert": "warn",
1470
- "stub-abandonment": "warn",
1471
- "mock-leak": "warn"
1472
- },
1638
+ rules: { ...DEFAULT_CONFIG.rules },
1473
1639
  scoreThreshold: 70
1474
1640
  };
1475
1641
  var GITHUB_WORKFLOW_TEMPLATE = `name: Agent Roast
1476
1642
  on:
1477
1643
  pull_request:
1478
1644
  branches: [main, master]
1479
- push:
1480
- branches: [main, master]
1645
+
1646
+ permissions:
1647
+ contents: read
1481
1648
 
1482
1649
  jobs:
1483
1650
  audit:
@@ -1489,22 +1656,17 @@ jobs:
1489
1656
  with:
1490
1657
  fetch-depth: 0
1491
1658
 
1492
- - name: Setup Node.js
1493
- uses: actions/setup-node@v4
1494
- with:
1495
- node-version: 20
1496
-
1497
1659
  - name: Run agent-roast PR check
1498
1660
  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
1661
+ uses: Nari-Ab/agent-roast@v1
1662
+ with:
1663
+ base: \${{ github.event.pull_request.base.sha }}
1664
+ head: \${{ github.event.pull_request.head.sha }}
1665
+ format: github
1504
1666
  `;
1505
1667
  var PRE_COMMIT_HOOK_TEMPLATE = `#!/bin/sh
1506
1668
  # Run agent-roast pre-commit verification
1507
- npx agent-roast --fail-under 70
1669
+ npx --no-install agent-roast --staged
1508
1670
  `;
1509
1671
  async function initProject(options = {}) {
1510
1672
  const cwd = path2.resolve(options.cwd || process.cwd());
@@ -1512,7 +1674,7 @@ async function initProject(options = {}) {
1512
1674
  const skipped = [];
1513
1675
  const configPath = path2.join(cwd, ".agentroastrc.json");
1514
1676
  const altConfigPath = path2.join(cwd, "agent-roast.config.json");
1515
- if (fs2.existsSync(configPath) || fs2.existsSync(altConfigPath)) {
1677
+ if (fs2.existsSync(configPath) || fs2.existsSync(altConfigPath) || fs2.existsSync(path2.join(cwd, ".agentroastrc"))) {
1516
1678
  skipped.push(".agentroastrc.json");
1517
1679
  } else {
1518
1680
  fs2.writeFileSync(
@@ -1613,6 +1775,230 @@ function generateShieldsJson(score, label = "AI Discipline") {
1613
1775
  };
1614
1776
  }
1615
1777
 
1778
+ // src/check.ts
1779
+ import { spawnSync as spawnSync2 } from "child_process";
1780
+ import * as fs4 from "fs/promises";
1781
+ import { constants as constants2 } from "fs";
1782
+ import path4 from "path";
1783
+
1784
+ // src/baseline.ts
1785
+ import { createHash, randomUUID } from "crypto";
1786
+ import * as fs3 from "fs";
1787
+ import path3 from "path";
1788
+ function findingFingerprint(finding) {
1789
+ return createHash("sha256").update(JSON.stringify([finding.type, finding.file, finding.lineSnippet?.trim()])).digest("hex");
1790
+ }
1791
+ function createBaseline(findings) {
1792
+ const counts = /* @__PURE__ */ new Map();
1793
+ for (const finding of findings) {
1794
+ const fingerprint = findingFingerprint(finding);
1795
+ counts.set(fingerprint, (counts.get(fingerprint) || 0) + 1);
1796
+ }
1797
+ return {
1798
+ schemaVersion: "1.0.0",
1799
+ fingerprintVersion: 1,
1800
+ entries: [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([fingerprint, count]) => ({ fingerprint, count }))
1801
+ };
1802
+ }
1803
+ function loadBaseline(filename) {
1804
+ try {
1805
+ const stat = fs3.lstatSync(filename);
1806
+ if (!stat.isFile() || stat.size > 5 * 1024 * 1024) throw new Error("Expected a regular baseline file under 5 MiB.");
1807
+ const fd = fs3.openSync(filename, fs3.constants.O_RDONLY | (fs3.constants.O_NOFOLLOW || 0));
1808
+ let content;
1809
+ try {
1810
+ const opened = fs3.fstatSync(fd);
1811
+ if (opened.ino !== stat.ino || opened.dev !== stat.dev) throw new Error("Baseline changed while opening.");
1812
+ const buffer = Buffer.alloc(5 * 1024 * 1024 + 1);
1813
+ let size = 0;
1814
+ while (size < buffer.length) {
1815
+ const read = fs3.readSync(fd, buffer, size, buffer.length - size, null);
1816
+ if (!read) break;
1817
+ size += read;
1818
+ }
1819
+ const after = fs3.fstatSync(fd);
1820
+ if (size > 5 * 1024 * 1024 || size !== after.size || opened.mtimeMs !== after.mtimeMs) throw new Error("Baseline changed or exceeded the size limit.");
1821
+ content = buffer.subarray(0, size).toString("utf8");
1822
+ } finally {
1823
+ fs3.closeSync(fd);
1824
+ }
1825
+ const parsed = JSON.parse(content);
1826
+ if (!parsed || parsed.schemaVersion !== "1.0.0" || parsed.fingerprintVersion !== 1 || !Array.isArray(parsed.entries) || parsed.entries.length > 1e5) {
1827
+ throw new Error("Unsupported baseline schema.");
1828
+ }
1829
+ const seen = /* @__PURE__ */ new Set();
1830
+ for (const entry of parsed.entries) {
1831
+ 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)) {
1832
+ throw new Error("Invalid or duplicate baseline entry.");
1833
+ }
1834
+ seen.add(entry.fingerprint);
1835
+ }
1836
+ return parsed;
1837
+ } catch (error) {
1838
+ throw new ConfigError(`Cannot load baseline '${filename}': ${error instanceof Error ? error.message : String(error)}`);
1839
+ }
1840
+ }
1841
+ function applyBaseline(findings, baseline) {
1842
+ const remaining = new Map(baseline.entries.map((entry) => [entry.fingerprint, entry.count]));
1843
+ const fresh = [];
1844
+ let baselineCount = 0;
1845
+ for (const finding of findings) {
1846
+ const fingerprint = findingFingerprint(finding);
1847
+ const count = remaining.get(fingerprint) || 0;
1848
+ if (count > 0) {
1849
+ remaining.set(fingerprint, count - 1);
1850
+ baselineCount++;
1851
+ } else {
1852
+ fresh.push(finding);
1853
+ }
1854
+ }
1855
+ return { findings: fresh, baselineCount, staleBaselineCount: [...remaining.values()].reduce((a, b) => a + b, 0) };
1856
+ }
1857
+ function writeBaseline(filename, findings) {
1858
+ const destination = path3.resolve(filename);
1859
+ const temporary = path3.join(path3.dirname(destination), `.agent-roast-${randomUUID()}.tmp`);
1860
+ let fd;
1861
+ try {
1862
+ fd = fs3.openSync(temporary, "wx", 384);
1863
+ fs3.writeFileSync(fd, JSON.stringify(createBaseline(findings), null, 2) + "\n");
1864
+ fs3.fsyncSync(fd);
1865
+ fs3.closeSync(fd);
1866
+ fd = void 0;
1867
+ fs3.linkSync(temporary, destination);
1868
+ } catch (error) {
1869
+ throw new ConfigError(`Cannot write baseline '${filename}' (existing files are preserved): ${error instanceof Error ? error.message : String(error)}`);
1870
+ } finally {
1871
+ if (fd !== void 0) fs3.closeSync(fd);
1872
+ fs3.rmSync(temporary, { force: true });
1873
+ }
1874
+ }
1875
+
1876
+ // src/check.ts
1877
+ var MAX_FILE_BYTES = 2 * 1024 * 1024;
1878
+ var MAX_SCAN_BYTES = 50 * 1024 * 1024;
1879
+ var GENERATED_PATHS = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
1880
+ function git(cwd, args) {
1881
+ const result = spawnSync2("git", [...HERMETIC_GIT_FLAGS, ...args], {
1882
+ cwd,
1883
+ env: getHermeticGitEnv(),
1884
+ encoding: "utf8",
1885
+ maxBuffer: 16 * 1024 * 1024,
1886
+ windowsHide: true
1887
+ });
1888
+ if (result.error || result.status !== 0) throw new GitError(result.error?.message || result.stderr, result.status);
1889
+ return result.stdout;
1890
+ }
1891
+ function checkAborted(signal) {
1892
+ if (signal?.aborted) throw new ConfigError("Project check cancelled; no complete result was produced.");
1893
+ }
1894
+ function insideRoot(root, filename) {
1895
+ const relative = path4.relative(root, filename);
1896
+ return relative.length > 0 && relative !== ".." && !relative.startsWith(".." + path4.sep) && !path4.isAbsolute(relative);
1897
+ }
1898
+ async function readSource(root, file, signal) {
1899
+ const filename = path4.resolve(root, file);
1900
+ if (!insideRoot(root, filename)) throw new Error("File escapes the repository.");
1901
+ let stat;
1902
+ try {
1903
+ stat = await fs4.lstat(filename);
1904
+ } catch (error) {
1905
+ if (error.code === "ENOENT") return null;
1906
+ throw error;
1907
+ }
1908
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Symlinks and special files are not scanned.");
1909
+ const real = await fs4.realpath(filename);
1910
+ if (!insideRoot(root, real)) throw new Error("File resolves outside the repository.");
1911
+ if (stat.size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
1912
+ const handle = await fs4.open(filename, constants2.O_RDONLY | (constants2.O_NOFOLLOW || 0));
1913
+ try {
1914
+ const opened = await handle.stat();
1915
+ if (opened.ino !== stat.ino || opened.dev !== stat.dev || !opened.isFile()) throw new Error("File changed while opening.");
1916
+ const chunks = [];
1917
+ let size = 0;
1918
+ while (true) {
1919
+ checkAborted(signal);
1920
+ const chunk = Buffer.alloc(Math.min(64 * 1024, MAX_FILE_BYTES + 1 - size));
1921
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
1922
+ if (!bytesRead) break;
1923
+ size += bytesRead;
1924
+ if (size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
1925
+ chunks.push(chunk.subarray(0, bytesRead));
1926
+ }
1927
+ const after = await handle.stat();
1928
+ if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || size !== after.size) throw new Error("File changed while reading; rerun the check.");
1929
+ return Buffer.concat(chunks);
1930
+ } finally {
1931
+ await handle.close();
1932
+ }
1933
+ }
1934
+ async function checkProject(options = {}) {
1935
+ checkAborted(options.signal);
1936
+ const cwd = path4.resolve(options.cwd || process.cwd());
1937
+ const root = await fs4.realpath(git(cwd, ["rev-parse", "--show-toplevel"]).trim());
1938
+ const config = loadConfig(root);
1939
+ const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert"];
1940
+ for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown rule '${rule}'.`);
1941
+ const format = options.format || "terminal";
1942
+ if (!["terminal", "json", "github", "markdown"].includes(format)) throw new ConfigError("Unsupported check output format.");
1943
+ const baseline = options.baseline ? loadBaseline(path4.resolve(cwd, options.baseline)) : void 0;
1944
+ const filenames = [...new Set(git(root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]).split("\0").filter(Boolean))].sort();
1945
+ const infractions = [];
1946
+ const skippedFiles = [];
1947
+ let filesInspected = 0;
1948
+ let linesInspected = 0;
1949
+ let totalBytes = 0;
1950
+ for (const file of filenames) {
1951
+ checkAborted(options.signal);
1952
+ if (!isCodeFile(file, [...GENERATED_PATHS, ...config.ignorePaths || []])) continue;
1953
+ try {
1954
+ const content = await readSource(root, file, options.signal);
1955
+ if (content === null) continue;
1956
+ totalBytes += content.length;
1957
+ if (totalBytes > MAX_SCAN_BYTES) throw new Error("Project exceeds the 50 MiB scan limit.");
1958
+ if (content.includes(0)) throw new Error("Binary content in a source file.");
1959
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
1960
+ const lines = text.split(/\r?\n/);
1961
+ if (lines[lines.length - 1] === "") lines.pop();
1962
+ const audit = auditFileDiffs([{ path: file, addedLines: lines.map((line, index) => ({ line, lineNumber: index + 1 })) }], "WORKTREE", "", { rules: config.rules });
1963
+ infractions.push(...audit.infractions);
1964
+ filesInspected++;
1965
+ linesInspected += lines.length;
1966
+ } catch (error) {
1967
+ checkAborted(options.signal);
1968
+ skippedFiles.push({ file, reason: error instanceof Error ? error.message : String(error) });
1969
+ if (totalBytes > MAX_SCAN_BYTES) break;
1970
+ }
1971
+ }
1972
+ checkAborted(options.signal);
1973
+ const applied = baseline ? applyBaseline(infractions, baseline) : { findings: infractions, baselineCount: 0, staleBaselineCount: 0 };
1974
+ const findings = applied.findings;
1975
+ const failedRules = [...new Set(findings.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
1976
+ const complete = skippedFiles.length === 0;
1977
+ const passed = complete && failedRules.length === 0;
1978
+ const result = {
1979
+ schemaVersion: "1.0.0",
1980
+ mode: "check",
1981
+ filesInspected,
1982
+ linesInspected,
1983
+ totalFindings: infractions.length,
1984
+ infractions: findings,
1985
+ baselineCount: applied.baselineCount,
1986
+ staleBaselineCount: applied.staleBaselineCount,
1987
+ skippedFiles,
1988
+ complete,
1989
+ failedRules,
1990
+ passed,
1991
+ exitCode: complete ? passed ? 0 : 1 : 2,
1992
+ output: ""
1993
+ };
1994
+ if (format === "json") result.output = JSON.stringify(result, null, 2);
1995
+ else if (format === "markdown") result.output = formatCheckMarkdown(result);
1996
+ else if (format === "github") {
1997
+ result.output = formatGithubAnnotations(result) + (complete ? "" : "\n::error::Project scan incomplete. See the JSON or terminal report for skipped files.");
1998
+ } else result.output = formatCheckTerminal(result);
1999
+ return result;
2000
+ }
2001
+
1616
2002
  // src/index.ts
1617
2003
  var InvalidDetectorError = class extends Error {
1618
2004
  constructor(message) {
@@ -1621,10 +2007,10 @@ var InvalidDetectorError = class extends Error {
1621
2007
  }
1622
2008
  };
1623
2009
  async function roastRepository(options = {}) {
1624
- const cwd = path3.resolve(options.cwd || process.cwd());
2010
+ const cwd = path5.resolve(options.cwd || process.cwd());
1625
2011
  const since = options.since || "90 days ago";
1626
- const repoName = path3.basename(cwd);
1627
- const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths });
2012
+ const repoName = path5.basename(cwd);
2013
+ const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths, rules: options.rules, panicLoopWindowMinutes: options.panicLoopWindowMinutes });
1628
2014
  const gitStream = streamGitLog(cwd, since);
1629
2015
  await parseGitLogStream(gitStream, (commit, stats) => {
1630
2016
  collector.processCommit(commit);
@@ -1638,7 +2024,7 @@ async function roastRepository(options = {}) {
1638
2024
  return { summary, output };
1639
2025
  }
1640
2026
  async function auditPullRequest(options) {
1641
- const cwd = path3.resolve(options.cwd || process.cwd());
2027
+ const cwd = path5.resolve(options.cwd || process.cwd());
1642
2028
  const format = options.format || "terminal";
1643
2029
  const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
1644
2030
  for (const det of failOn) {
@@ -1648,10 +2034,16 @@ async function auditPullRequest(options) {
1648
2034
  );
1649
2035
  }
1650
2036
  }
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);
2037
+ if (options.staged && options.base) {
2038
+ throw new GitResolutionError("--staged cannot be combined with --base.");
2039
+ }
2040
+ if (!options.staged && !options.base) {
2041
+ throw new GitResolutionError("A base ref is required for a pull request audit.");
2042
+ }
2043
+ const baseSha = options.staged ? "INDEX" : resolveCommitSha(cwd, options.base, "Base ref");
2044
+ const headSha = options.staged ? "INDEX" : resolveCommitSha(cwd, options.head || "HEAD", "Head ref");
2045
+ const mergeBaseSha = options.staged ? "INDEX" : findMergeBase(cwd, baseSha, headSha);
2046
+ const diffStream = streamGitDiff(cwd, mergeBaseSha, headSha, options.staged);
1655
2047
  let rawDiff = "";
1656
2048
  await new Promise((resolve2, reject) => {
1657
2049
  diffStream.on("data", (chunk) => {
@@ -1661,7 +2053,7 @@ async function auditPullRequest(options) {
1661
2053
  diffStream.on("error", reject);
1662
2054
  });
1663
2055
  const { files, skippedLongLines, skippedUnknownFiles } = parseDiff(rawDiff);
1664
- const audit = auditFileDiffs(files, headSha, (/* @__PURE__ */ new Date()).toISOString(), { ignorePaths: options.ignorePaths });
2056
+ const audit = auditFileDiffs(files, headSha, (/* @__PURE__ */ new Date()).toISOString(), { ignorePaths: options.ignorePaths, rules: options.rules });
1665
2057
  const infractions = audit.infractions;
1666
2058
  const defectDensityPer100Lines = audit.linesAdded > 0 ? Math.round(infractions.length / audit.linesAdded * 100 * 100) / 100 : 0;
1667
2059
  const failedRules = [...new Set(
@@ -1671,6 +2063,7 @@ async function auditPullRequest(options) {
1671
2063
  const exitCode = passed ? 0 : 1;
1672
2064
  const result = {
1673
2065
  schemaVersion: "1.0.0",
2066
+ mode: options.staged ? "staged" : "pr",
1674
2067
  baseSha,
1675
2068
  headSha,
1676
2069
  mergeBaseSha,
@@ -1713,7 +2106,10 @@ export {
1713
2106
  resolveCommitSha,
1714
2107
  findMergeBase,
1715
2108
  GitResolutionError,
2109
+ RULE_GUIDES,
2110
+ VALID_DETECTORS,
1716
2111
  DEFAULT_CONFIG,
2112
+ ConfigError,
1717
2113
  loadConfig,
1718
2114
  matchIgnorePath,
1719
2115
  isCodeFile,
@@ -1727,7 +2123,8 @@ export {
1727
2123
  formatGithubAnnotations,
1728
2124
  formatGithubStepSummary,
1729
2125
  formatPrMarkdownComment,
1730
- VALID_DETECTORS,
2126
+ formatCheckTerminal,
2127
+ formatCheckMarkdown,
1731
2128
  DEFAULT_CONFIG_TEMPLATE,
1732
2129
  GITHUB_WORKFLOW_TEMPLATE,
1733
2130
  PRE_COMMIT_HOOK_TEMPLATE,
@@ -1736,6 +2133,12 @@ export {
1736
2133
  generateSvgBadge,
1737
2134
  generateShieldsMarkdown,
1738
2135
  generateShieldsJson,
2136
+ findingFingerprint,
2137
+ createBaseline,
2138
+ loadBaseline,
2139
+ applyBaseline,
2140
+ writeBaseline,
2141
+ checkProject,
1739
2142
  InvalidDetectorError,
1740
2143
  roastRepository,
1741
2144
  auditPullRequest