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 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 stays silent until a check gets weaker.**
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
- - **126 of 126** hand-labeled cheating edits are caught and **90 of 90** harmless look-alikes (reformats, moves, raised
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
- - **About one finding in five was a false alarm**, and most of the rest were deliberate, explained changes a reviewer should
122
- see rather than mistakes. The labels come from one reader (an AI assistant), so treat them as a first estimate.
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
- patched.set(key, [...patched.get(key) ?? [], { index: match.index, scope: blockOf(line) ?? scopeOf(line) }]);
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 < text.length; i++) {
3609
- const char = text[i];
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|toBeUndefined)\s*\(|\.not\s*\.\s*(?:toBeNull|toBeUndefined|toThrow)\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*\)/;
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.\[\]]+\s*(?:,.*)?$|^\s*assert\s+.+\bis\s+not\s+None\s*(?:,.*)?$|^\s*assert\s+True\b/;
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) return true;
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 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;
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(oldAssertion, newAssertion, language) {
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
- return WEAK_JS.test(newAssertion) || BOUND_JS.test(newAssertion) && !ZERO_JS.test(oldAssertion);
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
- return WEAK_PY.test(newAssertion) || BOUND_PY.test(newAssertion) && !EMPTY_PY.test(oldAssertion) && /==|\bin\b|[<>]=?/.test(oldAssertion);
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 isKnownFailureMarker(finding2) {
3943
- return finding2.type === "test-skip" && /\bxfail\b|\bexpectedFailure\b|\bfixme\b|expected to fail/i.test(finding2.reason);
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 indent = (lines[index] ?? "").search(/\S/);
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 < indent) return { key: `py:${def[2]}`, title: def[2] };
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 = /(['"`])((?:\\.|(?!\1)[^\\\n])+)\1/.exec(snippet);
3966
- return title ? { key: `js:${title[0]}`, title: title[0] } : null;
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 newKnownFailureMarkers(before, after, oldText, newText) {
3969
- const markers = after.filter(isKnownFailureMarker);
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 Set();
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(isKnownFailureMarker)) {
4155
+ for (const finding2 of before.filter(isTestMarker)) {
3976
4156
  const test = markedTest(finding2, oldLines);
3977
- if (test) markedBefore.add(test.key);
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 Set();
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
- if (markedBefore.has(test.key) || seen.has(test.key) || !existed(test)) continue;
3988
- seen.add(test.key);
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/])` + String.raw`|\brun:.*(?<=\s)(?:tests?|unit|e2e)(?![\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) => !isKnownFailureMarker(finding2)), ...newKnownFailureMarkers(before, after, oldText, newText));
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
@@ -38,7 +38,7 @@ import {
38
38
  terminalText,
39
39
  uninstallHooks,
40
40
  writeBaseline
41
- } from "./chunk-IKXGHCWB.js";
41
+ } from "./chunk-2KWC2IAP.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-IKXGHCWB.js";
94
+ } from "./chunk-2KWC2IAP.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.17.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",