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 +17 -6
- package/dist/{chunk-52FUQ4EP.js → chunk-2KWC2IAP.js} +326 -44
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
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
|
|
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)).
|
|
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
|
|
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
|
-
|
|
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,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
|
|
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
|
-
var EMPTY_PY = /==\s*(?:0|\[\s*\]|\{\s*\}|""|''|None|False
|
|
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(
|
|
3701
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
3843
|
-
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 ?? "");
|
|
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
|
|
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 <
|
|
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 =
|
|
3866
|
-
|
|
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
|
|
3869
|
-
const markers = after.filter(
|
|
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
|
|
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(
|
|
4155
|
+
for (const finding2 of before.filter(isTestMarker)) {
|
|
3876
4156
|
const test = markedTest(finding2, oldLines);
|
|
3877
|
-
if (test) markedBefore.
|
|
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
|
|
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
|
-
|
|
3888
|
-
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;
|
|
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/])` +
|
|
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) => !
|
|
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
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",
|