agent-roast 0.10.0 → 0.11.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
@@ -28,6 +28,7 @@ review AI-assisted changes, but it cannot tell who wrote a change, and the measu
28
28
  show these moves are not specific to AI.
29
29
 
30
30
  ```bash
31
+ npx agent-roast replay # your last 50 commits, one at a time
31
32
  npx agent-roast review --base origin/main # a pull request
32
33
  npx agent-roast --staged # what you are about to commit
33
34
  ```
@@ -42,7 +43,10 @@ npx agent-roast --staged # what you are about to commit
42
43
  | `test-skip`, `test-focus`, `trivial-assert`, `swallowed-error`, `type-escape`, `stub-abandonment` | the classic shortcuts, compared before/after so old debt does not block | skip, focus and trivial-assert block |
43
44
 
44
45
  Renames, moved tests, parametrization, assertions moved into a new helper, and formatter
45
- rewraps (prettier, black) are recognized and not reported. Intentional cases can be accepted
46
+ rewraps (prettier, black) are recognized and not reported. So is a skip that depends on the
47
+ environment: `test.skip(isMobile, 'reason')` and Playwright's other condition forms,
48
+ `if not has_gpu(): pytest.skip(...)`, and a skip inside a fixture or helper. A skip that names a
49
+ test, stands alone in a test body or depends on `CI` is still reported. Intentional cases can be accepted
46
50
  one by one with an expiring, reviewed exception (see below). Policy is read from the base
47
51
  branch, so a PR cannot switch the rules off for itself.
48
52
 
@@ -52,9 +56,9 @@ Measured, not promised; method, corpus and raw numbers are in
52
56
  [docs/benchmarks.md](docs/benchmarks.md). **Short version: weakened checks are rare, usually
53
57
  deliberate, and not specific to AI. The tool finds them; a person decides.**
54
58
 
55
- - **Labeled changes** (`benchmarks/cases.mjs`): 40 cheating edits across JS/TS, Python, Go and
56
- CI/config files are caught and 33 harmless look-alikes (reformat, rename, move, parametrize,
57
- helper extraction, raised thresholds, CI restructuring) stay silent. The cases were written
59
+ - **Labeled changes** (`benchmarks/cases.mjs`): 43 cheating edits across JS/TS, Python, Go and
60
+ CI/config files are caught and 36 harmless look-alikes (reformat, rename, move, parametrize,
61
+ helper extraction, raised thresholds, CI restructuring, skips that depend on the environment) stay silent. The cases were written
58
62
  together with the detector, so they guard against regressions; they do not prove accuracy.
59
63
  Three known gaps are listed in the file and still open: mocking the module under test,
60
64
  hardcoding the tested value, and vacuous matchers such as `expect.anything()`.
@@ -98,6 +102,10 @@ re-labeled.
98
102
  It is a **review aid**: it cannot prove who wrote a change or that code is correct, and it
99
103
  reads text, not program semantics. A green result is not a quality certificate.
100
104
 
105
+ **Want to see what it says about your repository?** [docs/pilot.md](docs/pilot.md) has a
106
+ two-minute replay of your last commits, an advisory workflow that never fails a pull request, and a
107
+ feedback form. Counts are enough; no code is needed.
108
+
101
109
  ### Quick start
102
110
 
103
111
  ```bash
@@ -114,8 +122,25 @@ npx agent-roast --all --verbose
114
122
  ```
115
123
 
116
124
  A finding includes its file and line, the evidence, and a concrete next step.
117
- `--format json|markdown|sarif|github` are available; terminal output shows the first
118
- 20 findings.
125
+ `--format json|markdown|sarif|github` are available; terminal output shows the 20 most
126
+ important findings (rules that block first, then the before/after rules, then the noisier
127
+ line patterns).
128
+
129
+ ### Replay recent history
130
+
131
+ ```bash
132
+ # Review each of the last 50 commits on its own, as a pull-request check would have seen it
133
+ npx agent-roast replay
134
+ npx agent-roast replay --last 200 --json # more history, every finding
135
+ npx agent-roast replay --all-rules # also list line patterns such as `as any`
136
+ ```
137
+
138
+ `replay` answers "what would this have said about our last 50 changes?" before you put it in CI.
139
+ It follows the first-parent history, so a squash, a merge and a direct commit each count as one
140
+ change, reviews each against its parent with the policy at that parent, and lists the commits
141
+ that would have been blocked or drew a warning about a weakened check. It never fails because of a
142
+ finding, and a commit too large to read is reported, not fatal. Line patterns (`type-escape`,
143
+ `swallowed-error`, `stub-abandonment`) are counted and listed only with `--all-rules`.
119
144
 
120
145
  ### Context review for pull requests
121
146
 
@@ -148,7 +173,7 @@ severity but does not enable rules disabled by base policy.
148
173
 
149
174
  ```bash
150
175
  # Advisory pilot: inspect findings before deciding what should block the team
151
- agent-roast review --base origin/main --fail-on ''
176
+ agent-roast review --base origin/main --advisory
152
177
  # Gate the shortcuts the team agrees matter
153
178
  agent-roast review --base origin/main --fail-on test-skip,trivial-assert,swallowed-error
154
179
  ```
@@ -175,12 +200,12 @@ regressions) may appear in the report/summary rather than as an inline PR annota
175
200
  Use a separate SARIF category for introduced-findings review; it does not represent
176
201
  a full scan of all outstanding alerts.
177
202
 
178
- Install 0.10.0: `npx agent-roast@0.10.0 review --base origin/main`. For local development,
203
+ Install 0.11.0: `npx agent-roast@0.11.0 review --base origin/main`. For local development,
179
204
  run `npm run build` and use `node dist/cli.js`.
180
205
 
181
206
  ### Precise exceptions (since 0.8.0)
182
207
 
183
- Use `npx agent-roast@0.10.0 check --format json`, or build this checkout with
208
+ Use `npx agent-roast@0.11.0 check --format json`, or build this checkout with
184
209
  `npm run build` and run `node dist/cli.js`.
185
210
 
186
211
  Some skipped integrations or abstract stubs are intentional. In 0.8.0 you can accept
@@ -416,7 +441,9 @@ jobs:
416
441
 
417
442
  To pilot the Action without blocking on findings, set `advisory: true`. It passes
418
443
  an explicit empty fail-on list; invalid configuration, Git errors and incomplete
419
- scans still fail. The `v1` Action installs 0.10.0. The CLI equivalent is `--fail-on ''`.
444
+ scans still fail. The `v1` Action installs 0.11.0. The CLI equivalent is `--advisory`
445
+ (`--fail-on ''` does the same in POSIX shells, but cmd.exe and Windows PowerShell 5.1 do not pass
446
+ an empty argument through).
420
447
 
421
448
  > **Security Note:** `agent-roast` runs in an isolated runner directory (`$RUNNER_TEMP`) with `--ignore-scripts` to neutralize malicious `.npmrc` files in untrusted pull requests. `pull_request_target` is not supported.
422
449
 
@@ -469,7 +496,10 @@ the added diff in legacy audit modes are not fully parsed. Custom test API alias
469
496
  computed property calls and type/import resolution are not handled. Panic loops are an observational history heuristic.
470
497
  Test registration options such as Node's `test('name', { skip: true }, fn)` and
471
498
  `{ only: true }` are currently not inspected; use explicit `.skip`/`.only` APIs and
472
- the runner's CI safeguards. Regexp masking is contextual, not a full JavaScript
499
+ the runner's CI safeguards. A skip that depends on the environment (`test.skip(isMobile, 'reason')`,
500
+ `pytest.skip` under an `if`, or in a fixture) is not reported, because such skips are overwhelmingly
501
+ platform or service gates; one that hides a failing test behind a condition is therefore missed unless
502
+ the condition mentions `CI`. Regexp masking is contextual, not a full JavaScript
473
503
  parser. Formatting across contiguous lines is supported for casts, empty catches
474
504
  and placeholder errors, but legacy added-line audits cannot recover missing hunks.
475
505
  The discipline score summarizes added history; it does not certify current code quality.
@@ -504,7 +534,7 @@ node scripts/verify-user-workflows.mjs dist/cli.js
504
534
  against current code. Regression tests use disposable Git repositories. The programmatic
505
535
  `auditPullRequest({ staged: true })` API retains the legacy added-lines contract;
506
536
  use `reviewStagedChanges()` for the new context comparison and `ReviewResult`.
507
- The composite Action installs exactly 0.10.0. For local development, use
537
+ The composite Action installs exactly 0.11.0. For local development, use
508
538
  `node dist/cli.js --staged` after building.
509
539
 
510
540
  ### Author & Support
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import path7 from "path";
4
+ import path8 from "path";
5
5
 
6
6
  // src/git.ts
7
7
  import { spawn, spawnSync } from "child_process";
@@ -1161,11 +1161,57 @@ function matchIgnorePath(filePath, ignorePatterns) {
1161
1161
  // src/metrics.ts
1162
1162
  var PYTHON_TEST_SKIP_PATTERNS = [
1163
1163
  { regex: /@pytest\s*\.\s*mark\s*\.\s*skip\b/, name: "skipped pytest (@pytest.mark.skip)" },
1164
- { regex: /(?<![\w.])pytest\s*\.\s*skip\s*\(/, name: "imperative pytest skip (pytest.skip)" },
1165
- { regex: /@unittest\s*\.\s*skip\s*\(/, name: "skipped unittest (@unittest.skip)" },
1166
- { regex: /\bself\s*\.\s*skipTest\s*\(/, name: "imperative unittest skip (self.skipTest)" }
1164
+ { regex: /(?<![\w.])pytest\s*\.\s*skip\s*\(/, name: "imperative pytest skip (pytest.skip)", imperative: true },
1165
+ { regex: /@unittest\s*\.\s*skip\s*\(/, name: "skipped unittest (@unittest.skip)", imperative: false },
1166
+ { regex: /\bself\s*\.\s*skipTest\s*\(/, name: "imperative unittest skip (self.skipTest)", imperative: true }
1167
1167
  ];
1168
+ function isGuardedSkip(lines) {
1169
+ const indentOf = (text) => text.length - text.trimStart().length;
1170
+ const mentionsCi = (raw) => /\bCI\b|\bGITHUB_ACTIONS\b/.test(raw);
1171
+ const own = lines[lines.length - 1];
1172
+ if (/^\s*(?:if|elif|else)\b.*:\s*\S/.test(own.code)) return !mentionsCi(own.raw);
1173
+ let limit = indentOf(own.code);
1174
+ for (let i = lines.length - 2; i >= 0 && lines.length - 1 - i <= 200; i--) {
1175
+ if (lines[i].number !== lines[i + 1].number - 1) return false;
1176
+ const { code, raw } = lines[i];
1177
+ if (!code.trim() || indentOf(code) >= limit) continue;
1178
+ const keyword = /^\s*(async\s+def|def|class|if|elif|else|try|except|finally|with|for|while)\b/.exec(code)?.[1];
1179
+ if (keyword === "def" || keyword === "async def") return !/^\s*(?:async\s+)?def\s+test/.test(code);
1180
+ if (keyword === "class") return false;
1181
+ if (keyword === "if" || keyword === "elif" || keyword === "else" || keyword === "try" || keyword === "except" || keyword === "finally") return !mentionsCi(raw);
1182
+ limit = indentOf(code) + (keyword ? 0 : 1);
1183
+ }
1184
+ return false;
1185
+ }
1168
1186
  var JS_TEST_API = /(?<![\w$.])(?:(?:it|test|describe|suite)(?:\s*\.\s*(?:concurrent|serial|parallel|describe|failing|sequential))*\s*\.\s*(skip|todo|only)|((?:xit|xtest|xdescribe|fit|fdescribe)))\b((?:\s*\.\s*(?:each|failing|concurrent|serial|parallel|sequential))*)\s*(?=\(|\u0001)/g;
1187
+ function isConditionalSkip(source, raw, open2) {
1188
+ if (source[open2] !== "(") return false;
1189
+ const args = [];
1190
+ let depth = 0;
1191
+ let start = open2 + 1;
1192
+ for (let i = start; i < source.length && i - open2 <= 2e3; i++) {
1193
+ const char = source[i];
1194
+ if (char === "\0") return false;
1195
+ if ("([{".includes(char)) depth++;
1196
+ else if (")]}".includes(char)) {
1197
+ if (depth) {
1198
+ depth--;
1199
+ continue;
1200
+ }
1201
+ if (char !== ")") return false;
1202
+ args.push(raw.slice(start, i).trim());
1203
+ while (args.length && !args.at(-1)) args.pop();
1204
+ const isString = (arg) => /^(['"`])[\s\S]*\1$/.test(arg);
1205
+ if (!args.length || isString(args[0]) || args[0] === "true") return false;
1206
+ if (args.length > 1 && !isString(args.at(-1))) return false;
1207
+ return !/\bCI\b|\bisCI\b|GITHUB_ACTIONS/.test(args[0]);
1208
+ } else if (char === "," && !depth) {
1209
+ args.push(raw.slice(start, i).trim());
1210
+ start = i + 1;
1211
+ }
1212
+ }
1213
+ return false;
1214
+ }
1169
1215
  var JS_COMMENT_DIRECTIVE_PATTERNS = [
1170
1216
  { regex: /^\s*\/\/\s*@ts-ignore\b/, name: "compiler ignore (@ts-ignore)" },
1171
1217
  { regex: /^\s*\/\/\s*@ts-expect-error\b/, name: "compiler suppression (@ts-expect-error)" },
@@ -1271,6 +1317,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
1271
1317
  if (python && !isComment) {
1272
1318
  for (const rule of PYTHON_TEST_SKIP_PATTERNS) {
1273
1319
  if (rule.regex.test(cleanedLine)) {
1320
+ if (rule.imperative && isGuardedSkip(maskedLines)) break;
1274
1321
  infractions.push({
1275
1322
  type: "test-skip",
1276
1323
  file: file.path,
@@ -1376,6 +1423,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
1376
1423
  if (match[2] && !isTestFile(file.path)) continue;
1377
1424
  if (/\bfunction$/.test(source.slice(Math.max(0, previous - 8), previous + 1))) continue;
1378
1425
  if (source[match.index + match[0].length] === "" && !/\.\s*each\s*$/.test(match[3])) continue;
1426
+ if (match[1] === "skip" && !match[3] && isConditionalSkip(source, raw, match.index + match[0].length)) continue;
1379
1427
  const focus = match[1] === "only" || match[2] === "fit" || match[2] === "fdescribe";
1380
1428
  infractions.push({
1381
1429
  type: focus ? "test-focus" : "test-skip",
@@ -1853,7 +1901,7 @@ function formatPrTerminalCard(result) {
1853
1901
  } else {
1854
1902
  lines.push(pc.bold(` INFRACTIONS DETECTED (${result.infractions.length})`));
1855
1903
  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"));
1856
- for (const inf of result.infractions) {
1904
+ for (const inf of mostImportantFirst(result.infractions, result.failedRules)) {
1857
1905
  const isFailedRule = result.failedRules.includes(inf.type);
1858
1906
  const badge = isFailedRule ? pc.red("FAIL") : pc.yellow("WARN");
1859
1907
  const loc = `${inf.file || "unknown"}:${inf.lineNumber || 1}`;
@@ -1877,12 +1925,28 @@ function formatPrTerminalCard(result) {
1877
1925
  lines.push("");
1878
1926
  return lines.join("\n");
1879
1927
  }
1880
- function sortInfractionsDeterministic(infractions) {
1881
- const getLevelRank = (type) => {
1882
- return type === "test-skip" || type === "trivial-assert" || type === "swallowed-error" ? 0 : 1;
1883
- };
1928
+ var RULE_PRIORITY = {
1929
+ "assertion-weakened": 0,
1930
+ "guard-weakened": 0,
1931
+ "test-skip": 0,
1932
+ "test-focus": 0,
1933
+ "trivial-assert": 0,
1934
+ "assertion-removed": 1,
1935
+ "expectation-rewritten": 1,
1936
+ "test-removed": 1
1937
+ };
1938
+ function isCheckRule(type) {
1939
+ return RULE_PRIORITY[type] !== void 0;
1940
+ }
1941
+ function findingRank(type, failedRules) {
1942
+ return failedRules.includes(type) ? -1 : RULE_PRIORITY[type] ?? 2;
1943
+ }
1944
+ function mostImportantFirst(findings, failedRules) {
1945
+ return [...findings].sort((a, b) => findingRank(a.type, failedRules) - findingRank(b.type, failedRules));
1946
+ }
1947
+ function sortInfractionsDeterministic(infractions, failedRules) {
1884
1948
  return [...infractions].sort((a, b) => {
1885
- const rankDiff = getLevelRank(a.type) - getLevelRank(b.type);
1949
+ const rankDiff = findingRank(a.type, failedRules) - findingRank(b.type, failedRules);
1886
1950
  if (rankDiff !== 0) return rankDiff;
1887
1951
  const fileA = a.file || "";
1888
1952
  const fileB = b.file || "";
@@ -1896,7 +1960,7 @@ function escapeCommand(value, property = false) {
1896
1960
  }
1897
1961
  function formatGithubAnnotations(result) {
1898
1962
  const outputLines = [];
1899
- const sorted = sortInfractionsDeterministic(result.infractions);
1963
+ const sorted = sortInfractionsDeterministic(result.infractions, result.failedRules);
1900
1964
  const MAX_PER_LEVEL = 10;
1901
1965
  let errorCount = 0;
1902
1966
  let warnCount = 0;
@@ -1951,7 +2015,7 @@ function formatGithubStepSummary(result) {
1951
2015
  lines.push("");
1952
2016
  lines.push("| Type | File:Line | Description | Snippet |");
1953
2017
  lines.push("| --- | --- | --- | --- |");
1954
- const sorted = sortInfractionsDeterministic(result.infractions);
2018
+ const sorted = sortInfractionsDeterministic(result.infractions, result.failedRules);
1955
2019
  for (const inf of sorted) {
1956
2020
  const loc = inf.file ? markdownCell(`${inf.file}:${inf.lineNumber || 1}`) : "history";
1957
2021
  const snippet = inf.lineSnippet ? markdownCell(inf.lineSnippet) : "-";
@@ -1991,7 +2055,7 @@ function formatPrMarkdownComment(result) {
1991
2055
  lines.push("");
1992
2056
  lines.push("| Type | File:Line | Description | Snippet |");
1993
2057
  lines.push("| :--- | :--- | :--- | :--- |");
1994
- const sorted = sortInfractionsDeterministic(result.infractions);
2058
+ const sorted = sortInfractionsDeterministic(result.infractions, result.failedRules);
1995
2059
  for (const inf of sorted) {
1996
2060
  const loc = inf.file ? markdownCell(`${inf.file}:${inf.lineNumber || 1}`) : "history";
1997
2061
  const snippet = inf.lineSnippet ? markdownCell(inf.lineSnippet) : "-";
@@ -2051,7 +2115,7 @@ function formatCheckTerminal(result) {
2051
2115
  ];
2052
2116
  lines.push(...exceptionTerminal(result));
2053
2117
  if (result.staleBaselineCount) lines.push(` ${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
2054
- for (const inf of result.infractions.slice(0, 20)) {
2118
+ for (const inf of mostImportantFirst(result.infractions, result.failedRules).slice(0, 20)) {
2055
2119
  lines.push("", ` ${result.failedRules.includes(inf.type) ? pc.red("FAIL") : pc.yellow("WARN")} [${inf.type}] ${terminalText(inf.file || "unknown")}:${inf.lineNumber}`, ` ${terminalText(inf.lineSnippet || inf.reason)}`);
2056
2120
  if (inf.suggestion) lines.push(` Next: ${terminalText(inf.suggestion)}`);
2057
2121
  }
@@ -2076,7 +2140,7 @@ function formatCheckMarkdown(result) {
2076
2140
  "| Rule | Location | Finding | Next step |",
2077
2141
  "| --- | --- | --- | --- |"
2078
2142
  ];
2079
- for (const inf of result.infractions) lines.push(`| ${inf.type} | ${markdownCell(`${inf.file}:${inf.lineNumber}`)} | ${markdownCell(inf.lineSnippet || inf.reason)} | ${markdownCell(inf.suggestion || "Review this occurrence.")} |`);
2143
+ for (const inf of mostImportantFirst(result.infractions, result.failedRules)) lines.push(`| ${inf.type} | ${markdownCell(`${inf.file}:${inf.lineNumber}`)} | ${markdownCell(inf.lineSnippet || inf.reason)} | ${markdownCell(inf.suggestion || "Review this occurrence.")} |`);
2080
2144
  lines.push(...exceptionMarkdown(result));
2081
2145
  if (result.staleBaselineCount) lines.push("", `${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
2082
2146
  for (const skipped of result.skippedFiles) lines.push("", `Not scanned: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
@@ -2094,7 +2158,7 @@ function formatReviewTerminal(result) {
2094
2158
  lines.push(...exceptionTerminal(result));
2095
2159
  if (result.policyChanges.length) lines.push(" Candidate policy changes were ignored; adopt them separately after review.");
2096
2160
  if (result.excludedFiles || result.unsupportedFiles) lines.push(` ${result.excludedFiles} excluded \xB7 ${result.unsupportedFiles} unsupported/non-source changes`);
2097
- for (const finding2 of result.infractions.slice(0, 20)) {
2161
+ for (const finding2 of mostImportantFirst(result.infractions, result.failedRules).slice(0, 20)) {
2098
2162
  lines.push(
2099
2163
  "",
2100
2164
  ` ${result.failedRules.includes(finding2.type) ? "FAIL" : "WARN"} [${finding2.type}] ${terminalText(finding2.file || "unknown")}:${finding2.lineNumber}`,
@@ -2120,7 +2184,7 @@ function formatReviewMarkdown(result) {
2120
2184
  ];
2121
2185
  if (result.policyChanges.length) lines.push("Candidate policy changes were ignored. This review uses the supplied base commit's policy.", "");
2122
2186
  lines.push("| New finding | Head location | Evidence | Next step |", "| --- | --- | --- | --- |");
2123
- for (const finding2 of result.infractions) lines.push(`| ${finding2.type} | ${markdownCell(`${finding2.file}:${finding2.lineNumber}`)} | ${markdownCell(finding2.lineSnippet || finding2.reason)} | ${markdownCell(finding2.suggestion || "Review this occurrence.")} |`);
2187
+ for (const finding2 of mostImportantFirst(result.infractions, result.failedRules)) lines.push(`| ${finding2.type} | ${markdownCell(`${finding2.file}:${finding2.lineNumber}`)} | ${markdownCell(finding2.lineSnippet || finding2.reason)} | ${markdownCell(finding2.suggestion || "Review this occurrence.")} |`);
2124
2188
  lines.push(...exceptionMarkdown(result));
2125
2189
  if (result.removedFindings.length) {
2126
2190
  lines.push(
@@ -2517,6 +2581,20 @@ async function requireLocalObjects2(cwd, signal) {
2517
2581
  if (!(error instanceof GitError && error.code === 1)) throw error;
2518
2582
  }
2519
2583
  }
2584
+ async function unresolvedRefHint(cwd, ref, signal) {
2585
+ try {
2586
+ const count = async (...args) => Number((await readGit(cwd, ["rev-list", "--count", ...args, "HEAD"], signal, 64)).toString().trim());
2587
+ if ((await readGit(cwd, ["rev-parse", "--is-shallow-repository"], signal, 64)).toString().trim() === "true") {
2588
+ return `This clone is shallow (commits available: ${await count()}), so the ref may lie outside it. Run 'git fetch --unshallow' (in GitHub Actions: actions/checkout with fetch-depth: 0) or check the name.`;
2589
+ }
2590
+ const back = /^HEAD~(\d+)$/.exec(ref);
2591
+ const depth = back ? await count("--first-parent") : 0;
2592
+ if (back && Number(back[1]) >= depth) return `HEAD has only ${depth} commit${depth === 1 ? "" : "s"} on its main line${depth > 1 ? `; try HEAD~${depth - 1}` : ""}.`;
2593
+ } catch {
2594
+ assertNotAborted(signal);
2595
+ }
2596
+ return "Check that it names a commit, branch or tag of this repository.";
2597
+ }
2520
2598
  async function reviewRefs(cwd, base, head, signal) {
2521
2599
  const resolve2 = async (ref) => {
2522
2600
  if (!ref.trim()) throw new ConfigError("Review refs must not be empty.");
@@ -2524,7 +2602,7 @@ async function reviewRefs(cwd, base, head, signal) {
2524
2602
  return (await readGit(cwd, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], signal, 1024)).toString().trim();
2525
2603
  } catch (error) {
2526
2604
  assertNotAborted(signal);
2527
- 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)}`);
2605
+ throw new ConfigError(`Cannot resolve review ref '${ref}'. ${await unresolvedRefHint(cwd, ref, signal)} ${error instanceof Error ? error.message : String(error)}`);
2528
2606
  }
2529
2607
  };
2530
2608
  await requireLocalObjects2(cwd, signal);
@@ -3361,6 +3439,125 @@ async function compareSourceChanges(options) {
3361
3439
  return result;
3362
3440
  }
3363
3441
 
3442
+ // src/replay.ts
3443
+ import path7 from "path";
3444
+ var MAX_COMMITS = 1e3;
3445
+ var CONCURRENCY = 4;
3446
+ var LISTED_COMMITS = 15;
3447
+ var LISTED_PER_RULE = 2;
3448
+ var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
3449
+ function clipEvidence(text, limit = 200) {
3450
+ if (text.length <= limit) return text;
3451
+ const arrow = text.indexOf(" \u2192 ");
3452
+ const clip = (part, size) => part.length > size ? `${part.slice(0, size - 1)}\u2026` : part;
3453
+ if (arrow < 0) return clip(text, limit);
3454
+ const side = Math.floor((limit - 3) / 2);
3455
+ return `${clip(text.slice(0, arrow), side)} \u2192 ${clip(text.slice(arrow + 3), side)}`;
3456
+ }
3457
+ async function replayHistory(options = {}) {
3458
+ assertNotAborted(options.signal);
3459
+ const cwd = path7.resolve(options.cwd || process.cwd());
3460
+ const last = options.last ?? 50;
3461
+ if (!Number.isInteger(last) || last < 1 || last > MAX_COMMITS) throw new ConfigError(`Replay needs a whole number of commits between 1 and ${MAX_COMMITS}.`);
3462
+ const format = options.format ?? "terminal";
3463
+ if (format !== "terminal" && format !== "json") throw new ConfigError("Replay supports the terminal and json formats.");
3464
+ let log;
3465
+ try {
3466
+ log = (await readGit(cwd, ["log", "--first-parent", "-n", String(last), "--format=%H%x1f%P%x1f%cI%x1f%s"], options.signal)).toString("utf8");
3467
+ } catch (error) {
3468
+ assertNotAborted(options.signal);
3469
+ throw new ConfigError(`Cannot read the commit history. ${error instanceof Error ? error.message : String(error)}`);
3470
+ }
3471
+ const entries = log.split("\n").filter(Boolean).map((line) => {
3472
+ const [sha, parents, date, ...subject] = line.split("");
3473
+ return { sha, base: parents.split(" ")[0], date, subject: subject.join(" ") };
3474
+ });
3475
+ if (!entries.length) throw new ConfigError("The repository has no commits to replay.");
3476
+ const reviewable = entries.filter((entry) => entry.base);
3477
+ const commits = new Array(reviewable.length);
3478
+ let next = 0;
3479
+ let done = 0;
3480
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, reviewable.length) }, async () => {
3481
+ while (next < reviewable.length) {
3482
+ const index = next++;
3483
+ commits[index] = await replayCommit(cwd, reviewable[index], options.signal);
3484
+ options.onProgress?.(++done, reviewable.length);
3485
+ }
3486
+ }));
3487
+ if (commits.length && commits.every((commit) => commit.status === "failed")) throw new ConfigError(`No commit could be reviewed. ${commits[0].error}`);
3488
+ const summary = { commits: commits.length, blocked: 0, warned: 0, noted: 0, clean: 0, untouched: 0, incomplete: 0, failed: 0, findingsByRule: {} };
3489
+ for (const commit of commits) {
3490
+ summary[commit.status]++;
3491
+ for (const finding2 of commit.findings) summary.findingsByRule[finding2.type] = (summary.findingsByRule[finding2.type] ?? 0) + 1;
3492
+ }
3493
+ const shallow = (await readGit(cwd, ["rev-parse", "--is-shallow-repository"], options.signal, 64).catch(() => Buffer.from(""))).toString().trim() === "true";
3494
+ const result = { schemaVersion: "1.0.0", mode: "replay", head: entries[0].sha, requested: last, commitsWithoutParent: entries.length - reviewable.length, shallow, summary, commits, exitCode: 0, output: "" };
3495
+ result.output = format === "json" ? JSON.stringify({ ...result, output: void 0 }, null, 2) : formatReplayTerminal(result, options.allRules);
3496
+ return result;
3497
+ }
3498
+ async function replayCommit(cwd, entry, signal) {
3499
+ const identity = { sha: entry.sha, date: entry.date, subject: entry.subject };
3500
+ try {
3501
+ const review = await reviewChanges({ cwd, base: entry.base, head: entry.sha, format: "json", signal });
3502
+ const status = review.failedRules.length ? "blocked" : !review.complete ? "incomplete" : review.infractions.some((finding2) => isCheckRule(finding2.type)) ? "warned" : review.infractions.length ? "noted" : review.filesInspected ? "clean" : "untouched";
3503
+ return { ...identity, status, failedRules: review.failedRules, findings: mostImportantFirst(review.infractions, review.failedRules), filesInspected: review.filesInspected, skipped: review.skippedFiles };
3504
+ } catch (error) {
3505
+ assertNotAborted(signal);
3506
+ return { ...identity, status: "failed", failedRules: [], findings: [], filesInspected: 0, skipped: [], error: error instanceof Error ? error.message : String(error) };
3507
+ }
3508
+ }
3509
+ function formatReplayTerminal(result, allRules = false) {
3510
+ const { summary } = result;
3511
+ const lines = [
3512
+ "",
3513
+ `agent-roast \u2014 replay of the last ${plural(summary.commits, "commit")} (up to ${result.head.slice(0, 7)})`,
3514
+ " Each commit is reviewed on its own against its parent, as a pull-request check would have seen it."
3515
+ ];
3516
+ const shallowHint = result.shallow ? [" This clone is shallow, so only the commits it holds were replayed. Run 'git fetch --unshallow' to replay more."] : [];
3517
+ if (!summary.commits) return [...lines, " There is no earlier commit to compare with, so nothing was reviewed.", ...shallowHint, ""].join("\n");
3518
+ lines.push(...shallowHint);
3519
+ lines.push(
3520
+ ` ${plural(summary.commits - summary.untouched - summary.failed, "commit")} changed source, tests or CI config \xB7 ${summary.untouched} did not`,
3521
+ ` Would have blocked: ${plural(summary.blocked, "commit")} \xB7 warnings on ${summary.warned} \xB7 line patterns only on ${summary.noted} \xB7 nothing to report on ${summary.clean}`
3522
+ );
3523
+ if (summary.incomplete + summary.failed) lines.push(` Not reviewed in full: ${plural(summary.incomplete + summary.failed, "commit")}`);
3524
+ const listed = result.commits.filter((commit) => commit.status === "blocked" || commit.status === "warned" || allRules && commit.status === "noted");
3525
+ const ordered = [...listed.filter((commit) => commit.status === "blocked"), ...listed.filter((commit) => commit.status !== "blocked")];
3526
+ for (const commit of ordered.slice(0, LISTED_COMMITS)) {
3527
+ lines.push("", ` ${commit.status === "blocked" ? "BLOCK" : "WARN "} ${commit.sha.slice(0, 7)} ${commit.date.slice(0, 10)} ${terminalText(commit.subject).slice(0, 80)}`);
3528
+ const shown = commit.findings.filter((finding2) => allRules || isCheckRule(finding2.type));
3529
+ for (const rule of new Set(shown.map((finding2) => finding2.type))) {
3530
+ const group = shown.filter((finding2) => finding2.type === rule);
3531
+ for (const finding2 of group.slice(0, LISTED_PER_RULE)) {
3532
+ lines.push(
3533
+ ` ${commit.failedRules.includes(rule) ? "FAIL" : "WARN"} [${rule}] ${terminalText(finding2.file || "unknown")}:${finding2.lineNumber}`,
3534
+ ` ${clipEvidence(terminalText(finding2.lineSnippet || finding2.reason))}`
3535
+ );
3536
+ }
3537
+ if (group.length > LISTED_PER_RULE) lines.push(` \u2026 and ${group.length - LISTED_PER_RULE} more [${rule}] in this commit`);
3538
+ }
3539
+ }
3540
+ if (ordered.length > LISTED_COMMITS) lines.push("", ` \u2026 ${plural(ordered.length - LISTED_COMMITS, "more commit")} with findings; use --json for all of them.`);
3541
+ if (!ordered.length) lines.push("", " Nothing to report on these commits.");
3542
+ if (!allRules) {
3543
+ const hidden = result.commits.flatMap((commit) => commit.findings.filter((finding2) => !isCheckRule(finding2.type)));
3544
+ if (hidden.length) {
3545
+ const byRule = Object.entries(hidden.reduce((counts, finding2) => ({ ...counts, [finding2.type]: (counts[finding2.type] ?? 0) + 1 }), {})).sort((a, b) => b[1] - a[1]);
3546
+ lines.push("", ` Line patterns not listed: ${plural(hidden.length, "finding")} (${byRule.slice(0, 3).map(([rule, count]) => `${rule} ${count}`).join(", ")}). Add --all-rules to list them.`);
3547
+ }
3548
+ }
3549
+ for (const commit of result.commits.filter((commit2) => commit2.status === "incomplete" || commit2.status === "failed").slice(0, 5)) {
3550
+ lines.push(` Not reviewed in full: ${commit.sha.slice(0, 7)} ${terminalText(commit.subject).slice(0, 60)} \u2014 ${terminalText(commit.error ?? `${plural(commit.skipped.length, "changed file")} could not be compared`)}`);
3551
+ }
3552
+ lines.push(
3553
+ "",
3554
+ " Findings are review candidates, not verdicts: `agent-roast explain <rule>` says what a rule means.",
3555
+ " To run this on every pull request: `agent-roast init --ci`.",
3556
+ ""
3557
+ );
3558
+ return lines.join("\n");
3559
+ }
3560
+
3364
3561
  // src/index.ts
3365
3562
  var InvalidDetectorError = class extends Error {
3366
3563
  constructor(message) {
@@ -3369,9 +3566,9 @@ var InvalidDetectorError = class extends Error {
3369
3566
  }
3370
3567
  };
3371
3568
  async function roastRepository(options = {}) {
3372
- const cwd = path7.resolve(options.cwd || process.cwd());
3569
+ const cwd = path8.resolve(options.cwd || process.cwd());
3373
3570
  const since = options.since || "90 days ago";
3374
- const repoName = path7.basename(cwd);
3571
+ const repoName = path8.basename(cwd);
3375
3572
  const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths, rules: options.rules, panicLoopWindowMinutes: options.panicLoopWindowMinutes });
3376
3573
  const gitStream = streamGitLog(cwd, since);
3377
3574
  await parseGitLogStream(gitStream, (commit, stats) => {
@@ -3386,7 +3583,7 @@ async function roastRepository(options = {}) {
3386
3583
  return { summary, output };
3387
3584
  }
3388
3585
  async function auditPullRequest(options) {
3389
- const cwd = path7.resolve(options.cwd || process.cwd());
3586
+ const cwd = path8.resolve(options.cwd || process.cwd());
3390
3587
  const format = options.format || "terminal";
3391
3588
  const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
3392
3589
  for (const det of failOn) {
@@ -3509,6 +3706,8 @@ export {
3509
3706
  generateShareUrl,
3510
3707
  formatTerminalCard,
3511
3708
  formatPrTerminalCard,
3709
+ isCheckRule,
3710
+ mostImportantFirst,
3512
3711
  formatGithubAnnotations,
3513
3712
  formatGithubStepSummary,
3514
3713
  formatPrMarkdownComment,
@@ -3530,6 +3729,8 @@ export {
3530
3729
  compareFindings,
3531
3730
  reviewChanges,
3532
3731
  reviewStagedChanges,
3732
+ replayHistory,
3733
+ formatReplayTerminal,
3533
3734
  InvalidDetectorError,
3534
3735
  roastRepository,
3535
3736
  auditPullRequest
package/dist/cli.js CHANGED
@@ -17,12 +17,13 @@ import {
17
17
  initProject,
18
18
  loadConfig,
19
19
  readGit,
20
+ replayHistory,
20
21
  reviewChanges,
21
22
  reviewStagedChanges,
22
23
  roastRepository,
23
24
  terminalText,
24
25
  writeBaseline
25
- } from "./chunk-NSGANOZ5.js";
26
+ } from "./chunk-WMHB65AL.js";
26
27
 
27
28
  // src/cli.ts
28
29
  import { Command } from "commander";
@@ -32,6 +33,13 @@ import * as path from "path";
32
33
  var require2 = createRequire(import.meta.url);
33
34
  var pkg = require2("../package.json");
34
35
  var errorMessage = (error) => terminalText(error instanceof Error ? error.message : String(error));
36
+ function failOnOption(options) {
37
+ if (options.advisory) {
38
+ if (options.failOn !== void 0) throw new ConfigError("--advisory cannot be combined with --fail-on.");
39
+ return [];
40
+ }
41
+ return options.failOn?.split(",").map((rule) => rule.trim()).filter(Boolean);
42
+ }
35
43
  async function runContextReview(repoPath, options, staged) {
36
44
  const controller = new AbortController();
37
45
  const abort = () => controller.abort();
@@ -40,7 +48,7 @@ async function runContextReview(repoPath, options, staged) {
40
48
  try {
41
49
  const common = {
42
50
  cwd: repoPath,
43
- failOn: options.failOn !== void 0 ? options.failOn.split(",").map((rule) => rule.trim()).filter(Boolean) : void 0,
51
+ failOn: failOnOption(options),
44
52
  ignoreExceptions: options.ignoreExceptions,
45
53
  format: options.json ? "json" : options.format,
46
54
  signal: controller.signal
@@ -71,7 +79,7 @@ removed-count=${result.removedFindings.length}
71
79
  }
72
80
  }
73
81
  var program = new Command();
74
- program.name("agent-roast").description("Check current code, review changes and audit Git history for coding shortcuts").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("--ignore-exceptions", "Report findings even when accepted by reviewed, unexpired exceptions", false).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) => {
82
+ program.name("agent-roast").description("Check current code, review changes and audit Git history for coding shortcuts").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("--ignore-exceptions", "Report findings even when accepted by reviewed, unexpired exceptions", false).option("--fail-on <rules>", "Comma-separated detectors that trigger CI failure").option("--advisory", "Report findings without failing the run, the same as an empty --fail-on (setup errors and incomplete scans still fail)", false).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) => {
75
83
  try {
76
84
  if (process.env.GITHUB_EVENT_NAME === "pull_request_target") {
77
85
  console.warn(
@@ -93,8 +101,8 @@ program.name("agent-roast").description("Check current code, review changes and
93
101
  if (options.failUnder !== void 0 && (options.failUnder.trim() === "" || !Number.isFinite(Number(options.failUnder)) || Number(options.failUnder) < 0 || Number(options.failUnder) > 100)) {
94
102
  throw new ConfigError("--fail-under must be a number between 0 and 100.");
95
103
  }
96
- if (!options.base && !options.staged && options.failOn !== void 0) {
97
- throw new ConfigError("--fail-on requires --base or --staged.");
104
+ if (!options.base && !options.staged && (options.failOn !== void 0 || options.advisory)) {
105
+ throw new ConfigError(`${options.advisory ? "--advisory" : "--fail-on"} requires --base or --staged.`);
98
106
  }
99
107
  if (!options.base && !options.staged && !options.json && !["terminal", "json"].includes(options.format)) {
100
108
  throw new ConfigError("Repository audits support terminal or json format; use --base or --staged for github/markdown.");
@@ -102,7 +110,7 @@ program.name("agent-roast").description("Check current code, review changes and
102
110
  const configRoot = gitOutputPath((await readGit(path.resolve(repoPath), ["rev-parse", "--show-toplevel"])).toString("utf8"));
103
111
  const config = loadConfig(configRoot);
104
112
  if (options.base || options.staged) {
105
- const failOnRules = options.failOn !== void 0 ? options.failOn.split(",").map((s) => s.trim()).filter(Boolean) : config.failOn;
113
+ const failOnRules = failOnOption(options) ?? config.failOn;
106
114
  const chosenFormat = options.json ? "json" : options.format || "terminal";
107
115
  const prResult = await auditPullRequest({
108
116
  cwd: repoPath,
@@ -176,7 +184,7 @@ has-supported-changes=${prResult.hasSupportedChanges}
176
184
  }
177
185
  }
178
186
  });
179
- 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) => {
187
+ 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>, --advisory\nExamples:\n agent-roast review --base origin/main --format markdown\n agent-roast review --base HEAD~50 --advisory").action(async (repoPath) => {
180
188
  try {
181
189
  const options = program.opts();
182
190
  if (options.staged ? options.base || program.getOptionValueSource("head") === "cli" : !options.base) {
@@ -191,7 +199,41 @@ program.command("review").description("Compare full changed source at merge-base
191
199
  process.exitCode = 2;
192
200
  }
193
201
  });
194
- 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) => {
202
+ program.command("replay").description("Review each of the newest commits on its own, to see what a pull-request check would have said").argument("[path]", "Path to the Git repository", ".").option("--last <n>", "How many of the newest commits to review (default 50, at most 1000)").option("--all-rules", "List line-pattern warnings (type-escape, swallowed-error, stub-abandonment) as well", false).addHelpText("after", "\nOutput: --format terminal|json, --json\nExamples:\n agent-roast replay\n agent-roast replay --last 200 --json").action(async (repoPath, opts) => {
203
+ const controller = new AbortController();
204
+ const abort = () => controller.abort();
205
+ process.once("SIGINT", abort);
206
+ process.once("SIGTERM", abort);
207
+ try {
208
+ const global = program.opts();
209
+ if (global.base || global.staged || global.failOn !== void 0 || global.advisory || global.failUnder !== void 0 || global.all || global.ignoreExceptions || program.getOptionValueSource("head") === "cli" || program.getOptionValueSource("since") === "cli") {
210
+ throw new ConfigError("replay reviews each commit on its own; --base, --head, --staged, --fail-on, --advisory, --fail-under, --all, --since and --ignore-exceptions do not apply.");
211
+ }
212
+ const format = global.json ? "json" : global.format;
213
+ if (format !== "terminal" && format !== "json") throw new ConfigError("replay supports --format terminal or json.");
214
+ const last = opts.last === void 0 ? 50 : Number(opts.last);
215
+ if (!/^\d+$/.test(opts.last ?? "50") || last < 1 || last > 1e3) throw new ConfigError("--last must be a whole number between 1 and 1000.");
216
+ const progress = process.stderr.isTTY && format === "terminal";
217
+ const result = await replayHistory({
218
+ cwd: repoPath,
219
+ last,
220
+ allRules: opts.allRules,
221
+ format,
222
+ signal: controller.signal,
223
+ onProgress: progress ? (done, total) => process.stderr.write(`\r reviewed ${done}/${total} commits`) : void 0
224
+ });
225
+ if (progress) process.stderr.write("\r\x1B[K");
226
+ console.log(result.output);
227
+ process.exitCode = result.exitCode;
228
+ } catch (error) {
229
+ console.error(`agent-roast replay error: ${errorMessage(error)}`);
230
+ process.exitCode = 2;
231
+ } finally {
232
+ process.removeListener("SIGINT", abort);
233
+ process.removeListener("SIGTERM", abort);
234
+ }
235
+ });
236
+ 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>, --advisory\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) => {
195
237
  try {
196
238
  const global = program.opts();
197
239
  if (global.base || global.staged || global.failUnder !== void 0 || global.all || program.getOptionValueSource("head") === "cli" || program.getOptionValueSource("since") === "cli") {
@@ -202,7 +244,7 @@ program.command("check").description("Check current tracked and non-ignored untr
202
244
  cwd: repoPath,
203
245
  baseline: opts.baseline,
204
246
  ignoreExceptions: global.ignoreExceptions,
205
- failOn: global.failOn !== void 0 ? global.failOn.split(",").map((rule) => rule.trim()).filter(Boolean) : void 0,
247
+ failOn: failOnOption(global),
206
248
  format: global.json ? "json" : global.format
207
249
  });
208
250
  if (opts.writeBaseline) {
package/dist/index.d.ts CHANGED
@@ -317,6 +317,15 @@ declare function formatTerminalCard(summary: MetricSummary, options: {
317
317
  verbose?: boolean;
318
318
  }): string;
319
319
  declare function formatPrTerminalCard(result: PrAuditResult): string;
320
+ /** Rules about the checks themselves (tests, assertions, quality gates), as opposed to line patterns such as `as any`. */
321
+ declare function isCheckRule(type: string): boolean;
322
+ /**
323
+ * Puts the findings that matter first, so a long list of warnings cannot push them below a
324
+ * display cap. The order inside each rank is unchanged.
325
+ */
326
+ declare function mostImportantFirst<T extends {
327
+ type: string;
328
+ }>(findings: readonly T[], failedRules: readonly string[]): T[];
320
329
  declare function formatGithubAnnotations(result: Pick<PrAuditResult, "infractions" | "failedRules">): string;
321
330
  /**
322
331
  * Generates GitHub Step Summary Markdown
@@ -410,6 +419,55 @@ declare function compareFindings(before: Infraction[], after: Infraction[], newP
410
419
  declare function reviewChanges(options: ReviewOptions): Promise<ReviewResult>;
411
420
  declare function reviewStagedChanges(options?: StagedReviewOptions): Promise<ReviewResult>;
412
421
 
422
+ type ReplayStatus = "blocked" | "warned" | "noted" | "clean" | "untouched" | "incomplete" | "failed";
423
+ interface ReplayCommit {
424
+ sha: string;
425
+ date: string;
426
+ subject: string;
427
+ /** blocked: a rule that blocks by default fired; warned: a check rule fired without blocking; noted: only line patterns. */
428
+ status: ReplayStatus;
429
+ failedRules: string[];
430
+ findings: Infraction[];
431
+ filesInspected: number;
432
+ skipped: {
433
+ file: string;
434
+ reason: string;
435
+ }[];
436
+ error?: string;
437
+ }
438
+ interface ReplayOptions {
439
+ cwd?: string;
440
+ /** How many of the newest commits to review (default 50). */
441
+ last?: number;
442
+ /** List the line-pattern findings (type-escape, swallowed-error, ...) as well as the check rules. */
443
+ allRules?: boolean;
444
+ format?: "terminal" | "json";
445
+ signal?: AbortSignal;
446
+ onProgress?: (done: number, total: number) => void;
447
+ }
448
+ interface ReplayResult {
449
+ schemaVersion: string;
450
+ mode: "replay";
451
+ head: string;
452
+ requested: number;
453
+ /** Commits without a parent (the first commit, or the edge of a shallow clone) have nothing to compare with. */
454
+ commitsWithoutParent: number;
455
+ /** A shallow clone holds only part of the history, so fewer commits than requested may have been replayed. */
456
+ shallow: boolean;
457
+ summary: Record<ReplayStatus | "commits", number> & {
458
+ findingsByRule: Record<string, number>;
459
+ };
460
+ commits: ReplayCommit[];
461
+ exitCode: number;
462
+ output: string;
463
+ }
464
+ /**
465
+ * Reviews each of the newest commits of the first-parent history on its own against its parent, which is what a
466
+ * pull-request check would have seen: one change per squash, merge or direct commit. Reads Git objects only.
467
+ */
468
+ declare function replayHistory(options?: ReplayOptions): Promise<ReplayResult>;
469
+ declare function formatReplayTerminal(result: ReplayResult, allRules?: boolean): string;
470
+
413
471
  /** Offline SARIF 2.1.0, with relative URIs and explicit incomplete-run status.
414
472
  * Snippets are omitted to avoid embedding source code in integration artifacts. */
415
473
  declare function formatSarif(report: CheckResult | ReviewResult): string;
@@ -452,4 +510,4 @@ declare function roastRepository(options?: RoastOptions): Promise<{
452
510
  }>;
453
511
  declare function auditPullRequest(options: PrAuditOptions): Promise<PrAuditResult>;
454
512
 
455
- 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, type FindingException, GITHUB_WORKFLOW_TEMPLATE, GitError, GitResolutionError, HERMETIC_GIT_FLAGS, type Infraction, type InitOptions, type InitResult, InvalidDetectorError, MAX_CONFIG_BYTES, 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, type SuppressedFinding, VALID_DETECTORS, applyBaseline, applyExceptions, 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, gitOutputPath, initProject, isCodeFile, isTestFile, loadBaseline, loadConfig, matchIgnorePath, parseDiff, parseGitLogStream, resolveCommitSha, reviewChanges, reviewStagedChanges, roastRepository, streamGitDiff, streamGitLog, stripStringAndRegexLiterals, terminalText, unquoteGitPath, validateExceptions, writeBaseline };
513
+ 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, type FindingException, GITHUB_WORKFLOW_TEMPLATE, GitError, GitResolutionError, HERMETIC_GIT_FLAGS, type Infraction, type InitOptions, type InitResult, InvalidDetectorError, MAX_CONFIG_BYTES, type MetricSummary, MetricsCollector, PRE_COMMIT_HOOK_TEMPLATE, type ParseDiffResult, type ParsedCommit, type ParsedDiffLine, type ParsedFileDiff, type PrAuditOptions, type PrAuditResult, RECORD_SEPARATOR, RULE_GUIDES, type ReplayCommit, type ReplayOptions, type ReplayResult, type ReplayStatus, type ReviewFormat, type ReviewOptions, type ReviewResult, type RoastOptions, type RuleGuide, type ShieldsEndpointResponse, type StagedReviewOptions, type SuppressedFinding, VALID_DETECTORS, applyBaseline, applyExceptions, auditFileDiffs, auditPullRequest, checkProject, compareFindings, configFromText, createBaseline, detectAiAttribution, findMergeBase, findingFingerprint, formatCheckMarkdown, formatCheckTerminal, formatGithubAnnotations, formatGithubStepSummary, formatPrMarkdownComment, formatPrTerminalCard, formatReplayTerminal, formatReviewMarkdown, formatReviewTerminal, formatSarif, formatTerminalCard, generateShareUrl, generateShieldsJson, generateShieldsMarkdown, generateSvgBadge, getHermeticGitEnv, getScoreColor, gitOutputPath, initProject, isCheckRule, isCodeFile, isTestFile, loadBaseline, loadConfig, matchIgnorePath, mostImportantFirst, parseDiff, parseGitLogStream, replayHistory, resolveCommitSha, reviewChanges, reviewStagedChanges, roastRepository, streamGitDiff, streamGitLog, stripStringAndRegexLiterals, terminalText, unquoteGitPath, validateExceptions, writeBaseline };
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  formatGithubStepSummary,
34
34
  formatPrMarkdownComment,
35
35
  formatPrTerminalCard,
36
+ formatReplayTerminal,
36
37
  formatReviewMarkdown,
37
38
  formatReviewTerminal,
38
39
  formatSarif,
@@ -45,13 +46,16 @@ import {
45
46
  getScoreColor,
46
47
  gitOutputPath,
47
48
  initProject,
49
+ isCheckRule,
48
50
  isCodeFile,
49
51
  isTestFile,
50
52
  loadBaseline,
51
53
  loadConfig,
52
54
  matchIgnorePath,
55
+ mostImportantFirst,
53
56
  parseDiff,
54
57
  parseGitLogStream,
58
+ replayHistory,
55
59
  resolveCommitSha,
56
60
  reviewChanges,
57
61
  reviewStagedChanges,
@@ -63,7 +67,7 @@ import {
63
67
  unquoteGitPath,
64
68
  validateExceptions,
65
69
  writeBaseline
66
- } from "./chunk-NSGANOZ5.js";
70
+ } from "./chunk-WMHB65AL.js";
67
71
  export {
68
72
  CONFIG_FILENAMES,
69
73
  ConfigError,
@@ -98,6 +102,7 @@ export {
98
102
  formatGithubStepSummary,
99
103
  formatPrMarkdownComment,
100
104
  formatPrTerminalCard,
105
+ formatReplayTerminal,
101
106
  formatReviewMarkdown,
102
107
  formatReviewTerminal,
103
108
  formatSarif,
@@ -110,13 +115,16 @@ export {
110
115
  getScoreColor,
111
116
  gitOutputPath,
112
117
  initProject,
118
+ isCheckRule,
113
119
  isCodeFile,
114
120
  isTestFile,
115
121
  loadBaseline,
116
122
  loadConfig,
117
123
  matchIgnorePath,
124
+ mostImportantFirst,
118
125
  parseDiff,
119
126
  parseGitLogStream,
127
+ replayHistory,
120
128
  resolveCommitSha,
121
129
  reviewChanges,
122
130
  reviewStagedChanges,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-roast",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Offline review for AI-assisted changes: catches weakened tests, lowered quality gates, skipped tests and swallowed errors",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",