agent-roast 0.16.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
@@ -78,9 +78,9 @@ 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
- | `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,28 @@ 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
- - **119 of 119** hand-labeled cheating edits are caught and **84 of 84** 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)).
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
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);
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
132
+ fixed. One reader, and a convenience sample ([section 7](docs/benchmarks.md#7-ai-signed-commits-and-a-test-that-checks-its-own-mock),
133
+ [section 8](docs/benchmarks.md#8-an-exact-check-turned-into-a-bound)).
123
134
  - `replay` ran on 179 public repositories that use coding agents: no commit would have been blocked in 149 of them.
124
135
  - The rules for **eight more languages** (Go, Rust, Java, Kotlin, C#, Ruby, PHP, Swift) ran on the newest 300 commits of 39
125
136
  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",
@@ -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,15 +3787,106 @@ 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
- var EMPTY_PY = /==\s*(?:0|\[\s*\]|\{\s*\}|""|''|None|False)\b|\bis\s+None\b|\bnot\b|!=\s*(?:0|None)\b/;
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*\(/;
3678
3797
  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
3798
  var THROWING_QUERY = /\b(?:get|find)(?:All)?By\w*\s*\(/;
3799
+ function checkedExpression(assertion, language) {
3800
+ const squash = (text) => text.replace(/\s+/g, "").replace(/^len\((.*)\)$/, "$1").replace(/\.length$/, "");
3801
+ if (language === "py") {
3802
+ 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];
3803
+ return operand ? squash(operand) : null;
3804
+ }
3805
+ const start = assertion.search(/(?<![\w$.])expect\s*\(/);
3806
+ if (start >= 0) {
3807
+ const open2 = assertion.indexOf("(", start);
3808
+ let depth = 0;
3809
+ for (let i = open2; i < assertion.length; i++) {
3810
+ if (assertion[i] === "(") depth++;
3811
+ else if (assertion[i] === ")" && --depth === 0) return squash(assertion.slice(open2 + 1, i));
3812
+ }
3813
+ return null;
3814
+ }
3815
+ const node = /assert\s*\.\s*(?:equal|strictEqual|deepEqual|deepStrictEqual|ok)\s*\(\s*([^,)]+?)\s*(?:,|\)|>=|<=|>|<)/.exec(assertion)?.[1];
3816
+ return node ? squash(node) : null;
3817
+ }
3818
+ function matcherArgument(assertion, name) {
3819
+ const open2 = new RegExp(`\\.\\s*${name}\\s*\\(`).exec(assertion);
3820
+ if (!open2) return null;
3821
+ let depth = 1;
3822
+ for (let i = open2.index + open2[0].length; i < assertion.length; i++) {
3823
+ if ("([{".includes(assertion[i])) depth++;
3824
+ else if (")]}".includes(assertion[i]) && --depth === 0) return assertion.slice(open2.index + open2[0].length, i).replace(/\s+/g, "");
3825
+ }
3826
+ return null;
3827
+ }
3828
+ function isExact(assertion, language) {
3829
+ if (language === "js") {
3830
+ return !/\.\s*not\s*\./.test(assertion) && (/\.(?:toBe|toEqual|toStrictEqual|toHaveLength)\s*\(/.test(assertion) || /assert\s*\.\s*(?:equal|strictEqual|deepEqual|deepStrictEqual)\s*\(/.test(assertion));
3831
+ }
3832
+ if (language === "py") return /^\s*assert\s+.+?\s*==\s*\S/.test(assertion) || /\bself\s*\.\s*assertEqual\s*\(/.test(assertion);
3833
+ return false;
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
+ }
3849
+ function relaxes(oldAssertion, newAssertion, language, siblings = []) {
3850
+ const subject = checkedExpression(oldAssertion, language);
3851
+ if (!subject || subject !== checkedExpression(newAssertion, language)) return false;
3852
+ if (siblings.some((other) => isExact(other, language) && checkedExpression(other, language) === subject)) return false;
3853
+ if (language === "js") {
3854
+ if (/\.\s*not\s*\./.test(oldAssertion) || /\.\s*not\s*\./.test(newAssertion)) return false;
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];
3856
+ const bound = /\.(?:toBeGreaterThan|toBeGreaterThanOrEqual|toBeLessThan|toBeLessThanOrEqual)\s*\(/.test(newAssertion) || /assert\s*\.\s*ok\s*\([^;]*(?:>=|<=|>|<)/.test(newAssertion);
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
+ }
3865
+ const same = matcherArgument(oldAssertion, "toEqual") ?? matcherArgument(oldAssertion, "toStrictEqual");
3866
+ return same !== null && same !== "" && same === matcherArgument(newAssertion, "toMatchObject");
3867
+ }
3868
+ if (language === "py") {
3869
+ if (EMPTY_PY.test(oldAssertion)) return false;
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 === "<"));
3887
+ }
3888
+ return false;
3889
+ }
3680
3890
  function pinsLess(oldAssertion, newAssertion, language) {
3681
3891
  if (language === "js") {
3682
3892
  const was = /\.toThrow(?:Error)?\s*\(\s*([^)\s][^)]*)\)/.exec(oldAssertion);
@@ -3697,15 +3907,18 @@ function pinsLess(oldAssertion, newAssertion, language) {
3697
3907
  }
3698
3908
  return false;
3699
3909
  }
3700
- function isWeaker(oldAssertion, newAssertion, language) {
3701
- if (pinsLess(oldAssertion, newAssertion, language)) return true;
3910
+ function isWeaker(rawOld, rawNew, language) {
3911
+ const oldAssertion = language === "py" ? withoutOuterParens(rawOld) : rawOld, newAssertion = language === "py" ? withoutOuterParens(rawNew) : rawNew;
3912
+ if (pinsLess(oldAssertion, newAssertion, language) || relaxes(oldAssertion, newAssertion, language)) return true;
3702
3913
  if (language === "js") {
3703
3914
  if (THROWING_QUERY.test(newAssertion) || !JS_SPECIFIC.test(oldAssertion) || WEAK_JS.test(oldAssertion) || BOUND_JS.test(oldAssertion)) return false;
3704
- 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()));
3705
3917
  }
3706
3918
  if (language === "py") {
3707
3919
  if (WEAK_PY.test(oldAssertion) || BOUND_PY.test(oldAssertion) || !PY_SPECIFIC.test(oldAssertion)) return false;
3708
- 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));
3709
3922
  }
3710
3923
  return false;
3711
3924
  }
@@ -3724,6 +3937,34 @@ var HELPER_CALL = /(?<![\w$])((?:expect|assert|verify|check|require)[A-Z]\w*|_{0
3724
3937
  function finding(type, file, line, snippet, reason, commit) {
3725
3938
  return { type, file, lineNumber: Math.max(1, line), lineSnippet: snippet, commitHash: commit, commitDate: "", reason, suggestion: RULE_GUIDES[type].suggestion };
3726
3939
  }
3940
+ function alignAssertions(before, after) {
3941
+ const table = Array.from({ length: before.length + 1 }, () => new Array(after.length + 1).fill(0));
3942
+ for (let i = before.length - 1; i >= 0; i--) {
3943
+ 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]);
3944
+ }
3945
+ const pairs = /* @__PURE__ */ new Map();
3946
+ for (let i = 0, j = 0; i < before.length && j < after.length; ) {
3947
+ if (before[i] === after[j]) {
3948
+ pairs.set(i, j);
3949
+ i++;
3950
+ j++;
3951
+ } else if (table[i + 1][j] >= table[i][j + 1]) i++;
3952
+ else j++;
3953
+ }
3954
+ return pairs;
3955
+ }
3956
+ function unmatchedIndexes(from, against) {
3957
+ const budget = /* @__PURE__ */ new Map();
3958
+ for (const assertion of against) budget.set(assertion, (budget.get(assertion) ?? 0) + 1);
3959
+ return from.map((assertion, i) => ({ assertion, i })).filter(({ assertion }) => {
3960
+ const available = budget.get(assertion) ?? 0;
3961
+ if (available > 0) {
3962
+ budget.set(assertion, available - 1);
3963
+ return false;
3964
+ }
3965
+ return true;
3966
+ }).map(({ i }) => i);
3967
+ }
3727
3968
  function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
3728
3969
  const language = languageOf(newText === null ? oldPath : newPath);
3729
3970
  if (!language || !(isTestFile(newPath) || isTestFile(oldPath))) return EMPTY;
@@ -3765,6 +4006,34 @@ function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
3765
4006
  if (current.parametrized && !old.parametrized) continue;
3766
4007
  const snapshot = (test) => test.raw.some((statement) => /\.toMatch(?:Inline)?Snapshot\b|\.matchSnapshot\b|\bsnapshot\s*\(/.test(statement));
3767
4008
  if (snapshot(current) && !snapshot(old)) continue;
4009
+ if (old.assertions.length && current.assertions.length && current.assertions.length !== old.assertions.length && old.assertions.length * current.assertions.length <= 25e4) {
4010
+ const aligned = alignAssertions(old.assertions, current.assertions);
4011
+ const fresh = unmatchedIndexes(current.assertions, old.assertions);
4012
+ const gone = unmatchedIndexes(old.assertions, current.assertions).filter((index) => (newTotals.get(old.assertions[index]) ?? 0) < (oldTotals.get(old.assertions[index]) ?? 0));
4013
+ for (const index of gone) {
4014
+ const was = checkedExpression(old.raw[index], language);
4015
+ if (was === null) continue;
4016
+ let low = -1, high = current.assertions.length;
4017
+ for (const [from, to] of aligned) {
4018
+ if (from < index) low = Math.max(low, to);
4019
+ else if (from > index) high = Math.min(high, to);
4020
+ }
4021
+ const successors = fresh.filter((candidate) => candidate > low && candidate < high);
4022
+ const siblings = successors.map((candidate) => current.raw[candidate]);
4023
+ const partner = successors.find((candidate) => checkedExpression(current.raw[candidate], language) === was && relaxes(old.raw[index], current.raw[candidate], language, siblings));
4024
+ if (partner !== void 0) {
4025
+ result.immediate.push(finding(
4026
+ "assertion-weakened",
4027
+ newPath,
4028
+ current.line,
4029
+ `${old.name}: ${old.raw[index].slice(0, 80)} \u2192 ${current.raw[partner].slice(0, 80)}`,
4030
+ "Assertion replaced by a weaker check",
4031
+ commit
4032
+ ));
4033
+ break;
4034
+ }
4035
+ }
4036
+ }
3768
4037
  if (current.assertions.length < old.assertions.length) {
3769
4038
  if ([...current.text.matchAll(HELPER_CALL)].some((call) => !(oldText ?? "").includes(call[1]))) continue;
3770
4039
  const budget = tally([current]);
@@ -3793,19 +4062,7 @@ ${addedText}`;
3793
4062
  continue;
3794
4063
  }
3795
4064
  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);
4065
+ const goneIndexes = unmatchedIndexes(old.assertions, current.assertions), freshIndexes = unmatchedIndexes(current.assertions, old.assertions);
3809
4066
  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
4067
  const weaker = changed.find((pair) => isWeaker(pair.rawBefore, pair.rawAfter, language));
3811
4068
  if (weaker) {
@@ -3839,8 +4096,8 @@ function resolveTampering(files, productionChanged) {
3839
4096
  ...productionChanged ? files.flatMap((file) => file.rewritten) : []
3840
4097
  ];
3841
4098
  }
3842
- function isKnownFailureMarker(finding2) {
3843
- 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 ?? "");
3844
4101
  }
3845
4102
  function markedTest(finding2, lines) {
3846
4103
  const snippet = finding2.lineSnippet ?? "";
@@ -3848,10 +4105,10 @@ function markedTest(finding2, lines) {
3848
4105
  if (/\.py$/i.test(finding2.file ?? "")) {
3849
4106
  if (/^\s*pytestmark\b/.test(snippet)) return null;
3850
4107
  if (/imperative/.test(finding2.reason)) {
3851
- const indent = (lines[index] ?? "").search(/\S/);
4108
+ const indent2 = (lines[index] ?? "").search(/\S/);
3852
4109
  for (let i = index - 1; i >= 0 && i > index - 200; i--) {
3853
4110
  const def = /^(\s*)(?:async\s+)?def\s+(\w+)\s*\(/.exec(lines[i]);
3854
- 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] };
3855
4112
  }
3856
4113
  return null;
3857
4114
  }
@@ -3861,31 +4118,55 @@ function markedTest(finding2, lines) {
3861
4118
  }
3862
4119
  return null;
3863
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
+ }
3864
4135
  if (/\.each\b/.test(snippet)) return null;
3865
- const title = /(['"`])((?:\\.|(?!\1)[^\\\n])+)\1/.exec(snippet);
3866
- 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;
3867
4147
  }
3868
- function newKnownFailureMarkers(before, after, oldText, newText) {
3869
- const markers = after.filter(isKnownFailureMarker);
4148
+ function newTestMarkers(before, after, oldText, newText) {
4149
+ const markers = after.filter(isTestMarker);
3870
4150
  if (!markers.length || oldText === null) return [];
3871
4151
  const oldLines = oldText.split(/\r?\n/), newLines = (newText ?? "").split(/\r?\n/);
3872
- const existed = (test) => test.key.startsWith("py:") ? new RegExp(`^\\s*(?:async\\s+)?def\\s+${test.title}\\s*\\(`, "m").test(oldText) : oldText.includes(test.title);
3873
- 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();
3874
4154
  let unnamedBefore = 0;
3875
- for (const finding2 of before.filter(isKnownFailureMarker)) {
4155
+ for (const finding2 of before.filter(isTestMarker)) {
3876
4156
  const test = markedTest(finding2, oldLines);
3877
- if (test) markedBefore.add(test.key);
4157
+ if (test) markedBefore.set(test.key, (markedBefore.get(test.key) ?? 0) + 1);
3878
4158
  else unnamedBefore++;
3879
4159
  }
3880
- const found = [], unnamed = [], seen = /* @__PURE__ */ new Set();
4160
+ const found = [], unnamed = [], seen = /* @__PURE__ */ new Map();
3881
4161
  for (const finding2 of markers) {
3882
4162
  const test = markedTest(finding2, newLines);
3883
4163
  if (!test) {
3884
4164
  unnamed.push(finding2);
3885
4165
  continue;
3886
4166
  }
3887
- if (markedBefore.has(test.key) || seen.has(test.key) || !existed(test)) continue;
3888
- 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;
3889
4170
  found.push(finding2);
3890
4171
  }
3891
4172
  return [...found, ...unnamed.slice(unnamedBefore)];
@@ -3919,7 +4200,8 @@ var THRESHOLD_PATTERNS = [
3919
4200
  var SWITCHES = ["strict", "noImplicitAny", "strictNullChecks", "noImplicitReturns", "useUnknownInCatchVariables"];
3920
4201
  var GATE_SCRIPT = /^(?:pre|post)?(?:test|lint|typecheck|type-check|check|ci|verify|validate)(?::[\w:.-]+)?$/;
3921
4202
  var CI_RUNNER = new RegExp(
3922
- 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/.-])`
3923
4205
  );
3924
4206
  var CI_FILE = /(?:\.github\/workflows\/|\.gitlab-ci)/;
3925
4207
  var CI_REUSABLE = /^\s*-?\s*uses:\s*\S*\.github\/workflows\/\S+/;
@@ -4221,7 +4503,7 @@ async function compareSnapshot(options, cwd, snapshot) {
4221
4503
  const fingerprint = findingFingerprint(finding2);
4222
4504
  existingCounts.set(fingerprint, (existingCounts.get(fingerprint) ?? 0) + 1);
4223
4505
  }
4224
- 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));
4225
4507
  result.removedFindings.push(...compared.removed);
4226
4508
  result.unchangedCount += compared.unchanged;
4227
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-52FUQ4EP.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-52FUQ4EP.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.16.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",