agent-roast 0.9.0 → 0.10.1

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
@@ -5,7 +5,7 @@
5
5
  <img src="docs/assets/logo.svg" alt="agent-roast" width="380">
6
6
  </picture>
7
7
 
8
- <h2>Catch coding shortcuts before they ship</h2>
8
+ <h2>Catch weakened tests and quality gates before they ship</h2>
9
9
 
10
10
  <p>
11
11
  <a href="https://github.com/Nari-Ab/agent-roast/actions/workflows/test.yml"><img src="https://github.com/Nari-Ab/agent-roast/actions/workflows/test.yml/badge.svg" alt="CLI tests"></a>
@@ -17,57 +17,91 @@
17
17
 
18
18
  </div>
19
19
 
20
- **Did the AI fix the code, or just make the checks pass?**
20
+ **Did this change fix the code, or only make the checks pass?**
21
21
 
22
- Coding agents (and tired humans) often get a red build green by changing the *checks*
23
- instead of the *code*: deleting the failing assertion, weakening it to `toBeDefined()`,
24
- removing the test, lowering the coverage threshold, switching off `strict`, or letting
25
- CI steps fail. A linter reading the new file cannot see that, because nothing in the new
26
- file looks wrong. `agent-roast review` compares the **before and after** of a change and
27
- flags exactly these moves. It is offline, runs no project code, and needs only Git.
22
+ A red build can be turned green by changing the *checks* instead of the *code*: loosening or
23
+ deleting the failing assertion, removing the test, lowering the coverage threshold, switching
24
+ off `strict`, or letting a CI step fail. Nothing in the new file looks wrong, so a linter
25
+ cannot see it. `agent-roast review` compares the **before and after** of a change and reports
26
+ exactly these moves. It is offline, runs no project code, and needs only Git. It was built to
27
+ review AI-assisted changes, but it cannot tell who wrote a change, and the measurements below
28
+ show these moves are not specific to AI.
28
29
 
29
30
  ```bash
30
31
  npx agent-roast review --base origin/main # a pull request
31
32
  npx agent-roast --staged # what you are about to commit
32
33
  ```
33
34
 
34
- | Rule | Fires when | Blocks by default |
35
+ | Rule | Fires when | By default |
35
36
  | --- | --- | --- |
36
- | `assertion-weakened` | a test keeps its name but loses assertions, or an exact check becomes `toBeDefined`/`toBeTruthy`/`assert x` | yes |
37
- | `guard-weakened` | coverage threshold lowered, `strict`/`noImplicitAny` off, lint rule downgraded, CI step may fail or no longer runs tests, test script cannot fail | yes |
38
- | `test-removed` | a test (or test file) disappears and no identical body appears elsewhere | no, reported |
39
- | `expectation-rewritten` | an expected value changed, test inputs did not, and production code changed in the same PR | no, reported |
40
- | `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/trivial: yes |
41
-
42
- Renames, moved tests, parametrization, and formatter rewraps (prettier, black) are
43
- recognized and not reported. Intentional cases can be accepted one by one with an
44
- expiring, reviewed exception (see below). Policy is read from the base branch, so a PR
45
- cannot switch the rules off for itself.
37
+ | `assertion-weakened` | an exact check is replaced by a weaker one: `toBe(42)` → `toBeDefined()`, `assert x == 1` → `assert x`, `isinstance(...)` → `is not None` | blocks |
38
+ | `guard-weakened` | coverage threshold lowered, `strict`/`noImplicitAny` off, lint rule downgraded, an existing CI step allowed to fail, a workflow that stops running tests or stops running on push and pull requests, a test script that cannot fail | blocks |
39
+ | `assertion-removed` | a test keeps its name but loses assertions | warns |
40
+ | `test-removed` | a test (or test file) disappears and no identical body appears elsewhere | warns |
41
+ | `expectation-rewritten` | an expected value changed, test inputs did not, and production code changed in the same PR | warns |
42
+ | `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
+ 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
+ one by one with an expiring, reviewed exception (see below). Policy is read from the base
47
+ branch, so a PR cannot switch the rules off for itself.
46
48
 
47
49
  ### How well does it work?
48
50
 
49
- Measured, not promised (`npm run build`, then `npm run bench` and `npm run bench:noise`;
50
- method and raw numbers are in [docs/benchmarks.md](docs/benchmarks.md)):
51
-
52
- - **Labeled changes** (`benchmarks/cases.mjs`): 32 cheating edits across JS/TS, Python, Go
53
- and CI/config files are caught, and 15 harmless look-alikes (reformat, rename, move,
54
- parametrize, snapshot, raised thresholds, CI step split) stay silent. The cases were
55
- written together with the detector, so they guard against regressions; they do not prove
56
- accuracy. Three known gaps are listed in the file and still open: mocking the module
57
- under test, hardcoding the tested value, and vacuous matchers such as `expect.anything()`.
58
- - **Noise on real human history** (the last 300 commits of commander.js, execa, click,
59
- requests and zod; 606 of them touched tests or CI/config): the two blocking rules fired on
60
- 7 commits (1.2%). Each was read by hand: one was a real defect (a refactor in `requests`
61
- dropped the `assert` keyword, leaving a comparison with no effect); the other six were
62
- deliberate rewrites a reviewer should still see, such as a test-framework migration,
63
- dropping Python 2, or a documented `continue-on-error`. `test-removed` fired on 6.1% and
64
- `expectation-rewritten` on 2.8% of those commits, which is why they only report.
65
- - **Not measured yet:** accuracy on real AI-agent pull requests. The sample above is human
66
- history, and it is small. Treat the numbers as a floor for noise, not a guarantee.
67
-
68
- It is a **review aid**. It cannot prove who wrote a change or that code is correct, and it
51
+ Measured, not promised; method, corpus and raw numbers are in
52
+ [docs/benchmarks.md](docs/benchmarks.md). **Short version: weakened checks are rare, usually
53
+ deliberate, and not specific to AI. The tool finds them; a person decides.**
54
+
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
58
+ together with the detector, so they guard against regressions; they do not prove accuracy.
59
+ Three known gaps are listed in the file and still open: mocking the module under test,
60
+ hardcoding the tested value, and vacuous matchers such as `expect.anything()`.
61
+ - **Five mature open-source projects** (commander.js, execa, click, requests, zod; 606 commits
62
+ that touch tests or CI/config): the blocking rules fired on 3 commits (0.5%), all of them
63
+ deliberate. `assertion-removed` fired on 5 (0.8%), including one real defect:
64
+ a refactor in `requests` dropped the `assert` keyword and left a comparison with no effect.
65
+ - **AI-signed commits in 166 public repositories** (signatures from Claude, Copilot, Cursor,
66
+ Codex, Devin and aider; 12,645 commits that touch tests or CI/config):
67
+
68
+ | | AI-signed | Other commits |
69
+ | --- | ---: | ---: |
70
+ | Repositories | 160 | 141 |
71
+ | Commits reviewed | 6,895 | 5,750 |
72
+ | Blocking rules | 0.3% (20) | 0.3% (16) |
73
+ | `assertion-removed` (warns) | 2.4% | 2.2% |
74
+ | `test-removed` (warns) | 15.4% | 12.7% |
75
+ | `expectation-rewritten` (warns) | 5.7% | 5.5% |
76
+
77
+ Compared inside the same repository (135 repositories with both kinds), the odds of a
78
+ blocking finding in an AI-signed commit are 0.81 times those of another commit (95% CI
79
+ 0.40-1.67): no sign that AI-signed commits weaken checks more often. Claude accounts for
80
+ 5,380 of the AI-signed commits; the other agents are too few to compare.
81
+ - **Hand-read precision.** Findings were read without knowing who wrote the commit (one
82
+ reader, an AI assistant; not independently verified). On repositories the detector had never
83
+ seen, the 11 blocking findings were 2 unexplained relaxations (TypeScript strictness switched
84
+ off in commits titled "Changes"), 7 deliberate and explained changes, and 2 false alarms. Of
85
+ 24 `assertion-removed` warnings, 1 was an unexplained weakening, 12 deliberate, 6 unclear and
86
+ 5 false alarms. So most findings are decisions a reviewer should see rather than mistakes,
87
+ and roughly one in five is a false alarm. Earlier rounds (76 findings) were used to fix the
88
+ detector and are not counted here.
89
+
90
+ Caveats. A signature shows a tool was involved, not how much, and unsigned AI help in the
91
+ "other" group would hide a difference. The repositories came from searching for those
92
+ signatures, so many are small personal projects. This measures what was committed, not what an
93
+ agent tries mid-session: a weakened check fixed before the commit is invisible. Labels are one
94
+ reader's opinion. Three small fixes (assertion helpers, throwing test-library queries,
95
+ scheduled-only workflows) were added after the labeling and have regression cases, but were not
96
+ re-labeled.
97
+
98
+ It is a **review aid**: it cannot prove who wrote a change or that code is correct, and it
69
99
  reads text, not program semantics. A green result is not a quality certificate.
70
100
 
101
+ **Want to see what it says about your repository?** [docs/pilot.md](docs/pilot.md) has a
102
+ five-minute look back, an advisory workflow that never fails a pull request, and a feedback form.
103
+ Counts are enough; no code is needed.
104
+
71
105
  ### Quick start
72
106
 
73
107
  ```bash
@@ -84,8 +118,9 @@ npx agent-roast --all --verbose
84
118
  ```
85
119
 
86
120
  A finding includes its file and line, the evidence, and a concrete next step.
87
- `--format json|markdown|sarif|github` are available; terminal output shows the first
88
- 20 findings.
121
+ `--format json|markdown|sarif|github` are available; terminal output shows the 20 most
122
+ important findings (rules that block first, then the before/after rules, then the noisier
123
+ line patterns).
89
124
 
90
125
  ### Context review for pull requests
91
126
 
@@ -145,12 +180,12 @@ regressions) may appear in the report/summary rather than as an inline PR annota
145
180
  Use a separate SARIF category for introduced-findings review; it does not represent
146
181
  a full scan of all outstanding alerts.
147
182
 
148
- Install 0.9.0: `npx agent-roast@0.9.0 review --base origin/main`. For local development,
183
+ Install 0.10.1: `npx agent-roast@0.10.1 review --base origin/main`. For local development,
149
184
  run `npm run build` and use `node dist/cli.js`.
150
185
 
151
186
  ### Precise exceptions (since 0.8.0)
152
187
 
153
- Use `npx agent-roast@0.9.0 check --format json`, or build this checkout with
188
+ Use `npx agent-roast@0.10.1 check --format json`, or build this checkout with
154
189
  `npm run build` and run `node dist/cli.js`.
155
190
 
156
191
  Some skipped integrations or abstract stubs are intentional. In 0.8.0 you can accept
@@ -386,7 +421,7 @@ jobs:
386
421
 
387
422
  To pilot the Action without blocking on findings, set `advisory: true`. It passes
388
423
  an explicit empty fail-on list; invalid configuration, Git errors and incomplete
389
- scans still fail. The `v1` Action installs 0.9.0. The CLI equivalent is `--fail-on ''`.
424
+ scans still fail. The `v1` Action installs 0.10.1. The CLI equivalent is `--fail-on ''`.
390
425
 
391
426
  > **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.
392
427
 
@@ -474,7 +509,7 @@ node scripts/verify-user-workflows.mjs dist/cli.js
474
509
  against current code. Regression tests use disposable Git repositories. The programmatic
475
510
  `auditPullRequest({ staged: true })` API retains the legacy added-lines contract;
476
511
  use `reviewStagedChanges()` for the new context comparison and `ReviewResult`.
477
- The composite Action installs exactly 0.9.0. For local development, use
512
+ The composite Action installs exactly 0.10.1. For local development, use
478
513
  `node dist/cli.js --staged` after building.
479
514
 
480
515
  ### Author & Support
@@ -787,11 +787,17 @@ var RULE_GUIDES = {
787
787
  example: "throw new Error('Not implemented') \u2192 implement the operation and add a behavior test"
788
788
  },
789
789
  "assertion-weakened": {
790
- title: "Test checks less than before",
791
- why: "A test that keeps its name but loses assertions, or has one replaced by a weaker check, still passes while verifying less behavior.",
792
- suggestion: "Restore the removed assertion or explain in the PR why that behavior is no longer required. Fix the code under test instead of weakening its check.",
790
+ title: "Exact check replaced by a weaker one",
791
+ why: "Swapping an exact assertion for toBeDefined, a truthiness check or `is not None` keeps the test green while it verifies far less.",
792
+ suggestion: "Restore the exact check, or assert the specific value the code should produce.",
793
793
  example: "expect(total).toBe(42) \u2192 expect(total).toBeDefined() (restore the exact check)"
794
794
  },
795
+ "assertion-removed": {
796
+ title: "Test checks less than before",
797
+ why: "A test that keeps its name but loses assertions verifies less behavior. Often the behavior changed on purpose; sometimes a check was dropped to get the suite green.",
798
+ suggestion: "Confirm the removed checks no longer apply, or restore them. Say so in the PR when the behavior was removed on purpose.",
799
+ example: "expect(a).toBe(1); expect(b).toBe(2) \u2192 expect(a).toBe(1) (is the second check still needed?)"
800
+ },
795
801
  "test-removed": {
796
802
  title: "Test removed",
797
803
  why: "Deleting a failing test makes a suite green without fixing the behavior it covered. Removals are legitimate when the feature itself is gone.",
@@ -832,6 +838,7 @@ var VALID_DETECTORS = [
832
838
  "stub-abandonment",
833
839
  "test-focus",
834
840
  "assertion-weakened",
841
+ "assertion-removed",
835
842
  "test-removed",
836
843
  "expectation-rewritten",
837
844
  "guard-weakened"
@@ -995,6 +1002,7 @@ var DEFAULT_CONFIG = {
995
1002
  "trivial-assert": "error",
996
1003
  "test-focus": "error",
997
1004
  "assertion-weakened": "error",
1005
+ "assertion-removed": "warn",
998
1006
  "guard-weakened": "error",
999
1007
  "test-removed": "warn",
1000
1008
  "expectation-rewritten": "warn",
@@ -1845,7 +1853,7 @@ function formatPrTerminalCard(result) {
1845
1853
  } else {
1846
1854
  lines.push(pc.bold(` INFRACTIONS DETECTED (${result.infractions.length})`));
1847
1855
  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"));
1848
- for (const inf of result.infractions) {
1856
+ for (const inf of mostImportantFirst(result.infractions, result.failedRules)) {
1849
1857
  const isFailedRule = result.failedRules.includes(inf.type);
1850
1858
  const badge = isFailedRule ? pc.red("FAIL") : pc.yellow("WARN");
1851
1859
  const loc = `${inf.file || "unknown"}:${inf.lineNumber || 1}`;
@@ -1869,12 +1877,25 @@ function formatPrTerminalCard(result) {
1869
1877
  lines.push("");
1870
1878
  return lines.join("\n");
1871
1879
  }
1872
- function sortInfractionsDeterministic(infractions) {
1873
- const getLevelRank = (type) => {
1874
- return type === "test-skip" || type === "trivial-assert" || type === "swallowed-error" ? 0 : 1;
1875
- };
1880
+ var RULE_PRIORITY = {
1881
+ "assertion-weakened": 0,
1882
+ "guard-weakened": 0,
1883
+ "test-skip": 0,
1884
+ "test-focus": 0,
1885
+ "trivial-assert": 0,
1886
+ "assertion-removed": 1,
1887
+ "expectation-rewritten": 1,
1888
+ "test-removed": 1
1889
+ };
1890
+ function findingRank(type, failedRules) {
1891
+ return failedRules.includes(type) ? -1 : RULE_PRIORITY[type] ?? 2;
1892
+ }
1893
+ function mostImportantFirst(findings, failedRules) {
1894
+ return [...findings].sort((a, b) => findingRank(a.type, failedRules) - findingRank(b.type, failedRules));
1895
+ }
1896
+ function sortInfractionsDeterministic(infractions, failedRules) {
1876
1897
  return [...infractions].sort((a, b) => {
1877
- const rankDiff = getLevelRank(a.type) - getLevelRank(b.type);
1898
+ const rankDiff = findingRank(a.type, failedRules) - findingRank(b.type, failedRules);
1878
1899
  if (rankDiff !== 0) return rankDiff;
1879
1900
  const fileA = a.file || "";
1880
1901
  const fileB = b.file || "";
@@ -1888,7 +1909,7 @@ function escapeCommand(value, property = false) {
1888
1909
  }
1889
1910
  function formatGithubAnnotations(result) {
1890
1911
  const outputLines = [];
1891
- const sorted = sortInfractionsDeterministic(result.infractions);
1912
+ const sorted = sortInfractionsDeterministic(result.infractions, result.failedRules);
1892
1913
  const MAX_PER_LEVEL = 10;
1893
1914
  let errorCount = 0;
1894
1915
  let warnCount = 0;
@@ -1943,7 +1964,7 @@ function formatGithubStepSummary(result) {
1943
1964
  lines.push("");
1944
1965
  lines.push("| Type | File:Line | Description | Snippet |");
1945
1966
  lines.push("| --- | --- | --- | --- |");
1946
- const sorted = sortInfractionsDeterministic(result.infractions);
1967
+ const sorted = sortInfractionsDeterministic(result.infractions, result.failedRules);
1947
1968
  for (const inf of sorted) {
1948
1969
  const loc = inf.file ? markdownCell(`${inf.file}:${inf.lineNumber || 1}`) : "history";
1949
1970
  const snippet = inf.lineSnippet ? markdownCell(inf.lineSnippet) : "-";
@@ -1983,7 +2004,7 @@ function formatPrMarkdownComment(result) {
1983
2004
  lines.push("");
1984
2005
  lines.push("| Type | File:Line | Description | Snippet |");
1985
2006
  lines.push("| :--- | :--- | :--- | :--- |");
1986
- const sorted = sortInfractionsDeterministic(result.infractions);
2007
+ const sorted = sortInfractionsDeterministic(result.infractions, result.failedRules);
1987
2008
  for (const inf of sorted) {
1988
2009
  const loc = inf.file ? markdownCell(`${inf.file}:${inf.lineNumber || 1}`) : "history";
1989
2010
  const snippet = inf.lineSnippet ? markdownCell(inf.lineSnippet) : "-";
@@ -2043,7 +2064,7 @@ function formatCheckTerminal(result) {
2043
2064
  ];
2044
2065
  lines.push(...exceptionTerminal(result));
2045
2066
  if (result.staleBaselineCount) lines.push(` ${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
2046
- for (const inf of result.infractions.slice(0, 20)) {
2067
+ for (const inf of mostImportantFirst(result.infractions, result.failedRules).slice(0, 20)) {
2047
2068
  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)}`);
2048
2069
  if (inf.suggestion) lines.push(` Next: ${terminalText(inf.suggestion)}`);
2049
2070
  }
@@ -2068,7 +2089,7 @@ function formatCheckMarkdown(result) {
2068
2089
  "| Rule | Location | Finding | Next step |",
2069
2090
  "| --- | --- | --- | --- |"
2070
2091
  ];
2071
- 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.")} |`);
2092
+ 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.")} |`);
2072
2093
  lines.push(...exceptionMarkdown(result));
2073
2094
  if (result.staleBaselineCount) lines.push("", `${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
2074
2095
  for (const skipped of result.skippedFiles) lines.push("", `Not scanned: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
@@ -2086,7 +2107,7 @@ function formatReviewTerminal(result) {
2086
2107
  lines.push(...exceptionTerminal(result));
2087
2108
  if (result.policyChanges.length) lines.push(" Candidate policy changes were ignored; adopt them separately after review.");
2088
2109
  if (result.excludedFiles || result.unsupportedFiles) lines.push(` ${result.excludedFiles} excluded \xB7 ${result.unsupportedFiles} unsupported/non-source changes`);
2089
- for (const finding2 of result.infractions.slice(0, 20)) {
2110
+ for (const finding2 of mostImportantFirst(result.infractions, result.failedRules).slice(0, 20)) {
2090
2111
  lines.push(
2091
2112
  "",
2092
2113
  ` ${result.failedRules.includes(finding2.type) ? "FAIL" : "WARN"} [${finding2.type}] ${terminalText(finding2.file || "unknown")}:${finding2.lineNumber}`,
@@ -2112,7 +2133,7 @@ function formatReviewMarkdown(result) {
2112
2133
  ];
2113
2134
  if (result.policyChanges.length) lines.push("Candidate policy changes were ignored. This review uses the supplied base commit's policy.", "");
2114
2135
  lines.push("| New finding | Head location | Evidence | Next step |", "| --- | --- | --- | --- |");
2115
- 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.")} |`);
2136
+ 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.")} |`);
2116
2137
  lines.push(...exceptionMarkdown(result));
2117
2138
  if (result.removedFindings.length) {
2118
2139
  lines.push(
@@ -2802,7 +2823,7 @@ var JS_TEST_START = /(?<![\w$.])(?:it|test)(?:\s*\.\s*(?:only|skip|concurrent|se
2802
2823
  var PY_TEST_START = /^\s*(?:async\s+)?def\s+(test\w*)\s*\(/;
2803
2824
  var GO_TEST_START = /^func\s+(Test\w+)\s*\(/;
2804
2825
  var JS_ASSERTION = /(?<![\w$.])(?:expect|assert|expectTypeOf)\b(?!\s*\.\s*(?:assertions|hasAssertions|extend|anything|any|objectContaining|arrayContaining|stringContaining|stringMatching)\b)(?:\s*\.\s*\w+)*\s*\(|\.should\b/;
2805
- var PY_ASSERTION = /^\s*assert\b|\bself\s*\.\s*assert\w*\s*\(|\bpytest\s*\.\s*raises\b|\bassertRaises\b/;
2826
+ var PY_ASSERTION = /^\s*assert\b|\bself\s*\.\s*assert\w*\s*\(|\bpytest\s*\.\s*raises\b|\bassertRaises\b|(?<!\w)assert_\w+\s*\(/;
2806
2827
  var GO_ASSERTION = /\bt\s*\.\s*(?:Error|Errorf|Fatal|Fatalf|Fail|FailNow)\b|\b(?:assert|require)\s*\.\s*\w+\s*\(/;
2807
2828
  function callEnd(text, from, limit) {
2808
2829
  let depth = 1, quote = "";
@@ -2883,12 +2904,25 @@ function splitStatements(text) {
2883
2904
  pieces.push(text.slice(start));
2884
2905
  return pieces.map((piece) => piece.trim()).filter(Boolean);
2885
2906
  }
2907
+ function stripTrailingComment(line, language) {
2908
+ const marker = language === "py" ? "#" : "//";
2909
+ let quote = "";
2910
+ for (let i = 0; i < line.length; i++) {
2911
+ const char = line[i];
2912
+ if (quote) {
2913
+ if (char === "\\") i++;
2914
+ else if (char === quote) quote = "";
2915
+ } else if (char === '"' || char === "'" || char === "`") quote = char;
2916
+ else if (line.startsWith(marker, i) && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i).trimEnd();
2917
+ }
2918
+ return line;
2919
+ }
2886
2920
  var CALLBACK_HEADER = /^[,\s]*(?:async\s*)?(?:\([^)]*\)|[\w$]+)\s*=>\s*\{\s*|^[,\s]*(?:async\s+)?function\s*\w*\s*\([^)]*\)\s*\{\s*/;
2887
2921
  function parseTests(text, language) {
2888
2922
  const assertionPattern = language === "js" ? JS_ASSERTION : language === "py" ? PY_ASSERTION : GO_ASSERTION;
2889
2923
  const seen = /* @__PURE__ */ new Map();
2890
2924
  return testBodies(text, language).map((test) => {
2891
- const body = test.body.split("\n");
2925
+ const body = test.body.split("\n").map((line) => language === "go" ? line : stripTrailingComment(line, language));
2892
2926
  const assertions = [], raw = [], setupLines = [];
2893
2927
  for (let i = 0; i < body.length; i++) {
2894
2928
  if (!assertionPattern.test(body[i])) {
@@ -2913,19 +2947,34 @@ function parseTests(text, language) {
2913
2947
  const bodyHash = createHash3("sha1").update(test.body.replace(/\s+/g, "")).digest("hex");
2914
2948
  const count = (seen.get(test.name) ?? 0) + 1;
2915
2949
  seen.set(test.name, count);
2916
- return { key: `${test.name}#${count}`, parametrized: test.parametrized, name: test.name, line: test.line, assertions, raw, setup, bodyHash };
2950
+ return { key: `${test.name}#${count}`, parametrized: test.parametrized, name: test.name, line: test.line, assertions, raw, setup, bodyHash, text: test.body };
2917
2951
  });
2918
2952
  }
2919
2953
  function skeleton(assertion) {
2920
2954
  return assertion.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, "S").replace(/\b\d+(?:\.\d+)?\b/g, "N").replace(/\b(?:true|false|null|undefined|None|True|False|nil)\b/g, "V").replace(/\s+/g, "");
2921
2955
  }
2922
2956
  var WEAK_JS = /\.(?:toBeDefined|toBeTruthy|toBeFalsy|toBeUndefined)\s*\(|\.not\s*\.\s*(?:toBeNull|toBeUndefined|toThrow)\s*\(|\.toBeGreaterThanOrEqual\s*\(\s*0\s*\)/;
2923
- var WEAK_PY = /^\s*assert\s+[\w.()\[\]]+\s*$|^\s*assert\s+.+\sis\s+not\s+None\s*$|^\s*assert\s+True\s*$/;
2957
+ var WEAK_PY = /^\s*assert\s+[\w.\[\]]+\s*(?:,.*)?$|^\s*assert\s+.+\bis\s+not\s+None\s*(?:,.*)?$|^\s*assert\s+True\b/;
2958
+ var PY_SPECIFIC = /==|!=|<=|>=|\bin\b|\bis\b|\bisinstance\s*\(|\.startswith\s*\(|\.endswith\s*\(|\bcallable\s*\(/;
2959
+ var JS_SPECIFIC = /\.(?:toBe|toEqual|toStrictEqual|toHaveLength|toContain|toContainEqual|toMatch|toMatchObject|toHaveProperty|toHaveBeenCalledWith|toHaveBeenCalledTimes|toHaveTextContent|toHaveAttribute|toHaveValue|toThrow\w*|toBeGreaterThan\w*|toBeLessThan\w*)\s*\(/;
2960
+ var THROWING_QUERY = /\b(?:get|find)(?:All)?By\w*\s*\(/;
2924
2961
  function isWeaker(oldAssertion, newAssertion, language) {
2925
- if (language === "js") return WEAK_JS.test(newAssertion) && !WEAK_JS.test(oldAssertion);
2926
- if (language === "py") return WEAK_PY.test(newAssertion) && !WEAK_PY.test(oldAssertion);
2962
+ if (language === "js") return !THROWING_QUERY.test(newAssertion) && WEAK_JS.test(newAssertion) && !WEAK_JS.test(oldAssertion) && JS_SPECIFIC.test(oldAssertion);
2963
+ if (language === "py") return WEAK_PY.test(newAssertion) && !WEAK_PY.test(oldAssertion) && PY_SPECIFIC.test(oldAssertion);
2927
2964
  return false;
2928
2965
  }
2966
+ var GENERIC_ARGUMENT = /^(?:not\.)?(?:be|have|contain|include|exist|match|eq|equal|length|text|value|attr|class|css|visible|checked|disabled|enabled|selected|focus|empty|true|false|null|undefined|deep)(?:\.\w+)*$/;
2967
+ function literalTokens(statement) {
2968
+ const tokens = /* @__PURE__ */ new Set();
2969
+ const literal = /(['"`])((?:\\.|(?!\1)[^\\\n])*)\1|(?<=[(,]\s*)\/((?:\\.|[^/\\\n])+)\/[a-z]*/g;
2970
+ for (const match of statement.matchAll(literal)) {
2971
+ for (const token of (match[2] ?? match[3] ?? "").match(/[A-Za-z0-9_:.=@#%-]{4,}/g) ?? []) {
2972
+ if (!GENERIC_ARGUMENT.test(token)) tokens.add(token);
2973
+ }
2974
+ }
2975
+ return [...tokens];
2976
+ }
2977
+ var HELPER_CALL = /(?<![\w$])((?:expect|assert|verify|check|require)[A-Z]\w*|_{0,2}(?:assert|check|verify|expect|require)_\w+)\s*\(/g;
2929
2978
  function finding(type, file, line, snippet, reason, commit) {
2930
2979
  return { type, file, lineNumber: Math.max(1, line), lineSnippet: snippet, commitHash: commit, commitDate: "", reason, suggestion: RULE_GUIDES[type].suggestion };
2931
2980
  }
@@ -2943,6 +2992,7 @@ function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
2943
2992
  };
2944
2993
  const oldTotals = tally(before), newTotals = tally(after);
2945
2994
  const beforeKeys = new Set(before.map((test) => test.key));
2995
+ const addedText = after.filter((test) => !beforeKeys.has(test.key)).map((test) => test.text).join("\n");
2946
2996
  const result = { immediate: [], removedTests: [], addedHashes: [], rewritten: [] };
2947
2997
  for (const test of after) if (!beforeKeys.has(test.key)) result.addedHashes.push(test.bodyHash);
2948
2998
  for (const old of before) {
@@ -2966,18 +3016,24 @@ function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
2966
3016
  const snapshot = (test) => test.raw.some((statement) => /\.toMatch(?:Inline)?Snapshot\b|\.matchSnapshot\b|\bsnapshot\s*\(/.test(statement));
2967
3017
  if (snapshot(current) && !snapshot(old)) continue;
2968
3018
  if (current.assertions.length < old.assertions.length) {
3019
+ if ([...current.text.matchAll(HELPER_CALL)].some((call) => !(oldText ?? "").includes(call[1]))) continue;
2969
3020
  const budget = tally([current]);
2970
- const lost = old.assertions.filter((assertion) => {
3021
+ const lost = [];
3022
+ old.assertions.forEach((assertion, i) => {
2971
3023
  const left = budget.get(assertion) ?? 0;
2972
- if (left > 0) {
2973
- budget.set(assertion, left - 1);
2974
- return false;
2975
- }
2976
- return true;
3024
+ if (left > 0) budget.set(assertion, left - 1);
3025
+ else lost.push({ normalized: assertion, raw: old.raw[i] });
2977
3026
  });
2978
- if (lost.every((assertion) => (newTotals.get(assertion) ?? 0) >= (oldTotals.get(assertion) ?? 0))) continue;
3027
+ const introduced = `${current.text}
3028
+ ${addedText}`;
3029
+ const accounted = (item) => {
3030
+ if ((newTotals.get(item.normalized) ?? 0) >= (oldTotals.get(item.normalized) ?? 0)) return true;
3031
+ const tokens = literalTokens(item.raw);
3032
+ return tokens.length > 0 && tokens.every((token) => introduced.includes(token));
3033
+ };
3034
+ if (lost.every(accounted)) continue;
2979
3035
  result.immediate.push(finding(
2980
- "assertion-weakened",
3036
+ "assertion-removed",
2981
3037
  newPath,
2982
3038
  current.line,
2983
3039
  `${old.name}: ${old.assertions.length} \u2192 ${current.assertions.length} assertions`,
@@ -3044,7 +3100,46 @@ var THRESHOLD_PATTERNS = [
3044
3100
  /(--cov-fail-under)[= ](\d+(?:\.\d+)?)/g
3045
3101
  ];
3046
3102
  var SWITCHES = ["strict", "noImplicitAny", "strictNullChecks", "noImplicitReturns", "useUnknownInCatchVariables"];
3047
- var TEST_COMMAND = /\brun:.*\b(?:tests?|unit|pytest|vitest|jest|mocha|ava|tap|tox|nox|e2e|spec)\b|^\s*-?\s*(?:npm|yarn|pnpm|go|cargo|mvn|gradle|make)\s+(?:run\s+)?(?:\w+\s+)?tests?\b/;
3103
+ var CI_RUNNER = new RegExp(
3104
+ String.raw`(?<![\w./-])(?:pytest|vitest|jest|mocha|tox|nox)(?![\w/-])` + String.raw`|(?<![\w./-])(?:npm|yarn|pnpm|bun|go|cargo|mvn|gradle|make|dotnet|flutter)\s+(?:run\s+)?(?:-\S+\s+)*test(?![\w/])` + String.raw`|\brun:.*(?<=\s)(?:tests?|unit|e2e)(?![\w/.-])`
3105
+ );
3106
+ var CI_FILE = /(?:\.github\/workflows\/|\.gitlab-ci)/;
3107
+ var CI_REUSABLE = /^\s*-?\s*uses:\s*\S*\.github\/workflows\/\S+/;
3108
+ var CI_GATE = /^\s*(?:pull_request|pull_request_target|push|merge_group)\s*:|^\s*on\s*:\s*(?:\[[^\]]*\b(?:push|pull_request|pull_request_target|merge_group)\b[^\]]*\]|(?:push|pull_request|pull_request_target|merge_group)\s*$)|^\s*-\s*(?:push|pull_request|pull_request_target|merge_group)\s*$/m;
3109
+ function gatingTestLines(file, text) {
3110
+ return /\.gitlab-ci/.test(file) || CI_GATE.test(text) ? matchingLines(text, CI_RUNNER) : 0;
3111
+ }
3112
+ function matchingLines(text, pattern) {
3113
+ return text.split(/\r?\n/).filter((line) => !line.trim().startsWith("#") && pattern.test(line)).length;
3114
+ }
3115
+ function ciTestSteps(file, oldText, newText) {
3116
+ if (!CI_FILE.test(file) || oldText === null && newText === null) return null;
3117
+ const was = oldText ?? "", now = newText ?? "";
3118
+ const after = gatingTestLines(file, now);
3119
+ return { file, before: gatingTestLines(file, was), after, reusableAfter: matchingLines(now, CI_REUSABLE), ungatedAfter: after === 0 ? matchingLines(now, CI_RUNNER) : 0 };
3120
+ }
3121
+ function resolveCiTestSteps(counts, commit) {
3122
+ const before = counts.reduce((sum, item) => sum + item.before, 0);
3123
+ const after = counts.reduce((sum, item) => sum + item.after + item.reusableAfter, 0);
3124
+ if (after >= before) return [];
3125
+ return counts.filter((item) => item.before > 0 && item.after === 0 && item.reusableAfter === 0).map((item) => item.ungatedAfter > 0 ? finding("guard-weakened", item.file, 1, "tests no longer run on push or pull_request", "Workflow keeps its tests but no longer runs on pushes or pull requests", commit) : finding("guard-weakened", item.file, 1, "test command no longer runs in CI", "Test step removed from CI", commit));
3126
+ }
3127
+ var LENIENT = /continue-on-error:\s*true|allow_failure:\s*true/;
3128
+ function lenientSteps(text) {
3129
+ const lines = text.split(/\r?\n/);
3130
+ const indentOf = (line) => line.length - line.trimStart().length;
3131
+ const found = /* @__PURE__ */ new Map();
3132
+ lines.forEach((line, i) => {
3133
+ if (line.trim().startsWith("#") || !LENIENT.test(line)) return;
3134
+ let j = i - 1;
3135
+ while (j >= 0 && (!lines[j].trim() || lines[j].trim().startsWith("#") || indentOf(lines[j]) >= indentOf(line))) j--;
3136
+ const identity = j >= 0 ? lines[j].trim() : "";
3137
+ const entry = found.get(identity);
3138
+ if (entry) entry.count++;
3139
+ else found.set(identity, { count: 1, line: i + 1 });
3140
+ });
3141
+ return found;
3142
+ }
3048
3143
  function severityOf(value) {
3049
3144
  return /^(?:error|2)$/.test(value) ? 2 : /^(?:warn|1)$/.test(value) ? 1 : 0;
3050
3145
  }
@@ -3071,21 +3166,29 @@ function analyzeGuardFile(file, oldText, newText, commit) {
3071
3166
  }
3072
3167
  }
3073
3168
  if (/eslint/i.test(file)) {
3074
- const rules = (text) => new Map([...text.matchAll(/["']([@\w/.-]+)["']\s*:\s*(?:\[\s*)?["']?(error|warn|off|[012])["']?/g)].map((match) => [match[1], { level: severityOf(match[2]), index: match.index ?? 0 }]));
3075
- const before = rules(oldText);
3076
- for (const [name, now] of rules(newText)) {
3169
+ const rules = (text) => {
3170
+ const found = /* @__PURE__ */ new Map();
3171
+ for (const match of text.matchAll(/["']([@\w/.-]+)["']\s*:\s*(?:\[\s*)?["']?(error|warn|off|[012])["']?/g)) {
3172
+ const level = severityOf(match[2]);
3173
+ const known = found.get(match[1]);
3174
+ if (!known || level > known.level) found.set(match[1], { level, index: match.index ?? 0 });
3175
+ }
3176
+ return found;
3177
+ };
3178
+ const before = rules(oldText), after = rules(newText);
3179
+ for (const [name, now] of after) {
3077
3180
  const previous = before.get(name);
3078
- if (previous && previous.level === 2 && now.level < 2) add(now.index, `${name}: error \u2192 ${now.level ? "warn" : "off"}`, "Lint rule downgraded");
3181
+ const replaced = [...after].some(([other, setting]) => other !== name && other.endsWith(`/${name}`) && setting.level === 2);
3182
+ if (previous && previous.level === 2 && now.level < 2 && !replaced) add(now.index, `${name}: error \u2192 ${now.level ? "warn" : "off"}`, "Lint rule downgraded");
3079
3183
  }
3080
3184
  }
3081
- if (/(?:\.github\/workflows\/|\.gitlab-ci)/.test(file)) {
3082
- const count = (text, pattern) => text.split(/\r?\n/).filter((line) => !line.trim().startsWith("#") && pattern.test(line)).length;
3083
- const lenient = /continue-on-error:\s*true|allow_failure:\s*true/;
3084
- if (count(newText, lenient) > count(oldText, lenient)) {
3085
- add(newText.search(lenient), "continue-on-error: true", "CI step is now allowed to fail");
3086
- }
3087
- if (count(newText, TEST_COMMAND) < count(oldText, TEST_COMMAND)) {
3088
- add(0, "test command no longer runs in CI", "Test step removed from CI");
3185
+ if (CI_FILE.test(file)) {
3186
+ const before = lenientSteps(oldText), existing = new Set(oldText.split(/\r?\n/).map((line) => line.trim()));
3187
+ for (const [identity, now] of lenientSteps(newText)) {
3188
+ if (identity && existing.has(identity) && now.count > (before.get(identity)?.count ?? 0)) {
3189
+ out.push(finding("guard-weakened", file, now.line, "continue-on-error: true", "CI step is now allowed to fail", commit));
3190
+ break;
3191
+ }
3089
3192
  }
3090
3193
  }
3091
3194
  if (/(?:^|\/)package\.json$/i.test(file)) {
@@ -3170,6 +3273,7 @@ async function compareSourceChanges(options) {
3170
3273
  let totalBytes = 0;
3171
3274
  const tamperingFiles = [];
3172
3275
  const guardFindings = [];
3276
+ const ciCounts = [];
3173
3277
  let productionChanged = false;
3174
3278
  const readText = async (mode, oid) => {
3175
3279
  if (mode === "000000") return null;
@@ -3195,6 +3299,8 @@ async function compareSourceChanges(options) {
3195
3299
  const oldText = await readText(change.oldMode, change.oldOid);
3196
3300
  const newText = await readText(change.newMode, change.newOid);
3197
3301
  guardFindings.push(...analyzeGuardFile(change.newPath, oldText, newText, refs.headSha));
3302
+ const ci = ciTestSteps(change.newPath, oldText, newText);
3303
+ if (ci) ciCounts.push(ci);
3198
3304
  result.filesInspected++;
3199
3305
  } catch (error) {
3200
3306
  assertNotAborted(options.signal);
@@ -3252,7 +3358,7 @@ async function compareSourceChanges(options) {
3252
3358
  await staged?.verify();
3253
3359
  result.complete = result.skippedFiles.length === 0;
3254
3360
  const rules = policy.config.rules ?? {};
3255
- result.infractions.push(...[...resolveTampering(tamperingFiles, productionChanged), ...guardFindings].filter((finding2) => rules[finding2.type] !== "off"));
3361
+ result.infractions.push(...[...resolveTampering(tamperingFiles, productionChanged), ...guardFindings, ...resolveCiTestSteps(ciCounts, refs.headSha)].filter((finding2) => rules[finding2.type] !== "off"));
3256
3362
  const exceptions = applyExceptions(result.infractions, options.ignoreExceptions ? [] : policy.config.exceptions, /* @__PURE__ */ new Date(), existingCounts);
3257
3363
  result.infractions = exceptions.findings;
3258
3364
  result.suppressedFindings = exceptions.suppressedFindings;
package/dist/cli.js CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  roastRepository,
23
23
  terminalText,
24
24
  writeBaseline
25
- } from "./chunk-GMBQNL6Z.js";
25
+ } from "./chunk-FFKEBW6D.js";
26
26
 
27
27
  // src/cli.ts
28
28
  import { Command } from "commander";
package/dist/index.d.ts CHANGED
@@ -32,7 +32,7 @@ interface CommitInfo {
32
32
  isAiAttributed: boolean;
33
33
  aiSignatures: string[];
34
34
  }
35
- type DetectorType = "test-skip" | "type-escape" | "swallowed-error" | "panic-loop" | "trivial-assert" | "stub-abandonment" | "test-focus" | "assertion-weakened" | "test-removed" | "expectation-rewritten" | "guard-weakened";
35
+ type DetectorType = "test-skip" | "type-escape" | "swallowed-error" | "panic-loop" | "trivial-assert" | "stub-abandonment" | "test-focus" | "assertion-weakened" | "assertion-removed" | "test-removed" | "expectation-rewritten" | "guard-weakened";
36
36
  declare const VALID_DETECTORS: readonly DetectorType[];
37
37
  interface Infraction {
38
38
  fingerprint?: string;
package/dist/index.js CHANGED
@@ -63,7 +63,7 @@ import {
63
63
  unquoteGitPath,
64
64
  validateExceptions,
65
65
  writeBaseline
66
- } from "./chunk-GMBQNL6Z.js";
66
+ } from "./chunk-FFKEBW6D.js";
67
67
  export {
68
68
  CONFIG_FILENAMES,
69
69
  ConfigError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-roast",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
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",
package/schema.json CHANGED
@@ -22,6 +22,7 @@
22
22
  "stub-abandonment",
23
23
  "test-focus",
24
24
  "assertion-weakened",
25
+ "assertion-removed",
25
26
  "test-removed",
26
27
  "expectation-rewritten",
27
28
  "guard-weakened"
@@ -126,6 +127,13 @@
126
127
  "warn",
127
128
  "off"
128
129
  ]
130
+ },
131
+ "assertion-removed": {
132
+ "enum": [
133
+ "error",
134
+ "warn",
135
+ "off"
136
+ ]
129
137
  }
130
138
  }
131
139
  },