agent-roast 0.2.1 → 0.4.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.
package/README.md CHANGED
@@ -18,17 +18,29 @@
18
18
 
19
19
  </div>
20
20
 
21
- `agent-roast` scans recorded git diffs for shortcuts taken by AI coding assistants (Claude Code, Cursor, Codex, Antigravity, Aider). It checks for skipped tests, compiler type escapes (`as any`), swallowed errors, and panic revert loops, then outputs a discipline score and line proofs. 100% offline, zero API keys, zero telemetry.
21
+ `agent-roast` scans recorded git diffs for shortcuts taken by AI coding assistants (Claude Code, Cursor, Codex, Antigravity, Aider). It checks for skipped tests, fake tautological assertions, abandoned stubs, compiler type escapes (`as any`), swallowed errors, and panic revert loops, then outputs a discipline score and line proofs. 100% offline, zero API keys, zero telemetry.
22
22
 
23
23
  ```bash
24
+ # Initialize in your repository (creates .agentroastrc.json)
25
+ npx agent-roast init
26
+ npx agent-roast init --ci # generate .github/workflows/agent-roast.yml
27
+ npx agent-roast init --hook # install .git/hooks/pre-commit
28
+
24
29
  # Audit repository history
25
30
  npx agent-roast # scan AI-attributed commits (last 90 days)
26
31
  npx agent-roast --all # scan entire repository history
27
32
  npx agent-roast --verbose # show commit SHAs and line proofs
33
+ npx agent-roast --fail-under 70 # exit code 1 if score is below 70
28
34
 
29
35
  # Audit pull request diffs (CI gate)
30
36
  npx agent-roast --base origin/main --head HEAD
31
- npx agent-roast --base origin/main --head HEAD --fail-on test-skip,swallowed-error --format github
37
+ npx agent-roast --base origin/main --head HEAD --fail-on test-skip,trivial-assert,stub-abandonment --format github
38
+ npx agent-roast --base origin/main --format markdown # rich markdown for PR comments
39
+
40
+ # Generate AI discipline badge for your README
41
+ npx agent-roast badge # prints Shields.io Markdown snippet
42
+ npx agent-roast badge --out ./badge.svg # exports standalone SVG badge
43
+ npx agent-roast badge --json # Shields.io dynamic endpoint payload
32
44
  ```
33
45
 
34
46
  What a report looks like (a real repository audit, trimmed):
@@ -50,6 +62,12 @@ INFRACTIONS DETECTED (42)
50
62
  ● test-skip (6) — disabled or skipped test suites
51
63
  e.g. tests/test_outbound.py:18 — pytest.skip(...)
52
64
 
65
+ ● trivial-assert (2) — tautological assertions faking test coverage
66
+ e.g. tests/auth.test.ts:31 — expect(true).toBe(true)
67
+
68
+ ● stub-abandonment (1) — incomplete stubs left behind
69
+ e.g. src/payment.ts:18 — throw new Error("Not implemented")
70
+
53
71
  ● swallowed-error (2) — empty catch blocks or except: pass
54
72
  ```
55
73
 
@@ -59,12 +77,38 @@ INFRACTIONS DETECTED (42)
59
77
 
60
78
  ---
61
79
 
80
+ ### Configuration (`.agentroastrc.json`)
81
+
82
+ Configure custom ignore paths, rule severities, and CI thresholds with a `.agentroastrc.json` file in your repository root:
83
+
84
+ ```json
85
+ {
86
+ "$schema": "https://raw.githubusercontent.com/Nari-Ab/agent-roast/main/schema.json",
87
+ "ignorePaths": [
88
+ "**/*.min.js",
89
+ "**/vendor/**",
90
+ "**/dist/**",
91
+ "**/fixtures/**"
92
+ ],
93
+ "rules": {
94
+ "hallucinated-package": "error",
95
+ "empty-catch": "warn",
96
+ "trivial-assert": "warn",
97
+ "stub-abandonment": "warn",
98
+ "mock-leak": "warn"
99
+ },
100
+ "scoreThreshold": 70
101
+ }
102
+ ```
103
+
104
+ ---
105
+
62
106
  ### GitHub Actions Integration
63
107
 
64
- Add `agent-roast` as a gate in your pull request workflow (`.github/workflows/audit.yml`):
108
+ Add `agent-roast` as a gate in your pull request workflow (`.github/workflows/agent-roast.yml`):
65
109
 
66
110
  ```yaml
67
- name: Agent Audit
111
+ name: Agent Roast
68
112
  on:
69
113
  pull_request:
70
114
  branches: [main]
@@ -83,7 +127,7 @@ jobs:
83
127
  - name: Run agent-roast audit
84
128
  uses: Nari-Ab/agent-roast@v1
85
129
  with:
86
- fail-on: test-skip
130
+ fail-on: test-skip,trivial-assert,stub-abandonment
87
131
  format: github
88
132
  ```
89
133
 
@@ -96,6 +140,8 @@ jobs:
96
140
  Only **added lines** (`+`) in source code files are scanned — deleted lines, file moves, and comments never trigger false alarms.
97
141
 
98
142
  - **Skipped tests (`test-skip`):** `it.skip`, `describe.skip`, `xit`, `pytest.skip`, `test.todo` added to silence failing tests. (Default blocking gate).
143
+ - **Trivial assertions (`trivial-assert`):** Tautological or dummy assertions added to fabricate test green checks (`expect(true).toBe(true)`, `expect(1).toBe(1)`, `assert True`, `self.assertTrue(True)`).
144
+ - **Stub abandonment (`stub-abandonment`):** Dummy stubs left unimplemented in committed code (`throw new Error("Not implemented")`, `raise NotImplementedError`, Rust `todo!()` / `unimplemented!()`).
99
145
  - **Type escapes (`type-escape`):** `as any`, `as unknown as`, `as never`, `// @ts-ignore`, `// @ts-nocheck`, `// @ts-expect-error`, `# type: ignore`, `# noqa`, `eslint-disable`.
100
146
  - **Swallowed errors (`swallowed-error`):** Empty `catch {}` or `except: pass` blocks hiding runtime failures.
101
147
  - **Panic fix loops (`panic-loop`):** Heuristic chain analysis of rapid-fire fix/revert commits by the same author within 15 minutes touching overlapping files. *(Observational metric for repository audits; not a PR blocker)*.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import path from "path";
4
+ import path3 from "path";
5
5
 
6
6
  // src/git.ts
7
7
  import { spawn, spawnSync } from "child_process";
@@ -136,12 +136,12 @@ async function parseGitLogStream(stream, onCommit) {
136
136
  { skippedLongLines, skippedUnknownFiles }
137
137
  );
138
138
  };
139
- return new Promise((resolve, reject) => {
139
+ return new Promise((resolve2, reject) => {
140
140
  let settled = false;
141
141
  const safeResolve = (val) => {
142
142
  if (!settled) {
143
143
  settled = true;
144
- resolve(val);
144
+ resolve2(val);
145
145
  }
146
146
  };
147
147
  const safeReject = (err) => {
@@ -542,6 +542,90 @@ var GitResolutionError = class extends Error {
542
542
  }
543
543
  };
544
544
 
545
+ // src/config.ts
546
+ import * as fs from "fs";
547
+ import * as path from "path";
548
+ var DEFAULT_CONFIG = {
549
+ failOn: ["test-skip", "trivial-assert"],
550
+ ignorePaths: [
551
+ "**/fixtures/**",
552
+ "**/__fixtures__/**",
553
+ "**/testdata/**",
554
+ "**/mocks/**",
555
+ "**/test_data/**",
556
+ "**/__snapshots__/**",
557
+ "**/sample/**",
558
+ "**/samples/**"
559
+ ],
560
+ scoreThreshold: 70,
561
+ rules: {
562
+ "test-skip": "error",
563
+ "trivial-assert": "error",
564
+ "swallowed-error": "warn",
565
+ "stub-abandonment": "warn",
566
+ "type-escape": "warn"
567
+ }
568
+ };
569
+ var CONFIG_FILENAMES = [
570
+ ".agentroastrc.json",
571
+ ".agentroastrc",
572
+ "agent-roast.config.json"
573
+ ];
574
+ function loadConfig(cwd = process.cwd()) {
575
+ for (const filename of CONFIG_FILENAMES) {
576
+ 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
+ }
596
+ }
597
+ }
598
+ return { ...DEFAULT_CONFIG };
599
+ }
600
+ function matchIgnorePath(filePath, ignorePatterns) {
601
+ if (!ignorePatterns || ignorePatterns.length === 0) {
602
+ return false;
603
+ }
604
+ const normalized = filePath.replace(/\\/g, "/");
605
+ for (const pattern of ignorePatterns) {
606
+ if (!pattern) continue;
607
+ const cleanPattern = pattern.replace(/\\/g, "/");
608
+ let regexPattern = cleanPattern.replace(/[.+^${}()|[\]]/g, "\\$&").replace(/\*\*\//g, "(?:^|.*?/)").replace(/\/\*\*/g, "(?:/.*)?").replace(/\*/g, "[^/]*");
609
+ if (!regexPattern.startsWith("^") && !regexPattern.startsWith("(?:^")) {
610
+ regexPattern = "(?:^|/)" + regexPattern;
611
+ }
612
+ if (!regexPattern.endsWith("$")) {
613
+ regexPattern = regexPattern + "(?:$|/)";
614
+ }
615
+ try {
616
+ const rx = new RegExp(regexPattern, "i");
617
+ if (rx.test(normalized)) {
618
+ return true;
619
+ }
620
+ } catch {
621
+ if (normalized.includes(cleanPattern.replace(/\*/g, ""))) {
622
+ return true;
623
+ }
624
+ }
625
+ }
626
+ return false;
627
+ }
628
+
545
629
  // src/metrics.ts
546
630
  var TEST_SKIP_PATTERNS = [
547
631
  { regex: /(?:it|test|describe)\.skip\b/, name: "skipped test (it.skip / describe.skip)" },
@@ -567,6 +651,21 @@ var SWALLOWED_ERROR_PATTERNS = [
567
651
  { regex: /catch\s*(?:\([^)]*\))?\s*\{\s*\}/, name: "empty catch block (catch {})" },
568
652
  { regex: /except(?:\s+[^:]*)?:\s*pass\b/, name: "silent python error swallow (except: pass)" }
569
653
  ];
654
+ 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)" },
658
+ { regex: /self\.assertTrue\s*\(\s*(?:True|1)\s*\)/, name: "trivial assert (self.assertTrue(True))" },
659
+ { regex: /self\.assertEqual\s*\(\s*(?:1\s*,\s*1|True\s*,\s*True)\s*\)/, name: "trivial assert (self.assertEqual(1, 1))" },
660
+ { regex: /assert\.(?:strictEqual|equal)\s*\(\s*(?:true\s*,\s*true|1\s*,\s*1)\s*\)/, name: "trivial assert (assert.equal(1, 1))" }
661
+ ];
662
+ var STUB_ABANDONMENT_PATTERNS = [
663
+ { regex: /\bthrow\s+new\s+Error\s*\(\s*["'](?:TODO|Not implemented|to be implemented|stub|implement me)/i, name: "unimplemented stub (throw new Error('Not implemented'))" },
664
+ { regex: /\braise\s+NotImplementedError\b/, name: "unimplemented stub (raise NotImplementedError)" },
665
+ { regex: /\braise\s+NotImplemented\b/, name: "unimplemented stub (raise NotImplemented)" },
666
+ { regex: /\btodo!\s*\(\s*\)/, name: "unimplemented rust stub (todo!())" },
667
+ { regex: /\bunimplemented!\s*\(\s*\)/, name: "unimplemented rust stub (unimplemented!())" }
668
+ ];
570
669
  var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
571
670
  ".ts",
572
671
  ".tsx",
@@ -588,21 +687,28 @@ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
588
687
  ".c"
589
688
  ]);
590
689
  var IGNORED_PATH_REGEX = /(?:^|\/)(?:fixtures|__fixtures__|testdata|mocks|test_data|__snapshots__|sample[s]?)\//i;
591
- function isCodeFile(filePath) {
690
+ var TEST_PATH_REGEX = /(?:^|\/)(?:tests?|__tests__|specs?)\/|[._-](?:test|spec)\.[a-z0-9]+$/i;
691
+ function isCodeFile(filePath, customIgnorePaths) {
592
692
  if (IGNORED_PATH_REGEX.test(filePath)) {
593
693
  return false;
594
694
  }
695
+ if (customIgnorePaths && matchIgnorePath(filePath, customIgnorePaths)) {
696
+ return false;
697
+ }
595
698
  const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
596
699
  return CODE_EXTENSIONS.has(ext);
597
700
  }
598
- function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ */ new Date()).toISOString()) {
701
+ function isTestFile(filePath) {
702
+ return TEST_PATH_REGEX.test(filePath);
703
+ }
704
+ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ */ new Date()).toISOString(), options = {}) {
599
705
  const infractions = [];
600
706
  let hasSupportedChanges = false;
601
707
  let filesInspected = 0;
602
708
  let linesAdded = 0;
603
709
  for (const file of files) {
604
710
  if (file.deleted || file.path === "unknown") continue;
605
- if (!isCodeFile(file.path)) continue;
711
+ if (!isCodeFile(file.path, options.ignorePaths)) continue;
606
712
  hasSupportedChanges = true;
607
713
  filesInspected++;
608
714
  linesAdded += file.addedLines.length;
@@ -675,6 +781,38 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
675
781
  }
676
782
  }
677
783
  }
784
+ if (!isComment && isTestFile(file.path)) {
785
+ for (const rule of TRIVIAL_ASSERT_PATTERNS) {
786
+ if (rule.regex.test(cleanedLine)) {
787
+ infractions.push({
788
+ type: "trivial-assert",
789
+ file: file.path,
790
+ lineNumber: added.lineNumber,
791
+ lineSnippet: rawLine.trim(),
792
+ commitHash,
793
+ commitDate,
794
+ reason: rule.name
795
+ });
796
+ break;
797
+ }
798
+ }
799
+ }
800
+ if (!isComment) {
801
+ for (const rule of STUB_ABANDONMENT_PATTERNS) {
802
+ if (rule.regex.test(rawLine)) {
803
+ infractions.push({
804
+ type: "stub-abandonment",
805
+ file: file.path,
806
+ lineNumber: added.lineNumber,
807
+ lineSnippet: rawLine.trim(),
808
+ commitHash,
809
+ commitDate,
810
+ reason: rule.name
811
+ });
812
+ break;
813
+ }
814
+ }
815
+ }
678
816
  }
679
817
  }
680
818
  return { infractions, hasSupportedChanges, filesInspected, linesAdded };
@@ -697,12 +835,16 @@ var MetricsCollector = class {
697
835
  typeEscapes = [];
698
836
  swallowedErrors = [];
699
837
  panicLoops = [];
838
+ trivialAsserts = [];
839
+ stubAbandonments = [];
700
840
  skippedLongLines = 0;
701
841
  skippedUnknownFiles = 0;
702
842
  activePanicChain = null;
703
843
  includeAll;
844
+ ignorePaths;
704
845
  constructor(options = {}) {
705
846
  this.includeAll = options.all ?? false;
847
+ this.ignorePaths = options.ignorePaths;
706
848
  }
707
849
  recordSkipped(stats) {
708
850
  if (stats.skippedLongLines) this.skippedLongLines += stats.skippedLongLines;
@@ -788,8 +930,10 @@ var MetricsCollector = class {
788
930
  let commitSkips = 0;
789
931
  let commitEscapes = 0;
790
932
  let commitSwallowed = 0;
933
+ let commitTrivial = 0;
934
+ let commitStubs = 0;
791
935
  for (const file of commit.files) {
792
- if (!isCodeFile(file.path)) {
936
+ if (!isCodeFile(file.path, this.ignorePaths)) {
793
937
  continue;
794
938
  }
795
939
  for (const added of file.addedLines) {
@@ -865,6 +1009,40 @@ var MetricsCollector = class {
865
1009
  }
866
1010
  }
867
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
+ }
868
1046
  }
869
1047
  }
870
1048
  }
@@ -872,7 +1050,7 @@ var MetricsCollector = class {
872
1050
  this.flushPanicChain();
873
1051
  const linesSample = this.includeAll ? this.totalLinesAdded : this.aiLinesAdded;
874
1052
  const isSufficientData = linesSample >= MIN_SAMPLE_LOC;
875
- const codeInfractionPoints = this.testSkips.length * 15 + this.swallowedErrors.length * 10 + this.typeEscapes.length * 3;
1053
+ const codeInfractionPoints = this.testSkips.length * 15 + this.trivialAsserts.length * 15 + this.swallowedErrors.length * 10 + this.stubAbandonments.length * 8 + this.typeEscapes.length * 3;
876
1054
  let panicInfractionPoints = 0;
877
1055
  for (const loop of this.panicLoops) {
878
1056
  const countMatch = loop.reason.match(/chain of (\d+) quick fixes/);
@@ -901,16 +1079,24 @@ var MetricsCollector = class {
901
1079
  }
902
1080
  } else {
903
1081
  const testSkipPoints = this.testSkips.length * 15;
1082
+ const trivialPoints = this.trivialAsserts.length * 15;
904
1083
  const panicPoints = this.panicLoops.length * 8;
1084
+ const stubPoints = this.stubAbandonments.length * 8;
905
1085
  const escapePoints = this.typeEscapes.length * 3;
906
1086
  const swallowPoints = this.swallowedErrors.length * 10;
907
- const maxPoints = Math.max(testSkipPoints, panicPoints, escapePoints, swallowPoints);
1087
+ const maxPoints = Math.max(testSkipPoints, trivialPoints, panicPoints, stubPoints, escapePoints, swallowPoints);
908
1088
  if (maxPoints === panicPoints && this.panicLoops.length >= 2) {
909
1089
  archetype = "The Panic Looper";
910
1090
  archetypeDescription = "Prone to rapid-fire fix and revert thrashing cycles when wrestling stubborn bugs.";
911
1091
  } else if (maxPoints === testSkipPoints && this.testSkips.length >= 3) {
912
1092
  archetype = "The Silent Vandal";
913
1093
  archetypeDescription = "When test suites push back, tends to disable or skip tests to keep pipelines green.";
1094
+ } else if (maxPoints === trivialPoints && this.trivialAsserts.length >= 2) {
1095
+ archetype = "The Fake Asserter";
1096
+ archetypeDescription = "Masks broken tests by replacing real assertions with trivial expect(true).toBe(true) or assert True.";
1097
+ } else if (maxPoints === stubPoints && this.stubAbandonments.length >= 2) {
1098
+ archetype = "The Stub Abandoner";
1099
+ archetypeDescription = "Rushes through implementations by leaving unimplemented placeholders and TODO throws.";
914
1100
  } else if (maxPoints === escapePoints && this.typeEscapes.length >= 5) {
915
1101
  archetype = "The Any Architect";
916
1102
  archetypeDescription = "Leans on compiler bypasses (`as any`, `@ts-ignore`) rather than strict type modeling.";
@@ -936,6 +1122,8 @@ var MetricsCollector = class {
936
1122
  typeEscapes: this.typeEscapes,
937
1123
  swallowedErrors: this.swallowedErrors,
938
1124
  panicLoops: this.panicLoops,
1125
+ trivialAsserts: this.trivialAsserts,
1126
+ stubAbandonments: this.stubAbandonments,
939
1127
  rawScore: totalInfractionPoints,
940
1128
  score,
941
1129
  isSufficientData,
@@ -986,26 +1174,26 @@ function formatTerminalCard(summary, options) {
986
1174
  );
987
1175
  lines.push(` ${pc.dim(summary.archetypeDescription)}`);
988
1176
  lines.push("");
989
- const totalInfractions = summary.testSkips.length + summary.typeEscapes.length + summary.swallowedErrors.length + summary.panicLoops.length;
1177
+ 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);
990
1178
  lines.push(pc.bold(` INFRACTIONS (${totalInfractions})`));
991
1179
  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"));
992
- if (summary.panicLoops.length > 0) {
1180
+ if (summary.panicLoops && summary.panicLoops.length > 0) {
993
1181
  lines.push(
994
1182
  ` ${pc.magenta("\u25CF")} ${pc.bold("panic-loop")} (${summary.panicLoops.length}) ${pc.dim("consecutive quick fixes < 15m apart")}`
995
1183
  );
996
1184
  const sample = summary.panicLoops[0];
997
1185
  lines.push(` ${pc.dim(sample.reason)}`);
998
1186
  }
999
- if (summary.typeEscapes.length > 0) {
1187
+ if (summary.trivialAsserts && summary.trivialAsserts.length > 0) {
1000
1188
  lines.push(
1001
- ` ${pc.yellow("\u25CF")} ${pc.bold("type-escape")} (${summary.typeEscapes.length}) ${pc.dim("compiler bypasses (as any, @ts-ignore)")}`
1189
+ ` ${pc.red("\u25CF")} ${pc.bold("trivial-assert")} (${summary.trivialAsserts.length}) ${pc.dim("vacuous test assertions (expect(true).toBe(true), assert True)")}`
1002
1190
  );
1003
- const sample = summary.typeEscapes[0];
1191
+ const sample = summary.trivialAsserts[0];
1004
1192
  if (sample.file) {
1005
1193
  lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1006
1194
  }
1007
1195
  }
1008
- if (summary.testSkips.length > 0) {
1196
+ if (summary.testSkips && summary.testSkips.length > 0) {
1009
1197
  lines.push(
1010
1198
  ` ${pc.red("\u25CF")} ${pc.bold("test-skip")} (${summary.testSkips.length}) ${pc.dim("skipped or disabled tests")}`
1011
1199
  );
@@ -1014,7 +1202,7 @@ function formatTerminalCard(summary, options) {
1014
1202
  lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1015
1203
  }
1016
1204
  }
1017
- if (summary.swallowedErrors.length > 0) {
1205
+ if (summary.swallowedErrors && summary.swallowedErrors.length > 0) {
1018
1206
  lines.push(
1019
1207
  ` ${pc.red("\u25CF")} ${pc.bold("swallowed-error")} (${summary.swallowedErrors.length}) ${pc.dim("empty catch blocks or except: pass")}`
1020
1208
  );
@@ -1023,6 +1211,24 @@ function formatTerminalCard(summary, options) {
1023
1211
  lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1024
1212
  }
1025
1213
  }
1214
+ if (summary.stubAbandonments && summary.stubAbandonments.length > 0) {
1215
+ lines.push(
1216
+ ` ${pc.yellow("\u25CF")} ${pc.bold("stub-abandonment")} (${summary.stubAbandonments.length}) ${pc.dim("unimplemented placeholders (throw new Error('TODO'), NotImplementedError)")}`
1217
+ );
1218
+ const sample = summary.stubAbandonments[0];
1219
+ if (sample.file) {
1220
+ lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1221
+ }
1222
+ }
1223
+ if (summary.typeEscapes && summary.typeEscapes.length > 0) {
1224
+ lines.push(
1225
+ ` ${pc.yellow("\u25CF")} ${pc.bold("type-escape")} (${summary.typeEscapes.length}) ${pc.dim("compiler bypasses (as any, @ts-ignore)")}`
1226
+ );
1227
+ const sample = summary.typeEscapes[0];
1228
+ if (sample.file) {
1229
+ lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1230
+ }
1231
+ }
1026
1232
  if (totalInfractions === 0) {
1027
1233
  lines.push(` ${pc.green("\u25CF")} ${pc.dim("zero infractions detected across all inspected lines.")}`);
1028
1234
  }
@@ -1040,10 +1246,12 @@ function formatTerminalCard(summary, options) {
1040
1246
  lines.push("");
1041
1247
  }
1042
1248
  const allInfractions = [
1043
- ...summary.testSkips,
1044
- ...summary.typeEscapes,
1045
- ...summary.swallowedErrors,
1046
- ...summary.panicLoops
1249
+ ...summary.testSkips || [],
1250
+ ...summary.trivialAsserts || [],
1251
+ ...summary.swallowedErrors || [],
1252
+ ...summary.stubAbandonments || [],
1253
+ ...summary.typeEscapes || [],
1254
+ ...summary.panicLoops || []
1047
1255
  ];
1048
1256
  if (allInfractions.length > 0) {
1049
1257
  lines.push(pc.bold(" EVIDENCE (First 10 occurrences):"));
@@ -1109,7 +1317,7 @@ function formatPrTerminalCard(result) {
1109
1317
  }
1110
1318
  function sortInfractionsDeterministic(infractions) {
1111
1319
  const getLevelRank = (type) => {
1112
- return type === "test-skip" || type === "swallowed-error" ? 0 : 1;
1320
+ return type === "test-skip" || type === "trivial-assert" || type === "swallowed-error" ? 0 : 1;
1113
1321
  };
1114
1322
  return [...infractions].sort((a, b) => {
1115
1323
  const rankDiff = getLevelRank(a.type) - getLevelRank(b.type);
@@ -1129,7 +1337,7 @@ function formatGithubAnnotations(result) {
1129
1337
  let omittedCount = 0;
1130
1338
  for (const inf of sorted) {
1131
1339
  if (!inf.file || inf.file === "unknown") continue;
1132
- const isError = result.failedRules.includes(inf.type) || inf.type === "test-skip" || inf.type === "swallowed-error";
1340
+ const isError = result.failedRules.includes(inf.type) || inf.type === "test-skip" || inf.type === "trivial-assert" || inf.type === "swallowed-error";
1133
1341
  const line = inf.lineNumber || 1;
1134
1342
  const title = inf.reason.replace(/[\r\n]/g, " ");
1135
1343
  const msg = (inf.lineSnippet || inf.reason).replace(/[\r\n]/g, " ");
@@ -1189,15 +1397,222 @@ function formatGithubStepSummary(result) {
1189
1397
  }
1190
1398
  return lines.join("\n");
1191
1399
  }
1400
+ function formatPrMarkdownComment(result) {
1401
+ const lines = [];
1402
+ const titleEmoji = result.passed ? "\u2705" : "\u274C";
1403
+ const titleStatus = result.passed ? "PASSED" : "BLOCKED";
1404
+ lines.push(`## \u{1F6E1}\uFE0F agent-roast audit: ${titleStatus} ${titleEmoji}`);
1405
+ lines.push("");
1406
+ if (!result.hasSupportedChanges) {
1407
+ lines.push("No supported source file changes detected in this pull request.");
1408
+ lines.push("");
1409
+ lines.push("---");
1410
+ lines.push("*Audited 100% offline with [agent-roast](https://github.com/Nari-Ab/agent-roast)*");
1411
+ return lines.join("\n");
1412
+ }
1413
+ const statusText = result.passed ? "\u2705 Passed" : `\u274C Blocked on ${result.failedRules.join(", ")}`;
1414
+ lines.push("| Status | Defect Density | Files Inspected | Lines Added | Infractions |");
1415
+ lines.push("| :--- | :--- | :--- | :--- | :--- |");
1416
+ lines.push(
1417
+ `| ${statusText} | ${result.defectDensityPer100Lines} / 100 LOC | ${result.filesInspected} | +${result.linesAdded} | ${result.infractions.length} |`
1418
+ );
1419
+ lines.push("");
1420
+ if (result.infractions.length > 0) {
1421
+ lines.push("<details>");
1422
+ lines.push(`<summary><b>View Detected Infractions (${result.infractions.length})</b></summary>`);
1423
+ lines.push("");
1424
+ lines.push("| Type | File:Line | Description | Snippet |");
1425
+ lines.push("| :--- | :--- | :--- | :--- |");
1426
+ const sorted = sortInfractionsDeterministic(result.infractions);
1427
+ for (const inf of sorted) {
1428
+ const loc = inf.file ? `\`${inf.file}:${inf.lineNumber || 1}\`` : "*history*";
1429
+ const snippet = inf.lineSnippet ? `\`${inf.lineSnippet.replace(/\|/g, "\\|")}\`` : "-";
1430
+ lines.push(`| \`${inf.type}\` | ${loc} | ${inf.reason} | ${snippet} |`);
1431
+ }
1432
+ lines.push("");
1433
+ lines.push("</details>");
1434
+ lines.push("");
1435
+ } else {
1436
+ lines.push("\u2728 **Clean pull request**: Zero AI shortcuts, bypassed tests, or compiler type escapes detected.");
1437
+ lines.push("");
1438
+ }
1439
+ lines.push("---");
1440
+ lines.push("*Audited 100% offline with [agent-roast](https://github.com/Nari-Ab/agent-roast)*");
1441
+ return lines.join("\n");
1442
+ }
1192
1443
 
1193
1444
  // src/types.ts
1194
1445
  var VALID_DETECTORS = [
1195
1446
  "test-skip",
1196
1447
  "type-escape",
1197
1448
  "swallowed-error",
1198
- "panic-loop"
1449
+ "panic-loop",
1450
+ "trivial-assert",
1451
+ "stub-abandonment"
1199
1452
  ];
1200
1453
 
1454
+ // src/init.ts
1455
+ import * as fs2 from "fs";
1456
+ import * as path2 from "path";
1457
+ var DEFAULT_CONFIG_TEMPLATE = {
1458
+ $schema: "https://raw.githubusercontent.com/Nari-Ab/agent-roast/main/schema.json",
1459
+ ignorePaths: [
1460
+ "**/*.min.js",
1461
+ "**/vendor/**",
1462
+ "**/dist/**",
1463
+ "**/build/**",
1464
+ "**/fixtures/**"
1465
+ ],
1466
+ rules: {
1467
+ "hallucinated-package": "error",
1468
+ "empty-catch": "warn",
1469
+ "trivial-assert": "warn",
1470
+ "stub-abandonment": "warn",
1471
+ "mock-leak": "warn"
1472
+ },
1473
+ scoreThreshold: 70
1474
+ };
1475
+ var GITHUB_WORKFLOW_TEMPLATE = `name: Agent Roast
1476
+ on:
1477
+ pull_request:
1478
+ branches: [main, master]
1479
+ push:
1480
+ branches: [main, master]
1481
+
1482
+ jobs:
1483
+ audit:
1484
+ name: Audit AI Shortcuts
1485
+ runs-on: ubuntu-latest
1486
+ steps:
1487
+ - name: Checkout repository
1488
+ uses: actions/checkout@v4
1489
+ with:
1490
+ fetch-depth: 0
1491
+
1492
+ - name: Setup Node.js
1493
+ uses: actions/setup-node@v4
1494
+ with:
1495
+ node-version: 20
1496
+
1497
+ - name: Run agent-roast PR check
1498
+ 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
1504
+ `;
1505
+ var PRE_COMMIT_HOOK_TEMPLATE = `#!/bin/sh
1506
+ # Run agent-roast pre-commit verification
1507
+ npx agent-roast --fail-under 70
1508
+ `;
1509
+ async function initProject(options = {}) {
1510
+ const cwd = path2.resolve(options.cwd || process.cwd());
1511
+ const created = [];
1512
+ const skipped = [];
1513
+ const configPath = path2.join(cwd, ".agentroastrc.json");
1514
+ const altConfigPath = path2.join(cwd, "agent-roast.config.json");
1515
+ if (fs2.existsSync(configPath) || fs2.existsSync(altConfigPath)) {
1516
+ skipped.push(".agentroastrc.json");
1517
+ } else {
1518
+ fs2.writeFileSync(
1519
+ configPath,
1520
+ JSON.stringify(DEFAULT_CONFIG_TEMPLATE, null, 2) + "\n",
1521
+ "utf-8"
1522
+ );
1523
+ created.push(".agentroastrc.json");
1524
+ }
1525
+ if (options.ci) {
1526
+ const workflowDir = path2.join(cwd, ".github", "workflows");
1527
+ const workflowPath = path2.join(workflowDir, "agent-roast.yml");
1528
+ if (fs2.existsSync(workflowPath)) {
1529
+ skipped.push(".github/workflows/agent-roast.yml");
1530
+ } else {
1531
+ fs2.mkdirSync(workflowDir, { recursive: true });
1532
+ fs2.writeFileSync(workflowPath, GITHUB_WORKFLOW_TEMPLATE, "utf-8");
1533
+ created.push(".github/workflows/agent-roast.yml");
1534
+ }
1535
+ }
1536
+ if (options.hook) {
1537
+ const gitDir = path2.join(cwd, ".git");
1538
+ if (!fs2.existsSync(gitDir) || !fs2.statSync(gitDir).isDirectory()) {
1539
+ skipped.push(".git/hooks/pre-commit (no .git directory found)");
1540
+ } else {
1541
+ const hooksDir = path2.join(gitDir, "hooks");
1542
+ const hookPath = path2.join(hooksDir, "pre-commit");
1543
+ if (fs2.existsSync(hookPath)) {
1544
+ skipped.push(".git/hooks/pre-commit");
1545
+ } else {
1546
+ fs2.mkdirSync(hooksDir, { recursive: true });
1547
+ fs2.writeFileSync(hookPath, PRE_COMMIT_HOOK_TEMPLATE, {
1548
+ encoding: "utf-8",
1549
+ mode: 493
1550
+ });
1551
+ created.push(".git/hooks/pre-commit");
1552
+ }
1553
+ }
1554
+ }
1555
+ return { created, skipped };
1556
+ }
1557
+
1558
+ // src/badge.ts
1559
+ function getScoreColor(score) {
1560
+ if (score === null || isNaN(score)) {
1561
+ return { name: "lightgrey", hex: "#9f9f9f" };
1562
+ }
1563
+ if (score >= 90) return { name: "brightgreen", hex: "#4c1" };
1564
+ if (score >= 80) return { name: "green", hex: "#97ca00" };
1565
+ if (score >= 70) return { name: "yellowgreen", hex: "#a4a61d" };
1566
+ if (score >= 60) return { name: "yellow", hex: "#dfb317" };
1567
+ return { name: "red", hex: "#e05d44" };
1568
+ }
1569
+ function generateSvgBadge(score, label = "AI Discipline") {
1570
+ const value = score !== null && !isNaN(score) ? `${score}/100` : "N/A";
1571
+ const color = getScoreColor(score);
1572
+ const labelWidth = Math.round(label.length * 6.8 + 14);
1573
+ const valueWidth = Math.round(value.length * 7.2 + 14);
1574
+ const totalWidth = labelWidth + valueWidth;
1575
+ const labelX = Math.round(labelWidth / 2);
1576
+ const valueX = Math.round(labelWidth + valueWidth / 2);
1577
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${label}: ${value}">
1578
+ <linearGradient id="s" x2="0" y2="100%">
1579
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
1580
+ <stop offset="1" stop-opacity=".1"/>
1581
+ </linearGradient>
1582
+ <clipPath id="r">
1583
+ <rect width="${totalWidth}" height="20" rx="3" fill="#fff"/>
1584
+ </clipPath>
1585
+ <g clip-path="url(#r)">
1586
+ <rect width="${labelWidth}" height="20" fill="#555"/>
1587
+ <rect x="${labelWidth}" width="${valueWidth}" height="20" fill="${color.hex}"/>
1588
+ <rect width="${totalWidth}" height="20" fill="url(#s)"/>
1589
+ </g>
1590
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
1591
+ <text aria-hidden="true" x="${labelX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(labelWidth - 14) * 10}">${label}</text>
1592
+ <text x="${labelX * 10}" y="140" transform="scale(.1)" fill="#fff" textLength="${(labelWidth - 14) * 10}">${label}</text>
1593
+ <text aria-hidden="true" x="${valueX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(valueWidth - 14) * 10}">${value}</text>
1594
+ <text x="${valueX * 10}" y="140" transform="scale(.1)" fill="#fff" textLength="${(valueWidth - 14) * 10}">${value}</text>
1595
+ </g>
1596
+ </svg>`;
1597
+ }
1598
+ function generateShieldsMarkdown(score, label = "AI Discipline", repoUrl = "https://github.com/Nari-Ab/agent-roast") {
1599
+ const value = score !== null && !isNaN(score) ? `${score}%2F100` : "N%2FA";
1600
+ const color = getScoreColor(score);
1601
+ const encodedLabel = encodeURIComponent(label);
1602
+ const badgeUrl = `https://img.shields.io/badge/${encodedLabel}-${value}-${color.name}?style=flat-square`;
1603
+ return `[![${label}](${badgeUrl})](${repoUrl})`;
1604
+ }
1605
+ function generateShieldsJson(score, label = "AI Discipline") {
1606
+ const value = score !== null && !isNaN(score) ? `${score}/100` : "N/A";
1607
+ const color = getScoreColor(score);
1608
+ return {
1609
+ schemaVersion: 1,
1610
+ label,
1611
+ message: value,
1612
+ color: color.name
1613
+ };
1614
+ }
1615
+
1201
1616
  // src/index.ts
1202
1617
  var InvalidDetectorError = class extends Error {
1203
1618
  constructor(message) {
@@ -1206,10 +1621,10 @@ var InvalidDetectorError = class extends Error {
1206
1621
  }
1207
1622
  };
1208
1623
  async function roastRepository(options = {}) {
1209
- const cwd = path.resolve(options.cwd || process.cwd());
1624
+ const cwd = path3.resolve(options.cwd || process.cwd());
1210
1625
  const since = options.since || "90 days ago";
1211
- const repoName = path.basename(cwd);
1212
- const collector = new MetricsCollector({ all: options.all });
1626
+ const repoName = path3.basename(cwd);
1627
+ const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths });
1213
1628
  const gitStream = streamGitLog(cwd, since);
1214
1629
  await parseGitLogStream(gitStream, (commit, stats) => {
1215
1630
  collector.processCommit(commit);
@@ -1223,7 +1638,7 @@ async function roastRepository(options = {}) {
1223
1638
  return { summary, output };
1224
1639
  }
1225
1640
  async function auditPullRequest(options) {
1226
- const cwd = path.resolve(options.cwd || process.cwd());
1641
+ const cwd = path3.resolve(options.cwd || process.cwd());
1227
1642
  const format = options.format || "terminal";
1228
1643
  const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
1229
1644
  for (const det of failOn) {
@@ -1238,15 +1653,15 @@ async function auditPullRequest(options) {
1238
1653
  const mergeBaseSha = findMergeBase(cwd, baseSha, headSha);
1239
1654
  const diffStream = streamGitDiff(cwd, mergeBaseSha, headSha);
1240
1655
  let rawDiff = "";
1241
- await new Promise((resolve, reject) => {
1656
+ await new Promise((resolve2, reject) => {
1242
1657
  diffStream.on("data", (chunk) => {
1243
1658
  rawDiff += chunk.toString();
1244
1659
  });
1245
- diffStream.on("end", () => resolve());
1660
+ diffStream.on("end", () => resolve2());
1246
1661
  diffStream.on("error", reject);
1247
1662
  });
1248
1663
  const { files, skippedLongLines, skippedUnknownFiles } = parseDiff(rawDiff);
1249
- const audit = auditFileDiffs(files, headSha);
1664
+ const audit = auditFileDiffs(files, headSha, (/* @__PURE__ */ new Date()).toISOString(), { ignorePaths: options.ignorePaths });
1250
1665
  const infractions = audit.infractions;
1251
1666
  const defectDensityPer100Lines = audit.linesAdded > 0 ? Math.round(infractions.length / audit.linesAdded * 100 * 100) / 100 : 0;
1252
1667
  const failedRules = [...new Set(
@@ -1275,6 +1690,8 @@ async function auditPullRequest(options) {
1275
1690
  result.output = JSON.stringify(result, null, 2);
1276
1691
  } else if (format === "github") {
1277
1692
  result.output = formatGithubAnnotations(result);
1693
+ } else if (format === "markdown") {
1694
+ result.output = formatPrMarkdownComment(result);
1278
1695
  } else {
1279
1696
  result.output = formatPrTerminalCard(result);
1280
1697
  }
@@ -1296,7 +1713,11 @@ export {
1296
1713
  resolveCommitSha,
1297
1714
  findMergeBase,
1298
1715
  GitResolutionError,
1716
+ DEFAULT_CONFIG,
1717
+ loadConfig,
1718
+ matchIgnorePath,
1299
1719
  isCodeFile,
1720
+ isTestFile,
1300
1721
  auditFileDiffs,
1301
1722
  stripStringAndRegexLiterals,
1302
1723
  MetricsCollector,
@@ -1305,7 +1726,16 @@ export {
1305
1726
  formatPrTerminalCard,
1306
1727
  formatGithubAnnotations,
1307
1728
  formatGithubStepSummary,
1729
+ formatPrMarkdownComment,
1308
1730
  VALID_DETECTORS,
1731
+ DEFAULT_CONFIG_TEMPLATE,
1732
+ GITHUB_WORKFLOW_TEMPLATE,
1733
+ PRE_COMMIT_HOOK_TEMPLATE,
1734
+ initProject,
1735
+ getScoreColor,
1736
+ generateSvgBadge,
1737
+ generateShieldsMarkdown,
1738
+ generateShieldsJson,
1309
1739
  InvalidDetectorError,
1310
1740
  roastRepository,
1311
1741
  auditPullRequest
package/dist/cli.js CHANGED
@@ -5,25 +5,38 @@ import {
5
5
  InvalidDetectorError,
6
6
  auditPullRequest,
7
7
  formatGithubStepSummary,
8
+ generateShieldsJson,
9
+ generateShieldsMarkdown,
10
+ generateSvgBadge,
11
+ initProject,
12
+ loadConfig,
8
13
  roastRepository
9
- } from "./chunk-XUNF5YCA.js";
14
+ } from "./chunk-O4SB2FCT.js";
10
15
 
11
16
  // src/cli.ts
12
17
  import { Command } from "commander";
13
18
  import { createRequire } from "module";
14
19
  import * as fs from "fs";
20
+ import * as path from "path";
15
21
  var require2 = createRequire(import.meta.url);
16
22
  var pkg = require2("../package.json");
17
23
  var program = new Command();
18
- program.name("agent-roast").description("Audit your git history or PR diffs for AI coding shortcuts and panic loops").version(pkg.version || "0.2.0").argument("[path]", "Path to git repository (default: current directory)", ".").option("-a, --all", "Audit ALL commits regardless of AI attribution tags", false).option("-s, --since <time>", "Time window for git analysis", "90 days ago").option("-v, --verbose", "Show commit SHAs and line snippets for all infractions", false).option("--json", "Output results as raw JSON", false).option("--base <ref>", "Base git ref/branch for PR mode audit (e.g. origin/main)").option("--head <ref>", "Head git ref/SHA for PR mode audit (default: HEAD)", "HEAD").option("--fail-on <rules>", "Comma-separated detectors that trigger CI failure", "test-skip").option("--format <format>", "Output format: terminal, json, github", "terminal").option("--authors", "Display author names and emails in report", false).action(async (repoPath, options) => {
24
+ program.name("agent-roast").description("Audit your git history or PR diffs for AI coding shortcuts and panic loops").version(pkg.version || "0.4.0").argument("[path]", "Path to git repository (default: current directory)", ".").option("-a, --all", "Audit ALL commits regardless of AI attribution tags", false).option("-s, --since <time>", "Time window for git analysis", "90 days ago").option("-v, --verbose", "Show commit SHAs and line snippets for all infractions", false).option("--json", "Output results as raw JSON", false).option("--base <ref>", "Base git ref/branch for PR mode audit (e.g. origin/main)").option("--head <ref>", "Head git ref/SHA for PR mode audit (default: HEAD)", "HEAD").option("--fail-on <rules>", "Comma-separated detectors that trigger CI failure").option("--fail-under <score>", "Fail CI if agent score is below threshold (0-100)").option("--format <format>", "Output format: terminal, json, github, markdown", "terminal").option("--authors", "Display author names and emails in report", false).action(async (repoPath, options) => {
19
25
  try {
20
26
  if (process.env.GITHUB_EVENT_NAME === "pull_request_target") {
21
27
  console.warn(
22
28
  "Warning: pull_request_target runs with elevated repository privileges. agent-roast recommends running on 'pull_request'."
23
29
  );
24
30
  }
31
+ const config = await loadConfig(repoPath);
25
32
  if (options.base) {
26
- const failOnRules = options.failOn ? options.failOn.split(",").map((s) => s.trim()).filter(Boolean) : ["test-skip"];
33
+ let failOnRules;
34
+ if (options.failOn) {
35
+ failOnRules = options.failOn.split(",").map((s) => s.trim()).filter(Boolean);
36
+ } else {
37
+ const errorRules = Object.entries(config.rules || {}).filter(([_, level]) => level === "error").map(([rule]) => rule);
38
+ failOnRules = errorRules.length > 0 ? errorRules : ["test-skip"];
39
+ }
27
40
  const chosenFormat = options.json ? "json" : options.format || "terminal";
28
41
  const prResult = await auditPullRequest({
29
42
  cwd: repoPath,
@@ -32,7 +45,8 @@ program.name("agent-roast").description("Audit your git history or PR diffs for
32
45
  failOn: failOnRules,
33
46
  format: chosenFormat,
34
47
  verbose: options.verbose,
35
- authors: options.authors
48
+ authors: options.authors,
49
+ ignorePaths: config.ignorePaths
36
50
  });
37
51
  if (process.env.GITHUB_STEP_SUMMARY) {
38
52
  try {
@@ -65,14 +79,20 @@ has-supported-changes=${prResult.hasSupportedChanges}
65
79
  since: options.since,
66
80
  all: options.all,
67
81
  verbose: options.verbose,
68
- json: options.json
82
+ json: options.json,
83
+ ignorePaths: config.ignorePaths
69
84
  });
70
85
  if (options.json) {
71
86
  console.log(JSON.stringify(result.summary, null, 2));
72
87
  } else {
73
88
  console.log(result.output);
74
89
  }
75
- process.exitCode = 0;
90
+ const scoreThreshold = options.failUnder !== void 0 ? Number(options.failUnder) : config.scoreThreshold;
91
+ if (scoreThreshold !== void 0 && !isNaN(scoreThreshold) && result.summary.score !== null && result.summary.score < scoreThreshold) {
92
+ process.exitCode = 1;
93
+ } else {
94
+ process.exitCode = 0;
95
+ }
76
96
  }
77
97
  } catch (err) {
78
98
  if (err instanceof GitResolutionError || err instanceof InvalidDetectorError || err instanceof GitError || err.name === "GitError") {
@@ -84,4 +104,73 @@ has-supported-changes=${prResult.hasSupportedChanges}
84
104
  }
85
105
  }
86
106
  });
107
+ program.command("init").description("Initialize .agentroastrc.json and CI/hook configurations").argument("[dir]", "Directory to initialize", ".").option("-y, --yes", "Skip interactive prompts and use defaults", false).option("--ci", "Generate GitHub Actions workflow file", false).option("--hook", "Install Git pre-commit hook", false).action(async (targetDir, opts) => {
108
+ try {
109
+ const res = await initProject({
110
+ cwd: targetDir,
111
+ yes: opts.yes,
112
+ ci: opts.ci,
113
+ hook: opts.hook
114
+ });
115
+ if (res.created.length > 0) {
116
+ console.log(`Initialized agent-roast in ${targetDir}:`);
117
+ for (const file of res.created) {
118
+ console.log(` + Created ${file}`);
119
+ }
120
+ }
121
+ if (res.skipped.length > 0) {
122
+ for (const file of res.skipped) {
123
+ console.log(` - Skipped ${file} (already exists or not applicable)`);
124
+ }
125
+ }
126
+ if (res.created.length === 0 && res.skipped.length === 0) {
127
+ console.log("No files were created.");
128
+ }
129
+ } catch (err) {
130
+ console.error(`Failed to initialize agent-roast: ${err.message}`);
131
+ process.exitCode = 1;
132
+ }
133
+ });
134
+ program.command("badge").description("Generate an AI discipline badge (SVG, Markdown, or Shields JSON)").argument("[path]", "Path to git repository (default: current directory)", ".").option("-a, --all", "Audit ALL commits regardless of AI attribution tags", false).option("-s, --since <time>", "Time window for git analysis", "90 days ago").option("--label <text>", "Badge label text", "AI Discipline").option("--out <file>", "Output SVG file path (e.g. ./badge.svg)").action(async (repoPath, opts) => {
135
+ try {
136
+ const rootOpts = program.opts();
137
+ const isJson = Boolean(opts.json || rootOpts.json);
138
+ const isAll = Boolean(opts.all || rootOpts.all);
139
+ const since = opts.since || rootOpts.since || "90 days ago";
140
+ const config = await loadConfig(repoPath);
141
+ const result = await roastRepository({
142
+ cwd: repoPath,
143
+ since,
144
+ all: isAll,
145
+ ignorePaths: config.ignorePaths
146
+ });
147
+ const score = result.summary.score;
148
+ const label = opts.label || "AI Discipline";
149
+ if (isJson) {
150
+ const payload = generateShieldsJson(score, label);
151
+ console.log(JSON.stringify(payload, null, 2));
152
+ process.exitCode = 0;
153
+ return;
154
+ }
155
+ if (opts.out) {
156
+ const svg = generateSvgBadge(score, label);
157
+ const outPath = path.resolve(opts.out);
158
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
159
+ fs.writeFileSync(outPath, svg, "utf-8");
160
+ console.log(`Generated SVG badge at: ${opts.out}`);
161
+ process.exitCode = 0;
162
+ return;
163
+ }
164
+ const markdown = generateShieldsMarkdown(score, label);
165
+ console.log(`
166
+ Badge for README.md:
167
+
168
+ ${markdown}
169
+ `);
170
+ process.exitCode = 0;
171
+ } catch (err) {
172
+ console.error(`Error generating badge: ${err.message}`);
173
+ process.exitCode = 1;
174
+ }
175
+ });
87
176
  program.parse(process.argv);
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@ interface CommitInfo {
9
9
  isAiAttributed: boolean;
10
10
  aiSignatures: string[];
11
11
  }
12
- type DetectorType = "test-skip" | "type-escape" | "swallowed-error" | "panic-loop";
12
+ type DetectorType = "test-skip" | "type-escape" | "swallowed-error" | "panic-loop" | "trivial-assert" | "stub-abandonment";
13
13
  declare const VALID_DETECTORS: readonly DetectorType[];
14
14
  interface Infraction {
15
15
  type: DetectorType;
@@ -30,6 +30,8 @@ interface MetricSummary {
30
30
  typeEscapes: Infraction[];
31
31
  swallowedErrors: Infraction[];
32
32
  panicLoops: Infraction[];
33
+ trivialAsserts: Infraction[];
34
+ stubAbandonments: Infraction[];
33
35
  rawScore: number;
34
36
  score: number | null;
35
37
  isSufficientData: boolean;
@@ -45,15 +47,18 @@ interface RoastOptions {
45
47
  verbose?: boolean;
46
48
  json?: boolean;
47
49
  authors?: boolean;
50
+ ignorePaths?: string[];
51
+ scoreThreshold?: number;
48
52
  }
49
53
  interface PrAuditOptions {
50
54
  cwd?: string;
51
55
  base: string;
52
56
  head: string;
53
57
  failOn?: string[];
54
- format?: "terminal" | "json" | "github";
58
+ format?: "terminal" | "json" | "github" | "markdown";
55
59
  verbose?: boolean;
56
60
  authors?: boolean;
61
+ ignorePaths?: string[];
57
62
  }
58
63
  interface PrAuditResult {
59
64
  schemaVersion: string;
@@ -139,11 +144,14 @@ declare class GitResolutionError extends Error {
139
144
  constructor(message: string);
140
145
  }
141
146
 
142
- declare function isCodeFile(filePath: string): boolean;
147
+ declare function isCodeFile(filePath: string, customIgnorePaths?: string[]): boolean;
148
+ declare function isTestFile(filePath: string): boolean;
143
149
  /**
144
150
  * Audits a collection of file diffs (e.g. for PR mode) against detector rules.
145
151
  */
146
- declare function auditFileDiffs(files: ParsedFileDiff[], commitHash?: string, commitDate?: string): {
152
+ declare function auditFileDiffs(files: ParsedFileDiff[], commitHash?: string, commitDate?: string, options?: {
153
+ ignorePaths?: string[];
154
+ }): {
147
155
  infractions: Infraction[];
148
156
  hasSupportedChanges: boolean;
149
157
  filesInspected: number;
@@ -163,12 +171,16 @@ declare class MetricsCollector {
163
171
  private typeEscapes;
164
172
  private swallowedErrors;
165
173
  private panicLoops;
174
+ private trivialAsserts;
175
+ private stubAbandonments;
166
176
  private skippedLongLines;
167
177
  private skippedUnknownFiles;
168
178
  private activePanicChain;
169
179
  private includeAll;
180
+ private ignorePaths?;
170
181
  constructor(options?: {
171
182
  all?: boolean;
183
+ ignorePaths?: string[];
172
184
  });
173
185
  recordSkipped(stats: {
174
186
  skippedLongLines?: number;
@@ -194,6 +206,73 @@ declare function formatGithubAnnotations(result: PrAuditResult): string;
194
206
  * Generates GitHub Step Summary Markdown
195
207
  */
196
208
  declare function formatGithubStepSummary(result: PrAuditResult): string;
209
+ /**
210
+ * Generates rich GitHub PR Comment Markdown (for PR comments / sticky bots)
211
+ */
212
+ declare function formatPrMarkdownComment(result: PrAuditResult): string;
213
+
214
+ interface AgentRoastConfig {
215
+ /** Detector rules to fail CI on in PR mode */
216
+ failOn?: string[];
217
+ /** Glob patterns or prefixes to ignore from analysis */
218
+ ignorePaths?: string[];
219
+ /** Minimum passing score for full repository audits (0-100) */
220
+ scoreThreshold?: number;
221
+ /** Granular rule configuration (error | warn | off) */
222
+ rules?: Record<string, "error" | "warn" | "off">;
223
+ /** Maximum minutes between fix commits before flagging panic-loop */
224
+ panicLoopWindowMinutes?: number;
225
+ }
226
+ declare const DEFAULT_CONFIG: AgentRoastConfig;
227
+ /**
228
+ * Discovers and parses repository configuration file.
229
+ * Returns DEFAULT_CONFIG if no configuration file is found or if parsing fails.
230
+ */
231
+ declare function loadConfig(cwd?: string): AgentRoastConfig;
232
+ /**
233
+ * Matches a relative or normalized file path against an array of glob ignore patterns.
234
+ */
235
+ declare function matchIgnorePath(filePath: string, ignorePatterns?: string[]): boolean;
236
+
237
+ interface InitOptions {
238
+ cwd?: string;
239
+ yes?: boolean;
240
+ ci?: boolean;
241
+ hook?: boolean;
242
+ }
243
+ interface InitResult {
244
+ created: string[];
245
+ skipped: string[];
246
+ }
247
+ declare const DEFAULT_CONFIG_TEMPLATE: {
248
+ $schema: string;
249
+ ignorePaths: string[];
250
+ rules: {
251
+ "hallucinated-package": string;
252
+ "empty-catch": string;
253
+ "trivial-assert": string;
254
+ "stub-abandonment": string;
255
+ "mock-leak": string;
256
+ };
257
+ scoreThreshold: number;
258
+ };
259
+ declare const GITHUB_WORKFLOW_TEMPLATE = "name: Agent Roast\non:\n pull_request:\n branches: [main, master]\n push:\n branches: [main, master]\n\njobs:\n audit:\n name: Audit AI Shortcuts\n runs-on: ubuntu-latest\n steps:\n - name: Checkout repository\n uses: actions/checkout@v4\n with:\n fetch-depth: 0\n\n - name: Setup Node.js\n uses: actions/setup-node@v4\n with:\n node-version: 20\n\n - name: Run agent-roast PR check\n if: github.event_name == 'pull_request'\n run: npx agent-roast --base ${{ github.event.pull_request.base.sha }} --head ${{ github.event.pull_request.head.sha }} --format github\n\n - name: Run agent-roast repo audit\n if: github.event_name != 'pull_request'\n run: npx agent-roast --fail-under 70\n";
260
+ declare const PRE_COMMIT_HOOK_TEMPLATE = "#!/bin/sh\n# Run agent-roast pre-commit verification\nnpx agent-roast --fail-under 70\n";
261
+ declare function initProject(options?: InitOptions): Promise<InitResult>;
262
+
263
+ interface ShieldsEndpointResponse {
264
+ schemaVersion: 1;
265
+ label: string;
266
+ message: string;
267
+ color: string;
268
+ }
269
+ declare function getScoreColor(score: number | null): {
270
+ name: string;
271
+ hex: string;
272
+ };
273
+ declare function generateSvgBadge(score: number | null, label?: string): string;
274
+ declare function generateShieldsMarkdown(score: number | null, label?: string, repoUrl?: string): string;
275
+ declare function generateShieldsJson(score: number | null, label?: string): ShieldsEndpointResponse;
197
276
 
198
277
  declare class InvalidDetectorError extends Error {
199
278
  constructor(message: string);
@@ -204,4 +283,4 @@ declare function roastRepository(options?: RoastOptions): Promise<{
204
283
  }>;
205
284
  declare function auditPullRequest(options: PrAuditOptions): Promise<PrAuditResult>;
206
285
 
207
- export { type AiAttributionResult, type CommitInfo, type DetectorType, FIELD_SEPARATOR, GitError, GitResolutionError, HERMETIC_GIT_FLAGS, type Infraction, InvalidDetectorError, type MetricSummary, MetricsCollector, type ParseDiffResult, type ParsedCommit, type ParsedDiffLine, type ParsedFileDiff, type PrAuditOptions, type PrAuditResult, RECORD_SEPARATOR, type RoastOptions, VALID_DETECTORS, auditFileDiffs, auditPullRequest, detectAiAttribution, findMergeBase, formatGithubAnnotations, formatGithubStepSummary, formatPrTerminalCard, formatTerminalCard, generateShareUrl, getHermeticGitEnv, isCodeFile, parseDiff, parseGitLogStream, resolveCommitSha, roastRepository, streamGitDiff, streamGitLog, stripStringAndRegexLiterals, unquoteGitPath };
286
+ export { type AgentRoastConfig, type AiAttributionResult, type CommitInfo, DEFAULT_CONFIG, DEFAULT_CONFIG_TEMPLATE, type DetectorType, FIELD_SEPARATOR, GITHUB_WORKFLOW_TEMPLATE, GitError, GitResolutionError, HERMETIC_GIT_FLAGS, type Infraction, type InitOptions, type InitResult, InvalidDetectorError, type MetricSummary, MetricsCollector, PRE_COMMIT_HOOK_TEMPLATE, type ParseDiffResult, type ParsedCommit, type ParsedDiffLine, type ParsedFileDiff, type PrAuditOptions, type PrAuditResult, RECORD_SEPARATOR, type RoastOptions, type ShieldsEndpointResponse, VALID_DETECTORS, auditFileDiffs, auditPullRequest, detectAiAttribution, findMergeBase, formatGithubAnnotations, formatGithubStepSummary, formatPrMarkdownComment, formatPrTerminalCard, formatTerminalCard, generateShareUrl, generateShieldsJson, generateShieldsMarkdown, generateSvgBadge, getHermeticGitEnv, getScoreColor, initProject, isCodeFile, isTestFile, loadConfig, matchIgnorePath, parseDiff, parseGitLogStream, resolveCommitSha, roastRepository, streamGitDiff, streamGitLog, stripStringAndRegexLiterals, unquoteGitPath };
package/dist/index.js CHANGED
@@ -1,11 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ DEFAULT_CONFIG,
4
+ DEFAULT_CONFIG_TEMPLATE,
3
5
  FIELD_SEPARATOR,
6
+ GITHUB_WORKFLOW_TEMPLATE,
4
7
  GitError,
5
8
  GitResolutionError,
6
9
  HERMETIC_GIT_FLAGS,
7
10
  InvalidDetectorError,
8
11
  MetricsCollector,
12
+ PRE_COMMIT_HOOK_TEMPLATE,
9
13
  RECORD_SEPARATOR,
10
14
  VALID_DETECTORS,
11
15
  auditFileDiffs,
@@ -14,11 +18,20 @@ import {
14
18
  findMergeBase,
15
19
  formatGithubAnnotations,
16
20
  formatGithubStepSummary,
21
+ formatPrMarkdownComment,
17
22
  formatPrTerminalCard,
18
23
  formatTerminalCard,
19
24
  generateShareUrl,
25
+ generateShieldsJson,
26
+ generateShieldsMarkdown,
27
+ generateSvgBadge,
20
28
  getHermeticGitEnv,
29
+ getScoreColor,
30
+ initProject,
21
31
  isCodeFile,
32
+ isTestFile,
33
+ loadConfig,
34
+ matchIgnorePath,
22
35
  parseDiff,
23
36
  parseGitLogStream,
24
37
  resolveCommitSha,
@@ -27,14 +40,18 @@ import {
27
40
  streamGitLog,
28
41
  stripStringAndRegexLiterals,
29
42
  unquoteGitPath
30
- } from "./chunk-XUNF5YCA.js";
43
+ } from "./chunk-O4SB2FCT.js";
31
44
  export {
45
+ DEFAULT_CONFIG,
46
+ DEFAULT_CONFIG_TEMPLATE,
32
47
  FIELD_SEPARATOR,
48
+ GITHUB_WORKFLOW_TEMPLATE,
33
49
  GitError,
34
50
  GitResolutionError,
35
51
  HERMETIC_GIT_FLAGS,
36
52
  InvalidDetectorError,
37
53
  MetricsCollector,
54
+ PRE_COMMIT_HOOK_TEMPLATE,
38
55
  RECORD_SEPARATOR,
39
56
  VALID_DETECTORS,
40
57
  auditFileDiffs,
@@ -43,11 +60,20 @@ export {
43
60
  findMergeBase,
44
61
  formatGithubAnnotations,
45
62
  formatGithubStepSummary,
63
+ formatPrMarkdownComment,
46
64
  formatPrTerminalCard,
47
65
  formatTerminalCard,
48
66
  generateShareUrl,
67
+ generateShieldsJson,
68
+ generateShieldsMarkdown,
69
+ generateSvgBadge,
49
70
  getHermeticGitEnv,
71
+ getScoreColor,
72
+ initProject,
50
73
  isCodeFile,
74
+ isTestFile,
75
+ loadConfig,
76
+ matchIgnorePath,
51
77
  parseDiff,
52
78
  parseGitLogStream,
53
79
  resolveCommitSha,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-roast",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Zero-telemetry CLI scorecard analyzing git history for AI coding agent infractions and panic loops",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",