agent-roast 0.5.0 → 0.7.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
@@ -38,7 +38,7 @@ npx agent-roast check --baseline .agentroast-baseline.json
38
38
  npx agent-roast --staged
39
39
 
40
40
  # Audit pull request diffs (CI gate)
41
- npx agent-roast --base origin/main --head HEAD --format github
41
+ npx agent-roast review --base origin/main --head HEAD --format github
42
42
 
43
43
  # Optional historical audit
44
44
  npx agent-roast --all --verbose
@@ -48,6 +48,67 @@ A finding includes its file and line, the source snippet, and a concrete next st
48
48
  To see the complete machine-readable result, use `check --format json`; terminal
49
49
  output shows the first 20 findings. `check --format markdown` produces a review table.
50
50
 
51
+ ### Context review for pull requests
52
+
53
+ ```bash
54
+ # Compare committed source at the common ancestor and candidate head
55
+ agent-roast review --base origin/main --head HEAD
56
+ agent-roast review --base origin/main --format markdown
57
+
58
+ # Export a report for tools that consume SARIF 2.1.0
59
+ agent-roast review --base origin/main --format sarif > agentroast.sarif
60
+ agent-roast check --format sarif > current-code.sarif
61
+ ```
62
+
63
+ `review` compares **full contents of changed source files**, so deleting the only
64
+ handler inside a `catch` is detectable even when no suspicious line was added.
65
+ It reports new findings, existing findings, and patterns no longer detected. Only
66
+ new blocking findings fail the check; no manually generated baseline is required.
67
+ Deleting a file is reported as disappearance of a pattern, not proof of a correct fix.
68
+
69
+ Source is read directly from immutable Git blobs at the merge-base and head. The
70
+ worktree and index are left untouched, and project code, hooks, diff programs and
71
+ package scripts are never executed. Use `check` for uncommitted working files, and
72
+ `--staged` for the index. Source comparison and policy are pinned to the reported SHAs.
73
+
74
+ Policy comes from the explicitly supplied **base ref tip**; source comparison starts
75
+ at the merge-base. Candidate changes to `.agentroastrc.json`, `.agentroastrc` or
76
+ `agent-roast.config.json` are reported and ignored for this review. Adopt policy
77
+ changes through separate human review. An explicit `--fail-on` overrides blocking
78
+ severity but does not enable rules disabled by base policy.
79
+
80
+ ```bash
81
+ # Advisory pilot: inspect findings before deciding what should block the team
82
+ agent-roast review --base origin/main --fail-on ''
83
+ # Gate the shortcuts the team agrees matter
84
+ agent-roast review --base origin/main --fail-on test-skip,trivial-assert,swallowed-error
85
+ ```
86
+
87
+ Matching uses rule, source snippet and occurrence count within a file pair. Line
88
+ shifts and Git-recognized pure renames do not turn old debt into new debt. A second
89
+ identical occurrence is new. This is not semantic tracking: rewritten snippets or
90
+ language changes can require fresh review, and Git's rename recognition is heuristic.
91
+
92
+ Limits: 1,000 changed files, 2 MiB per source blob, 50 MiB across inspected versions,
93
+ 30 seconds per Git command. Symlinked/binary/oversized source and missing comparison
94
+ objects yield exit 2, never a clean pass. Partial clones and ambiguous multiple
95
+ merge-bases are unsupported; fetch complete local history (`fetch-depth: 0`). The
96
+ report counts excluded and unsupported changes explicitly. Only changed supported
97
+ files are compared; this is not a whole-repository safety certification.
98
+
99
+ SARIF includes rule metadata, relative locations, stable occurrence fingerprints
100
+ and whether the scan completed. Source snippets are omitted from SARIF. JSON and
101
+ Markdown reports contain evidence snippets and should be handled as source code.
102
+ SARIF output is generated offline; uploading it is a separate, optional operation.
103
+ GitHub supports SARIF ingestion, but this release has not been tested with a live
104
+ code-scanning upload. Some findings on unchanged lines (including deletion-only
105
+ regressions) may appear in the report/summary rather than as an inline PR annotation.
106
+ Use a separate SARIF category for introduced-findings review; it does not represent
107
+ a full scan of all outstanding alerts.
108
+
109
+ Install 0.7.0: `npx agent-roast@0.7.0 review --base origin/main`. For local development,
110
+ run `npm run build` and use `node dist/cli.js`.
111
+
51
112
  ### Baseline adoption
52
113
 
53
114
  A baseline explicitly accepts the findings present at adoption time. It does not fix
@@ -114,7 +175,6 @@ Configure custom ignore paths, rule severities, and CI thresholds with a `.agent
114
175
 
115
176
  ```json
116
177
  {
117
- "$schema": "https://raw.githubusercontent.com/Nari-Ab/agent-roast/main/schema.json",
118
178
  "ignorePaths": [
119
179
  "**/*.min.js",
120
180
  "**/vendor/**",
@@ -122,11 +182,10 @@ Configure custom ignore paths, rule severities, and CI thresholds with a `.agent
122
182
  "**/fixtures/**"
123
183
  ],
124
184
  "rules": {
125
- "hallucinated-package": "error",
126
- "empty-catch": "warn",
185
+ "test-focus": "error",
186
+ "swallowed-error": "warn",
127
187
  "trivial-assert": "warn",
128
- "stub-abandonment": "warn",
129
- "mock-leak": "warn"
188
+ "stub-abandonment": "warn"
130
189
  },
131
190
  "scoreThreshold": 70
132
191
  }
@@ -142,9 +201,22 @@ npx agent-roast --staged
142
201
  npx agent-roast init --hook
143
202
  ```
144
203
 
145
- `--staged` audits exactly the Git index, including an initial commit without HEAD.
146
- Unstaged edits and untracked files are excluded. The generated hook uses the locally
147
- installed CLI (`--no-install`) and blocks on configured error rules instead of scoring
204
+ Since 0.7.0, `--staged` (also `review --staged`) compares the **full source**
205
+ of changed files in HEAD and the Git index. It detects deletion-only empty handlers
206
+ and retains comment context. Existing findings do not block; additional occurrences
207
+ do. Unstaged edits, untracked files and `git add -N` intent-to-add entries are excluded.
208
+ The previous released 0.6.0 `--staged` command checks only added index lines.
209
+
210
+ Policy comes from HEAD. Staged/unstaged config edits cannot weaken this check and
211
+ are reported; adopt policy changes in a separately reviewed commit. With no HEAD,
212
+ the first commit uses built-in defaults; use `--fail-on` for an explicit override.
213
+ For example: `node dist/cli.js review --staged --fail-on test-skip,test-focus,swallowed-error`.
214
+ Source is pinned by Git blob IDs. If HEAD or the staged inventory changes before the
215
+ scan finishes, it exits 2 and asks for a rerun. The report's `source` is `index`;
216
+ `headSha` is an `INDEX:` snapshot digest, not a commit SHA, and an unborn base is `EMPTY`.
217
+ The JSON schema version for context reviews is now `1.1.0`.
218
+
219
+ The generated hook uses the locally installed CLI (`--no-install`) and blocks on configured error rules instead of scoring
148
220
  old commits. It preserves an existing pre-commit hook.
149
221
 
150
222
  ### Configure what blocks a change
@@ -155,6 +227,7 @@ Create `.agentroastrc.json` (or run `npx agent-roast init`):
155
227
  {
156
228
  "rules": {
157
229
  "test-skip": "error",
230
+ "test-focus": "error",
158
231
  "trivial-assert": "error",
159
232
  "swallowed-error": "warn",
160
233
  "type-escape": "off"
@@ -166,7 +239,7 @@ Create `.agentroastrc.json` (or run `npx agent-roast init`):
166
239
  ```
167
240
 
168
241
  `off` removes findings and score penalties; `warn` reports without blocking a diff;
169
- `error` blocks PR/staged checks. An explicit `failOn` array overrides which enabled
242
+ `error` blocks PR/staged checks, including `test-focus` by default in 0.7.0. An explicit `failOn` array overrides which enabled
170
243
  rules block (`[]` makes the audit advisory). CLI `--fail-on` takes precedence over
171
244
  that array. History and badge audits honor disabled rules and the panic-loop window.
172
245
  History exit status is controlled by `scoreThreshold` or `--fail-under`.
@@ -203,7 +276,6 @@ jobs:
203
276
  - name: Run agent-roast audit
204
277
  uses: Nari-Ab/agent-roast@v1
205
278
  with:
206
- fail-on: test-skip,trivial-assert,stub-abandonment
207
279
  format: github
208
280
  ```
209
281
 
@@ -213,8 +285,12 @@ jobs:
213
285
 
214
286
  ### What it checks
215
287
 
216
- Only **added lines** (`+`) in source code files are scanned — deleted lines, file moves, and comments never trigger false alarms.
288
+ `check` scans current source. `review` scans both full versions of changed source;
289
+ the 0.7.0 `--staged` command does the same for HEAD and index. Legacy
290
+ `--base` and history audits inspect added lines. Comment/literal masking reduces
291
+ noise; these are heuristic checks and can have false positives and missed findings.
217
292
 
293
+ - **Focused tests (`test-focus`):** Exclusive `test.only`, `it.only`, `describe.only`, `suite.only`, Playwright `test.describe.only` and common modifier chains in JS/TS-family sources. `fit` / `fdescribe` aliases are recognized in conventional test paths. Supports contiguous multiline chains and ignores literal/comment examples. Default blocking rule; an existing explicit `failOn` list must include it to block. This does not resolve imports or custom aliases; configure your runner's native forbid-only option too when available ([Playwright](https://playwright.dev/docs/api/class-testconfig#test-config-forbid-only), [Jest API](https://jestjs.io/docs/api#testonlyname-fn-timeout)).
218
294
  - **Skipped tests (`test-skip`):** `it.skip`, `describe.skip`, `xit`, `pytest.skip`, `test.todo` added to silence failing tests. (Default blocking gate).
219
295
  - **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)`).
220
296
  - **Stub abandonment (`stub-abandonment`):** Dummy stubs left unimplemented in committed code (`throw new Error("Not implemented")`, `raise NotImplementedError`, Rust `todo!()` / `unimplemented!()`).
@@ -238,7 +314,7 @@ Only **added lines** (`+`) in source code files are scanned — deleted lines, f
238
314
  - **Requirements:** Git 2.30+ (for `--end-of-options` argument isolation), Node.js 18+.
239
315
  - **100% Local:** Instant streaming parser runs directly on your machine.
240
316
  - **Zero Telemetry:** No cloud dependencies, no LLM API calls, no network traffic. Verified by static bundle inspection.
241
- - **Hermetic:** Enforces `-c core.quotepath=false -c diff.noprefix=false` to guarantee identical behavior across all environments.
317
+ - **Git isolation:** Forces stable diff paths, disables fsmonitor/external diff execution, and reads review content by object identity.
242
318
 
243
319
  ---
244
320
 
@@ -249,7 +325,8 @@ stubs, alongside a compiler, test runner and language-aware linter. Findings ide
249
325
  patterns, not proof of AI authorship or developer intent. Deliberate skips and abstract
250
326
  methods can be legitimate. Contiguous block comments, Python docstrings and multi-line empty handlers are
251
327
  recognized. Template interpolation, complex language syntax and context outside
252
- the added diff are not fully parsed. Panic loops are an observational history heuristic.
328
+ the added diff in legacy audit modes are not fully parsed. Custom test API aliases,
329
+ computed property calls and type/import resolution are not handled. Panic loops are an observational history heuristic.
253
330
  The discipline score summarizes added history; it does not certify current code quality.
254
331
  `check` reports actionable findings without assigning a personality or quality score.
255
332
 
@@ -262,7 +339,11 @@ npm test
262
339
  ```
263
340
 
264
341
  `npm test` builds the distribution first so CLI integration and bundle checks run
265
- against current code. Regression tests use disposable Git repositories.
342
+ against current code. Regression tests use disposable Git repositories. The programmatic
343
+ `auditPullRequest({ staged: true })` API retains the legacy added-lines contract;
344
+ use `reviewStagedChanges()` for the new context comparison and `ReviewResult`.
345
+ The composite Action installs exactly 0.7.0. For local development, use
346
+ `node dist/cli.js --staged` after building.
266
347
 
267
348
  ### Author & Support
268
349
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import path5 from "path";
4
+ import path6 from "path";
5
5
 
6
6
  // src/git.ts
7
7
  import { spawn, spawnSync } from "child_process";
@@ -640,6 +640,12 @@ var SourceMasker = class {
640
640
 
641
641
  // src/rules.ts
642
642
  var RULE_GUIDES = {
643
+ "test-focus": {
644
+ title: "Focused test narrows the test run",
645
+ why: "An exclusive test or suite can leave other tests unexecuted while the selected tests pass.",
646
+ suggestion: "Remove .only (or replace fit/fdescribe with it/describe) and run the full suite. Enable your test runner's forbid-only setting in CI when available.",
647
+ example: 'test.only("auth", ...) \u2192 test("auth", ...), then run the full suite'
648
+ },
643
649
  "test-skip": {
644
650
  title: "Skipped or disabled test",
645
651
  why: "The test runner may report success without executing this behavior check.",
@@ -689,12 +695,13 @@ var VALID_DETECTORS = [
689
695
  "swallowed-error",
690
696
  "panic-loop",
691
697
  "trivial-assert",
692
- "stub-abandonment"
698
+ "stub-abandonment",
699
+ "test-focus"
693
700
  ];
694
701
 
695
702
  // src/config.ts
696
703
  var DEFAULT_CONFIG = {
697
- failOn: ["test-skip", "trivial-assert"],
704
+ failOn: ["test-skip", "trivial-assert", "test-focus"],
698
705
  ignorePaths: [
699
706
  "**/fixtures/**",
700
707
  "**/__fixtures__/**",
@@ -709,6 +716,7 @@ var DEFAULT_CONFIG = {
709
716
  rules: {
710
717
  "test-skip": "error",
711
718
  "trivial-assert": "error",
719
+ "test-focus": "error",
712
720
  "swallowed-error": "warn",
713
721
  "stub-abandonment": "warn",
714
722
  "type-escape": "warn"
@@ -754,32 +762,36 @@ function validateConfig(value) {
754
762
  }
755
763
  }
756
764
  }
765
+ function configFromText(text, source) {
766
+ try {
767
+ const parsed = JSON.parse(text);
768
+ validateConfig(parsed);
769
+ const rules = { ...DEFAULT_CONFIG.rules, ...parsed.rules };
770
+ const failOn = parsed.failOn ?? Object.entries(rules).filter(([, level]) => level === "error").map(([rule]) => rule);
771
+ return {
772
+ ...DEFAULT_CONFIG,
773
+ ...parsed,
774
+ failOn,
775
+ ignorePaths: [.../* @__PURE__ */ new Set([...DEFAULT_CONFIG.ignorePaths || [], ...parsed.ignorePaths || []])],
776
+ rules
777
+ };
778
+ } catch (error) {
779
+ throw new ConfigError(`Invalid ${source}: ${error instanceof Error ? error.message : String(error)}`);
780
+ }
781
+ }
757
782
  function loadConfig(cwd = process.cwd()) {
758
783
  for (const filename of CONFIG_FILENAMES) {
759
784
  const filePath = path.join(cwd, filename);
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)}`);
785
+ if (fs.existsSync(filePath)) {
786
+ try {
787
+ return configFromText(fs.readFileSync(filePath, "utf8"), filename);
788
+ } catch (error) {
789
+ if (error instanceof ConfigError) throw error;
790
+ throw new ConfigError(`Cannot read ${filename}: ${error instanceof Error ? error.message : String(error)}`);
791
+ }
775
792
  }
776
793
  }
777
- return {
778
- ...DEFAULT_CONFIG,
779
- failOn: [...DEFAULT_CONFIG.failOn || []],
780
- ignorePaths: [...DEFAULT_CONFIG.ignorePaths || []],
781
- rules: { ...DEFAULT_CONFIG.rules }
782
- };
794
+ return configFromText("{}", "default configuration");
783
795
  }
784
796
  function matchIgnorePath(filePath, ignorePatterns) {
785
797
  if (!ignorePatterns || ignorePatterns.length === 0) {
@@ -836,6 +848,7 @@ var TEST_SKIP_PATTERNS = [
836
848
  { regex: /\bpytest\.skip\(/, name: "imperative pytest skip (pytest.skip)" },
837
849
  { regex: /\btest\.todo\(/, name: "todo placeholder test" }
838
850
  ];
851
+ var FOCUSED_TEST = /(?<![\w$.])(?:it|test|describe|suite)(?:\s*\.\s*(?:concurrent|serial|parallel|describe))*\s*\.\s*only\b(?=\s*(?:\(|\.\s*(?:each|failing|concurrent|serial|parallel)\b))|(?<![\w$.])(?:fit|fdescribe)\b(?=\s*(?:\(|\.\s*each\b))/g;
839
852
  var COMMENT_DIRECTIVE_PATTERNS = [
840
853
  { regex: /^\s*\/\/\s*@ts-ignore\b/, name: "compiler ignore (@ts-ignore)" },
841
854
  { regex: /^\s*\/\/\s*@ts-expect-error\b/, name: "compiler suppression (@ts-expect-error)" },
@@ -875,6 +888,8 @@ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
875
888
  ".jsx",
876
889
  ".mjs",
877
890
  ".cjs",
891
+ ".mts",
892
+ ".cts",
878
893
  ".py",
879
894
  ".go",
880
895
  ".rs",
@@ -1020,6 +1035,35 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
1020
1035
  }
1021
1036
  }
1022
1037
  }
1038
+ if (/\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file.path)) {
1039
+ let source = "";
1040
+ const offsets = [];
1041
+ for (let i = 0; i < maskedLines.length; i++) {
1042
+ if (i) source += maskedLines[i].number === maskedLines[i - 1].number + 1 ? "\n" : "\0";
1043
+ offsets.push(source.length);
1044
+ source += maskedLines[i].code;
1045
+ }
1046
+ let row = 0;
1047
+ for (const match of source.matchAll(FOCUSED_TEST)) {
1048
+ let previous = match.index - 1;
1049
+ while (previous >= 0 && /\s/.test(source[previous])) previous--;
1050
+ if (source[previous] === ".") continue;
1051
+ if (/^(?:fit|fdescribe)$/.test(match[0]) && !isTestFile(file.path)) continue;
1052
+ if (/\bfunction$/.test(source.slice(Math.max(0, previous - 8), previous + 1))) continue;
1053
+ while (row + 1 < offsets.length && offsets[row + 1] <= match.index) row++;
1054
+ let end = row;
1055
+ while (end + 1 < offsets.length && offsets[end + 1] < match.index + match[0].length) end++;
1056
+ infractions.push({
1057
+ type: "test-focus",
1058
+ file: file.path,
1059
+ lineNumber: maskedLines[row].number,
1060
+ lineSnippet: maskedLines.slice(row, end + 1).map((line) => line.raw.trim()).join(" "),
1061
+ commitHash,
1062
+ commitDate,
1063
+ reason: "focused test or suite excludes other tests from the run"
1064
+ });
1065
+ }
1066
+ }
1023
1067
  for (let index = 0; index < maskedLines.length; index++) {
1024
1068
  const start = maskedLines[index];
1025
1069
  const isCatch = /\bcatch\s*(?:\([^)]*\))?\s*\{\s*$/.test(start.code);
@@ -1063,6 +1107,7 @@ var MetricsCollector = class {
1063
1107
  totalCommits = 0;
1064
1108
  aiCommits = 0;
1065
1109
  testSkips = [];
1110
+ focusedTests = [];
1066
1111
  typeEscapes = [];
1067
1112
  swallowedErrors = [];
1068
1113
  panicLoops = [];
@@ -1169,6 +1214,7 @@ var MetricsCollector = class {
1169
1214
  });
1170
1215
  const buckets = {
1171
1216
  "test-skip": this.testSkips,
1217
+ "test-focus": this.focusedTests,
1172
1218
  "type-escape": this.typeEscapes,
1173
1219
  "swallowed-error": this.swallowedErrors,
1174
1220
  "trivial-assert": this.trivialAsserts,
@@ -1186,7 +1232,7 @@ var MetricsCollector = class {
1186
1232
  this.flushPanicChain();
1187
1233
  const linesSample = this.includeAll ? this.totalLinesAdded : this.aiLinesAdded;
1188
1234
  const isSufficientData = linesSample >= MIN_SAMPLE_LOC;
1189
- const codeInfractionPoints = this.testSkips.length * 15 + this.trivialAsserts.length * 15 + this.swallowedErrors.length * 10 + this.stubAbandonments.length * 8 + this.typeEscapes.length * 3;
1235
+ const codeInfractionPoints = this.testSkips.length * 15 + this.focusedTests.length * 15 + this.trivialAsserts.length * 15 + this.swallowedErrors.length * 10 + this.stubAbandonments.length * 8 + this.typeEscapes.length * 3;
1190
1236
  let panicInfractionPoints = 0;
1191
1237
  for (const loop of this.panicLoops) {
1192
1238
  const countMatch = loop.reason.match(/chain of (\d+) quick fixes/);
@@ -1255,6 +1301,7 @@ var MetricsCollector = class {
1255
1301
  totalCommits: this.totalCommits,
1256
1302
  aiCommits: this.aiCommits,
1257
1303
  testSkips: this.testSkips,
1304
+ focusedTests: this.focusedTests,
1258
1305
  typeEscapes: this.typeEscapes,
1259
1306
  swallowedErrors: this.swallowedErrors,
1260
1307
  panicLoops: this.panicLoops,
@@ -1311,7 +1358,7 @@ function formatTerminalCard(summary, options) {
1311
1358
  lines.push(` ${pc.dim(summary.archetypeDescription)}`);
1312
1359
  lines.push("");
1313
1360
  }
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);
1361
+ const totalInfractions = (summary.testSkips?.length || 0) + (summary.focusedTests?.length || 0) + (summary.typeEscapes?.length || 0) + (summary.swallowedErrors?.length || 0) + (summary.panicLoops?.length || 0) + (summary.trivialAsserts?.length || 0) + (summary.stubAbandonments?.length || 0);
1315
1362
  lines.push(pc.bold(` INFRACTIONS (${totalInfractions})`));
1316
1363
  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"));
1317
1364
  if (summary.panicLoops && summary.panicLoops.length > 0) {
@@ -1330,6 +1377,11 @@ function formatTerminalCard(summary, options) {
1330
1377
  lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1331
1378
  }
1332
1379
  }
1380
+ if (summary.focusedTests?.length) {
1381
+ lines.push(` ${pc.red("\u25CF")} ${pc.bold("test-focus")} (${summary.focusedTests.length}) ${pc.dim("exclusive tests narrow the test run")}`);
1382
+ const sample = summary.focusedTests[0];
1383
+ if (sample.file) lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
1384
+ }
1333
1385
  if (summary.testSkips && summary.testSkips.length > 0) {
1334
1386
  lines.push(
1335
1387
  ` ${pc.red("\u25CF")} ${pc.bold("test-skip")} (${summary.testSkips.length}) ${pc.dim("skipped or disabled tests")}`
@@ -1384,6 +1436,7 @@ function formatTerminalCard(summary, options) {
1384
1436
  }
1385
1437
  const allInfractions = [
1386
1438
  ...summary.testSkips || [],
1439
+ ...summary.focusedTests || [],
1387
1440
  ...summary.trivialAsserts || [],
1388
1441
  ...summary.swallowedErrors || [],
1389
1442
  ...summary.stubAbandonments || [],
@@ -1623,6 +1676,60 @@ function formatCheckMarkdown(result) {
1623
1676
  for (const skipped of result.skippedFiles) lines.push("", `Not scanned: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
1624
1677
  return lines.join("\n");
1625
1678
  }
1679
+ function formatReviewTerminal(result) {
1680
+ const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
1681
+ const lines = [
1682
+ "",
1683
+ pc.bold(`agent-roast \u2014 ${result.source === "index" ? "staged context review" : "context review"}: ${status}`),
1684
+ ` ${result.mergeBaseSha.slice(0, 7)} \u2192 ${result.headSha.slice(0, result.source === "index" ? 18 : 7)} \xB7 policy: ${result.policySha === "EMPTY" ? "built-in first-commit defaults" : "base " + result.policySha.slice(0, 7)}`,
1685
+ ` ${result.filesInspected} changed files compared \xB7 ${result.linesInspected} source lines inspected across both versions`,
1686
+ ` ${result.infractions.length} new \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected`
1687
+ ];
1688
+ if (result.policyChanges.length) lines.push(" Candidate policy changes were ignored; adopt them separately after review.");
1689
+ if (result.excludedFiles || result.unsupportedFiles) lines.push(` ${result.excludedFiles} excluded \xB7 ${result.unsupportedFiles} unsupported/non-source changes`);
1690
+ for (const finding of result.infractions.slice(0, 20)) {
1691
+ lines.push(
1692
+ "",
1693
+ ` ${result.failedRules.includes(finding.type) ? "FAIL" : "WARN"} [${finding.type}] ${terminalText(finding.file || "unknown")}:${finding.lineNumber}`,
1694
+ ` ${terminalText(finding.lineSnippet || finding.reason)}`,
1695
+ ` Next: ${finding.suggestion || "Review this occurrence."}`
1696
+ );
1697
+ }
1698
+ if (result.infractions.length > 20) lines.push(` ${result.infractions.length - 20} more new findings; use --format json for all results.`);
1699
+ for (const skipped of result.skippedFiles) lines.push(` NOT COMPARED ${terminalText(skipped.file)}: ${terminalText(skipped.reason)}`);
1700
+ lines.push("", !result.complete ? " INCOMPLETE: some changed source was not compared (exit 2)." : result.passed ? " PASSED: no new blocking findings." : ` BLOCKED: ${result.failedRules.join(", ")}.`, "");
1701
+ if (!result.filesInspected) lines.push(" No supported changed source files compared.", "");
1702
+ return lines.join("\n");
1703
+ }
1704
+ function formatReviewMarkdown(result) {
1705
+ const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
1706
+ const lines = [
1707
+ `## agent-roast ${result.source === "index" ? "staged context review" : "context review"}: ${status}`,
1708
+ "",
1709
+ `Compared ${result.filesInspected} changed files at \`${result.mergeBaseSha.slice(0, 7)}\` \u2192 \`${result.headSha.slice(0, result.source === "index" ? 18 : 7)}\`. Policy: \`${result.policySha === "EMPTY" ? "built-in first-commit defaults" : result.policySha.slice(0, 7)}\`.`,
1710
+ "",
1711
+ `**${result.infractions.length} new** \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected.`,
1712
+ ""
1713
+ ];
1714
+ if (result.policyChanges.length) lines.push("Candidate policy changes were ignored. This review uses the supplied base commit's policy.", "");
1715
+ lines.push("| New finding | Head location | Evidence | Next step |", "| --- | --- | --- | --- |");
1716
+ for (const finding of result.infractions) lines.push(`| ${finding.type} | ${markdownCell(`${finding.file}:${finding.lineNumber}`)} | ${markdownCell(finding.lineSnippet || finding.reason)} | ${markdownCell(finding.suggestion || "Review this occurrence.")} |`);
1717
+ if (result.removedFindings.length) {
1718
+ lines.push(
1719
+ "",
1720
+ "### No longer detected",
1721
+ "",
1722
+ "These patterns disappeared from the compared source. Deleting code is not proof of a behavioral fix.",
1723
+ "",
1724
+ "| Rule | Previous location | Previous evidence |",
1725
+ "| --- | --- | --- |"
1726
+ );
1727
+ for (const finding of result.removedFindings) lines.push(`| ${finding.type} | ${markdownCell(`${finding.file}:${finding.lineNumber}`)} | ${markdownCell(finding.lineSnippet || finding.reason)} |`);
1728
+ }
1729
+ for (const skipped of result.skippedFiles) lines.push("", `Not compared: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
1730
+ lines.push("", `Scope: changed supported files only; ${result.excludedFiles} excluded and ${result.unsupportedFiles} unsupported/non-source changes.`);
1731
+ return lines.join("\n");
1732
+ }
1626
1733
 
1627
1734
  // src/init.ts
1628
1735
  import * as fs2 from "fs";
@@ -1663,6 +1770,7 @@ jobs:
1663
1770
  base: \${{ github.event.pull_request.base.sha }}
1664
1771
  head: \${{ github.event.pull_request.head.sha }}
1665
1772
  format: github
1773
+ mode: review
1666
1774
  `;
1667
1775
  var PRE_COMMIT_HOOK_TEMPLATE = `#!/bin/sh
1668
1776
  # Run agent-roast pre-commit verification
@@ -1775,11 +1883,8 @@ function generateShieldsJson(score, label = "AI Discipline") {
1775
1883
  };
1776
1884
  }
1777
1885
 
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";
1886
+ // src/sarif.ts
1887
+ import { createRequire } from "module";
1783
1888
 
1784
1889
  // src/baseline.ts
1785
1890
  import { createHash, randomUUID } from "crypto";
@@ -1873,7 +1978,79 @@ function writeBaseline(filename, findings) {
1873
1978
  }
1874
1979
  }
1875
1980
 
1981
+ // src/sarif.ts
1982
+ var require2 = createRequire(import.meta.url);
1983
+ var version = require2("../package.json").version;
1984
+ function formatSarif(report) {
1985
+ const occurrences = /* @__PURE__ */ new Map();
1986
+ const results = report.infractions.map((finding) => {
1987
+ const filename = finding.file;
1988
+ if (!filename || filename.startsWith("/") || filename.split("/").includes("..") || /^[a-z]:/i.test(filename)) {
1989
+ throw new ConfigError("SARIF needs repository-relative finding paths.");
1990
+ }
1991
+ const fingerprint = findingFingerprint(finding);
1992
+ const occurrence = (occurrences.get(fingerprint) || 0) + 1;
1993
+ occurrences.set(fingerprint, occurrence);
1994
+ return {
1995
+ ruleId: finding.type,
1996
+ ruleIndex: VALID_DETECTORS.indexOf(finding.type),
1997
+ level: report.failedRules.includes(finding.type) ? "error" : "warning",
1998
+ message: { text: `${finding.reason}. ${finding.suggestion || RULE_GUIDES[finding.type].suggestion}` },
1999
+ locations: [{ physicalLocation: {
2000
+ artifactLocation: { uri: filename.split("/").map((segment) => encodeURIComponent(segment)).join("/") },
2001
+ region: { startLine: Math.max(1, finding.lineNumber || 1) }
2002
+ } }],
2003
+ partialFingerprints: { "agentRoastFinding/v1": `${fingerprint}:${occurrence}` },
2004
+ ...report.mode === "review" ? { baselineState: "new" } : {}
2005
+ };
2006
+ });
2007
+ return JSON.stringify({
2008
+ $schema: "https://json.schemastore.org/sarif-2.1.0.json",
2009
+ version: "2.1.0",
2010
+ runs: [{
2011
+ tool: { driver: {
2012
+ name: "agent-roast",
2013
+ semanticVersion: version,
2014
+ informationUri: "https://github.com/Nari-Ab/agent-roast",
2015
+ rules: VALID_DETECTORS.map((rule) => ({
2016
+ id: rule,
2017
+ name: rule,
2018
+ shortDescription: { text: RULE_GUIDES[rule].title },
2019
+ fullDescription: { text: RULE_GUIDES[rule].why },
2020
+ help: { text: `${RULE_GUIDES[rule].suggestion}
2021
+ Example: ${RULE_GUIDES[rule].example}` },
2022
+ defaultConfiguration: { level: "warning" },
2023
+ properties: { tags: ["maintainability", "code-review"] }
2024
+ }))
2025
+ } },
2026
+ invocations: [{
2027
+ executionSuccessful: report.complete,
2028
+ exitCode: report.exitCode,
2029
+ toolExecutionNotifications: report.skippedFiles.map((skipped) => ({
2030
+ level: "error",
2031
+ message: { text: `Not inspected: ${skipped.file}. ${skipped.reason}` }
2032
+ }))
2033
+ }],
2034
+ results,
2035
+ properties: report.mode === "review" ? {
2036
+ scope: "introduced findings in changed files",
2037
+ source: report.source ?? "commits",
2038
+ baseSha: report.baseSha,
2039
+ headSha: report.headSha,
2040
+ mergeBaseSha: report.mergeBaseSha,
2041
+ policySha: report.policySha,
2042
+ unchangedFindings: report.unchangedCount,
2043
+ removedFindings: report.removedFindings.length
2044
+ } : { scope: "current source", acceptedBaselineFindings: report.baselineCount }
2045
+ }]
2046
+ }, null, 2);
2047
+ }
2048
+
1876
2049
  // src/check.ts
2050
+ import { spawnSync as spawnSync2 } from "child_process";
2051
+ import * as fs4 from "fs/promises";
2052
+ import { constants as constants2 } from "fs";
2053
+ import path4 from "path";
1877
2054
  var MAX_FILE_BYTES = 2 * 1024 * 1024;
1878
2055
  var MAX_SCAN_BYTES = 50 * 1024 * 1024;
1879
2056
  var GENERATED_PATHS = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
@@ -1936,10 +2113,10 @@ async function checkProject(options = {}) {
1936
2113
  const cwd = path4.resolve(options.cwd || process.cwd());
1937
2114
  const root = await fs4.realpath(git(cwd, ["rev-parse", "--show-toplevel"]).trim());
1938
2115
  const config = loadConfig(root);
1939
- const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert"];
2116
+ const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert", "test-focus"];
1940
2117
  for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown rule '${rule}'.`);
1941
2118
  const format = options.format || "terminal";
1942
- if (!["terminal", "json", "github", "markdown"].includes(format)) throw new ConfigError("Unsupported check output format.");
2119
+ if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported check output format.");
1943
2120
  const baseline = options.baseline ? loadBaseline(path4.resolve(cwd, options.baseline)) : void 0;
1944
2121
  const filenames = [...new Set(git(root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]).split("\0").filter(Boolean))].sort();
1945
2122
  const infractions = [];
@@ -1992,6 +2169,7 @@ async function checkProject(options = {}) {
1992
2169
  output: ""
1993
2170
  };
1994
2171
  if (format === "json") result.output = JSON.stringify(result, null, 2);
2172
+ else if (format === "sarif") result.output = formatSarif(result);
1995
2173
  else if (format === "markdown") result.output = formatCheckMarkdown(result);
1996
2174
  else if (format === "github") {
1997
2175
  result.output = formatGithubAnnotations(result) + (complete ? "" : "\n::error::Project scan incomplete. See the JSON or terminal report for skipped files.");
@@ -1999,6 +2177,315 @@ async function checkProject(options = {}) {
1999
2177
  return result;
2000
2178
  }
2001
2179
 
2180
+ // src/review.ts
2181
+ import path5 from "path";
2182
+
2183
+ // src/snapshot.ts
2184
+ import { createHash as createHash2 } from "crypto";
2185
+ import { spawn as spawn2 } from "child_process";
2186
+ function assertNotAborted(signal) {
2187
+ if (signal?.aborted) throw new ConfigError("Review cancelled; no complete result was produced.");
2188
+ }
2189
+ async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024) {
2190
+ assertNotAborted(signal);
2191
+ const env = getHermeticGitEnv({ GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0" });
2192
+ for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_NAMESPACE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"]) delete env[key];
2193
+ return new Promise((resolve2, reject) => {
2194
+ const child = spawn2("git", [...HERMETIC_GIT_FLAGS, ...args], {
2195
+ cwd,
2196
+ env,
2197
+ stdio: ["ignore", "pipe", "pipe"],
2198
+ windowsHide: true
2199
+ });
2200
+ const chunks = [];
2201
+ let bytes = 0;
2202
+ let stderr = "";
2203
+ let settled = false;
2204
+ const finish = (error) => {
2205
+ if (settled) return;
2206
+ settled = true;
2207
+ clearTimeout(timer);
2208
+ signal?.removeEventListener("abort", abort);
2209
+ if (error) {
2210
+ child.kill();
2211
+ reject(error);
2212
+ } else resolve2(Buffer.concat(chunks));
2213
+ };
2214
+ const abort = () => finish(new ConfigError("Review cancelled; no complete result was produced."));
2215
+ const timer = setTimeout(() => finish(new ConfigError("Git review read timed out after 30 seconds.")), 3e4);
2216
+ signal?.addEventListener("abort", abort, { once: true });
2217
+ child.stdout.on("data", (chunk) => {
2218
+ if (settled) return;
2219
+ bytes += chunk.length;
2220
+ if (bytes > limit) finish(new ConfigError(`Git read exceeds the ${limit} byte limit.`));
2221
+ else chunks.push(chunk);
2222
+ });
2223
+ child.stderr.on("data", (chunk) => {
2224
+ if (stderr.length < 64 * 1024) stderr += chunk.toString("utf8").slice(0, 64 * 1024 - stderr.length);
2225
+ });
2226
+ child.on("error", (error) => finish(error));
2227
+ child.on("close", (code) => finish(code === 0 ? void 0 : new GitError(stderr, code)));
2228
+ if (signal?.aborted) abort();
2229
+ });
2230
+ }
2231
+ async function requireLocalObjects(cwd, signal) {
2232
+ try {
2233
+ const promises = (await readGit(cwd, ["config", "--type=bool", "--get-regexp", "^remote\\..*\\.promisor$"], signal, 64 * 1024)).toString();
2234
+ if (promises.split("\n").some((line) => /\s+(?:true|yes|on|1)\s*$/i.test(line))) {
2235
+ throw new ConfigError("Offline review requires a non-partial clone with local objects; promisor remotes are unsupported.");
2236
+ }
2237
+ } catch (error) {
2238
+ if (!(error instanceof GitError && error.code === 1)) throw error;
2239
+ }
2240
+ }
2241
+ async function reviewRefs(cwd, base, head, signal) {
2242
+ const resolve2 = async (ref) => {
2243
+ if (!ref.trim()) throw new ConfigError("Review refs must not be empty.");
2244
+ try {
2245
+ return (await readGit(cwd, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], signal, 1024)).toString().trim();
2246
+ } catch (error) {
2247
+ assertNotAborted(signal);
2248
+ throw new ConfigError(`Cannot resolve review ref '${ref}'. Use valid commits and fetch complete history (actions/checkout fetch-depth: 0). ${error instanceof Error ? error.message : String(error)}`);
2249
+ }
2250
+ };
2251
+ await requireLocalObjects(cwd, signal);
2252
+ const baseSha = await resolve2(base);
2253
+ const headSha = await resolve2(head);
2254
+ let ancestors;
2255
+ try {
2256
+ ancestors = (await readGit(cwd, ["merge-base", "--all", "--end-of-options", baseSha, headSha], signal, 8192)).toString().trim().split("\n");
2257
+ } catch (error) {
2258
+ assertNotAborted(signal);
2259
+ throw new ConfigError(`Cannot find review merge-base. Fetch full shared history (fetch-depth: 0). ${error instanceof Error ? error.message : String(error)}`);
2260
+ }
2261
+ if (ancestors.length !== 1 || !/^[a-f0-9]{40,64}$/.test(ancestors[0])) throw new ConfigError("Review needs exactly one merge base; ambiguous merge ancestry is unsupported.");
2262
+ return { baseSha, headSha, mergeBaseSha: ancestors[0] };
2263
+ }
2264
+ async function changedTreeFiles(cwd, before, after, signal) {
2265
+ const buffer = await readGit(cwd, ["diff", "--raw", "-z", "--no-relative", "--no-abbrev", "--no-ext-diff", "--no-textconv", "--find-renames=50%", "-l1000", "--end-of-options", before, after, "--"], signal);
2266
+ return parseTreeChanges(buffer);
2267
+ }
2268
+ function parseTreeChanges(buffer) {
2269
+ const tokens = new TextDecoder("utf-8", { fatal: true }).decode(buffer).split("\0");
2270
+ if (tokens.pop() !== "") throw new ConfigError("Truncated Git change inventory.");
2271
+ const changes = [];
2272
+ for (let i = 0; i < tokens.length; ) {
2273
+ const match = /^:(\d{6}) (\d{6}) ([a-f0-9]{40,64}) ([a-f0-9]{40,64}) ([AMDRT]\d*)$/.exec(tokens[i++]);
2274
+ if (!match) throw new ConfigError("Unsupported Git change inventory entry.");
2275
+ const oldPath = tokens[i++];
2276
+ const newPath = match[5].startsWith("R") ? tokens[i++] : oldPath;
2277
+ if (!oldPath || !newPath) throw new ConfigError("Incomplete Git path inventory.");
2278
+ changes.push({ oldMode: match[1], newMode: match[2], oldOid: match[3], newOid: match[4], status: match[5], oldPath, newPath });
2279
+ if (changes.length > 1e3) throw new ConfigError("Review exceeds the 1000 changed-file limit; split the change into smaller reviews.");
2280
+ }
2281
+ return changes;
2282
+ }
2283
+ async function readBlob(cwd, oid, signal, limit = 2 * 1024 * 1024) {
2284
+ if (!/^[a-f0-9]{40,64}$/.test(oid)) throw new ConfigError("Invalid Git blob identity.");
2285
+ const sizeText = (await readGit(cwd, ["cat-file", "-s", oid], signal, 128)).toString().trim();
2286
+ const size = Number(sizeText);
2287
+ if (!Number.isSafeInteger(size) || size < 0 || size > limit) throw new ConfigError(`Source blob exceeds the ${limit} byte limit or has an invalid size.`);
2288
+ const buffer = await readGit(cwd, ["cat-file", "blob", oid], signal, limit);
2289
+ if (buffer.length !== size) throw new ConfigError("Git blob size differs from the recorded object.");
2290
+ return buffer;
2291
+ }
2292
+ async function reviewPolicy(cwd, baseSha, signal) {
2293
+ const raw = await readGit(cwd, ["ls-tree", "--full-tree", "-z", baseSha, "--", ...CONFIG_FILENAMES], signal, 8192);
2294
+ const entries = raw.toString().split("\0").filter(Boolean);
2295
+ for (const filename of CONFIG_FILENAMES) {
2296
+ const entry = entries.find((line) => line.slice(line.indexOf(" ") + 1) === filename);
2297
+ if (!entry) continue;
2298
+ const match = /^(100644|100755) blob ([a-f0-9]{40,64})\t/.exec(entry);
2299
+ if (!match) throw new ConfigError(`Base policy '${filename}' must be a regular Git blob.`);
2300
+ const content = await readBlob(cwd, match[2], signal, 64 * 1024);
2301
+ return { config: configFromText(new TextDecoder("utf-8", { fatal: true }).decode(content), `${filename} in base ${baseSha.slice(0, 7)}`), filename };
2302
+ }
2303
+ return { config: configFromText("{}", "default review policy"), filename: null };
2304
+ }
2305
+ async function stagedSnapshot(cwd, signal) {
2306
+ await requireLocalObjects(cwd, signal);
2307
+ const head = async () => {
2308
+ try {
2309
+ return (await readGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], signal, 1024)).toString().trim();
2310
+ } catch (error) {
2311
+ if (!(error instanceof GitError && error.code === 1)) throw error;
2312
+ const ref = (await readGit(cwd, ["symbolic-ref", "--quiet", "HEAD"], signal, 1024)).toString().trim();
2313
+ try {
2314
+ await readGit(cwd, ["show-ref", "--verify", "--quiet", ref], signal, 1024);
2315
+ } catch (missing) {
2316
+ if (missing instanceof GitError && missing.code === 1) return "EMPTY";
2317
+ throw missing;
2318
+ }
2319
+ throw new ConfigError("HEAD exists but cannot be resolved to a commit.");
2320
+ }
2321
+ };
2322
+ const baseSha = await head();
2323
+ const inventory = () => readGit(cwd, [
2324
+ "diff",
2325
+ "--cached",
2326
+ "--raw",
2327
+ "-z",
2328
+ "--no-relative",
2329
+ "--no-abbrev",
2330
+ "--no-ext-diff",
2331
+ "--no-textconv",
2332
+ "--ignore-submodules=none",
2333
+ "--ita-invisible-in-index",
2334
+ "--find-renames=50%",
2335
+ "-l1000",
2336
+ "--end-of-options",
2337
+ ...baseSha === "EMPTY" ? [] : [baseSha],
2338
+ "--"
2339
+ ], signal);
2340
+ const conflicts = await readGit(cwd, ["ls-files", "--unmerged", "-z", "--full-name"], signal);
2341
+ if (conflicts.length) throw new ConfigError("Resolve unmerged index entries before reviewing staged changes.");
2342
+ const pinned = await inventory();
2343
+ const changes = parseTreeChanges(pinned);
2344
+ const headSha = "INDEX:" + createHash2("sha256").update(baseSha).update(pinned).digest("hex");
2345
+ return {
2346
+ baseSha,
2347
+ headSha,
2348
+ mergeBaseSha: baseSha,
2349
+ changes,
2350
+ verify: async () => {
2351
+ assertNotAborted(signal);
2352
+ if (await head() !== baseSha || !(await inventory()).equals(pinned)) {
2353
+ throw new ConfigError("HEAD or staged changes changed during review; rerun before committing. No complete result was produced.");
2354
+ }
2355
+ }
2356
+ };
2357
+ }
2358
+
2359
+ // src/review.ts
2360
+ var GENERATED_PATHS2 = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
2361
+ var MAX_SCAN_BYTES2 = 50 * 1024 * 1024;
2362
+ function compareFindings(before, after, newPath) {
2363
+ const old = /* @__PURE__ */ new Map();
2364
+ for (const finding of before) {
2365
+ const key = findingFingerprint({ ...finding, file: newPath });
2366
+ const bucket = old.get(key) || { findings: [], matched: 0 };
2367
+ bucket.findings.push(finding);
2368
+ old.set(key, bucket);
2369
+ }
2370
+ const introduced = [];
2371
+ let unchanged = 0;
2372
+ for (const finding of after) {
2373
+ const bucket = old.get(findingFingerprint(finding));
2374
+ if (bucket && bucket.matched < bucket.findings.length) {
2375
+ bucket.matched++;
2376
+ unchanged++;
2377
+ } else introduced.push(finding);
2378
+ }
2379
+ return { introduced, removed: [...old.values()].flatMap((bucket) => bucket.findings.slice(bucket.matched)), unchanged };
2380
+ }
2381
+ function reviewChanges(options) {
2382
+ return compareSourceChanges({ ...options, staged: false });
2383
+ }
2384
+ function reviewStagedChanges(options = {}) {
2385
+ return compareSourceChanges({ ...options, staged: true });
2386
+ }
2387
+ async function compareSourceChanges(options) {
2388
+ assertNotAborted(options.signal);
2389
+ const cwd = path5.resolve(options.cwd || process.cwd());
2390
+ const staged = options.staged ? await stagedSnapshot(cwd, options.signal) : void 0;
2391
+ const refs = options.staged ? { baseSha: staged.baseSha, headSha: staged.headSha, mergeBaseSha: staged.mergeBaseSha } : await reviewRefs(cwd, options.base, options.head ?? "HEAD", options.signal);
2392
+ const policy = refs.baseSha === "EMPTY" ? { config: configFromText("{}", "default first-commit policy"), filename: null } : await reviewPolicy(cwd, refs.baseSha, options.signal);
2393
+ const format = options.format ?? "terminal";
2394
+ if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported review format.");
2395
+ const failOn = options.failOn ?? policy.config.failOn ?? ["test-skip", "trivial-assert", "test-focus"];
2396
+ for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown review rule '${rule}'.`);
2397
+ const changes = staged?.changes ?? await changedTreeFiles(cwd, refs.mergeBaseSha, refs.headSha, options.signal);
2398
+ const policyChanges = changes.filter((change) => CONFIG_FILENAMES.includes(change.oldPath) || CONFIG_FILENAMES.includes(change.newPath)).map((change) => change.newPath);
2399
+ const ignores = [...GENERATED_PATHS2, ...policy.config.ignorePaths || []];
2400
+ const result = {
2401
+ schemaVersion: "1.1.0",
2402
+ mode: "review",
2403
+ source: staged ? "index" : "commits",
2404
+ ...refs,
2405
+ policySha: refs.baseSha,
2406
+ policyFile: policy.filename,
2407
+ policyChanges,
2408
+ filesChanged: changes.length,
2409
+ filesInspected: 0,
2410
+ unsupportedFiles: 0,
2411
+ excludedFiles: 0,
2412
+ renamedFiles: changes.filter((change) => change.status.startsWith("R")).length,
2413
+ linesInspected: 0,
2414
+ beforeFindings: 0,
2415
+ afterFindings: 0,
2416
+ unchangedCount: 0,
2417
+ infractions: [],
2418
+ removedFindings: [],
2419
+ skippedFiles: [],
2420
+ complete: true,
2421
+ failedRules: [],
2422
+ passed: false,
2423
+ exitCode: 2,
2424
+ output: ""
2425
+ };
2426
+ let totalBytes = 0;
2427
+ for (const change of changes) {
2428
+ assertNotAborted(options.signal);
2429
+ const oldSupported = change.oldMode !== "000000" && isCodeFile(change.oldPath);
2430
+ const newSupported = change.newMode !== "000000" && isCodeFile(change.newPath);
2431
+ if (!oldSupported && !newSupported) {
2432
+ result.unsupportedFiles++;
2433
+ continue;
2434
+ }
2435
+ const oldInScope = oldSupported && isCodeFile(change.oldPath, ignores);
2436
+ const newInScope = newSupported && isCodeFile(change.newPath, ignores);
2437
+ if (!oldInScope && !newInScope) {
2438
+ result.excludedFiles++;
2439
+ continue;
2440
+ }
2441
+ try {
2442
+ let inspectedLines = 0;
2443
+ const scan = async (mode, oid, filename, commit) => {
2444
+ if (mode === "000000") return [];
2445
+ if (!["100644", "100755"].includes(mode)) throw new ConfigError("Changed source is a symlink, submodule or unsupported Git mode; comparison is incomplete.");
2446
+ const content = await readBlob(cwd, oid, options.signal);
2447
+ totalBytes += content.length;
2448
+ if (totalBytes > MAX_SCAN_BYTES2) throw new ConfigError("Review exceeds the 50 MiB source comparison limit.");
2449
+ if (content.includes(0)) throw new ConfigError("Binary content in a source comparison.");
2450
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
2451
+ const lines = text.split(/\r?\n/);
2452
+ if (lines.at(-1) === "") lines.pop();
2453
+ inspectedLines += lines.length;
2454
+ return auditFileDiffs([{ path: filename, addedLines: lines.map((line, index) => ({ line, lineNumber: index + 1 })) }], commit, "", { rules: policy.config.rules }).infractions;
2455
+ };
2456
+ if (oldSupported && !newSupported && change.newMode !== "000000") {
2457
+ throw new ConfigError("Source moved to an unsupported extension; cannot claim its findings were resolved.");
2458
+ }
2459
+ const before = await scan(change.oldMode, change.oldOid, change.oldPath, refs.mergeBaseSha);
2460
+ const after = await scan(change.newMode, change.newOid, change.newPath, refs.headSha);
2461
+ const compared = compareFindings(before, after, change.newPath);
2462
+ result.infractions.push(...compared.introduced);
2463
+ result.removedFindings.push(...compared.removed);
2464
+ result.unchangedCount += compared.unchanged;
2465
+ result.beforeFindings += before.length;
2466
+ result.afterFindings += after.length;
2467
+ result.filesInspected++;
2468
+ result.linesInspected += inspectedLines;
2469
+ } catch (error) {
2470
+ assertNotAborted(options.signal);
2471
+ result.skippedFiles.push({ file: change.newPath, reason: error instanceof Error ? error.message : String(error) });
2472
+ if (totalBytes > MAX_SCAN_BYTES2) break;
2473
+ }
2474
+ }
2475
+ assertNotAborted(options.signal);
2476
+ await staged?.verify();
2477
+ result.complete = result.skippedFiles.length === 0;
2478
+ result.failedRules = [...new Set(result.infractions.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
2479
+ result.passed = result.complete && result.failedRules.length === 0;
2480
+ result.exitCode = !result.complete ? 2 : result.passed ? 0 : 1;
2481
+ if (format === "json") result.output = JSON.stringify(result, null, 2);
2482
+ else if (format === "markdown") result.output = formatReviewMarkdown(result);
2483
+ else if (format === "sarif") result.output = formatSarif(result);
2484
+ else if (format === "github") result.output = formatGithubAnnotations(result) + (!result.complete ? "\n::error::Context review incomplete. Inspect skipped source files in the JSON report." : "") + (policyChanges.length ? "\n::notice::Candidate policy changes were ignored; review uses policy from the supplied base commit." : "");
2485
+ else result.output = formatReviewTerminal(result);
2486
+ return result;
2487
+ }
2488
+
2002
2489
  // src/index.ts
2003
2490
  var InvalidDetectorError = class extends Error {
2004
2491
  constructor(message) {
@@ -2007,9 +2494,9 @@ var InvalidDetectorError = class extends Error {
2007
2494
  }
2008
2495
  };
2009
2496
  async function roastRepository(options = {}) {
2010
- const cwd = path5.resolve(options.cwd || process.cwd());
2497
+ const cwd = path6.resolve(options.cwd || process.cwd());
2011
2498
  const since = options.since || "90 days ago";
2012
- const repoName = path5.basename(cwd);
2499
+ const repoName = path6.basename(cwd);
2013
2500
  const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths, rules: options.rules, panicLoopWindowMinutes: options.panicLoopWindowMinutes });
2014
2501
  const gitStream = streamGitLog(cwd, since);
2015
2502
  await parseGitLogStream(gitStream, (commit, stats) => {
@@ -2024,7 +2511,7 @@ async function roastRepository(options = {}) {
2024
2511
  return { summary, output };
2025
2512
  }
2026
2513
  async function auditPullRequest(options) {
2027
- const cwd = path5.resolve(options.cwd || process.cwd());
2514
+ const cwd = path6.resolve(options.cwd || process.cwd());
2028
2515
  const format = options.format || "terminal";
2029
2516
  const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
2030
2517
  for (const det of failOn) {
@@ -2109,7 +2596,9 @@ export {
2109
2596
  RULE_GUIDES,
2110
2597
  VALID_DETECTORS,
2111
2598
  DEFAULT_CONFIG,
2599
+ CONFIG_FILENAMES,
2112
2600
  ConfigError,
2601
+ configFromText,
2113
2602
  loadConfig,
2114
2603
  matchIgnorePath,
2115
2604
  isCodeFile,
@@ -2125,6 +2614,8 @@ export {
2125
2614
  formatPrMarkdownComment,
2126
2615
  formatCheckTerminal,
2127
2616
  formatCheckMarkdown,
2617
+ formatReviewTerminal,
2618
+ formatReviewMarkdown,
2128
2619
  DEFAULT_CONFIG_TEMPLATE,
2129
2620
  GITHUB_WORKFLOW_TEMPLATE,
2130
2621
  PRE_COMMIT_HOOK_TEMPLATE,
@@ -2138,7 +2629,11 @@ export {
2138
2629
  loadBaseline,
2139
2630
  applyBaseline,
2140
2631
  writeBaseline,
2632
+ formatSarif,
2141
2633
  checkProject,
2634
+ compareFindings,
2635
+ reviewChanges,
2636
+ reviewStagedChanges,
2142
2637
  InvalidDetectorError,
2143
2638
  roastRepository,
2144
2639
  auditPullRequest
package/dist/cli.js CHANGED
@@ -9,14 +9,17 @@ import {
9
9
  auditPullRequest,
10
10
  checkProject,
11
11
  formatGithubStepSummary,
12
+ formatReviewMarkdown,
12
13
  generateShieldsJson,
13
14
  generateShieldsMarkdown,
14
15
  generateSvgBadge,
15
16
  initProject,
16
17
  loadConfig,
18
+ reviewChanges,
19
+ reviewStagedChanges,
17
20
  roastRepository,
18
21
  writeBaseline
19
- } from "./chunk-FUAUIURY.js";
22
+ } from "./chunk-YGHNTWHX.js";
20
23
 
21
24
  // src/cli.ts
22
25
  import { Command } from "commander";
@@ -25,8 +28,39 @@ import * as fs from "fs";
25
28
  import * as path from "path";
26
29
  var require2 = createRequire(import.meta.url);
27
30
  var pkg = require2("../package.json");
31
+ async function runContextReview(repoPath, options, staged) {
32
+ const controller = new AbortController();
33
+ const abort = () => controller.abort();
34
+ process.once("SIGINT", abort);
35
+ process.once("SIGTERM", abort);
36
+ try {
37
+ const common = {
38
+ cwd: repoPath,
39
+ failOn: options.failOn !== void 0 ? options.failOn.split(",").map((rule) => rule.trim()).filter(Boolean) : void 0,
40
+ format: options.json ? "json" : options.format,
41
+ signal: controller.signal
42
+ };
43
+ const result = staged ? await reviewStagedChanges(common) : await reviewChanges({ ...common, base: options.base, head: options.head });
44
+ if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, formatReviewMarkdown(result) + "\n");
45
+ if (process.env.GITHUB_OUTPUT) fs.appendFileSync(
46
+ process.env.GITHUB_OUTPUT,
47
+ `passed=${result.passed}
48
+ infractions-count=${result.infractions.length}
49
+ has-supported-changes=${result.filesInspected > 0}
50
+ complete=${result.complete}
51
+ existing-count=${result.unchangedCount}
52
+ removed-count=${result.removedFindings.length}
53
+ `
54
+ );
55
+ console.log(result.output);
56
+ process.exitCode = result.exitCode;
57
+ } finally {
58
+ process.removeListener("SIGINT", abort);
59
+ process.removeListener("SIGTERM", abort);
60
+ }
61
+ }
28
62
  var program = new Command();
29
- 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("--staged", "Audit staged changes before committing (including the first commit)", 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) => {
63
+ 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("--staged", "Compare full HEAD/index source and gate new staged findings (including the first commit)", 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 (check/review also support sarif)", "terminal").option("--authors", "Display author names and emails in report", false).action(async (repoPath, options) => {
30
64
  try {
31
65
  if (process.env.GITHUB_EVENT_NAME === "pull_request_target") {
32
66
  console.warn(
@@ -34,6 +68,13 @@ program.name("agent-roast").description("Audit your git history or PR diffs for
34
68
  );
35
69
  }
36
70
  if (options.staged && options.base) throw new ConfigError("--staged cannot be combined with --base.");
71
+ if (options.staged) {
72
+ if (options.all || options.failUnder !== void 0 || program.getOptionValueSource("since") === "cli" || program.getOptionValueSource("head") === "cli") {
73
+ throw new ConfigError("--staged compares HEAD and index; --all, --since, --head and --fail-under do not apply.");
74
+ }
75
+ await runContextReview(repoPath, options, true);
76
+ return;
77
+ }
37
78
  if (!["terminal", "json", "github", "markdown"].includes(options.format)) {
38
79
  throw new ConfigError("--format must be terminal, json, github, or markdown.");
39
80
  }
@@ -120,7 +161,22 @@ has-supported-changes=${prResult.hasSupportedChanges}
120
161
  }
121
162
  }
122
163
  });
123
- program.command("check").description("Check current tracked and non-ignored untracked source files").argument("[path]", "Path to a git repository", ".").option("--baseline <file>", "Report only findings not accepted in this baseline").option("--write-baseline <file>", "Save current findings for adoption (refuses to overwrite)").addHelpText("after", "\nOutput options: --format terminal|json|github|markdown, --json, --fail-on <rules>\nExamples:\n agent-roast check\n agent-roast check --write-baseline .agentroast-baseline.json\n agent-roast check --baseline .agentroast-baseline.json --format json").action(async (repoPath, opts) => {
164
+ program.command("review").description("Compare full changed source at merge-base and head; gate only new findings").argument("[path]", "Path to the Git repository", ".").addHelpText("after", "\nRequired: --base <trusted-ref> Optional: --head <ref> (default HEAD), or use --staged for HEAD/index\nOutput: --format terminal|json|github|markdown|sarif, --json, --fail-on <rules>\nExample: agent-roast review --base origin/main --format markdown").action(async (repoPath) => {
165
+ try {
166
+ const options = program.opts();
167
+ if (options.staged ? options.base || program.getOptionValueSource("head") === "cli" : !options.base) {
168
+ throw new ConfigError("Use review --base <trusted-ref> or review --staged; --base/--head cannot be combined with --staged.");
169
+ }
170
+ if (options.all || options.failUnder !== void 0 || program.getOptionValueSource("since") === "cli") {
171
+ throw new ConfigError("review compares source snapshots; --all, --since and --fail-under do not apply.");
172
+ }
173
+ await runContextReview(repoPath, options, options.staged);
174
+ } catch (error) {
175
+ console.error(`agent-roast review error: ${error instanceof Error ? error.message : String(error)}`);
176
+ process.exitCode = 2;
177
+ }
178
+ });
179
+ program.command("check").description("Check current tracked and non-ignored untracked source files").argument("[path]", "Path to a git repository", ".").option("--baseline <file>", "Report only findings not accepted in this baseline").option("--write-baseline <file>", "Save current findings for adoption (refuses to overwrite)").addHelpText("after", "\nOutput options: --format terminal|json|github|markdown|sarif, --json, --fail-on <rules>\nExamples:\n agent-roast check\n agent-roast check --write-baseline .agentroast-baseline.json\n agent-roast check --baseline .agentroast-baseline.json --format json").action(async (repoPath, opts) => {
124
180
  try {
125
181
  const global = program.opts();
126
182
  if (global.base || global.staged || global.failUnder !== void 0 || global.all || program.getOptionValueSource("head") === "cli" || program.getOptionValueSource("since") === "cli") {
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" | "trivial-assert" | "stub-abandonment";
12
+ type DetectorType = "test-skip" | "type-escape" | "swallowed-error" | "panic-loop" | "trivial-assert" | "stub-abandonment" | "test-focus";
13
13
  declare const VALID_DETECTORS: readonly DetectorType[];
14
14
  interface Infraction {
15
15
  type: DetectorType;
@@ -28,6 +28,7 @@ interface MetricSummary {
28
28
  totalCommits: number;
29
29
  aiCommits: number;
30
30
  testSkips: Infraction[];
31
+ focusedTests?: Infraction[];
31
32
  typeEscapes: Infraction[];
32
33
  swallowedErrors: Infraction[];
33
34
  panicLoops: Infraction[];
@@ -86,7 +87,7 @@ interface PrAuditResult {
86
87
  interface CheckOptions {
87
88
  cwd?: string;
88
89
  failOn?: string[];
89
- format?: "terminal" | "json" | "github" | "markdown";
90
+ format?: ReviewFormat;
90
91
  baseline?: string;
91
92
  signal?: AbortSignal;
92
93
  }
@@ -109,6 +110,47 @@ interface CheckResult {
109
110
  exitCode: number;
110
111
  output: string;
111
112
  }
113
+ type ReviewFormat = "terminal" | "json" | "github" | "markdown" | "sarif";
114
+ interface ReviewOptions {
115
+ cwd?: string;
116
+ base: string;
117
+ head?: string;
118
+ failOn?: string[];
119
+ format?: ReviewFormat;
120
+ signal?: AbortSignal;
121
+ }
122
+ type StagedReviewOptions = Omit<ReviewOptions, "base" | "head">;
123
+ interface ReviewResult {
124
+ schemaVersion: string;
125
+ mode: "review";
126
+ source?: "commits" | "index";
127
+ baseSha: string;
128
+ headSha: string;
129
+ mergeBaseSha: string;
130
+ policySha: string;
131
+ policyFile: string | null;
132
+ policyChanges: string[];
133
+ filesChanged: number;
134
+ filesInspected: number;
135
+ unsupportedFiles: number;
136
+ excludedFiles: number;
137
+ renamedFiles: number;
138
+ linesInspected: number;
139
+ beforeFindings: number;
140
+ afterFindings: number;
141
+ unchangedCount: number;
142
+ infractions: Infraction[];
143
+ removedFindings: Infraction[];
144
+ skippedFiles: {
145
+ file: string;
146
+ reason: string;
147
+ }[];
148
+ complete: boolean;
149
+ failedRules: string[];
150
+ passed: boolean;
151
+ exitCode: number;
152
+ output: string;
153
+ }
112
154
 
113
155
  interface AiAttributionResult {
114
156
  isAiAttributed: boolean;
@@ -201,6 +243,7 @@ declare class MetricsCollector {
201
243
  private totalCommits;
202
244
  private aiCommits;
203
245
  private testSkips;
246
+ private focusedTests;
204
247
  private typeEscapes;
205
248
  private swallowedErrors;
206
249
  private panicLoops;
@@ -246,6 +289,8 @@ declare function formatPrMarkdownComment(result: PrAuditResult): string;
246
289
  /** Current-tree report emphasizes next actions and explicitly accounts for baseline debt. */
247
290
  declare function formatCheckTerminal(result: CheckResult): string;
248
291
  declare function formatCheckMarkdown(result: CheckResult): string;
292
+ declare function formatReviewTerminal(result: ReviewResult): string;
293
+ declare function formatReviewMarkdown(result: ReviewResult): string;
249
294
 
250
295
  interface AgentRoastConfig {
251
296
  /** Detector rules to fail CI on in PR mode */
@@ -260,9 +305,12 @@ interface AgentRoastConfig {
260
305
  panicLoopWindowMinutes?: number;
261
306
  }
262
307
  declare const DEFAULT_CONFIG: AgentRoastConfig;
308
+ declare const CONFIG_FILENAMES: string[];
263
309
  declare class ConfigError extends Error {
264
310
  constructor(message: string);
265
311
  }
312
+ /** Parse one explicit policy source; shared by worktree and trusted Git reviews. */
313
+ declare function configFromText(text: string, source: string): AgentRoastConfig;
266
314
  /** Invalid policy must stop the audit instead of silently changing CI behavior. */
267
315
  declare function loadConfig(cwd?: string): AgentRoastConfig;
268
316
  /**
@@ -287,7 +335,7 @@ declare const DEFAULT_CONFIG_TEMPLATE: {
287
335
  };
288
336
  scoreThreshold: number;
289
337
  };
290
- declare const GITHUB_WORKFLOW_TEMPLATE = "name: Agent Roast\non:\n pull_request:\n branches: [main, master]\n\npermissions:\n contents: read\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: Run agent-roast PR check\n if: github.event_name == 'pull_request'\n uses: Nari-Ab/agent-roast@v1\n with:\n base: ${{ github.event.pull_request.base.sha }}\n head: ${{ github.event.pull_request.head.sha }}\n format: github\n";
338
+ declare const GITHUB_WORKFLOW_TEMPLATE = "name: Agent Roast\non:\n pull_request:\n branches: [main, master]\n\npermissions:\n contents: read\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: Run agent-roast PR check\n if: github.event_name == 'pull_request'\n uses: Nari-Ab/agent-roast@v1\n with:\n base: ${{ github.event.pull_request.base.sha }}\n head: ${{ github.event.pull_request.head.sha }}\n format: github\n mode: review\n";
291
339
  declare const PRE_COMMIT_HOOK_TEMPLATE = "#!/bin/sh\n# Run agent-roast pre-commit verification\nnpx --no-install agent-roast --staged\n";
292
340
  declare function initProject(options?: InitOptions): Promise<InitResult>;
293
341
 
@@ -308,6 +356,21 @@ declare function generateShieldsJson(score: number | null, label?: string): Shie
308
356
  /** Read current tracked and non-ignored untracked source; never run project code. */
309
357
  declare function checkProject(options?: CheckOptions): Promise<CheckResult>;
310
358
 
359
+ /** Match only within the same changed-file pair; Git renames remap old paths.
360
+ * Counts matter: an identical second shortcut is still a new finding. */
361
+ declare function compareFindings(before: Infraction[], after: Infraction[], newPath: string): {
362
+ introduced: Infraction[];
363
+ removed: Infraction[];
364
+ unchanged: number;
365
+ };
366
+ /** Compare full source blobs in changed files without executing or checking out the candidate. */
367
+ declare function reviewChanges(options: ReviewOptions): Promise<ReviewResult>;
368
+ declare function reviewStagedChanges(options?: StagedReviewOptions): Promise<ReviewResult>;
369
+
370
+ /** Offline SARIF 2.1.0, with relative URIs and explicit incomplete-run status.
371
+ * Snippets are omitted to avoid embedding source code in integration artifacts. */
372
+ declare function formatSarif(report: CheckResult | ReviewResult): string;
373
+
311
374
  interface FindingBaseline {
312
375
  schemaVersion: "1.0.0";
313
376
  fingerprintVersion: 1;
@@ -346,4 +409,4 @@ declare function roastRepository(options?: RoastOptions): Promise<{
346
409
  }>;
347
410
  declare function auditPullRequest(options: PrAuditOptions): Promise<PrAuditResult>;
348
411
 
349
- export { type AgentRoastConfig, type AiAttributionResult, type CheckOptions, type CheckResult, type CommitInfo, ConfigError, DEFAULT_CONFIG, DEFAULT_CONFIG_TEMPLATE, type DetectorType, FIELD_SEPARATOR, type FindingBaseline, 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, RULE_GUIDES, type RoastOptions, type RuleGuide, type ShieldsEndpointResponse, VALID_DETECTORS, applyBaseline, auditFileDiffs, auditPullRequest, checkProject, createBaseline, detectAiAttribution, findMergeBase, findingFingerprint, formatCheckMarkdown, formatCheckTerminal, formatGithubAnnotations, formatGithubStepSummary, formatPrMarkdownComment, formatPrTerminalCard, formatTerminalCard, generateShareUrl, generateShieldsJson, generateShieldsMarkdown, generateSvgBadge, getHermeticGitEnv, getScoreColor, initProject, isCodeFile, isTestFile, loadBaseline, loadConfig, matchIgnorePath, parseDiff, parseGitLogStream, resolveCommitSha, roastRepository, streamGitDiff, streamGitLog, stripStringAndRegexLiterals, unquoteGitPath, writeBaseline };
412
+ export { type AgentRoastConfig, type AiAttributionResult, CONFIG_FILENAMES, type CheckOptions, type CheckResult, type CommitInfo, ConfigError, DEFAULT_CONFIG, DEFAULT_CONFIG_TEMPLATE, type DetectorType, FIELD_SEPARATOR, type FindingBaseline, 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, RULE_GUIDES, type ReviewFormat, type ReviewOptions, type ReviewResult, type RoastOptions, type RuleGuide, type ShieldsEndpointResponse, type StagedReviewOptions, VALID_DETECTORS, applyBaseline, auditFileDiffs, auditPullRequest, checkProject, compareFindings, configFromText, createBaseline, detectAiAttribution, findMergeBase, findingFingerprint, formatCheckMarkdown, formatCheckTerminal, formatGithubAnnotations, formatGithubStepSummary, formatPrMarkdownComment, formatPrTerminalCard, formatReviewMarkdown, formatReviewTerminal, formatSarif, formatTerminalCard, generateShareUrl, generateShieldsJson, generateShieldsMarkdown, generateSvgBadge, getHermeticGitEnv, getScoreColor, initProject, isCodeFile, isTestFile, loadBaseline, loadConfig, matchIgnorePath, parseDiff, parseGitLogStream, resolveCommitSha, reviewChanges, reviewStagedChanges, roastRepository, streamGitDiff, streamGitLog, stripStringAndRegexLiterals, unquoteGitPath, writeBaseline };
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ CONFIG_FILENAMES,
3
4
  ConfigError,
4
5
  DEFAULT_CONFIG,
5
6
  DEFAULT_CONFIG_TEMPLATE,
@@ -18,6 +19,8 @@ import {
18
19
  auditFileDiffs,
19
20
  auditPullRequest,
20
21
  checkProject,
22
+ compareFindings,
23
+ configFromText,
21
24
  createBaseline,
22
25
  detectAiAttribution,
23
26
  findMergeBase,
@@ -28,6 +31,9 @@ import {
28
31
  formatGithubStepSummary,
29
32
  formatPrMarkdownComment,
30
33
  formatPrTerminalCard,
34
+ formatReviewMarkdown,
35
+ formatReviewTerminal,
36
+ formatSarif,
31
37
  formatTerminalCard,
32
38
  generateShareUrl,
33
39
  generateShieldsJson,
@@ -44,14 +50,17 @@ import {
44
50
  parseDiff,
45
51
  parseGitLogStream,
46
52
  resolveCommitSha,
53
+ reviewChanges,
54
+ reviewStagedChanges,
47
55
  roastRepository,
48
56
  streamGitDiff,
49
57
  streamGitLog,
50
58
  stripStringAndRegexLiterals,
51
59
  unquoteGitPath,
52
60
  writeBaseline
53
- } from "./chunk-FUAUIURY.js";
61
+ } from "./chunk-YGHNTWHX.js";
54
62
  export {
63
+ CONFIG_FILENAMES,
55
64
  ConfigError,
56
65
  DEFAULT_CONFIG,
57
66
  DEFAULT_CONFIG_TEMPLATE,
@@ -70,6 +79,8 @@ export {
70
79
  auditFileDiffs,
71
80
  auditPullRequest,
72
81
  checkProject,
82
+ compareFindings,
83
+ configFromText,
73
84
  createBaseline,
74
85
  detectAiAttribution,
75
86
  findMergeBase,
@@ -80,6 +91,9 @@ export {
80
91
  formatGithubStepSummary,
81
92
  formatPrMarkdownComment,
82
93
  formatPrTerminalCard,
94
+ formatReviewMarkdown,
95
+ formatReviewTerminal,
96
+ formatSarif,
83
97
  formatTerminalCard,
84
98
  generateShareUrl,
85
99
  generateShieldsJson,
@@ -96,6 +110,8 @@ export {
96
110
  parseDiff,
97
111
  parseGitLogStream,
98
112
  resolveCommitSha,
113
+ reviewChanges,
114
+ reviewStagedChanges,
99
115
  roastRepository,
100
116
  streamGitDiff,
101
117
  streamGitLog,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-roast",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Offline code checks for skipped tests, swallowed errors, unfinished stubs and AI coding shortcuts",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",