agent-roast 0.9.0 → 0.10.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
@@ -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,55 +17,85 @@
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
 
71
101
  ### Quick start
@@ -145,12 +175,12 @@ regressions) may appear in the report/summary rather than as an inline PR annota
145
175
  Use a separate SARIF category for introduced-findings review; it does not represent
146
176
  a full scan of all outstanding alerts.
147
177
 
148
- Install 0.9.0: `npx agent-roast@0.9.0 review --base origin/main`. For local development,
178
+ Install 0.10.0: `npx agent-roast@0.10.0 review --base origin/main`. For local development,
149
179
  run `npm run build` and use `node dist/cli.js`.
150
180
 
151
181
  ### Precise exceptions (since 0.8.0)
152
182
 
153
- Use `npx agent-roast@0.9.0 check --format json`, or build this checkout with
183
+ Use `npx agent-roast@0.10.0 check --format json`, or build this checkout with
154
184
  `npm run build` and run `node dist/cli.js`.
155
185
 
156
186
  Some skipped integrations or abstract stubs are intentional. In 0.8.0 you can accept
@@ -386,7 +416,7 @@ jobs:
386
416
 
387
417
  To pilot the Action without blocking on findings, set `advisory: true`. It passes
388
418
  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 ''`.
419
+ scans still fail. The `v1` Action installs 0.10.0. The CLI equivalent is `--fail-on ''`.
390
420
 
391
421
  > **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
422
 
@@ -474,7 +504,7 @@ node scripts/verify-user-workflows.mjs dist/cli.js
474
504
  against current code. Regression tests use disposable Git repositories. The programmatic
475
505
  `auditPullRequest({ staged: true })` API retains the legacy added-lines contract;
476
506
  use `reviewStagedChanges()` for the new context comparison and `ReviewResult`.
477
- The composite Action installs exactly 0.9.0. For local development, use
507
+ The composite Action installs exactly 0.10.0. For local development, use
478
508
  `node dist/cli.js --staged` after building.
479
509
 
480
510
  ### 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",
@@ -2802,7 +2810,7 @@ var JS_TEST_START = /(?<![\w$.])(?:it|test)(?:\s*\.\s*(?:only|skip|concurrent|se
2802
2810
  var PY_TEST_START = /^\s*(?:async\s+)?def\s+(test\w*)\s*\(/;
2803
2811
  var GO_TEST_START = /^func\s+(Test\w+)\s*\(/;
2804
2812
  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/;
2813
+ var PY_ASSERTION = /^\s*assert\b|\bself\s*\.\s*assert\w*\s*\(|\bpytest\s*\.\s*raises\b|\bassertRaises\b|(?<!\w)assert_\w+\s*\(/;
2806
2814
  var GO_ASSERTION = /\bt\s*\.\s*(?:Error|Errorf|Fatal|Fatalf|Fail|FailNow)\b|\b(?:assert|require)\s*\.\s*\w+\s*\(/;
2807
2815
  function callEnd(text, from, limit) {
2808
2816
  let depth = 1, quote = "";
@@ -2883,12 +2891,25 @@ function splitStatements(text) {
2883
2891
  pieces.push(text.slice(start));
2884
2892
  return pieces.map((piece) => piece.trim()).filter(Boolean);
2885
2893
  }
2894
+ function stripTrailingComment(line, language) {
2895
+ const marker = language === "py" ? "#" : "//";
2896
+ let quote = "";
2897
+ for (let i = 0; i < line.length; i++) {
2898
+ const char = line[i];
2899
+ if (quote) {
2900
+ if (char === "\\") i++;
2901
+ else if (char === quote) quote = "";
2902
+ } else if (char === '"' || char === "'" || char === "`") quote = char;
2903
+ else if (line.startsWith(marker, i) && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i).trimEnd();
2904
+ }
2905
+ return line;
2906
+ }
2886
2907
  var CALLBACK_HEADER = /^[,\s]*(?:async\s*)?(?:\([^)]*\)|[\w$]+)\s*=>\s*\{\s*|^[,\s]*(?:async\s+)?function\s*\w*\s*\([^)]*\)\s*\{\s*/;
2887
2908
  function parseTests(text, language) {
2888
2909
  const assertionPattern = language === "js" ? JS_ASSERTION : language === "py" ? PY_ASSERTION : GO_ASSERTION;
2889
2910
  const seen = /* @__PURE__ */ new Map();
2890
2911
  return testBodies(text, language).map((test) => {
2891
- const body = test.body.split("\n");
2912
+ const body = test.body.split("\n").map((line) => language === "go" ? line : stripTrailingComment(line, language));
2892
2913
  const assertions = [], raw = [], setupLines = [];
2893
2914
  for (let i = 0; i < body.length; i++) {
2894
2915
  if (!assertionPattern.test(body[i])) {
@@ -2913,19 +2934,34 @@ function parseTests(text, language) {
2913
2934
  const bodyHash = createHash3("sha1").update(test.body.replace(/\s+/g, "")).digest("hex");
2914
2935
  const count = (seen.get(test.name) ?? 0) + 1;
2915
2936
  seen.set(test.name, count);
2916
- return { key: `${test.name}#${count}`, parametrized: test.parametrized, name: test.name, line: test.line, assertions, raw, setup, bodyHash };
2937
+ return { key: `${test.name}#${count}`, parametrized: test.parametrized, name: test.name, line: test.line, assertions, raw, setup, bodyHash, text: test.body };
2917
2938
  });
2918
2939
  }
2919
2940
  function skeleton(assertion) {
2920
2941
  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
2942
  }
2922
2943
  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*$/;
2944
+ var WEAK_PY = /^\s*assert\s+[\w.\[\]]+\s*(?:,.*)?$|^\s*assert\s+.+\bis\s+not\s+None\s*(?:,.*)?$|^\s*assert\s+True\b/;
2945
+ var PY_SPECIFIC = /==|!=|<=|>=|\bin\b|\bis\b|\bisinstance\s*\(|\.startswith\s*\(|\.endswith\s*\(|\bcallable\s*\(/;
2946
+ var JS_SPECIFIC = /\.(?:toBe|toEqual|toStrictEqual|toHaveLength|toContain|toContainEqual|toMatch|toMatchObject|toHaveProperty|toHaveBeenCalledWith|toHaveBeenCalledTimes|toHaveTextContent|toHaveAttribute|toHaveValue|toThrow\w*|toBeGreaterThan\w*|toBeLessThan\w*)\s*\(/;
2947
+ var THROWING_QUERY = /\b(?:get|find)(?:All)?By\w*\s*\(/;
2924
2948
  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);
2949
+ if (language === "js") return !THROWING_QUERY.test(newAssertion) && WEAK_JS.test(newAssertion) && !WEAK_JS.test(oldAssertion) && JS_SPECIFIC.test(oldAssertion);
2950
+ if (language === "py") return WEAK_PY.test(newAssertion) && !WEAK_PY.test(oldAssertion) && PY_SPECIFIC.test(oldAssertion);
2927
2951
  return false;
2928
2952
  }
2953
+ 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+)*$/;
2954
+ function literalTokens(statement) {
2955
+ const tokens = /* @__PURE__ */ new Set();
2956
+ const literal = /(['"`])((?:\\.|(?!\1)[^\\\n])*)\1|(?<=[(,]\s*)\/((?:\\.|[^/\\\n])+)\/[a-z]*/g;
2957
+ for (const match of statement.matchAll(literal)) {
2958
+ for (const token of (match[2] ?? match[3] ?? "").match(/[A-Za-z0-9_:.=@#%-]{4,}/g) ?? []) {
2959
+ if (!GENERIC_ARGUMENT.test(token)) tokens.add(token);
2960
+ }
2961
+ }
2962
+ return [...tokens];
2963
+ }
2964
+ var HELPER_CALL = /(?<![\w$])((?:expect|assert|verify|check|require)[A-Z]\w*|_{0,2}(?:assert|check|verify|expect|require)_\w+)\s*\(/g;
2929
2965
  function finding(type, file, line, snippet, reason, commit) {
2930
2966
  return { type, file, lineNumber: Math.max(1, line), lineSnippet: snippet, commitHash: commit, commitDate: "", reason, suggestion: RULE_GUIDES[type].suggestion };
2931
2967
  }
@@ -2943,6 +2979,7 @@ function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
2943
2979
  };
2944
2980
  const oldTotals = tally(before), newTotals = tally(after);
2945
2981
  const beforeKeys = new Set(before.map((test) => test.key));
2982
+ const addedText = after.filter((test) => !beforeKeys.has(test.key)).map((test) => test.text).join("\n");
2946
2983
  const result = { immediate: [], removedTests: [], addedHashes: [], rewritten: [] };
2947
2984
  for (const test of after) if (!beforeKeys.has(test.key)) result.addedHashes.push(test.bodyHash);
2948
2985
  for (const old of before) {
@@ -2966,18 +3003,24 @@ function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
2966
3003
  const snapshot = (test) => test.raw.some((statement) => /\.toMatch(?:Inline)?Snapshot\b|\.matchSnapshot\b|\bsnapshot\s*\(/.test(statement));
2967
3004
  if (snapshot(current) && !snapshot(old)) continue;
2968
3005
  if (current.assertions.length < old.assertions.length) {
3006
+ if ([...current.text.matchAll(HELPER_CALL)].some((call) => !(oldText ?? "").includes(call[1]))) continue;
2969
3007
  const budget = tally([current]);
2970
- const lost = old.assertions.filter((assertion) => {
3008
+ const lost = [];
3009
+ old.assertions.forEach((assertion, i) => {
2971
3010
  const left = budget.get(assertion) ?? 0;
2972
- if (left > 0) {
2973
- budget.set(assertion, left - 1);
2974
- return false;
2975
- }
2976
- return true;
3011
+ if (left > 0) budget.set(assertion, left - 1);
3012
+ else lost.push({ normalized: assertion, raw: old.raw[i] });
2977
3013
  });
2978
- if (lost.every((assertion) => (newTotals.get(assertion) ?? 0) >= (oldTotals.get(assertion) ?? 0))) continue;
3014
+ const introduced = `${current.text}
3015
+ ${addedText}`;
3016
+ const accounted = (item) => {
3017
+ if ((newTotals.get(item.normalized) ?? 0) >= (oldTotals.get(item.normalized) ?? 0)) return true;
3018
+ const tokens = literalTokens(item.raw);
3019
+ return tokens.length > 0 && tokens.every((token) => introduced.includes(token));
3020
+ };
3021
+ if (lost.every(accounted)) continue;
2979
3022
  result.immediate.push(finding(
2980
- "assertion-weakened",
3023
+ "assertion-removed",
2981
3024
  newPath,
2982
3025
  current.line,
2983
3026
  `${old.name}: ${old.assertions.length} \u2192 ${current.assertions.length} assertions`,
@@ -3044,7 +3087,46 @@ var THRESHOLD_PATTERNS = [
3044
3087
  /(--cov-fail-under)[= ](\d+(?:\.\d+)?)/g
3045
3088
  ];
3046
3089
  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/;
3090
+ var CI_RUNNER = new RegExp(
3091
+ 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/.-])`
3092
+ );
3093
+ var CI_FILE = /(?:\.github\/workflows\/|\.gitlab-ci)/;
3094
+ var CI_REUSABLE = /^\s*-?\s*uses:\s*\S*\.github\/workflows\/\S+/;
3095
+ 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;
3096
+ function gatingTestLines(file, text) {
3097
+ return /\.gitlab-ci/.test(file) || CI_GATE.test(text) ? matchingLines(text, CI_RUNNER) : 0;
3098
+ }
3099
+ function matchingLines(text, pattern) {
3100
+ return text.split(/\r?\n/).filter((line) => !line.trim().startsWith("#") && pattern.test(line)).length;
3101
+ }
3102
+ function ciTestSteps(file, oldText, newText) {
3103
+ if (!CI_FILE.test(file) || oldText === null && newText === null) return null;
3104
+ const was = oldText ?? "", now = newText ?? "";
3105
+ const after = gatingTestLines(file, now);
3106
+ return { file, before: gatingTestLines(file, was), after, reusableAfter: matchingLines(now, CI_REUSABLE), ungatedAfter: after === 0 ? matchingLines(now, CI_RUNNER) : 0 };
3107
+ }
3108
+ function resolveCiTestSteps(counts, commit) {
3109
+ const before = counts.reduce((sum, item) => sum + item.before, 0);
3110
+ const after = counts.reduce((sum, item) => sum + item.after + item.reusableAfter, 0);
3111
+ if (after >= before) return [];
3112
+ 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));
3113
+ }
3114
+ var LENIENT = /continue-on-error:\s*true|allow_failure:\s*true/;
3115
+ function lenientSteps(text) {
3116
+ const lines = text.split(/\r?\n/);
3117
+ const indentOf = (line) => line.length - line.trimStart().length;
3118
+ const found = /* @__PURE__ */ new Map();
3119
+ lines.forEach((line, i) => {
3120
+ if (line.trim().startsWith("#") || !LENIENT.test(line)) return;
3121
+ let j = i - 1;
3122
+ while (j >= 0 && (!lines[j].trim() || lines[j].trim().startsWith("#") || indentOf(lines[j]) >= indentOf(line))) j--;
3123
+ const identity = j >= 0 ? lines[j].trim() : "";
3124
+ const entry = found.get(identity);
3125
+ if (entry) entry.count++;
3126
+ else found.set(identity, { count: 1, line: i + 1 });
3127
+ });
3128
+ return found;
3129
+ }
3048
3130
  function severityOf(value) {
3049
3131
  return /^(?:error|2)$/.test(value) ? 2 : /^(?:warn|1)$/.test(value) ? 1 : 0;
3050
3132
  }
@@ -3071,21 +3153,29 @@ function analyzeGuardFile(file, oldText, newText, commit) {
3071
3153
  }
3072
3154
  }
3073
3155
  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)) {
3156
+ const rules = (text) => {
3157
+ const found = /* @__PURE__ */ new Map();
3158
+ for (const match of text.matchAll(/["']([@\w/.-]+)["']\s*:\s*(?:\[\s*)?["']?(error|warn|off|[012])["']?/g)) {
3159
+ const level = severityOf(match[2]);
3160
+ const known = found.get(match[1]);
3161
+ if (!known || level > known.level) found.set(match[1], { level, index: match.index ?? 0 });
3162
+ }
3163
+ return found;
3164
+ };
3165
+ const before = rules(oldText), after = rules(newText);
3166
+ for (const [name, now] of after) {
3077
3167
  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");
3168
+ const replaced = [...after].some(([other, setting]) => other !== name && other.endsWith(`/${name}`) && setting.level === 2);
3169
+ if (previous && previous.level === 2 && now.level < 2 && !replaced) add(now.index, `${name}: error \u2192 ${now.level ? "warn" : "off"}`, "Lint rule downgraded");
3079
3170
  }
3080
3171
  }
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");
3172
+ if (CI_FILE.test(file)) {
3173
+ const before = lenientSteps(oldText), existing = new Set(oldText.split(/\r?\n/).map((line) => line.trim()));
3174
+ for (const [identity, now] of lenientSteps(newText)) {
3175
+ if (identity && existing.has(identity) && now.count > (before.get(identity)?.count ?? 0)) {
3176
+ out.push(finding("guard-weakened", file, now.line, "continue-on-error: true", "CI step is now allowed to fail", commit));
3177
+ break;
3178
+ }
3089
3179
  }
3090
3180
  }
3091
3181
  if (/(?:^|\/)package\.json$/i.test(file)) {
@@ -3170,6 +3260,7 @@ async function compareSourceChanges(options) {
3170
3260
  let totalBytes = 0;
3171
3261
  const tamperingFiles = [];
3172
3262
  const guardFindings = [];
3263
+ const ciCounts = [];
3173
3264
  let productionChanged = false;
3174
3265
  const readText = async (mode, oid) => {
3175
3266
  if (mode === "000000") return null;
@@ -3195,6 +3286,8 @@ async function compareSourceChanges(options) {
3195
3286
  const oldText = await readText(change.oldMode, change.oldOid);
3196
3287
  const newText = await readText(change.newMode, change.newOid);
3197
3288
  guardFindings.push(...analyzeGuardFile(change.newPath, oldText, newText, refs.headSha));
3289
+ const ci = ciTestSteps(change.newPath, oldText, newText);
3290
+ if (ci) ciCounts.push(ci);
3198
3291
  result.filesInspected++;
3199
3292
  } catch (error) {
3200
3293
  assertNotAborted(options.signal);
@@ -3252,7 +3345,7 @@ async function compareSourceChanges(options) {
3252
3345
  await staged?.verify();
3253
3346
  result.complete = result.skippedFiles.length === 0;
3254
3347
  const rules = policy.config.rules ?? {};
3255
- result.infractions.push(...[...resolveTampering(tamperingFiles, productionChanged), ...guardFindings].filter((finding2) => rules[finding2.type] !== "off"));
3348
+ result.infractions.push(...[...resolveTampering(tamperingFiles, productionChanged), ...guardFindings, ...resolveCiTestSteps(ciCounts, refs.headSha)].filter((finding2) => rules[finding2.type] !== "off"));
3256
3349
  const exceptions = applyExceptions(result.infractions, options.ignoreExceptions ? [] : policy.config.exceptions, /* @__PURE__ */ new Date(), existingCounts);
3257
3350
  result.infractions = exceptions.findings;
3258
3351
  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-NSGANOZ5.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-NSGANOZ5.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.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",
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
  },