agent-roast 0.16.0 → 0.17.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
@@ -78,7 +78,7 @@ Policy is read from the **base** branch, so a pull request cannot switch off the
78
78
 
79
79
  | Rule | Fires when | By default |
80
80
  | --- | --- | :-: |
81
- | `assertion-weakened` | an exact check is replaced by a weaker one: `toBe(42)` → `toBeDefined()`, `assert x == 1` → `assert x` | blocks |
81
+ | `assertion-weakened` | an exact check is replaced by a weaker one: `toBe(42)` → `toBeDefined()`, `toBe(2)` → `toBeLessThanOrEqual(2)`, `toEqual(x)` → `toMatchObject(x)`, `assert x == 1` → `assert x` | blocks |
82
82
  | `guard-weakened` | a coverage threshold is lowered, `strict` is switched off, a lint rule is downgraded, a CI step may fail, a workflow stops running tests, a `conftest.py` hook sets a report to `passed` | blocks |
83
83
  | `test-skip` | a test is switched off or expected to fail: `it.skip`, `@pytest.mark.skip`, `@pytest.mark.xfail`, `it.fails`, `test.fixme`, `t.Skip`, `#[ignore]`, `@Disabled`, `[Ignore]`, `xit`, ... | blocks |
84
84
  | `test-focus`, `trivial-assert` | `.only` / `fit`; `expect(true).toBe(true)` and its relatives in ten languages; a test that asserts on a function it mocked itself | blocks |
@@ -111,7 +111,7 @@ old debt with a baseline: [docs/reference.md](docs/reference.md#configuration).
111
111
  Measured, with the denominators and the caveats in [docs/benchmarks.md](docs/benchmarks.md). **Weakened checks are rare, mostly
112
112
  deliberate, and not specific to AI. agent-roast is a tripwire that stays silent until a check gets weaker.**
113
113
 
114
- - **119 of 119** hand-labeled cheating edits are caught and **84 of 84** harmless look-alikes (reformats, moves, raised
114
+ - **126 of 126** hand-labeled cheating edits are caught and **90 of 90** harmless look-alikes (reformats, moves, raised
115
115
  thresholds, environment-gated skips) stay silent. The cases were written with the detector, so they guard against regressions;
116
116
  they do not prove accuracy. One gap is documented and still open.
117
117
  - **12,645 commits** that touch tests or CI/config in 166 public repositories: blocking rules fired on 0.3% of AI-signed commits
@@ -120,6 +120,11 @@ deliberate, and not specific to AI. agent-roast is a tripwire that stays silent
120
120
  checks more often.
121
121
  - **About one finding in five was a false alarm**, and most of the rest were deliberate, explained changes a reviewer should
122
122
  see rather than mistakes. The labels come from one reader (an AI assistant), so treat them as a first estimate.
123
+ - **9,732 commits compared in full** from 164 public repositories that use coding agents (5,454 of them AI-signed) were reviewed again for the
124
+ last two releases, and every new finding was read. 0.15.0's rules reported 30 new findings there and 7 were false alarms (fixed in 0.16.0);
125
+ 0.17.0's rule for an exact check turned into a bound reported 18 in 14 commits, all of them loosened checks, after two false alarms were
126
+ fixed. One reader, and a convenience sample ([section 7](docs/benchmarks.md#7-ai-signed-commits-and-a-test-that-checks-its-own-mock),
127
+ [section 8](docs/benchmarks.md#8-an-exact-check-turned-into-a-bound)).
123
128
  - `replay` ran on 179 public repositories that use coding agents: no commit would have been blocked in 149 of them.
124
129
  - The rules for **eight more languages** (Go, Rust, Java, Kotlin, C#, Ruby, PHP, Swift) ran on the newest 300 commits of 39
125
130
  repositories and every finding was read: 26 of 99 were skips that depend on the environment, now fixed; of the 73 left, 56 are
@@ -788,9 +788,9 @@ var RULE_GUIDES = {
788
788
  },
789
789
  "assertion-weakened": {
790
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.",
791
+ why: "Swapping an exact assertion for toBeDefined, a truthiness check, `is not None`, a bound (`>= 2` in place of `== 2`) or toMatchObject in place of toEqual keeps the test green while it verifies far less.",
792
792
  suggestion: "Restore the exact check, or assert the specific value the code should produce.",
793
- example: "expect(total).toBe(42) \u2192 expect(total).toBeDefined() (restore the exact check)"
793
+ example: "expect(total).toBe(42) \u2192 expect(total).toBeDefined() (restore the exact check); expect(cost).toBe(2) \u2192 expect(cost).toBeLessThanOrEqual(2) (pin the value again)"
794
794
  },
795
795
  "assertion-removed": {
796
796
  title: "Test checks less than before",
@@ -3673,10 +3673,66 @@ var BOUND_JS = /\.toBeGreaterThan\s*\(\s*(?:0|-1)\s*\)|\.toBeGreaterThanOrEqual\
3673
3673
  var ZERO_JS = /\.(?:toBe|toEqual|toStrictEqual|toHaveLength|toHaveBeenCalledTimes)\s*\(\s*(?:0|\[\s*\]|\{\s*\}|null|undefined|false|"")\s*\)/;
3674
3674
  var WEAK_PY = /^\s*assert\s+[\w.\[\]]+\s*(?:,.*)?$|^\s*assert\s+.+\bis\s+not\s+None\s*(?:,.*)?$|^\s*assert\s+True\b/;
3675
3675
  var BOUND_PY = /^\s*assert\s+.+(?:>\s*0|>=\s*[01])\s*(?:,.*)?$/;
3676
- var EMPTY_PY = /==\s*(?:0|\[\s*\]|\{\s*\}|""|''|None|False)\b|\bis\s+None\b|\bnot\b|!=\s*(?:0|None)\b/;
3676
+ var EMPTY_PY = /==\s*(?:0(?:\.0+)?(?![\w.])|\[\s*\]|\{\s*\}|""|''|\(\s*\)|(?:set|dict|list|tuple|frozenset)\(\s*\)|None\b|False\b)|\bis\s+None\b|\bnot\b|!=\s*(?:0|None)\b/;
3677
3677
  var PY_SPECIFIC = /==|!=|<=|>=|\bin\b|\bis\b|\bisinstance\s*\(|\.startswith\s*\(|\.endswith\s*\(|\bcallable\s*\(/;
3678
3678
  var JS_SPECIFIC = /\.(?:toBe|toEqual|toStrictEqual|toHaveLength|toContain|toContainEqual|toMatch|toMatchObject|toHaveProperty|toHaveBeenCalledWith|toHaveBeenLastCalledWith|toHaveBeenNthCalledWith|toBeCalledWith|toHaveBeenCalledTimes|toHaveBeenCalledOnce|toHaveTextContent|toHaveAttribute|toHaveValue|toThrow\w*|toBeGreaterThan\w*|toBeLessThan\w*)\s*\(/;
3679
3679
  var THROWING_QUERY = /\b(?:get|find)(?:All)?By\w*\s*\(/;
3680
+ function checkedExpression(assertion, language) {
3681
+ const squash = (text) => text.replace(/\s+/g, "").replace(/^len\((.*)\)$/, "$1").replace(/\.length$/, "");
3682
+ if (language === "py") {
3683
+ const operand = /^\s*assert\s+(?:not\s+)?(.+?)\s*(?:==|!=|>=|<=|>|<|\bin\b|\bis\b)/.exec(assertion)?.[1] ?? /\bself\s*\.\s*assert\w*\s*\(\s*([^,]+?)\s*,/.exec(assertion)?.[1];
3684
+ return operand ? squash(operand) : null;
3685
+ }
3686
+ const start = assertion.search(/(?<![\w$.])expect\s*\(/);
3687
+ if (start >= 0) {
3688
+ const open2 = assertion.indexOf("(", start);
3689
+ let depth = 0;
3690
+ for (let i = open2; i < assertion.length; i++) {
3691
+ if (assertion[i] === "(") depth++;
3692
+ else if (assertion[i] === ")" && --depth === 0) return squash(assertion.slice(open2 + 1, i));
3693
+ }
3694
+ return null;
3695
+ }
3696
+ const node = /assert\s*\.\s*(?:equal|strictEqual|deepEqual|deepStrictEqual|ok)\s*\(\s*([^,)]+?)\s*(?:,|\)|>=|<=|>|<)/.exec(assertion)?.[1];
3697
+ return node ? squash(node) : null;
3698
+ }
3699
+ function matcherArgument(assertion, name) {
3700
+ const open2 = new RegExp(`\\.\\s*${name}\\s*\\(`).exec(assertion);
3701
+ if (!open2) return null;
3702
+ let depth = 1;
3703
+ for (let i = open2.index + open2[0].length; i < assertion.length; i++) {
3704
+ if ("([{".includes(assertion[i])) depth++;
3705
+ else if (")]}".includes(assertion[i]) && --depth === 0) return assertion.slice(open2.index + open2[0].length, i).replace(/\s+/g, "");
3706
+ }
3707
+ return null;
3708
+ }
3709
+ function isExact(assertion, language) {
3710
+ if (language === "js") {
3711
+ return !/\.\s*not\s*\./.test(assertion) && (/\.(?:toBe|toEqual|toStrictEqual|toHaveLength)\s*\(/.test(assertion) || /assert\s*\.\s*(?:equal|strictEqual|deepEqual|deepStrictEqual)\s*\(/.test(assertion));
3712
+ }
3713
+ if (language === "py") return /^\s*assert\s+.+?\s*==\s*\S/.test(assertion) || /\bself\s*\.\s*assertEqual\s*\(/.test(assertion);
3714
+ return false;
3715
+ }
3716
+ function relaxes(oldAssertion, newAssertion, language, siblings = []) {
3717
+ const subject = checkedExpression(oldAssertion, language);
3718
+ if (!subject || subject !== checkedExpression(newAssertion, language)) return false;
3719
+ if (siblings.some((other) => isExact(other, language) && checkedExpression(other, language) === subject)) return false;
3720
+ if (language === "js") {
3721
+ if (/\.\s*not\s*\./.test(oldAssertion) || /\.\s*not\s*\./.test(newAssertion)) return false;
3722
+ const exact = /\.(?:toBe|toEqual|toStrictEqual|toHaveLength)\s*\(\s*(-?\d+(?:\.\d+)?)\s*\)/.exec(oldAssertion)?.[1] ?? /assert\s*\.\s*(?:equal|strictEqual|deepEqual|deepStrictEqual)\s*\([^,]+,\s*(-?\d+(?:\.\d+)?)\s*[,)]/.exec(oldAssertion)?.[1];
3723
+ const bound = /\.(?:toBeGreaterThan|toBeGreaterThanOrEqual|toBeLessThan|toBeLessThanOrEqual)\s*\(/.test(newAssertion) || /assert\s*\.\s*ok\s*\([^;]*(?:>=|<=|>|<)/.test(newAssertion);
3724
+ if (exact !== void 0 && Number(exact) !== 0 && bound) return true;
3725
+ const same = matcherArgument(oldAssertion, "toEqual") ?? matcherArgument(oldAssertion, "toStrictEqual");
3726
+ return same !== null && same !== "" && same === matcherArgument(newAssertion, "toMatchObject");
3727
+ }
3728
+ if (language === "py") {
3729
+ if (EMPTY_PY.test(oldAssertion)) return false;
3730
+ const exact = /^\s*assert\s+.+?\s*==\s*\S/.test(oldAssertion) || /\bself\s*\.\s*assertEqual\s*\(/.test(oldAssertion);
3731
+ const bound = /^\s*assert\s+.+?\s*(?:>=|<=|>|<)\s*\S/.test(newAssertion) && !/==/.test(newAssertion.replace(/[<>]=/g, "")) || /\bself\s*\.\s*assert(?:Greater|Less)(?:Equal)?\s*\(/.test(newAssertion);
3732
+ return exact && bound;
3733
+ }
3734
+ return false;
3735
+ }
3680
3736
  function pinsLess(oldAssertion, newAssertion, language) {
3681
3737
  if (language === "js") {
3682
3738
  const was = /\.toThrow(?:Error)?\s*\(\s*([^)\s][^)]*)\)/.exec(oldAssertion);
@@ -3698,7 +3754,7 @@ function pinsLess(oldAssertion, newAssertion, language) {
3698
3754
  return false;
3699
3755
  }
3700
3756
  function isWeaker(oldAssertion, newAssertion, language) {
3701
- if (pinsLess(oldAssertion, newAssertion, language)) return true;
3757
+ if (pinsLess(oldAssertion, newAssertion, language) || relaxes(oldAssertion, newAssertion, language)) return true;
3702
3758
  if (language === "js") {
3703
3759
  if (THROWING_QUERY.test(newAssertion) || !JS_SPECIFIC.test(oldAssertion) || WEAK_JS.test(oldAssertion) || BOUND_JS.test(oldAssertion)) return false;
3704
3760
  return WEAK_JS.test(newAssertion) || BOUND_JS.test(newAssertion) && !ZERO_JS.test(oldAssertion);
@@ -3724,6 +3780,34 @@ var HELPER_CALL = /(?<![\w$])((?:expect|assert|verify|check|require)[A-Z]\w*|_{0
3724
3780
  function finding(type, file, line, snippet, reason, commit) {
3725
3781
  return { type, file, lineNumber: Math.max(1, line), lineSnippet: snippet, commitHash: commit, commitDate: "", reason, suggestion: RULE_GUIDES[type].suggestion };
3726
3782
  }
3783
+ function alignAssertions(before, after) {
3784
+ const table = Array.from({ length: before.length + 1 }, () => new Array(after.length + 1).fill(0));
3785
+ for (let i = before.length - 1; i >= 0; i--) {
3786
+ for (let j = after.length - 1; j >= 0; j--) table[i][j] = before[i] === after[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
3787
+ }
3788
+ const pairs = /* @__PURE__ */ new Map();
3789
+ for (let i = 0, j = 0; i < before.length && j < after.length; ) {
3790
+ if (before[i] === after[j]) {
3791
+ pairs.set(i, j);
3792
+ i++;
3793
+ j++;
3794
+ } else if (table[i + 1][j] >= table[i][j + 1]) i++;
3795
+ else j++;
3796
+ }
3797
+ return pairs;
3798
+ }
3799
+ function unmatchedIndexes(from, against) {
3800
+ const budget = /* @__PURE__ */ new Map();
3801
+ for (const assertion of against) budget.set(assertion, (budget.get(assertion) ?? 0) + 1);
3802
+ return from.map((assertion, i) => ({ assertion, i })).filter(({ assertion }) => {
3803
+ const available = budget.get(assertion) ?? 0;
3804
+ if (available > 0) {
3805
+ budget.set(assertion, available - 1);
3806
+ return false;
3807
+ }
3808
+ return true;
3809
+ }).map(({ i }) => i);
3810
+ }
3727
3811
  function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
3728
3812
  const language = languageOf(newText === null ? oldPath : newPath);
3729
3813
  if (!language || !(isTestFile(newPath) || isTestFile(oldPath))) return EMPTY;
@@ -3765,6 +3849,34 @@ function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
3765
3849
  if (current.parametrized && !old.parametrized) continue;
3766
3850
  const snapshot = (test) => test.raw.some((statement) => /\.toMatch(?:Inline)?Snapshot\b|\.matchSnapshot\b|\bsnapshot\s*\(/.test(statement));
3767
3851
  if (snapshot(current) && !snapshot(old)) continue;
3852
+ if (old.assertions.length && current.assertions.length && current.assertions.length !== old.assertions.length && old.assertions.length * current.assertions.length <= 25e4) {
3853
+ const aligned = alignAssertions(old.assertions, current.assertions);
3854
+ const fresh = unmatchedIndexes(current.assertions, old.assertions);
3855
+ const gone = unmatchedIndexes(old.assertions, current.assertions).filter((index) => (newTotals.get(old.assertions[index]) ?? 0) < (oldTotals.get(old.assertions[index]) ?? 0));
3856
+ for (const index of gone) {
3857
+ const was = checkedExpression(old.raw[index], language);
3858
+ if (was === null) continue;
3859
+ let low = -1, high = current.assertions.length;
3860
+ for (const [from, to] of aligned) {
3861
+ if (from < index) low = Math.max(low, to);
3862
+ else if (from > index) high = Math.min(high, to);
3863
+ }
3864
+ const successors = fresh.filter((candidate) => candidate > low && candidate < high);
3865
+ const siblings = successors.map((candidate) => current.raw[candidate]);
3866
+ const partner = successors.find((candidate) => checkedExpression(current.raw[candidate], language) === was && relaxes(old.raw[index], current.raw[candidate], language, siblings));
3867
+ if (partner !== void 0) {
3868
+ result.immediate.push(finding(
3869
+ "assertion-weakened",
3870
+ newPath,
3871
+ current.line,
3872
+ `${old.name}: ${old.raw[index].slice(0, 80)} \u2192 ${current.raw[partner].slice(0, 80)}`,
3873
+ "Assertion replaced by a weaker check",
3874
+ commit
3875
+ ));
3876
+ break;
3877
+ }
3878
+ }
3879
+ }
3768
3880
  if (current.assertions.length < old.assertions.length) {
3769
3881
  if ([...current.text.matchAll(HELPER_CALL)].some((call) => !(oldText ?? "").includes(call[1]))) continue;
3770
3882
  const budget = tally([current]);
@@ -3793,19 +3905,7 @@ ${addedText}`;
3793
3905
  continue;
3794
3906
  }
3795
3907
  if (current.assertions.length === old.assertions.length && old.assertions.length > 0) {
3796
- const left = (from, against) => {
3797
- const budget = /* @__PURE__ */ new Map();
3798
- for (const assertion of against) budget.set(assertion, (budget.get(assertion) ?? 0) + 1);
3799
- return from.map((assertion, i) => ({ assertion, i })).filter(({ assertion }) => {
3800
- const available = budget.get(assertion) ?? 0;
3801
- if (available > 0) {
3802
- budget.set(assertion, available - 1);
3803
- return false;
3804
- }
3805
- return true;
3806
- }).map(({ i }) => i);
3807
- };
3808
- const goneIndexes = left(old.assertions, current.assertions), freshIndexes = left(current.assertions, old.assertions);
3908
+ const goneIndexes = unmatchedIndexes(old.assertions, current.assertions), freshIndexes = unmatchedIndexes(current.assertions, old.assertions);
3809
3909
  const changed = goneIndexes.map((index, k) => ({ before: old.assertions[index], after: current.assertions[freshIndexes[k]], rawBefore: old.raw[index], rawAfter: current.raw[freshIndexes[k]] })).filter((pair) => pair.after !== void 0);
3810
3910
  const weaker = changed.find((pair) => isWeaker(pair.rawBefore, pair.rawAfter, language));
3811
3911
  if (weaker) {
package/dist/cli.js CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  terminalText,
39
39
  uninstallHooks,
40
40
  writeBaseline
41
- } from "./chunk-52FUQ4EP.js";
41
+ } from "./chunk-IKXGHCWB.js";
42
42
 
43
43
  // src/cli.ts
44
44
  import { Command } from "commander";
package/dist/index.js CHANGED
@@ -91,7 +91,7 @@ import {
91
91
  unquoteGitPath,
92
92
  validateExceptions,
93
93
  writeBaseline
94
- } from "./chunk-52FUQ4EP.js";
94
+ } from "./chunk-IKXGHCWB.js";
95
95
  export {
96
96
  AGENT_LABELS,
97
97
  CONFIG_FILENAMES,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-roast",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Stops AI coding agents (and rushed humans) from turning a build green by weakening tests, coverage gates or CI. Hook for Claude Code, Codex and Cursor, pre-commit, GitHub Action. Offline.",
5
5
  "mcpName": "io.github.Nari-Ab/agent-roast",
6
6
  "type": "module",