agent-roast 0.6.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
@@ -106,7 +106,7 @@ regressions) may appear in the report/summary rather than as an inline PR annota
106
106
  Use a separate SARIF category for introduced-findings review; it does not represent
107
107
  a full scan of all outstanding alerts.
108
108
 
109
- Install with `npx agent-roast@0.6.0 review --base origin/main`. For local development,
109
+ Install 0.7.0: `npx agent-roast@0.7.0 review --base origin/main`. For local development,
110
110
  run `npm run build` and use `node dist/cli.js`.
111
111
 
112
112
  ### Baseline adoption
@@ -175,7 +175,6 @@ Configure custom ignore paths, rule severities, and CI thresholds with a `.agent
175
175
 
176
176
  ```json
177
177
  {
178
- "$schema": "https://raw.githubusercontent.com/Nari-Ab/agent-roast/main/schema.json",
179
178
  "ignorePaths": [
180
179
  "**/*.min.js",
181
180
  "**/vendor/**",
@@ -183,11 +182,10 @@ Configure custom ignore paths, rule severities, and CI thresholds with a `.agent
183
182
  "**/fixtures/**"
184
183
  ],
185
184
  "rules": {
186
- "hallucinated-package": "error",
187
- "empty-catch": "warn",
185
+ "test-focus": "error",
186
+ "swallowed-error": "warn",
188
187
  "trivial-assert": "warn",
189
- "stub-abandonment": "warn",
190
- "mock-leak": "warn"
188
+ "stub-abandonment": "warn"
191
189
  },
192
190
  "scoreThreshold": 70
193
191
  }
@@ -203,9 +201,22 @@ npx agent-roast --staged
203
201
  npx agent-roast init --hook
204
202
  ```
205
203
 
206
- `--staged` audits exactly the Git index, including an initial commit without HEAD.
207
- Unstaged edits and untracked files are excluded. The generated hook uses the locally
208
- 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
209
220
  old commits. It preserves an existing pre-commit hook.
210
221
 
211
222
  ### Configure what blocks a change
@@ -216,6 +227,7 @@ Create `.agentroastrc.json` (or run `npx agent-roast init`):
216
227
  {
217
228
  "rules": {
218
229
  "test-skip": "error",
230
+ "test-focus": "error",
219
231
  "trivial-assert": "error",
220
232
  "swallowed-error": "warn",
221
233
  "type-escape": "off"
@@ -227,7 +239,7 @@ Create `.agentroastrc.json` (or run `npx agent-roast init`):
227
239
  ```
228
240
 
229
241
  `off` removes findings and score penalties; `warn` reports without blocking a diff;
230
- `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
231
243
  rules block (`[]` makes the audit advisory). CLI `--fail-on` takes precedence over
232
244
  that array. History and badge audits honor disabled rules and the panic-loop window.
233
245
  History exit status is controlled by `scoreThreshold` or `--fail-under`.
@@ -264,7 +276,6 @@ jobs:
264
276
  - name: Run agent-roast audit
265
277
  uses: Nari-Ab/agent-roast@v1
266
278
  with:
267
- fail-on: test-skip,trivial-assert,stub-abandonment
268
279
  format: github
269
280
  ```
270
281
 
@@ -274,8 +285,12 @@ jobs:
274
285
 
275
286
  ### What it checks
276
287
 
277
- 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.
278
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)).
279
294
  - **Skipped tests (`test-skip`):** `it.skip`, `describe.skip`, `xit`, `pytest.skip`, `test.todo` added to silence failing tests. (Default blocking gate).
280
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)`).
281
296
  - **Stub abandonment (`stub-abandonment`):** Dummy stubs left unimplemented in committed code (`throw new Error("Not implemented")`, `raise NotImplementedError`, Rust `todo!()` / `unimplemented!()`).
@@ -310,7 +325,8 @@ stubs, alongside a compiler, test runner and language-aware linter. Findings ide
310
325
  patterns, not proof of AI authorship or developer intent. Deliberate skips and abstract
311
326
  methods can be legitimate. Contiguous block comments, Python docstrings and multi-line empty handlers are
312
327
  recognized. Template interpolation, complex language syntax and context outside
313
- 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.
314
330
  The discipline score summarizes added history; it does not certify current code quality.
315
331
  `check` reports actionable findings without assigning a personality or quality score.
316
332
 
@@ -323,7 +339,11 @@ npm test
323
339
  ```
324
340
 
325
341
  `npm test` builds the distribution first so CLI integration and bundle checks run
326
- 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.
327
347
 
328
348
  ### Author & Support
329
349
 
@@ -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"
@@ -840,6 +848,7 @@ var TEST_SKIP_PATTERNS = [
840
848
  { regex: /\bpytest\.skip\(/, name: "imperative pytest skip (pytest.skip)" },
841
849
  { regex: /\btest\.todo\(/, name: "todo placeholder test" }
842
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;
843
852
  var COMMENT_DIRECTIVE_PATTERNS = [
844
853
  { regex: /^\s*\/\/\s*@ts-ignore\b/, name: "compiler ignore (@ts-ignore)" },
845
854
  { regex: /^\s*\/\/\s*@ts-expect-error\b/, name: "compiler suppression (@ts-expect-error)" },
@@ -879,6 +888,8 @@ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
879
888
  ".jsx",
880
889
  ".mjs",
881
890
  ".cjs",
891
+ ".mts",
892
+ ".cts",
882
893
  ".py",
883
894
  ".go",
884
895
  ".rs",
@@ -1024,6 +1035,35 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
1024
1035
  }
1025
1036
  }
1026
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
+ }
1027
1067
  for (let index = 0; index < maskedLines.length; index++) {
1028
1068
  const start = maskedLines[index];
1029
1069
  const isCatch = /\bcatch\s*(?:\([^)]*\))?\s*\{\s*$/.test(start.code);
@@ -1067,6 +1107,7 @@ var MetricsCollector = class {
1067
1107
  totalCommits = 0;
1068
1108
  aiCommits = 0;
1069
1109
  testSkips = [];
1110
+ focusedTests = [];
1070
1111
  typeEscapes = [];
1071
1112
  swallowedErrors = [];
1072
1113
  panicLoops = [];
@@ -1173,6 +1214,7 @@ var MetricsCollector = class {
1173
1214
  });
1174
1215
  const buckets = {
1175
1216
  "test-skip": this.testSkips,
1217
+ "test-focus": this.focusedTests,
1176
1218
  "type-escape": this.typeEscapes,
1177
1219
  "swallowed-error": this.swallowedErrors,
1178
1220
  "trivial-assert": this.trivialAsserts,
@@ -1190,7 +1232,7 @@ var MetricsCollector = class {
1190
1232
  this.flushPanicChain();
1191
1233
  const linesSample = this.includeAll ? this.totalLinesAdded : this.aiLinesAdded;
1192
1234
  const isSufficientData = linesSample >= MIN_SAMPLE_LOC;
1193
- 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;
1194
1236
  let panicInfractionPoints = 0;
1195
1237
  for (const loop of this.panicLoops) {
1196
1238
  const countMatch = loop.reason.match(/chain of (\d+) quick fixes/);
@@ -1259,6 +1301,7 @@ var MetricsCollector = class {
1259
1301
  totalCommits: this.totalCommits,
1260
1302
  aiCommits: this.aiCommits,
1261
1303
  testSkips: this.testSkips,
1304
+ focusedTests: this.focusedTests,
1262
1305
  typeEscapes: this.typeEscapes,
1263
1306
  swallowedErrors: this.swallowedErrors,
1264
1307
  panicLoops: this.panicLoops,
@@ -1315,7 +1358,7 @@ function formatTerminalCard(summary, options) {
1315
1358
  lines.push(` ${pc.dim(summary.archetypeDescription)}`);
1316
1359
  lines.push("");
1317
1360
  }
1318
- const totalInfractions = (summary.testSkips?.length || 0) + (summary.typeEscapes?.length || 0) + (summary.swallowedErrors?.length || 0) + (summary.panicLoops?.length || 0) + (summary.trivialAsserts?.length || 0) + (summary.stubAbandonments?.length || 0);
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);
1319
1362
  lines.push(pc.bold(` INFRACTIONS (${totalInfractions})`));
1320
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"));
1321
1364
  if (summary.panicLoops && summary.panicLoops.length > 0) {
@@ -1334,6 +1377,11 @@ function formatTerminalCard(summary, options) {
1334
1377
  lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
1335
1378
  }
1336
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
+ }
1337
1385
  if (summary.testSkips && summary.testSkips.length > 0) {
1338
1386
  lines.push(
1339
1387
  ` ${pc.red("\u25CF")} ${pc.bold("test-skip")} (${summary.testSkips.length}) ${pc.dim("skipped or disabled tests")}`
@@ -1388,6 +1436,7 @@ function formatTerminalCard(summary, options) {
1388
1436
  }
1389
1437
  const allInfractions = [
1390
1438
  ...summary.testSkips || [],
1439
+ ...summary.focusedTests || [],
1391
1440
  ...summary.trivialAsserts || [],
1392
1441
  ...summary.swallowedErrors || [],
1393
1442
  ...summary.stubAbandonments || [],
@@ -1631,8 +1680,8 @@ function formatReviewTerminal(result) {
1631
1680
  const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
1632
1681
  const lines = [
1633
1682
  "",
1634
- pc.bold(`agent-roast \u2014 context review: ${status}`),
1635
- ` ${result.mergeBaseSha.slice(0, 7)} \u2192 ${result.headSha.slice(0, 7)} \xB7 policy from base ${result.policySha.slice(0, 7)}`,
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)}`,
1636
1685
  ` ${result.filesInspected} changed files compared \xB7 ${result.linesInspected} source lines inspected across both versions`,
1637
1686
  ` ${result.infractions.length} new \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected`
1638
1687
  ];
@@ -1655,9 +1704,9 @@ function formatReviewTerminal(result) {
1655
1704
  function formatReviewMarkdown(result) {
1656
1705
  const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
1657
1706
  const lines = [
1658
- `## agent-roast context review: ${status}`,
1707
+ `## agent-roast ${result.source === "index" ? "staged context review" : "context review"}: ${status}`,
1659
1708
  "",
1660
- `Compared ${result.filesInspected} changed files at \`${result.mergeBaseSha.slice(0, 7)}\` \u2192 \`${result.headSha.slice(0, 7)}\`. Policy: \`${result.policySha.slice(0, 7)}\`.`,
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)}\`.`,
1661
1710
  "",
1662
1711
  `**${result.infractions.length} new** \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected.`,
1663
1712
  ""
@@ -1985,6 +2034,7 @@ Example: ${RULE_GUIDES[rule].example}` },
1985
2034
  results,
1986
2035
  properties: report.mode === "review" ? {
1987
2036
  scope: "introduced findings in changed files",
2037
+ source: report.source ?? "commits",
1988
2038
  baseSha: report.baseSha,
1989
2039
  headSha: report.headSha,
1990
2040
  mergeBaseSha: report.mergeBaseSha,
@@ -2063,7 +2113,7 @@ async function checkProject(options = {}) {
2063
2113
  const cwd = path4.resolve(options.cwd || process.cwd());
2064
2114
  const root = await fs4.realpath(git(cwd, ["rev-parse", "--show-toplevel"]).trim());
2065
2115
  const config = loadConfig(root);
2066
- const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert"];
2116
+ const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert", "test-focus"];
2067
2117
  for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown rule '${rule}'.`);
2068
2118
  const format = options.format || "terminal";
2069
2119
  if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported check output format.");
@@ -2131,6 +2181,7 @@ async function checkProject(options = {}) {
2131
2181
  import path5 from "path";
2132
2182
 
2133
2183
  // src/snapshot.ts
2184
+ import { createHash as createHash2 } from "crypto";
2134
2185
  import { spawn as spawn2 } from "child_process";
2135
2186
  function assertNotAborted(signal) {
2136
2187
  if (signal?.aborted) throw new ConfigError("Review cancelled; no complete result was produced.");
@@ -2177,6 +2228,16 @@ async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024) {
2177
2228
  if (signal?.aborted) abort();
2178
2229
  });
2179
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
+ }
2180
2241
  async function reviewRefs(cwd, base, head, signal) {
2181
2242
  const resolve2 = async (ref) => {
2182
2243
  if (!ref.trim()) throw new ConfigError("Review refs must not be empty.");
@@ -2187,14 +2248,7 @@ async function reviewRefs(cwd, base, head, signal) {
2187
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)}`);
2188
2249
  }
2189
2250
  };
2190
- try {
2191
- const promises = (await readGit(cwd, ["config", "--type=bool", "--get-regexp", "^remote\\..*\\.promisor$"], signal, 64 * 1024)).toString();
2192
- if (promises.split("\n").some((line) => /\s+(?:true|yes|on|1)\s*$/i.test(line))) {
2193
- throw new ConfigError("Offline review requires a non-partial clone with local objects; promisor remotes are unsupported.");
2194
- }
2195
- } catch (error) {
2196
- if (!(error instanceof GitError && error.code === 1)) throw error;
2197
- }
2251
+ await requireLocalObjects(cwd, signal);
2198
2252
  const baseSha = await resolve2(base);
2199
2253
  const headSha = await resolve2(head);
2200
2254
  let ancestors;
@@ -2209,6 +2263,9 @@ async function reviewRefs(cwd, base, head, signal) {
2209
2263
  }
2210
2264
  async function changedTreeFiles(cwd, before, after, signal) {
2211
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) {
2212
2269
  const tokens = new TextDecoder("utf-8", { fatal: true }).decode(buffer).split("\0");
2213
2270
  if (tokens.pop() !== "") throw new ConfigError("Truncated Git change inventory.");
2214
2271
  const changes = [];
@@ -2245,6 +2302,59 @@ async function reviewPolicy(cwd, baseSha, signal) {
2245
2302
  }
2246
2303
  return { config: configFromText("{}", "default review policy"), filename: null };
2247
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
+ }
2248
2358
 
2249
2359
  // src/review.ts
2250
2360
  var GENERATED_PATHS2 = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
@@ -2268,21 +2378,29 @@ function compareFindings(before, after, newPath) {
2268
2378
  }
2269
2379
  return { introduced, removed: [...old.values()].flatMap((bucket) => bucket.findings.slice(bucket.matched)), unchanged };
2270
2380
  }
2271
- async function reviewChanges(options) {
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) {
2272
2388
  assertNotAborted(options.signal);
2273
2389
  const cwd = path5.resolve(options.cwd || process.cwd());
2274
- const refs = await reviewRefs(cwd, options.base, options.head ?? "HEAD", options.signal);
2275
- const policy = await reviewPolicy(cwd, refs.baseSha, options.signal);
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);
2276
2393
  const format = options.format ?? "terminal";
2277
2394
  if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported review format.");
2278
- const failOn = options.failOn ?? policy.config.failOn ?? ["test-skip", "trivial-assert"];
2395
+ const failOn = options.failOn ?? policy.config.failOn ?? ["test-skip", "trivial-assert", "test-focus"];
2279
2396
  for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown review rule '${rule}'.`);
2280
- const changes = await changedTreeFiles(cwd, refs.mergeBaseSha, refs.headSha, options.signal);
2397
+ const changes = staged?.changes ?? await changedTreeFiles(cwd, refs.mergeBaseSha, refs.headSha, options.signal);
2281
2398
  const policyChanges = changes.filter((change) => CONFIG_FILENAMES.includes(change.oldPath) || CONFIG_FILENAMES.includes(change.newPath)).map((change) => change.newPath);
2282
2399
  const ignores = [...GENERATED_PATHS2, ...policy.config.ignorePaths || []];
2283
2400
  const result = {
2284
- schemaVersion: "1.0.0",
2401
+ schemaVersion: "1.1.0",
2285
2402
  mode: "review",
2403
+ source: staged ? "index" : "commits",
2286
2404
  ...refs,
2287
2405
  policySha: refs.baseSha,
2288
2406
  policyFile: policy.filename,
@@ -2355,6 +2473,7 @@ async function reviewChanges(options) {
2355
2473
  }
2356
2474
  }
2357
2475
  assertNotAborted(options.signal);
2476
+ await staged?.verify();
2358
2477
  result.complete = result.skippedFiles.length === 0;
2359
2478
  result.failedRules = [...new Set(result.infractions.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
2360
2479
  result.passed = result.complete && result.failedRules.length === 0;
@@ -2514,6 +2633,7 @@ export {
2514
2633
  checkProject,
2515
2634
  compareFindings,
2516
2635
  reviewChanges,
2636
+ reviewStagedChanges,
2517
2637
  InvalidDetectorError,
2518
2638
  roastRepository,
2519
2639
  auditPullRequest
package/dist/cli.js CHANGED
@@ -16,9 +16,10 @@ import {
16
16
  initProject,
17
17
  loadConfig,
18
18
  reviewChanges,
19
+ reviewStagedChanges,
19
20
  roastRepository,
20
21
  writeBaseline
21
- } from "./chunk-RU5OYHLR.js";
22
+ } from "./chunk-YGHNTWHX.js";
22
23
 
23
24
  // src/cli.ts
24
25
  import { Command } from "commander";
@@ -27,8 +28,39 @@ import * as fs from "fs";
27
28
  import * as path from "path";
28
29
  var require2 = createRequire(import.meta.url);
29
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
+ }
30
62
  var program = new Command();
31
- 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 (check/review also support sarif)", "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) => {
32
64
  try {
33
65
  if (process.env.GITHUB_EVENT_NAME === "pull_request_target") {
34
66
  console.warn(
@@ -36,6 +68,13 @@ program.name("agent-roast").description("Audit your git history or PR diffs for
36
68
  );
37
69
  }
38
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
+ }
39
78
  if (!["terminal", "json", "github", "markdown"].includes(options.format)) {
40
79
  throw new ConfigError("--format must be terminal, json, github, or markdown.");
41
80
  }
@@ -122,43 +161,16 @@ has-supported-changes=${prResult.hasSupportedChanges}
122
161
  }
123
162
  }
124
163
  });
125
- 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)\nOutput: --format terminal|json|github|markdown|sarif, --json, --fail-on <rules>\nExample: agent-roast review --base origin/main --format markdown").action(async (repoPath) => {
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) => {
126
165
  try {
127
166
  const options = program.opts();
128
- if (!options.base) throw new ConfigError("review requires --base <trusted-ref>.");
129
- if (options.staged || options.all || options.failUnder !== void 0 || program.getOptionValueSource("since") === "cli") {
130
- throw new ConfigError("review compares committed refs; --staged, --all, --since and --fail-under do not apply.");
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.");
131
169
  }
132
- const controller = new AbortController();
133
- const abort = () => controller.abort();
134
- process.once("SIGINT", abort);
135
- process.once("SIGTERM", abort);
136
- try {
137
- const result = await reviewChanges({
138
- cwd: repoPath,
139
- base: options.base,
140
- head: options.head,
141
- failOn: options.failOn !== void 0 ? options.failOn.split(",").map((rule) => rule.trim()).filter(Boolean) : void 0,
142
- format: options.json ? "json" : options.format,
143
- signal: controller.signal
144
- });
145
- if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, formatReviewMarkdown(result) + "\n");
146
- if (process.env.GITHUB_OUTPUT) fs.appendFileSync(
147
- process.env.GITHUB_OUTPUT,
148
- `passed=${result.passed}
149
- infractions-count=${result.infractions.length}
150
- has-supported-changes=${result.filesInspected > 0}
151
- complete=${result.complete}
152
- existing-count=${result.unchangedCount}
153
- removed-count=${result.removedFindings.length}
154
- `
155
- );
156
- console.log(result.output);
157
- process.exitCode = result.exitCode;
158
- } finally {
159
- process.removeListener("SIGINT", abort);
160
- process.removeListener("SIGTERM", abort);
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.");
161
172
  }
173
+ await runContextReview(repoPath, options, options.staged);
162
174
  } catch (error) {
163
175
  console.error(`agent-roast review error: ${error instanceof Error ? error.message : String(error)}`);
164
176
  process.exitCode = 2;
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[];
@@ -118,9 +119,11 @@ interface ReviewOptions {
118
119
  format?: ReviewFormat;
119
120
  signal?: AbortSignal;
120
121
  }
122
+ type StagedReviewOptions = Omit<ReviewOptions, "base" | "head">;
121
123
  interface ReviewResult {
122
124
  schemaVersion: string;
123
125
  mode: "review";
126
+ source?: "commits" | "index";
124
127
  baseSha: string;
125
128
  headSha: string;
126
129
  mergeBaseSha: string;
@@ -240,6 +243,7 @@ declare class MetricsCollector {
240
243
  private totalCommits;
241
244
  private aiCommits;
242
245
  private testSkips;
246
+ private focusedTests;
243
247
  private typeEscapes;
244
248
  private swallowedErrors;
245
249
  private panicLoops;
@@ -361,6 +365,7 @@ declare function compareFindings(before: Infraction[], after: Infraction[], newP
361
365
  };
362
366
  /** Compare full source blobs in changed files without executing or checking out the candidate. */
363
367
  declare function reviewChanges(options: ReviewOptions): Promise<ReviewResult>;
368
+ declare function reviewStagedChanges(options?: StagedReviewOptions): Promise<ReviewResult>;
364
369
 
365
370
  /** Offline SARIF 2.1.0, with relative URIs and explicit incomplete-run status.
366
371
  * Snippets are omitted to avoid embedding source code in integration artifacts. */
@@ -404,4 +409,4 @@ declare function roastRepository(options?: RoastOptions): Promise<{
404
409
  }>;
405
410
  declare function auditPullRequest(options: PrAuditOptions): Promise<PrAuditResult>;
406
411
 
407
- 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, 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, 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
@@ -51,13 +51,14 @@ import {
51
51
  parseGitLogStream,
52
52
  resolveCommitSha,
53
53
  reviewChanges,
54
+ reviewStagedChanges,
54
55
  roastRepository,
55
56
  streamGitDiff,
56
57
  streamGitLog,
57
58
  stripStringAndRegexLiterals,
58
59
  unquoteGitPath,
59
60
  writeBaseline
60
- } from "./chunk-RU5OYHLR.js";
61
+ } from "./chunk-YGHNTWHX.js";
61
62
  export {
62
63
  CONFIG_FILENAMES,
63
64
  ConfigError,
@@ -110,6 +111,7 @@ export {
110
111
  parseGitLogStream,
111
112
  resolveCommitSha,
112
113
  reviewChanges,
114
+ reviewStagedChanges,
113
115
  roastRepository,
114
116
  streamGitDiff,
115
117
  streamGitLog,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-roast",
3
- "version": "0.6.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",