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 +7 -2
- package/dist/{chunk-52FUQ4EP.js → chunk-IKXGHCWB.js} +117 -17
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
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
|
-
- **
|
|
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
|
|
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
|
|
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
|
|
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
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-roast",
|
|
3
|
-
"version": "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",
|