agent-roast 0.17.0 → 0.18.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 +11 -5
- package/dist/{chunk-IKXGHCWB.js → chunk-2KWC2IAP.js} +213 -31
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -80,7 +80,7 @@ Policy is read from the **base** branch, so a pull request cannot switch off the
|
|
|
80
80
|
| --- | --- | :-: |
|
|
81
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
|
-
| `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 |
|
|
83
|
+
| `test-skip` | a test that was already there 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`, ... (a new test that is skipped is not reported) | 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 |
|
|
85
85
|
| `assertion-removed`, `test-removed`, `expectation-rewritten` | a test loses assertions, disappears, or its expected value is edited next to production code | warns |
|
|
86
86
|
| `swallowed-error`, `type-escape`, `stub-abandonment` | `catch {}` / `except: pass`, `as any` / `# type: ignore` / `# pragma: no cover` / `istanbul ignore`, `throw new Error("Not implemented")` | warns |
|
|
@@ -109,17 +109,23 @@ old debt with a baseline: [docs/reference.md](docs/reference.md#configuration).
|
|
|
109
109
|
## Does it work?
|
|
110
110
|
|
|
111
111
|
Measured, with the denominators and the caveats in [docs/benchmarks.md](docs/benchmarks.md). **Weakened checks are rare, mostly
|
|
112
|
-
deliberate, and not specific to AI. agent-roast is a tripwire that
|
|
112
|
+
deliberate, and not specific to AI. agent-roast is a tripwire that asks about them. On repositories it had not seen, about half of its
|
|
113
|
+
blocks were questions with a harmless answer.**
|
|
113
114
|
|
|
114
|
-
- **
|
|
115
|
+
- **131 of 131** hand-labeled cheating edits are caught and **110 of 110** harmless look-alikes (reformats, moves, raised
|
|
115
116
|
thresholds, environment-gated skips) stay silent. The cases were written with the detector, so they guard against regressions;
|
|
116
117
|
they do not prove accuracy. One gap is documented and still open.
|
|
117
118
|
- **12,645 commits** that touch tests or CI/config in 166 public repositories: blocking rules fired on 0.3% of AI-signed commits
|
|
118
119
|
(Claude, Copilot, Cursor, Codex, Devin, aider) and on 0.3% of the others. Inside the same repositories the odds for an
|
|
119
120
|
AI-signed commit were 0.81 times those of another commit (95% CI 0.40–1.67). There is no sign that AI-signed commits weaken
|
|
120
121
|
checks more often.
|
|
121
|
-
- **
|
|
122
|
-
|
|
122
|
+
- **On repositories it was not written from, about half of the blocked commits were false alarms.** Four samples of 39 or 40 public
|
|
123
|
+
repositories (158 in all, 44,878 commits) were replayed and every blocked commit was read. 0.17.0 blocked 74 of 11,604 commits in the
|
|
124
|
+
first sample and 65 of 10,726 in the fourth, and 69% of both were false alarms, mostly a skip on a test that the same commit adds.
|
|
125
|
+
This version, frozen before the fourth sample was drawn, blocked 41 of its 10,726 commits: 20 changed a check a reviewer should see
|
|
126
|
+
(an assertion that got weaker, a gate that was relaxed, a test switched off or left focused) and 21 were false alarms, nine of them in
|
|
127
|
+
one repository. Most of the rest, in the earlier measurements too, were deliberate and explained. One reader (an AI assistant) sorted
|
|
128
|
+
them, so treat the figures as a first estimate ([section 9](docs/benchmarks.md#9-repositories-nobody-had-tuned-on)).
|
|
123
129
|
- **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
130
|
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
131
|
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
|
|
@@ -1623,6 +1623,8 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1623
1623
|
while (previous >= 0 && /\s/.test(source[previous])) previous--;
|
|
1624
1624
|
if (source[previous] === ".") continue;
|
|
1625
1625
|
if (match[2] && !isTestFile(file.path)) continue;
|
|
1626
|
+
if (match[2] && callArguments(source, raw, match.index + match[0].length)?.length === 0) continue;
|
|
1627
|
+
if (match[2] && previous >= 0 && (/[(,=:?[&|!+\-*%<]/.test(source[previous]) || /(?:await|return|yield|typeof|void)$/.test(source.slice(Math.max(0, previous - 6), previous + 1)))) continue;
|
|
1626
1628
|
if (/\bfunction$/.test(source.slice(Math.max(0, previous - 8), previous + 1))) continue;
|
|
1627
1629
|
if (source[match.index + match[0].length] === "" && !/\.\s*each\s*$/.test(match[3])) continue;
|
|
1628
1630
|
if ((match[1] === "skip" || match[1] === "fixme" || match[1] === "fail") && !match[3] && isConditionalSkip(source, raw, match.index + match[0].length)) continue;
|
|
@@ -3224,6 +3226,8 @@ function javascript(text) {
|
|
|
3224
3226
|
const end = closing(code, open2);
|
|
3225
3227
|
const args = end > 0 ? raw.slice(open2, end) : "";
|
|
3226
3228
|
if (/requireActual|importActual|importOriginal/.test(args)) continue;
|
|
3229
|
+
const loader = /^\(\s*(['"`])[^'"`\n]+\1\s*,\s*(?:async\s+)?(?:function\s*[\w$]*\s*)?\(\s*([A-Za-z_$][\w$]*)\s*[,)]/.exec(args)?.[2];
|
|
3230
|
+
if (loader && new RegExp(`(?<![\\w$.])${loader.replace(/\$/g, "\\$")}\\s*(?:<[^\\n]*?>)?\\s*\\(`).test(args.slice(args.indexOf(loader) + loader.length))) continue;
|
|
3227
3231
|
const factory = /^\(\s*(['"`])[^'"`\n]+\1\s*,/.test(args) && !/,\s*\{\s*spy\s*:\s*true\b/.test(args);
|
|
3228
3232
|
if (!mocked.has(specifier)) mocked.set(specifier, { index: match.index, fake: factory });
|
|
3229
3233
|
}
|
|
@@ -3339,6 +3343,15 @@ function python(text) {
|
|
|
3339
3343
|
}
|
|
3340
3344
|
return [line, end];
|
|
3341
3345
|
};
|
|
3346
|
+
const headerLine = (line) => {
|
|
3347
|
+
for (let k = line; k >= 0 && k > line - 8; k--) {
|
|
3348
|
+
if (!/^\s*with\b/.test(codeLines2[k] ?? "")) continue;
|
|
3349
|
+
const head = codeLines2.slice(k, line).join("\n");
|
|
3350
|
+
const open2 = (head.match(/[([{]/g) ?? []).length - (head.match(/[)\]}]/g) ?? []).length;
|
|
3351
|
+
return k === line || open2 > 0 || codeLines2.slice(k, line).every((text2) => /\\\s*$/.test(text2)) ? k : null;
|
|
3352
|
+
}
|
|
3353
|
+
return null;
|
|
3354
|
+
};
|
|
3342
3355
|
const patched = /* @__PURE__ */ new Map();
|
|
3343
3356
|
const patchCall = /(?<![\w.])(?:(?:mock|unittest\s*\.\s*mock)\s*\.\s*)?patch(\s*\.\s*object)?\s*\(|(?<![\w.])mocker\s*\.\s*patch(\s*\.\s*object)?\s*\(|(?<![\w.])monkeypatch\s*\.\s*setattr\s*\(/g;
|
|
3344
3357
|
for (const match of code.matchAll(patchCall)) {
|
|
@@ -3352,7 +3365,11 @@ function python(text) {
|
|
|
3352
3365
|
const key = target ? target[2] : parts ? `${expandObject(parts[1])}.${parts[3]}` : void 0;
|
|
3353
3366
|
if (!key || !DOTTED.test(key)) continue;
|
|
3354
3367
|
const line = lineIndex(match.index);
|
|
3355
|
-
|
|
3368
|
+
const fixture = /^(?:mocker|monkeypatch)/.test(match[0]);
|
|
3369
|
+
const header = headerLine(line);
|
|
3370
|
+
const inForce = fixture || /^\s*@/.test(codeLines2[line] ?? "") || header !== null || /^\s*\.\s*start\s*\(/.test(code.slice(end + 1)) || /enter_context\s*\(\s*$/.test(code.slice(Math.max(0, match.index - 40), match.index));
|
|
3371
|
+
if (!inForce) continue;
|
|
3372
|
+
patched.set(key, [...patched.get(key) ?? [], { index: match.index, end, object: parts ? parts[1] : null, scope: (header !== null ? blockOf(header) : null) ?? blockOf(line) ?? scopeOf(line) }]);
|
|
3356
3373
|
}
|
|
3357
3374
|
if (!patched.size) return [];
|
|
3358
3375
|
const found = /* @__PURE__ */ new Map();
|
|
@@ -3372,6 +3389,15 @@ function python(text) {
|
|
|
3372
3389
|
const at = lineIndex(match.index);
|
|
3373
3390
|
const patch = patched.get(key)?.find((item) => item.scope && within(item.scope, at) && (at >= lineIndex(item.index) || /^\s*@/.test(codeLines2[lineIndex(item.index)] ?? "")));
|
|
3374
3391
|
if (!patch) continue;
|
|
3392
|
+
if (patch.object) {
|
|
3393
|
+
const handed = new RegExp(`(?<![\\w.])(?!(?:[\\w.]*\\.)?(?:patch|object|setattr)\\b)[A-Za-z_][\\w.]*\\(\\s*(?:[^()]*,\\s*)?${patch.object.replace(/[.$]/g, "\\$&")}\\s*[,)]`);
|
|
3394
|
+
if (handed.test(code.slice(patch.end + 1, match.index))) continue;
|
|
3395
|
+
}
|
|
3396
|
+
if (patch.object) {
|
|
3397
|
+
const attribute = key.slice(key.lastIndexOf(".") + 1);
|
|
3398
|
+
const receiver = new RegExp(`(?<![\\w.])${patch.object.replace(/[.$]/g, "\\$&")}\\s*\\.\\s*(?!${attribute}\\b)\\w+\\s*\\(`);
|
|
3399
|
+
if (receiver.test(code.slice(patch.end + 1, match.index))) continue;
|
|
3400
|
+
}
|
|
3375
3401
|
const line = lineOf2(patch.index);
|
|
3376
3402
|
found.set(key, {
|
|
3377
3403
|
key,
|
|
@@ -3593,20 +3619,113 @@ function testBodies(text, language) {
|
|
|
3593
3619
|
};
|
|
3594
3620
|
});
|
|
3595
3621
|
}
|
|
3622
|
+
function maskRegexLiterals(text) {
|
|
3623
|
+
let out = "", quote = "";
|
|
3624
|
+
for (let i = 0; i < text.length; i++) {
|
|
3625
|
+
const char = text[i];
|
|
3626
|
+
if (quote) {
|
|
3627
|
+
out += char;
|
|
3628
|
+
if (char === "\\" && i + 1 < text.length) out += text[++i];
|
|
3629
|
+
else if (char === quote) quote = "";
|
|
3630
|
+
continue;
|
|
3631
|
+
}
|
|
3632
|
+
if (char === '"' || char === "'" || char === "`") {
|
|
3633
|
+
quote = char;
|
|
3634
|
+
out += char;
|
|
3635
|
+
continue;
|
|
3636
|
+
}
|
|
3637
|
+
if (char === "/" && text[i + 1] !== "/" && text[i + 1] !== "*") {
|
|
3638
|
+
let p = out.length - 1;
|
|
3639
|
+
while (p >= 0 && /\s/.test(out[p])) p--;
|
|
3640
|
+
if (p < 0 || /[(,=:[!&|?{};+\-*%~^]/.test(out[p]) || /(?:return|typeof|case)$/.test(out.slice(0, p + 1))) {
|
|
3641
|
+
let j = i + 1, inClass = false, closed = false;
|
|
3642
|
+
for (; j < text.length; j++) {
|
|
3643
|
+
const c = text[j];
|
|
3644
|
+
if (c === "\n") break;
|
|
3645
|
+
if (c === "\\") {
|
|
3646
|
+
j++;
|
|
3647
|
+
continue;
|
|
3648
|
+
}
|
|
3649
|
+
if (c === "[") inClass = true;
|
|
3650
|
+
else if (c === "]") inClass = false;
|
|
3651
|
+
else if (c === "/" && !inClass) {
|
|
3652
|
+
closed = true;
|
|
3653
|
+
break;
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
if (closed) {
|
|
3657
|
+
out += "/" + "_".repeat(j - i - 1) + "/";
|
|
3658
|
+
i = j;
|
|
3659
|
+
continue;
|
|
3660
|
+
}
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
out += char;
|
|
3664
|
+
}
|
|
3665
|
+
return out;
|
|
3666
|
+
}
|
|
3596
3667
|
function balance(line) {
|
|
3597
|
-
const bare = line.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, "");
|
|
3668
|
+
const bare = maskRegexLiterals(line).replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, "").replace(/\\./g, "");
|
|
3598
3669
|
let net = 0;
|
|
3599
3670
|
for (const char of bare) net += "([{".includes(char) ? 1 : ")]}".includes(char) ? -1 : 0;
|
|
3600
3671
|
return net;
|
|
3601
3672
|
}
|
|
3673
|
+
function withoutMessageParens(statement) {
|
|
3674
|
+
let depth = 0, quote = "";
|
|
3675
|
+
for (let i = 0; i < statement.length; i++) {
|
|
3676
|
+
const char = statement[i];
|
|
3677
|
+
if (quote) {
|
|
3678
|
+
if (char === "\\") i++;
|
|
3679
|
+
else if (char === quote) quote = "";
|
|
3680
|
+
} else if (char === '"' || char === "'") quote = char;
|
|
3681
|
+
else if ("([{".includes(char)) depth++;
|
|
3682
|
+
else if (")]}".includes(char)) depth--;
|
|
3683
|
+
else if (char === "," && depth === 0) {
|
|
3684
|
+
const message = statement.slice(i + 1).trim();
|
|
3685
|
+
if (!message.startsWith("(") || !message.endsWith(")")) return statement;
|
|
3686
|
+
let inner = 0, q = "";
|
|
3687
|
+
for (let j = 0; j < message.length; j++) {
|
|
3688
|
+
const c = message[j];
|
|
3689
|
+
if (q) {
|
|
3690
|
+
if (c === "\\") j++;
|
|
3691
|
+
else if (c === q) q = "";
|
|
3692
|
+
} else if (c === '"' || c === "'") q = c;
|
|
3693
|
+
else if (c === "(") inner++;
|
|
3694
|
+
else if (c === ")" && --inner === 0 && j < message.length - 1) return statement;
|
|
3695
|
+
}
|
|
3696
|
+
return `${statement.slice(0, i + 1)} ${message.slice(1, -1).trim()}`;
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
return statement;
|
|
3700
|
+
}
|
|
3701
|
+
function withoutOuterParens(statement) {
|
|
3702
|
+
const start = /^\s*assert\s+/.exec(statement);
|
|
3703
|
+
if (!start) return statement;
|
|
3704
|
+
if (statement[start[0].length] !== "(") return withoutMessageParens(statement);
|
|
3705
|
+
let depth = 0, quote = "";
|
|
3706
|
+
for (let i = start[0].length; i < statement.length; i++) {
|
|
3707
|
+
const char = statement[i];
|
|
3708
|
+
if (quote) {
|
|
3709
|
+
if (char === "\\") i++;
|
|
3710
|
+
else if (char === quote) quote = "";
|
|
3711
|
+
} else if (char === '"' || char === "'") quote = char;
|
|
3712
|
+
else if (char === "(") depth++;
|
|
3713
|
+
else if (char === ")" && --depth === 0) {
|
|
3714
|
+
const rest = statement.slice(i + 1);
|
|
3715
|
+
return /^\s*(?:,|$)/.test(rest) ? withoutMessageParens(`${statement.slice(0, start[0].length)}${statement.slice(start[0].length + 1, i)}${rest}`) : statement;
|
|
3716
|
+
}
|
|
3717
|
+
}
|
|
3718
|
+
return statement;
|
|
3719
|
+
}
|
|
3602
3720
|
function normalize(statement) {
|
|
3603
3721
|
return statement.replace(/\s+/g, "").replace(/'/g, '"').replace(/,([)\]}])/g, "$1").replace(/;$/, "");
|
|
3604
3722
|
}
|
|
3605
3723
|
function splitStatements(text) {
|
|
3606
3724
|
const pieces = [];
|
|
3725
|
+
const scanned = maskRegexLiterals(text);
|
|
3607
3726
|
let quote = "", start = 0;
|
|
3608
|
-
for (let i = 0; i <
|
|
3609
|
-
const char =
|
|
3727
|
+
for (let i = 0; i < scanned.length; i++) {
|
|
3728
|
+
const char = scanned[i];
|
|
3610
3729
|
if (quote) {
|
|
3611
3730
|
if (char === "\\") i++;
|
|
3612
3731
|
else if (char === quote) quote = "";
|
|
@@ -3654,7 +3773,7 @@ function parseTests(text, language) {
|
|
|
3654
3773
|
const clean = piece.replace(CALLBACK_HEADER, "");
|
|
3655
3774
|
if (assertionPattern.test(clean)) {
|
|
3656
3775
|
raw.push(clean);
|
|
3657
|
-
assertions.push(normalize(clean));
|
|
3776
|
+
assertions.push(normalize(language === "py" ? withoutOuterParens(clean) : clean));
|
|
3658
3777
|
} else setupLines.push(clean);
|
|
3659
3778
|
}
|
|
3660
3779
|
}
|
|
@@ -3668,10 +3787,10 @@ function parseTests(text, language) {
|
|
|
3668
3787
|
function skeleton(assertion) {
|
|
3669
3788
|
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, "");
|
|
3670
3789
|
}
|
|
3671
|
-
var WEAK_JS = /\.(?:toBeDefined|toBeTruthy|toBeFalsy
|
|
3790
|
+
var WEAK_JS = /\.(?:toBeDefined|toBeTruthy|toBeFalsy)\s*\(|\.not\s*\.\s*(?:toBeNull|toBeUndefined|toThrow)\s*\(\s*\)|\.toBeGreaterThanOrEqual\s*\(\s*0\s*\)|(?<!\.not)\.(?:toHaveBeenCalled|toBeCalled)\s*\(\s*\)|\.(?:toBe|toEqual|toStrictEqual)\s*\(\s*expect\s*\.\s*(?:anything\s*\(\s*\)|any\s*\(\s*\w+\s*\))\s*\)/;
|
|
3672
3791
|
var BOUND_JS = /\.toBeGreaterThan\s*\(\s*(?:0|-1)\s*\)|\.toBeGreaterThanOrEqual\s*\(\s*[01]\s*\)/;
|
|
3673
3792
|
var ZERO_JS = /\.(?:toBe|toEqual|toStrictEqual|toHaveLength|toHaveBeenCalledTimes)\s*\(\s*(?:0|\[\s*\]|\{\s*\}|null|undefined|false|"")\s*\)/;
|
|
3674
|
-
var WEAK_PY = /^\s*assert\s+[\w
|
|
3793
|
+
var WEAK_PY = /^\s*assert\s+[\w.]+(?:\[[^\]]*\])*\s*(?:,.*)?$|^\s*assert\s+.+\bis\s+not\s+None\s*(?:,.*)?$|^\s*assert\s+True\b/;
|
|
3675
3794
|
var BOUND_PY = /^\s*assert\s+.+(?:>\s*0|>=\s*[01])\s*(?:,.*)?$/;
|
|
3676
3795
|
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
3796
|
var PY_SPECIFIC = /==|!=|<=|>=|\bin\b|\bis\b|\bisinstance\s*\(|\.startswith\s*\(|\.endswith\s*\(|\bcallable\s*\(/;
|
|
@@ -3713,6 +3832,20 @@ function isExact(assertion, language) {
|
|
|
3713
3832
|
if (language === "py") return /^\s*assert\s+.+?\s*==\s*\S/.test(assertion) || /\bself\s*\.\s*assertEqual\s*\(/.test(assertion);
|
|
3714
3833
|
return false;
|
|
3715
3834
|
}
|
|
3835
|
+
function admits(value, operator, limit) {
|
|
3836
|
+
switch (operator) {
|
|
3837
|
+
case ">":
|
|
3838
|
+
return value > limit;
|
|
3839
|
+
case ">=":
|
|
3840
|
+
return value >= limit;
|
|
3841
|
+
case "<":
|
|
3842
|
+
return value < limit;
|
|
3843
|
+
case "<=":
|
|
3844
|
+
return value <= limit;
|
|
3845
|
+
default:
|
|
3846
|
+
return true;
|
|
3847
|
+
}
|
|
3848
|
+
}
|
|
3716
3849
|
function relaxes(oldAssertion, newAssertion, language, siblings = []) {
|
|
3717
3850
|
const subject = checkedExpression(oldAssertion, language);
|
|
3718
3851
|
if (!subject || subject !== checkedExpression(newAssertion, language)) return false;
|
|
@@ -3721,15 +3854,36 @@ function relaxes(oldAssertion, newAssertion, language, siblings = []) {
|
|
|
3721
3854
|
if (/\.\s*not\s*\./.test(oldAssertion) || /\.\s*not\s*\./.test(newAssertion)) return false;
|
|
3722
3855
|
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
3856
|
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)
|
|
3857
|
+
if (exact !== void 0 && Number(exact) !== 0 && bound) {
|
|
3858
|
+
const matcherLimit = /\.(toBeGreaterThan|toBeGreaterThanOrEqual|toBeLessThan|toBeLessThanOrEqual)\s*\(\s*(-?\d+(?:\.\d+)?)\s*\)/.exec(newAssertion);
|
|
3859
|
+
const okLimit = /assert\s*\.\s*ok\s*\([^;]*?(>=|<=|>|<)\s*(-?\d+(?:\.\d+)?)\s*\)/.exec(newAssertion);
|
|
3860
|
+
const operators = { toBeGreaterThan: ">", toBeGreaterThanOrEqual: ">=", toBeLessThan: "<", toBeLessThanOrEqual: "<=" };
|
|
3861
|
+
if (matcherLimit && !admits(Number(exact), operators[matcherLimit[1]], Number(matcherLimit[2]))) return false;
|
|
3862
|
+
if (okLimit && !admits(Number(exact), okLimit[1], Number(okLimit[2]))) return false;
|
|
3863
|
+
return true;
|
|
3864
|
+
}
|
|
3725
3865
|
const same = matcherArgument(oldAssertion, "toEqual") ?? matcherArgument(oldAssertion, "toStrictEqual");
|
|
3726
3866
|
return same !== null && same !== "" && same === matcherArgument(newAssertion, "toMatchObject");
|
|
3727
3867
|
}
|
|
3728
3868
|
if (language === "py") {
|
|
3729
3869
|
if (EMPTY_PY.test(oldAssertion)) return false;
|
|
3730
|
-
const
|
|
3731
|
-
const
|
|
3732
|
-
|
|
3870
|
+
const plain = (text) => !/\b(?:and|or)\b/.test(text.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, ""));
|
|
3871
|
+
const operator = (text) => /^\s*assert\s+(?:not\s+)?.+?\s*(==|!=|>=|<=|>|<|\bin\b|\bis\b)/.exec(text)?.[1] ?? "";
|
|
3872
|
+
const exact = operator(oldAssertion) === "==" && plain(oldAssertion) || /\bself\s*\.\s*assertEqual\s*\(/.test(oldAssertion);
|
|
3873
|
+
const bound = [">=", "<=", ">", "<"].includes(operator(newAssertion)) && plain(newAssertion) || /\bself\s*\.\s*assert(?:Greater|Less)(?:Equal)?\s*\(/.test(newAssertion);
|
|
3874
|
+
if (!exact || !bound) return false;
|
|
3875
|
+
const value = (text) => /==\s*(-?\d+(?:\.\d+)?|[\w.]+(?:\([^)]*\))?)\s*(?:,|$)/.exec(text)?.[1] ?? /\bassertEqual\s*\([^,]+,\s*(-?\d+(?:\.\d+)?|[\w.]+)\s*[,)]/.exec(text)?.[1];
|
|
3876
|
+
const limit = (text) => {
|
|
3877
|
+
const symbol = /^\s*assert\s+(?:not\s+)?.+?\s*(>=|<=|>|<)\s*(-?\d+(?:\.\d+)?|[\w.]+(?:\([^)]*\))?)\s*(?:,|$)/.exec(text);
|
|
3878
|
+
if (symbol) return [symbol[1], symbol[2]];
|
|
3879
|
+
const named = /\bassert(Greater|GreaterEqual|Less|LessEqual)\s*\([^,]+,\s*(-?\d+(?:\.\d+)?|[\w.]+)\s*[,)]/.exec(text);
|
|
3880
|
+
return named ? [{ Greater: ">", GreaterEqual: ">=", Less: "<", LessEqual: "<=" }[named[1]], named[2]] : null;
|
|
3881
|
+
};
|
|
3882
|
+
const pinned = value(oldAssertion), bounded = limit(newAssertion);
|
|
3883
|
+
if (pinned === void 0 || !bounded) return true;
|
|
3884
|
+
const [operator2, edge] = bounded;
|
|
3885
|
+
if (/^-?\d/.test(pinned) && /^-?\d/.test(edge)) return admits(Number(pinned), operator2, Number(edge));
|
|
3886
|
+
return !(pinned === edge && (operator2 === ">" || operator2 === "<"));
|
|
3733
3887
|
}
|
|
3734
3888
|
return false;
|
|
3735
3889
|
}
|
|
@@ -3753,15 +3907,18 @@ function pinsLess(oldAssertion, newAssertion, language) {
|
|
|
3753
3907
|
}
|
|
3754
3908
|
return false;
|
|
3755
3909
|
}
|
|
3756
|
-
function isWeaker(
|
|
3910
|
+
function isWeaker(rawOld, rawNew, language) {
|
|
3911
|
+
const oldAssertion = language === "py" ? withoutOuterParens(rawOld) : rawOld, newAssertion = language === "py" ? withoutOuterParens(rawNew) : rawNew;
|
|
3757
3912
|
if (pinsLess(oldAssertion, newAssertion, language) || relaxes(oldAssertion, newAssertion, language)) return true;
|
|
3758
3913
|
if (language === "js") {
|
|
3759
3914
|
if (THROWING_QUERY.test(newAssertion) || !JS_SPECIFIC.test(oldAssertion) || WEAK_JS.test(oldAssertion) || BOUND_JS.test(oldAssertion)) return false;
|
|
3760
|
-
|
|
3915
|
+
const limit = /\.(?:toBeGreaterThan|toBeGreaterThanOrEqual|toBeLessThan|toBeLessThanOrEqual)\s*\(\s*([^)]*)\)/.exec(oldAssertion)?.[1];
|
|
3916
|
+
return WEAK_JS.test(newAssertion) || BOUND_JS.test(newAssertion) && !ZERO_JS.test(oldAssertion) && (limit === void 0 || /^-?\d+(?:\.\d+)?$/.test(limit.trim()));
|
|
3761
3917
|
}
|
|
3762
3918
|
if (language === "py") {
|
|
3763
3919
|
if (WEAK_PY.test(oldAssertion) || BOUND_PY.test(oldAssertion) || !PY_SPECIFIC.test(oldAssertion)) return false;
|
|
3764
|
-
|
|
3920
|
+
const limit = /(?:>=|<=|>|<)\s*([^\s,)]+)/.exec(oldAssertion)?.[1];
|
|
3921
|
+
return WEAK_PY.test(newAssertion) || BOUND_PY.test(newAssertion) && !EMPTY_PY.test(oldAssertion) && /==|[<>]=?/.test(oldAssertion) && (limit === void 0 || /^-?\d+(?:\.\d+)?$/.test(limit));
|
|
3765
3922
|
}
|
|
3766
3923
|
return false;
|
|
3767
3924
|
}
|
|
@@ -3939,8 +4096,8 @@ function resolveTampering(files, productionChanged) {
|
|
|
3939
4096
|
...productionChanged ? files.flatMap((file) => file.rewritten) : []
|
|
3940
4097
|
];
|
|
3941
4098
|
}
|
|
3942
|
-
function
|
|
3943
|
-
return finding2.type === "test-skip" &&
|
|
4099
|
+
function isTestMarker(finding2) {
|
|
4100
|
+
return finding2.type === "test-skip" && /\.(?:py|[cm]?[jt]sx?|rs|go)$/i.test(finding2.file ?? "");
|
|
3944
4101
|
}
|
|
3945
4102
|
function markedTest(finding2, lines) {
|
|
3946
4103
|
const snippet = finding2.lineSnippet ?? "";
|
|
@@ -3948,10 +4105,10 @@ function markedTest(finding2, lines) {
|
|
|
3948
4105
|
if (/\.py$/i.test(finding2.file ?? "")) {
|
|
3949
4106
|
if (/^\s*pytestmark\b/.test(snippet)) return null;
|
|
3950
4107
|
if (/imperative/.test(finding2.reason)) {
|
|
3951
|
-
const
|
|
4108
|
+
const indent2 = (lines[index] ?? "").search(/\S/);
|
|
3952
4109
|
for (let i = index - 1; i >= 0 && i > index - 200; i--) {
|
|
3953
4110
|
const def = /^(\s*)(?:async\s+)?def\s+(\w+)\s*\(/.exec(lines[i]);
|
|
3954
|
-
if (def && def[1].length <
|
|
4111
|
+
if (def && def[1].length < indent2) return { key: `py:${def[2]}`, title: def[2] };
|
|
3955
4112
|
}
|
|
3956
4113
|
return null;
|
|
3957
4114
|
}
|
|
@@ -3961,31 +4118,55 @@ function markedTest(finding2, lines) {
|
|
|
3961
4118
|
}
|
|
3962
4119
|
return null;
|
|
3963
4120
|
}
|
|
4121
|
+
if (/\.rs$/i.test(finding2.file ?? "")) {
|
|
4122
|
+
for (let i = index; i < lines.length && i < index + 12; i++) {
|
|
4123
|
+
const fn = /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+(\w+)\s*[<(]/.exec(lines[i]);
|
|
4124
|
+
if (fn) return { key: `rs:${fn[1]}`, title: fn[1] };
|
|
4125
|
+
}
|
|
4126
|
+
return null;
|
|
4127
|
+
}
|
|
4128
|
+
if (/\.go$/i.test(finding2.file ?? "")) {
|
|
4129
|
+
for (let i = index; i >= 0 && i > index - 400; i--) {
|
|
4130
|
+
const fn = /^func\s+(?:\([^)]*\)\s*)?((?:Test|Benchmark|Fuzz|Example)\w*)\s*\(/.exec(lines[i]);
|
|
4131
|
+
if (fn) return { key: `go:${fn[1]}`, title: fn[1] };
|
|
4132
|
+
}
|
|
4133
|
+
return null;
|
|
4134
|
+
}
|
|
3964
4135
|
if (/\.each\b/.test(snippet)) return null;
|
|
3965
|
-
const title =
|
|
3966
|
-
|
|
4136
|
+
const title = /\b(?:it|test|describe|suite|xit|xtest|xdescribe|fit|fdescribe)\b[\w$.\s]*\(\s*(['"`])((?:\\.|(?!\1)[^\\\n])+)\1\s*[,)]/.exec(snippet);
|
|
4137
|
+
if (title) return { key: `js:${title[1]}${title[2]}${title[1]}`, title: `${title[1]}${title[2]}${title[1]}` };
|
|
4138
|
+
const indent = (lines[index] ?? "").search(/\S/);
|
|
4139
|
+
if (indent > 0 && /\b(?:test|it)\s*\.\s*(?:skip|fixme|fail)\s*\(/.test(snippet)) {
|
|
4140
|
+
for (let i = index - 1; i >= 0 && i > index - 200; i--) {
|
|
4141
|
+
if (!lines[i].trim() || lines[i].search(/\S/) >= indent) continue;
|
|
4142
|
+
const owner = /^\s*(?:test|it)(?:\s*\.\s*\w+)*\s*\(\s*(['"`])((?:\\.|(?!\1)[^\\\n])+)\1/.exec(lines[i]);
|
|
4143
|
+
return owner ? { key: `js:${owner[1]}${owner[2]}${owner[1]}`, title: `${owner[1]}${owner[2]}${owner[1]}` } : null;
|
|
4144
|
+
}
|
|
4145
|
+
}
|
|
4146
|
+
return null;
|
|
3967
4147
|
}
|
|
3968
|
-
function
|
|
3969
|
-
const markers = after.filter(
|
|
4148
|
+
function newTestMarkers(before, after, oldText, newText) {
|
|
4149
|
+
const markers = after.filter(isTestMarker);
|
|
3970
4150
|
if (!markers.length || oldText === null) return [];
|
|
3971
4151
|
const oldLines = oldText.split(/\r?\n/), newLines = (newText ?? "").split(/\r?\n/);
|
|
3972
|
-
const existed = (test) => test.key.startsWith("py:") ? new RegExp(`^\\s*(?:async\\s+)?def\\s+${test.title}\\s*\\(`, "m").test(oldText) : oldText.includes(test.title);
|
|
3973
|
-
const markedBefore = /* @__PURE__ */ new
|
|
4152
|
+
const existed = (test) => test.key.startsWith("py:") ? new RegExp(`^\\s*(?:async\\s+)?def\\s+${test.title}\\s*\\(`, "m").test(oldText) : test.key.startsWith("rs:") ? new RegExp(`\\bfn\\s+${test.title}\\s*[<(]`).test(oldText) : test.key.startsWith("go:") ? new RegExp(`^func\\s+(?:\\([^)]*\\)\\s*)?${test.title}\\s*\\(`, "m").test(oldText) : oldText.includes(test.title);
|
|
4153
|
+
const markedBefore = /* @__PURE__ */ new Map();
|
|
3974
4154
|
let unnamedBefore = 0;
|
|
3975
|
-
for (const finding2 of before.filter(
|
|
4155
|
+
for (const finding2 of before.filter(isTestMarker)) {
|
|
3976
4156
|
const test = markedTest(finding2, oldLines);
|
|
3977
|
-
if (test) markedBefore.
|
|
4157
|
+
if (test) markedBefore.set(test.key, (markedBefore.get(test.key) ?? 0) + 1);
|
|
3978
4158
|
else unnamedBefore++;
|
|
3979
4159
|
}
|
|
3980
|
-
const found = [], unnamed = [], seen = /* @__PURE__ */ new
|
|
4160
|
+
const found = [], unnamed = [], seen = /* @__PURE__ */ new Map();
|
|
3981
4161
|
for (const finding2 of markers) {
|
|
3982
4162
|
const test = markedTest(finding2, newLines);
|
|
3983
4163
|
if (!test) {
|
|
3984
4164
|
unnamed.push(finding2);
|
|
3985
4165
|
continue;
|
|
3986
4166
|
}
|
|
3987
|
-
|
|
3988
|
-
seen.
|
|
4167
|
+
const nth = (seen.get(test.key) ?? 0) + 1, had = markedBefore.get(test.key) ?? 0;
|
|
4168
|
+
seen.set(test.key, nth);
|
|
4169
|
+
if (nth <= had || had === 0 && (nth > 1 && !test.key.startsWith("js:") || !existed(test))) continue;
|
|
3989
4170
|
found.push(finding2);
|
|
3990
4171
|
}
|
|
3991
4172
|
return [...found, ...unnamed.slice(unnamedBefore)];
|
|
@@ -4019,7 +4200,8 @@ var THRESHOLD_PATTERNS = [
|
|
|
4019
4200
|
var SWITCHES = ["strict", "noImplicitAny", "strictNullChecks", "noImplicitReturns", "useUnknownInCatchVariables"];
|
|
4020
4201
|
var GATE_SCRIPT = /^(?:pre|post)?(?:test|lint|typecheck|type-check|check|ci|verify|validate)(?::[\w:.-]+)?$/;
|
|
4021
4202
|
var CI_RUNNER = new RegExp(
|
|
4022
|
-
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/])` +
|
|
4203
|
+
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/])` + // Monorepo runners: `nx affected --target=test`, `turbo run test`, `lerna run test`, `bazel test //...`.
|
|
4204
|
+
String.raw`|(?<![\w./-])(?:nx|turbo|lerna|rush)\s+(?:run-many|affected|run|test)\b[^\n]*(?<![\w-])(?:test|tests)(?![\w-])` + String.raw`|(?<![\w./-])(?:bazel\s+test|ctest|phpunit|rspec|deno\s+test)(?![\w/-])` + String.raw`|\brun:.*(?<=\s)(?:tests?|unit|e2e)(?![\w/.-])`
|
|
4023
4205
|
);
|
|
4024
4206
|
var CI_FILE = /(?:\.github\/workflows\/|\.gitlab-ci)/;
|
|
4025
4207
|
var CI_REUSABLE = /^\s*-?\s*uses:\s*\S*\.github\/workflows\/\S+/;
|
|
@@ -4321,7 +4503,7 @@ async function compareSnapshot(options, cwd, snapshot) {
|
|
|
4321
4503
|
const fingerprint = findingFingerprint(finding2);
|
|
4322
4504
|
existingCounts.set(fingerprint, (existingCounts.get(fingerprint) ?? 0) + 1);
|
|
4323
4505
|
}
|
|
4324
|
-
result.infractions.push(...compared.introduced.filter((finding2) => !
|
|
4506
|
+
result.infractions.push(...compared.introduced.filter((finding2) => !isTestMarker(finding2)), ...newTestMarkers(before, after, oldText, newText));
|
|
4325
4507
|
result.removedFindings.push(...compared.removed);
|
|
4326
4508
|
result.unchangedCount += compared.unchanged;
|
|
4327
4509
|
result.beforeFindings += before.length;
|
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.18.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",
|