agent-roast 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -4
- package/dist/{chunk-BWGYT2YZ.js → chunk-IKXGHCWB.js} +386 -28
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -78,10 +78,10 @@ Policy is read from the **base** branch, so a pull request cannot switch off the
|
|
|
78
78
|
|
|
79
79
|
| Rule | Fires when | By default |
|
|
80
80
|
| --- | --- | :-: |
|
|
81
|
-
| `assertion-weakened` | an exact check is replaced by a weaker one: `toBe(42)` → `toBeDefined()`, `assert x == 1` → `assert x` | blocks |
|
|
81
|
+
| `assertion-weakened` | an exact check is replaced by a weaker one: `toBe(42)` → `toBeDefined()`, `toBe(2)` → `toBeLessThanOrEqual(2)`, `toEqual(x)` → `toMatchObject(x)`, `assert x == 1` → `assert x` | blocks |
|
|
82
82
|
| `guard-weakened` | a coverage threshold is lowered, `strict` is switched off, a lint rule is downgraded, a CI step may fail, a workflow stops running tests, a `conftest.py` hook sets a report to `passed` | blocks |
|
|
83
83
|
| `test-skip` | a test is switched off or expected to fail: `it.skip`, `@pytest.mark.skip`, `@pytest.mark.xfail`, `it.fails`, `test.fixme`, `t.Skip`, `#[ignore]`, `@Disabled`, `[Ignore]`, `xit`, ... | blocks |
|
|
84
|
-
| `test-focus`, `trivial-assert` | `.only` / `fit`; `expect(true).toBe(true)` and its relatives in ten languages | blocks |
|
|
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 |
|
|
87
87
|
|
|
@@ -111,15 +111,20 @@ old debt with a baseline: [docs/reference.md](docs/reference.md#configuration).
|
|
|
111
111
|
Measured, with the denominators and the caveats in [docs/benchmarks.md](docs/benchmarks.md). **Weakened checks are rare, mostly
|
|
112
112
|
deliberate, and not specific to AI. agent-roast is a tripwire that stays silent until a check gets weaker.**
|
|
113
113
|
|
|
114
|
-
- **
|
|
114
|
+
- **126 of 126** hand-labeled cheating edits are caught and **90 of 90** harmless look-alikes (reformats, moves, raised
|
|
115
115
|
thresholds, environment-gated skips) stay silent. The cases were written with the detector, so they guard against regressions;
|
|
116
|
-
they do not prove accuracy.
|
|
116
|
+
they do not prove accuracy. One gap is documented and still open.
|
|
117
117
|
- **12,645 commits** that touch tests or CI/config in 166 public repositories: blocking rules fired on 0.3% of AI-signed commits
|
|
118
118
|
(Claude, Copilot, Cursor, Codex, Devin, aider) and on 0.3% of the others. Inside the same repositories the odds for an
|
|
119
119
|
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
120
|
checks more often.
|
|
121
121
|
- **About one finding in five was a false alarm**, and most of the rest were deliberate, explained changes a reviewer should
|
|
122
122
|
see rather than mistakes. The labels come from one reader (an AI assistant), so treat them as a first estimate.
|
|
123
|
+
- **9,732 commits compared in full** from 164 public repositories that use coding agents (5,454 of them AI-signed) were reviewed again for the
|
|
124
|
+
last two releases, and every new finding was read. 0.15.0's rules reported 30 new findings there and 7 were false alarms (fixed in 0.16.0);
|
|
125
|
+
0.17.0's rule for an exact check turned into a bound reported 18 in 14 commits, all of them loosened checks, after two false alarms were
|
|
126
|
+
fixed. One reader, and a convenience sample ([section 7](docs/benchmarks.md#7-ai-signed-commits-and-a-test-that-checks-its-own-mock),
|
|
127
|
+
[section 8](docs/benchmarks.md#8-an-exact-check-turned-into-a-bound)).
|
|
123
128
|
- `replay` ran on 179 public repositories that use coding agents: no commit would have been blocked in 149 of them.
|
|
124
129
|
- The rules for **eight more languages** (Go, Rust, Java, Kotlin, C#, Ruby, PHP, Swift) ran on the newest 300 commits of 39
|
|
125
130
|
repositories and every finding was read: 26 of 99 were skips that depend on the environment, now fixed; of the 73 left, 56 are
|
|
@@ -764,9 +764,9 @@ var RULE_GUIDES = {
|
|
|
764
764
|
},
|
|
765
765
|
"trivial-assert": {
|
|
766
766
|
title: "Assertion that always passes",
|
|
767
|
-
why: "Comparing a constant to itself does not check the implementation and cannot catch regressions.",
|
|
768
|
-
suggestion: "Call the real behavior and assert its output or observable side effect instead of comparing constants.",
|
|
769
|
-
example: "expect(true).toBe(true) \u2192 expect(canSignIn(validUser)).toBe(true)"
|
|
767
|
+
why: "Comparing a constant to itself, or checking the output of a function the test itself replaces with a mock, does not check the implementation and cannot catch regressions.",
|
|
768
|
+
suggestion: "Call the real behavior and assert its output or observable side effect instead of comparing constants. Mock the dependencies of the code under test, never the code under test.",
|
|
769
|
+
example: "expect(true).toBe(true) \u2192 expect(canSignIn(validUser)).toBe(true); jest.mock('./price', ...) then expect(price(cart)).toBe(42) \u2192 mock the price list and assert total(cart)"
|
|
770
770
|
},
|
|
771
771
|
"type-escape": {
|
|
772
772
|
title: "Type, lint or coverage check bypass",
|
|
@@ -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",
|
|
@@ -1453,13 +1453,13 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1453
1453
|
hasSupportedChanges = true;
|
|
1454
1454
|
filesInspected++;
|
|
1455
1455
|
linesAdded += file.addedLines.length;
|
|
1456
|
-
const
|
|
1456
|
+
const javascript2 = /\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file.path);
|
|
1457
1457
|
const typescript = /\.(?:[cm]?tsx?|vue|svelte)$/i.test(file.path);
|
|
1458
|
-
const
|
|
1458
|
+
const python2 = /\.py$/i.test(file.path);
|
|
1459
1459
|
const rust = /\.rs$/i.test(file.path);
|
|
1460
|
-
const braceCatches =
|
|
1461
|
-
const directives =
|
|
1462
|
-
const stubs =
|
|
1460
|
+
const braceCatches = javascript2 || /\.(?:java|kt|php|cs|cpp)$/i.test(file.path);
|
|
1461
|
+
const directives = javascript2 ? JS_COMMENT_DIRECTIVE_PATTERNS : python2 ? PYTHON_COMMENT_DIRECTIVE_PATTERNS : [];
|
|
1462
|
+
const stubs = javascript2 ? JS_STUB_PATTERNS : python2 ? PYTHON_STUB_PATTERNS : rust ? RUST_STUB_PATTERNS : [];
|
|
1463
1463
|
const firstFinding = infractions.length;
|
|
1464
1464
|
const languageRules = LANGUAGE_LINE_RULES.find((entry) => entry.files.test(file.path))?.rules;
|
|
1465
1465
|
const masker = new SourceMasker(file.path);
|
|
@@ -1472,7 +1472,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1472
1472
|
for (const start of masked.templateStarts) testCode[start] = "";
|
|
1473
1473
|
maskedLines.push({ code: cleanedLine, testCode: testCode.join(""), raw: rawLine, number: added.lineNumber });
|
|
1474
1474
|
const isComment = !cleanedLine.trim();
|
|
1475
|
-
if (
|
|
1475
|
+
if (python2 && !isComment) {
|
|
1476
1476
|
for (const rule of PYTHON_TEST_SKIP_PATTERNS) {
|
|
1477
1477
|
if (rule.regex.test(cleanedLine)) {
|
|
1478
1478
|
if (rule.imperative && isGuardedSkip(maskedLines)) break;
|
|
@@ -1513,12 +1513,12 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1513
1513
|
break;
|
|
1514
1514
|
}
|
|
1515
1515
|
}
|
|
1516
|
-
const blockDirective =
|
|
1516
|
+
const blockDirective = javascript2 && !foundEscape ? JS_BLOCK_DIRECTIVE_PATTERNS.find((rule) => masked.blockStarts.some((start) => rule.regex.test(rawLine.slice(start)))) : void 0;
|
|
1517
1517
|
if (blockDirective) {
|
|
1518
1518
|
infractions.push({ type: "type-escape", file: file.path, lineNumber: added.lineNumber, lineSnippet: rawLine.trim(), commitHash, commitDate, reason: blockDirective.name });
|
|
1519
1519
|
foundEscape = true;
|
|
1520
1520
|
}
|
|
1521
|
-
if (
|
|
1521
|
+
if (python2 && !isComment && isTestFile(file.path)) {
|
|
1522
1522
|
for (const rule of PYTHON_TRIVIAL_ASSERT_PATTERNS) {
|
|
1523
1523
|
if (rule.regex.test(cleanedLine)) {
|
|
1524
1524
|
infractions.push({
|
|
@@ -1578,7 +1578,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1578
1578
|
infractions.push({ type: "stub-abandonment", file: file.path, ...location, commitHash, commitDate, reason: rule.name });
|
|
1579
1579
|
}
|
|
1580
1580
|
}
|
|
1581
|
-
if (
|
|
1581
|
+
if (python2) {
|
|
1582
1582
|
const reportedSkips = new Set(infractions.slice(firstFinding).filter((f) => f.type === "test-skip").map((f) => f.lineNumber));
|
|
1583
1583
|
for (const match of source.matchAll(PYTHON_MARKER)) {
|
|
1584
1584
|
const xfail = match[2] === "xfail";
|
|
@@ -1617,7 +1617,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1617
1617
|
}
|
|
1618
1618
|
}
|
|
1619
1619
|
}
|
|
1620
|
-
if (
|
|
1620
|
+
if (javascript2) {
|
|
1621
1621
|
for (const match of source.matchAll(JS_TEST_API)) {
|
|
1622
1622
|
let previous = match.index - 1;
|
|
1623
1623
|
while (previous >= 0 && /\s/.test(source[previous])) previous--;
|
|
@@ -1659,9 +1659,9 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1659
1659
|
}
|
|
1660
1660
|
for (let index = 0; index < maskedLines.length; index++) {
|
|
1661
1661
|
const start = maskedLines[index];
|
|
1662
|
-
const inlineExcept =
|
|
1662
|
+
const inlineExcept = python2 && /^\s*except(?:\*?\s+[^:]+)?:\s*pass\s*;?\s*$/.test(start.code);
|
|
1663
1663
|
if (inlineExcept) infractions.push({ type: "swallowed-error", file: file.path, lineNumber: start.number, lineSnippet: start.raw.trim(), commitHash, commitDate, reason: "silent python error swallow (except: pass)" });
|
|
1664
|
-
const isExcept =
|
|
1664
|
+
const isExcept = python2 && /^\s*except(?:\*?\s+[^:]+)?:\s*$/.test(start.code);
|
|
1665
1665
|
if (!isExcept) continue;
|
|
1666
1666
|
let next = index + 1;
|
|
1667
1667
|
while (next < maskedLines.length && maskedLines[next].number === maskedLines[next - 1].number + 1 && !maskedLines[next].code.trim()) next++;
|
|
@@ -3165,6 +3165,230 @@ import path6 from "path";
|
|
|
3165
3165
|
// src/tampering.ts
|
|
3166
3166
|
import { createHash as createHash3 } from "crypto";
|
|
3167
3167
|
|
|
3168
|
+
// src/mocks.ts
|
|
3169
|
+
function views(text, language) {
|
|
3170
|
+
const masker = new SourceMasker(language === "js" ? "file.ts" : "file.py");
|
|
3171
|
+
const lines = text.split(/\r?\n/);
|
|
3172
|
+
const code = lines.map((line, index) => masker.mask(line, index + 1).code).join("\n");
|
|
3173
|
+
const starts = [];
|
|
3174
|
+
let offset = 0;
|
|
3175
|
+
for (const line of lines) {
|
|
3176
|
+
starts.push(offset);
|
|
3177
|
+
offset += line.length + 1;
|
|
3178
|
+
}
|
|
3179
|
+
const lineOf2 = (index) => {
|
|
3180
|
+
let low = 0, high = starts.length - 1;
|
|
3181
|
+
while (low < high) {
|
|
3182
|
+
const middle = low + high + 1 >>> 1;
|
|
3183
|
+
if (starts[middle] <= index) low = middle;
|
|
3184
|
+
else high = middle - 1;
|
|
3185
|
+
}
|
|
3186
|
+
return low + 1;
|
|
3187
|
+
};
|
|
3188
|
+
return { raw: lines.join("\n"), code, lines, starts, lineOf: lineOf2 };
|
|
3189
|
+
}
|
|
3190
|
+
function closing(code, open2) {
|
|
3191
|
+
let depth = 0;
|
|
3192
|
+
for (let i = open2; i < code.length && i - open2 < 2e4; i++) {
|
|
3193
|
+
if (code[i] === "(") depth++;
|
|
3194
|
+
else if (code[i] === ")" && --depth === 0) return i;
|
|
3195
|
+
}
|
|
3196
|
+
return -1;
|
|
3197
|
+
}
|
|
3198
|
+
var STRING_AFTER = /^\s*(['"`])([^'"`\n]+)\1/;
|
|
3199
|
+
var within = (range, index) => !range || index >= range[0] && index <= range[1];
|
|
3200
|
+
var innermost = (ranges, index) => ranges.filter((range) => index >= range[0] && index <= range[1]).sort((a, b) => a[1] - a[0] - (b[1] - b[0]))[0] ?? null;
|
|
3201
|
+
function javascript(text) {
|
|
3202
|
+
const { raw, code, lines, lineOf: lineOf2 } = views(text, "js");
|
|
3203
|
+
const callbacks = [];
|
|
3204
|
+
for (const match of code.matchAll(/(?<![\w$.])(?:it|test|beforeEach|beforeAll|afterEach|afterAll)(?:\s*\.\s*\w+)*\s*\(/g)) {
|
|
3205
|
+
const open2 = match.index + match[0].length - 1;
|
|
3206
|
+
const end = closing(code, open2);
|
|
3207
|
+
if (end > 0) callbacks.push([open2, end]);
|
|
3208
|
+
}
|
|
3209
|
+
const programLevel = (index) => {
|
|
3210
|
+
let depth = 0;
|
|
3211
|
+
for (let i = 0; i < index; i++) {
|
|
3212
|
+
const char = code[i];
|
|
3213
|
+
if (char === "(" || char === "{" || char === "[") depth++;
|
|
3214
|
+
else if (char === ")" || char === "}" || char === "]") depth--;
|
|
3215
|
+
}
|
|
3216
|
+
return depth === 0;
|
|
3217
|
+
};
|
|
3218
|
+
const mocked = /* @__PURE__ */ new Map();
|
|
3219
|
+
for (const match of code.matchAll(/(?<![\w$.])(?:jest|vi)\s*\.\s*mock\s*\(/g)) {
|
|
3220
|
+
if (!programLevel(match.index)) continue;
|
|
3221
|
+
const open2 = match.index + match[0].length - 1;
|
|
3222
|
+
const specifier = STRING_AFTER.exec(raw.slice(open2 + 1))?.[2];
|
|
3223
|
+
if (!specifier) continue;
|
|
3224
|
+
const end = closing(code, open2);
|
|
3225
|
+
const args = end > 0 ? raw.slice(open2, end) : "";
|
|
3226
|
+
if (/requireActual|importActual|importOriginal/.test(args)) continue;
|
|
3227
|
+
const factory = /^\(\s*(['"`])[^'"`\n]+\1\s*,/.test(args) && !/,\s*\{\s*spy\s*:\s*true\b/.test(args);
|
|
3228
|
+
if (!mocked.has(specifier)) mocked.set(specifier, { index: match.index, fake: factory });
|
|
3229
|
+
}
|
|
3230
|
+
if (!mocked.size) return [];
|
|
3231
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
3232
|
+
const bind = (clause, specifier) => {
|
|
3233
|
+
const named = /\{([^}]*)\}/.exec(clause)?.[1];
|
|
3234
|
+
for (const part of (named ?? "").split(",")) {
|
|
3235
|
+
const item = part.trim().replace(/^type\s+/, "");
|
|
3236
|
+
if (!item) continue;
|
|
3237
|
+
const [imported, local] = item.includes(" as ") ? item.split(/\s+as\s+/) : item.includes(":") ? item.split(/\s*:\s*/) : [item, item];
|
|
3238
|
+
if (/^[\w$]+$/.test(imported) && /^[\w$]+$/.test(local)) bindings.set(local, { specifier, name: imported, namespace: false });
|
|
3239
|
+
}
|
|
3240
|
+
const namespace = /\*\s*as\s+([\w$]+)/.exec(clause)?.[1];
|
|
3241
|
+
if (namespace) bindings.set(namespace, { specifier, name: "*", namespace: true });
|
|
3242
|
+
const standalone = /^\s*([\w$]+)\s*(?:,|$)/.exec(clause.replace(/\{[^}]*\}/, ""))?.[1];
|
|
3243
|
+
if (standalone && standalone !== "type" && !/^\s*\{/.test(clause)) bindings.set(standalone, { specifier, name: "default", namespace: false });
|
|
3244
|
+
};
|
|
3245
|
+
for (const match of code.matchAll(/\bimport\s+([^;'"`]*?)\bfrom\b/g)) {
|
|
3246
|
+
const specifier = STRING_AFTER.exec(raw.slice(match.index + match[0].length))?.[2];
|
|
3247
|
+
if (specifier) bind(match[1], specifier);
|
|
3248
|
+
}
|
|
3249
|
+
for (const match of code.matchAll(/\b(?:const|let|var)\s+(\{[^}]*\}|[\w$]+)\s*=\s*(?:await\s+)?require\s*\(/g)) {
|
|
3250
|
+
const specifier = STRING_AFTER.exec(raw.slice(match.index + match[0].length))?.[2];
|
|
3251
|
+
if (!specifier) continue;
|
|
3252
|
+
if (match[1].startsWith("{")) bind(match[1], specifier);
|
|
3253
|
+
else bindings.set(match[1], { specifier, name: "*", namespace: true });
|
|
3254
|
+
}
|
|
3255
|
+
const found = /* @__PURE__ */ new Map();
|
|
3256
|
+
for (const match of code.matchAll(/(?<![\w$.])expect\s*\(/g)) {
|
|
3257
|
+
const open2 = match.index + match[0].length - 1;
|
|
3258
|
+
const end = closing(code, open2);
|
|
3259
|
+
if (end < 0) continue;
|
|
3260
|
+
const checked = code.slice(open2 + 1, end);
|
|
3261
|
+
let call = /^\s*(?:await\s+)?([A-Za-z_$][\w$]*)\s*(?:\.\s*([A-Za-z_$][\w$]*)\s*)?\(/.exec(checked);
|
|
3262
|
+
const variable = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(checked)?.[1];
|
|
3263
|
+
if (!call && variable) {
|
|
3264
|
+
const from = innermost(callbacks, match.index)?.[0] ?? 0;
|
|
3265
|
+
const name2 = variable.replace(/\$/g, "\\$");
|
|
3266
|
+
const assigned = [...code.slice(from, match.index).matchAll(new RegExp(`(?<![\\w$.])${name2}\\s*=(?!=)\\s*(?:await\\s+)?([A-Za-z_$][\\w$]*)\\s*(?:\\.\\s*([A-Za-z_$][\\w$]*)\\s*)?\\(`, "g"))].at(-1);
|
|
3267
|
+
if (assigned) call = assigned;
|
|
3268
|
+
}
|
|
3269
|
+
if (!call) continue;
|
|
3270
|
+
const binding = bindings.get(call[1]);
|
|
3271
|
+
const mock = binding && mocked.get(binding.specifier);
|
|
3272
|
+
if (!binding || !mock) continue;
|
|
3273
|
+
const name = binding.namespace ? call[2] : call[2] ? void 0 : binding.name;
|
|
3274
|
+
if (!name) continue;
|
|
3275
|
+
const identifier = call[1].replace(/\$/g, "\\$");
|
|
3276
|
+
const configured = new RegExp(`(?<![\\w$.])${identifier}\\b[^;]{0,80}?\\.\\s*mock(?:Return|Resolved|Rejected|Implementation)\\w*\\s*\\(`).test(raw);
|
|
3277
|
+
if (!mock.fake && !configured) continue;
|
|
3278
|
+
const key = `${binding.specifier}::${name}`;
|
|
3279
|
+
if (found.has(key)) continue;
|
|
3280
|
+
const line = lineOf2(mock.index);
|
|
3281
|
+
found.set(key, {
|
|
3282
|
+
key,
|
|
3283
|
+
line,
|
|
3284
|
+
snippet: lines[line - 1].trim().slice(0, 120),
|
|
3285
|
+
reason: `assertion on the output of ${name}(), which this test replaces with a mock of "${binding.specifier}"`
|
|
3286
|
+
});
|
|
3287
|
+
}
|
|
3288
|
+
return [...found.values()];
|
|
3289
|
+
}
|
|
3290
|
+
var DOTTED = /^[A-Za-z_][\w.]*$/;
|
|
3291
|
+
function python(text) {
|
|
3292
|
+
const { raw, code, lines, starts, lineOf: lineOf2 } = views(text, "py");
|
|
3293
|
+
const alias = /* @__PURE__ */ new Map();
|
|
3294
|
+
for (const match of code.matchAll(/^[ \t]*import\s+([\w.]+)\s+as\s+(\w+)/gm)) alias.set(match[2], match[1]);
|
|
3295
|
+
for (const match of code.matchAll(/^[ \t]*from\s+([\w.]+)\s+import\s+([^\n(]+)/gm)) {
|
|
3296
|
+
for (const part of match[2].split(",")) {
|
|
3297
|
+
const [name, local] = part.trim().split(/\s+as\s+/);
|
|
3298
|
+
if (/^\w+$/.test(name)) alias.set(local || name, `${match[1]}.${name}`);
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
const expand = (dotted) => {
|
|
3302
|
+
const [head, ...rest] = dotted.split(".");
|
|
3303
|
+
return rest.length && alias.has(head) ? [alias.get(head), ...rest].join(".") : dotted;
|
|
3304
|
+
};
|
|
3305
|
+
const expandObject = (dotted) => {
|
|
3306
|
+
const [head, ...rest] = dotted.split(".");
|
|
3307
|
+
return alias.has(head) ? [alias.get(head), ...rest].join(".") : dotted;
|
|
3308
|
+
};
|
|
3309
|
+
const codeLines2 = code.split("\n");
|
|
3310
|
+
const indent = (line) => line.length - line.trimStart().length;
|
|
3311
|
+
const functions = [];
|
|
3312
|
+
codeLines2.forEach((line, i) => {
|
|
3313
|
+
if (!/^\s*(?:async\s+)?def\s+\w+/.test(line)) return;
|
|
3314
|
+
let end = i;
|
|
3315
|
+
for (let j = i + 1; j < codeLines2.length; j++) {
|
|
3316
|
+
if (!codeLines2[j].trim()) continue;
|
|
3317
|
+
if (indent(codeLines2[j]) <= indent(line)) break;
|
|
3318
|
+
end = j;
|
|
3319
|
+
}
|
|
3320
|
+
functions.push({ range: [i, end], header: i });
|
|
3321
|
+
});
|
|
3322
|
+
const lineIndex = (offset) => lineOf2(offset) - 1;
|
|
3323
|
+
const scopeOf = (line) => {
|
|
3324
|
+
if (/^\s*@/.test(codeLines2[line] ?? "")) {
|
|
3325
|
+
for (let j = line; j < codeLines2.length && j < line + 40; j++) {
|
|
3326
|
+
const owner = functions.find((fn) => fn.header === j);
|
|
3327
|
+
if (owner) return owner.range;
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
return innermost(functions.map((fn) => fn.range), line);
|
|
3331
|
+
};
|
|
3332
|
+
const blockOf = (line) => {
|
|
3333
|
+
if (!/^\s*with\b/.test(codeLines2[line] ?? "")) return null;
|
|
3334
|
+
let end = line;
|
|
3335
|
+
for (let j = line + 1; j < codeLines2.length; j++) {
|
|
3336
|
+
if (!codeLines2[j].trim()) continue;
|
|
3337
|
+
if (indent(codeLines2[j]) <= indent(codeLines2[line])) break;
|
|
3338
|
+
end = j;
|
|
3339
|
+
}
|
|
3340
|
+
return [line, end];
|
|
3341
|
+
};
|
|
3342
|
+
const patched = /* @__PURE__ */ new Map();
|
|
3343
|
+
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
|
+
for (const match of code.matchAll(patchCall)) {
|
|
3345
|
+
const open2 = match.index + match[0].length - 1;
|
|
3346
|
+
const end = closing(code, open2);
|
|
3347
|
+
if (end < 0) continue;
|
|
3348
|
+
const args = raw.slice(open2 + 1, end);
|
|
3349
|
+
if (/\bwraps\s*=/.test(args)) continue;
|
|
3350
|
+
const target = STRING_AFTER.exec(args);
|
|
3351
|
+
const parts = target ? null : /^\s*([A-Za-z_][\w.]*)\s*,\s*(['"])(\w+)\2/.exec(args);
|
|
3352
|
+
const key = target ? target[2] : parts ? `${expandObject(parts[1])}.${parts[3]}` : void 0;
|
|
3353
|
+
if (!key || !DOTTED.test(key)) continue;
|
|
3354
|
+
const line = lineIndex(match.index);
|
|
3355
|
+
patched.set(key, [...patched.get(key) ?? [], { index: match.index, scope: blockOf(line) ?? scopeOf(line) }]);
|
|
3356
|
+
}
|
|
3357
|
+
if (!patched.size) return [];
|
|
3358
|
+
const found = /* @__PURE__ */ new Map();
|
|
3359
|
+
const assertion = /^[ \t]*assert\s+(?:not\s+)?([A-Za-z_][\w.]*)\s*(\()?|\bself\s*\.\s*assert\w*\s*\(\s*([A-Za-z_][\w.]*)\s*(\()?/gm;
|
|
3360
|
+
for (const match of code.matchAll(assertion)) {
|
|
3361
|
+
let callee = (match[1] ?? match[3]) && (match[2] ?? match[4]) ? match[1] ?? match[3] : void 0;
|
|
3362
|
+
const variable = !callee ? match[1] ?? match[3] : void 0;
|
|
3363
|
+
if (!callee && variable && /^[A-Za-z_]\w*$/.test(variable)) {
|
|
3364
|
+
const owner = scopeOf(lineIndex(match.index));
|
|
3365
|
+
const from = owner ? starts[owner[0]] ?? 0 : 0;
|
|
3366
|
+
const assigned = [...code.slice(from, match.index).matchAll(new RegExp(`^[ \\t]*${variable}\\s*=(?!=)\\s*(?:await\\s+)?([A-Za-z_][\\w.]*)\\s*\\(`, "gm"))].at(-1);
|
|
3367
|
+
callee = assigned?.[1];
|
|
3368
|
+
}
|
|
3369
|
+
if (!callee) continue;
|
|
3370
|
+
const key = expand(callee);
|
|
3371
|
+
if (found.has(key)) continue;
|
|
3372
|
+
const at = lineIndex(match.index);
|
|
3373
|
+
const patch = patched.get(key)?.find((item) => item.scope && within(item.scope, at) && (at >= lineIndex(item.index) || /^\s*@/.test(codeLines2[lineIndex(item.index)] ?? "")));
|
|
3374
|
+
if (!patch) continue;
|
|
3375
|
+
const line = lineOf2(patch.index);
|
|
3376
|
+
found.set(key, {
|
|
3377
|
+
key,
|
|
3378
|
+
line,
|
|
3379
|
+
snippet: lines[line - 1].trim().slice(0, 120),
|
|
3380
|
+
reason: `assertion on the output of ${callee}(), which this test replaces with a mock`
|
|
3381
|
+
});
|
|
3382
|
+
}
|
|
3383
|
+
return [...found.values()];
|
|
3384
|
+
}
|
|
3385
|
+
function mockedSubjects(text, file) {
|
|
3386
|
+
if (/mock|monkeypatch/i.test(file)) return [];
|
|
3387
|
+
if (/\.[cm]?[jt]sx?$/i.test(file)) return javascript(text);
|
|
3388
|
+
if (/\.py$/i.test(file)) return python(text);
|
|
3389
|
+
return [];
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3168
3392
|
// src/pyconfig.ts
|
|
3169
3393
|
function openBrackets(text) {
|
|
3170
3394
|
const bare = text.replace(/"(?:\\.|[^"\\\n])*"|'[^'\n]*'/g, '""').replace(/#.*$/gm, "");
|
|
@@ -3205,6 +3429,13 @@ function members(value) {
|
|
|
3205
3429
|
if (quoted.length) return quoted;
|
|
3206
3430
|
return value.replace(/[#;].*$/gm, "").split(/[\s,]+/).map((word) => word.trim()).filter(Boolean);
|
|
3207
3431
|
}
|
|
3432
|
+
function covers(selector, code) {
|
|
3433
|
+
if (selector === "ALL") return true;
|
|
3434
|
+
const wanted = /^([A-Za-z]+)(\d*)$/.exec(selector), actual = /^([A-Za-z]+)(\d*)$/.exec(code);
|
|
3435
|
+
if (!wanted || !actual) return code.startsWith(selector);
|
|
3436
|
+
const linter = (letters) => letters.toUpperCase().replace(/^TCH$/, "TC");
|
|
3437
|
+
return linter(wanted[1]) === linter(actual[1]) && actual[2].startsWith(wanted[2]);
|
|
3438
|
+
}
|
|
3208
3439
|
var truthy = (value) => /^(?:true|1|yes|on)$/i.test(value.replace(/\s*[#;].*$/, "").trim());
|
|
3209
3440
|
function toolOf(file, section) {
|
|
3210
3441
|
const name = section.toLowerCase();
|
|
@@ -3240,17 +3471,24 @@ function analyzePythonConfig(file, oldText, newText, report) {
|
|
|
3240
3471
|
return found;
|
|
3241
3472
|
};
|
|
3242
3473
|
const was = ignored(before);
|
|
3474
|
+
const enforced = (tool, code) => {
|
|
3475
|
+
if (tool !== "ruff" && tool !== "flake8") return true;
|
|
3476
|
+
const chosen = (key) => before.filter((entry) => toolOf(file, entry.section) === tool && key.test(entry.key)).flatMap((entry) => members(entry.value));
|
|
3477
|
+
const explicit = chosen(/^select$/), extra = chosen(/^extend[-_]select$/);
|
|
3478
|
+
const base = explicit.length ? explicit : tool === "ruff" ? ["E4", "E7", "E9", "F"] : ["E", "F", "W", "C90"];
|
|
3479
|
+
return [...base, ...extra].some((selected2) => covers(selected2, code) || covers(code, selected2));
|
|
3480
|
+
};
|
|
3243
3481
|
for (const [tool, bucket] of ignored(after)) {
|
|
3244
3482
|
if (!configured.has(tool)) continue;
|
|
3245
3483
|
const old = [...was.get(tool)?.keys() ?? []];
|
|
3246
|
-
const fresh = [...bucket].filter(([code]) => !old.some((known) =>
|
|
3484
|
+
const fresh = [...bucket].filter(([code]) => !old.some((known) => covers(known, code)) && enforced(tool, code));
|
|
3247
3485
|
if (fresh.length) report(Math.min(...fresh.map(([, line]) => line)), `${tool} ignore: ${fresh.slice(0, 4).map(([code]) => "+" + code).join(", ")}${fresh.length > 4 ? ", \u2026" : ""}`, "Lint or type checks switched off in the configuration");
|
|
3248
3486
|
}
|
|
3249
3487
|
const selected = (entries, tool) => entries.filter((entry) => toolOf(file, entry.section) === tool && entry.key === "select").flatMap((entry) => members(entry.value));
|
|
3250
3488
|
for (const tool of ["ruff", "flake8"]) {
|
|
3251
3489
|
const old = selected(before, tool), now = selected(after, tool);
|
|
3252
3490
|
if (!old.length || !now.length || now.includes("ALL")) continue;
|
|
3253
|
-
const dropped = old.filter((code) => !now.some((kept) =>
|
|
3491
|
+
const dropped = old.filter((code) => !now.some((kept) => covers(kept, code)));
|
|
3254
3492
|
if (dropped.length) {
|
|
3255
3493
|
const line = after.find((entry) => toolOf(file, entry.section) === tool && entry.key === "select")?.line ?? 1;
|
|
3256
3494
|
report(line, `${tool} select: ${dropped.slice(0, 4).map((code) => "-" + code).join(", ")}${dropped.length > 4 ? ", \u2026" : ""}`, "Lint rules removed from the selected set");
|
|
@@ -3430,15 +3668,71 @@ function parseTests(text, language) {
|
|
|
3430
3668
|
function skeleton(assertion) {
|
|
3431
3669
|
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, "");
|
|
3432
3670
|
}
|
|
3433
|
-
var WEAK_JS = /\.(?:toBeDefined|toBeTruthy|toBeFalsy|toBeUndefined)\s*\(|\.not\s*\.\s*(?:toBeNull|toBeUndefined|toThrow)\s*\(|\.toBeGreaterThanOrEqual\s*\(\s*0\s*\)
|
|
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*\)/;
|
|
3434
3672
|
var BOUND_JS = /\.toBeGreaterThan\s*\(\s*(?:0|-1)\s*\)|\.toBeGreaterThanOrEqual\s*\(\s*[01]\s*\)/;
|
|
3435
3673
|
var ZERO_JS = /\.(?:toBe|toEqual|toStrictEqual|toHaveLength|toHaveBeenCalledTimes)\s*\(\s*(?:0|\[\s*\]|\{\s*\}|null|undefined|false|"")\s*\)/;
|
|
3436
3674
|
var WEAK_PY = /^\s*assert\s+[\w.\[\]]+\s*(?:,.*)?$|^\s*assert\s+.+\bis\s+not\s+None\s*(?:,.*)?$|^\s*assert\s+True\b/;
|
|
3437
3675
|
var BOUND_PY = /^\s*assert\s+.+(?:>\s*0|>=\s*[01])\s*(?:,.*)?$/;
|
|
3438
|
-
var EMPTY_PY = /==\s*(?:0|\[\s*\]|\{\s*\}|""|''|None|False
|
|
3676
|
+
var EMPTY_PY = /==\s*(?:0(?:\.0+)?(?![\w.])|\[\s*\]|\{\s*\}|""|''|\(\s*\)|(?:set|dict|list|tuple|frozenset)\(\s*\)|None\b|False\b)|\bis\s+None\b|\bnot\b|!=\s*(?:0|None)\b/;
|
|
3439
3677
|
var PY_SPECIFIC = /==|!=|<=|>=|\bin\b|\bis\b|\bisinstance\s*\(|\.startswith\s*\(|\.endswith\s*\(|\bcallable\s*\(/;
|
|
3440
3678
|
var JS_SPECIFIC = /\.(?:toBe|toEqual|toStrictEqual|toHaveLength|toContain|toContainEqual|toMatch|toMatchObject|toHaveProperty|toHaveBeenCalledWith|toHaveBeenLastCalledWith|toHaveBeenNthCalledWith|toBeCalledWith|toHaveBeenCalledTimes|toHaveBeenCalledOnce|toHaveTextContent|toHaveAttribute|toHaveValue|toThrow\w*|toBeGreaterThan\w*|toBeLessThan\w*)\s*\(/;
|
|
3441
3679
|
var THROWING_QUERY = /\b(?:get|find)(?:All)?By\w*\s*\(/;
|
|
3680
|
+
function checkedExpression(assertion, language) {
|
|
3681
|
+
const squash = (text) => text.replace(/\s+/g, "").replace(/^len\((.*)\)$/, "$1").replace(/\.length$/, "");
|
|
3682
|
+
if (language === "py") {
|
|
3683
|
+
const operand = /^\s*assert\s+(?:not\s+)?(.+?)\s*(?:==|!=|>=|<=|>|<|\bin\b|\bis\b)/.exec(assertion)?.[1] ?? /\bself\s*\.\s*assert\w*\s*\(\s*([^,]+?)\s*,/.exec(assertion)?.[1];
|
|
3684
|
+
return operand ? squash(operand) : null;
|
|
3685
|
+
}
|
|
3686
|
+
const start = assertion.search(/(?<![\w$.])expect\s*\(/);
|
|
3687
|
+
if (start >= 0) {
|
|
3688
|
+
const open2 = assertion.indexOf("(", start);
|
|
3689
|
+
let depth = 0;
|
|
3690
|
+
for (let i = open2; i < assertion.length; i++) {
|
|
3691
|
+
if (assertion[i] === "(") depth++;
|
|
3692
|
+
else if (assertion[i] === ")" && --depth === 0) return squash(assertion.slice(open2 + 1, i));
|
|
3693
|
+
}
|
|
3694
|
+
return null;
|
|
3695
|
+
}
|
|
3696
|
+
const node = /assert\s*\.\s*(?:equal|strictEqual|deepEqual|deepStrictEqual|ok)\s*\(\s*([^,)]+?)\s*(?:,|\)|>=|<=|>|<)/.exec(assertion)?.[1];
|
|
3697
|
+
return node ? squash(node) : null;
|
|
3698
|
+
}
|
|
3699
|
+
function matcherArgument(assertion, name) {
|
|
3700
|
+
const open2 = new RegExp(`\\.\\s*${name}\\s*\\(`).exec(assertion);
|
|
3701
|
+
if (!open2) return null;
|
|
3702
|
+
let depth = 1;
|
|
3703
|
+
for (let i = open2.index + open2[0].length; i < assertion.length; i++) {
|
|
3704
|
+
if ("([{".includes(assertion[i])) depth++;
|
|
3705
|
+
else if (")]}".includes(assertion[i]) && --depth === 0) return assertion.slice(open2.index + open2[0].length, i).replace(/\s+/g, "");
|
|
3706
|
+
}
|
|
3707
|
+
return null;
|
|
3708
|
+
}
|
|
3709
|
+
function isExact(assertion, language) {
|
|
3710
|
+
if (language === "js") {
|
|
3711
|
+
return !/\.\s*not\s*\./.test(assertion) && (/\.(?:toBe|toEqual|toStrictEqual|toHaveLength)\s*\(/.test(assertion) || /assert\s*\.\s*(?:equal|strictEqual|deepEqual|deepStrictEqual)\s*\(/.test(assertion));
|
|
3712
|
+
}
|
|
3713
|
+
if (language === "py") return /^\s*assert\s+.+?\s*==\s*\S/.test(assertion) || /\bself\s*\.\s*assertEqual\s*\(/.test(assertion);
|
|
3714
|
+
return false;
|
|
3715
|
+
}
|
|
3716
|
+
function relaxes(oldAssertion, newAssertion, language, siblings = []) {
|
|
3717
|
+
const subject = checkedExpression(oldAssertion, language);
|
|
3718
|
+
if (!subject || subject !== checkedExpression(newAssertion, language)) return false;
|
|
3719
|
+
if (siblings.some((other) => isExact(other, language) && checkedExpression(other, language) === subject)) return false;
|
|
3720
|
+
if (language === "js") {
|
|
3721
|
+
if (/\.\s*not\s*\./.test(oldAssertion) || /\.\s*not\s*\./.test(newAssertion)) return false;
|
|
3722
|
+
const exact = /\.(?:toBe|toEqual|toStrictEqual|toHaveLength)\s*\(\s*(-?\d+(?:\.\d+)?)\s*\)/.exec(oldAssertion)?.[1] ?? /assert\s*\.\s*(?:equal|strictEqual|deepEqual|deepStrictEqual)\s*\([^,]+,\s*(-?\d+(?:\.\d+)?)\s*[,)]/.exec(oldAssertion)?.[1];
|
|
3723
|
+
const bound = /\.(?:toBeGreaterThan|toBeGreaterThanOrEqual|toBeLessThan|toBeLessThanOrEqual)\s*\(/.test(newAssertion) || /assert\s*\.\s*ok\s*\([^;]*(?:>=|<=|>|<)/.test(newAssertion);
|
|
3724
|
+
if (exact !== void 0 && Number(exact) !== 0 && bound) return true;
|
|
3725
|
+
const same = matcherArgument(oldAssertion, "toEqual") ?? matcherArgument(oldAssertion, "toStrictEqual");
|
|
3726
|
+
return same !== null && same !== "" && same === matcherArgument(newAssertion, "toMatchObject");
|
|
3727
|
+
}
|
|
3728
|
+
if (language === "py") {
|
|
3729
|
+
if (EMPTY_PY.test(oldAssertion)) return false;
|
|
3730
|
+
const exact = /^\s*assert\s+.+?\s*==\s*\S/.test(oldAssertion) || /\bself\s*\.\s*assertEqual\s*\(/.test(oldAssertion);
|
|
3731
|
+
const bound = /^\s*assert\s+.+?\s*(?:>=|<=|>|<)\s*\S/.test(newAssertion) && !/==/.test(newAssertion.replace(/[<>]=/g, "")) || /\bself\s*\.\s*assert(?:Greater|Less)(?:Equal)?\s*\(/.test(newAssertion);
|
|
3732
|
+
return exact && bound;
|
|
3733
|
+
}
|
|
3734
|
+
return false;
|
|
3735
|
+
}
|
|
3442
3736
|
function pinsLess(oldAssertion, newAssertion, language) {
|
|
3443
3737
|
if (language === "js") {
|
|
3444
3738
|
const was = /\.toThrow(?:Error)?\s*\(\s*([^)\s][^)]*)\)/.exec(oldAssertion);
|
|
@@ -3447,26 +3741,27 @@ function pinsLess(oldAssertion, newAssertion, language) {
|
|
|
3447
3741
|
}
|
|
3448
3742
|
if (language === "py") {
|
|
3449
3743
|
const raised = (text) => {
|
|
3744
|
+
const captured = /\)\s*as\s+\w+/.test(text);
|
|
3450
3745
|
const marker = /\bpytest\s*\.\s*raises\s*\(\s*([\w.]+|\([^)]*\))([^)]*)\)/.exec(text);
|
|
3451
|
-
if (marker) return { type: marker[1], message: /\bmatch\s*=/.test(marker[2]) };
|
|
3746
|
+
if (marker) return { type: marker[1], message: /\bmatch\s*=/.test(marker[2]), captured };
|
|
3452
3747
|
const unit = /\bassertRaises(Regex)?\s*\(\s*([\w.]+|\([^)]*\))/.exec(text);
|
|
3453
|
-
return unit ? { type: unit[2], message: !!unit[1] } : null;
|
|
3748
|
+
return unit ? { type: unit[2], message: !!unit[1], captured } : null;
|
|
3454
3749
|
};
|
|
3455
3750
|
const was = raised(oldAssertion), now = raised(newAssertion);
|
|
3456
3751
|
const broad = (type) => /^(?:builtins\.)?(?:Base)?Exception$/.test(type);
|
|
3457
|
-
return !!was && !!now && (was.message && !now.message || !broad(was.type) && broad(now.type));
|
|
3752
|
+
return !!was && !!now && (was.message && !now.message && !now.captured || !broad(was.type) && broad(now.type));
|
|
3458
3753
|
}
|
|
3459
3754
|
return false;
|
|
3460
3755
|
}
|
|
3461
3756
|
function isWeaker(oldAssertion, newAssertion, language) {
|
|
3462
|
-
if (pinsLess(oldAssertion, newAssertion, language)) return true;
|
|
3757
|
+
if (pinsLess(oldAssertion, newAssertion, language) || relaxes(oldAssertion, newAssertion, language)) return true;
|
|
3463
3758
|
if (language === "js") {
|
|
3464
3759
|
if (THROWING_QUERY.test(newAssertion) || !JS_SPECIFIC.test(oldAssertion) || WEAK_JS.test(oldAssertion) || BOUND_JS.test(oldAssertion)) return false;
|
|
3465
3760
|
return WEAK_JS.test(newAssertion) || BOUND_JS.test(newAssertion) && !ZERO_JS.test(oldAssertion);
|
|
3466
3761
|
}
|
|
3467
3762
|
if (language === "py") {
|
|
3468
3763
|
if (WEAK_PY.test(oldAssertion) || BOUND_PY.test(oldAssertion) || !PY_SPECIFIC.test(oldAssertion)) return false;
|
|
3469
|
-
return WEAK_PY.test(newAssertion) || BOUND_PY.test(newAssertion) && !EMPTY_PY.test(oldAssertion);
|
|
3764
|
+
return WEAK_PY.test(newAssertion) || BOUND_PY.test(newAssertion) && !EMPTY_PY.test(oldAssertion) && /==|\bin\b|[<>]=?/.test(oldAssertion);
|
|
3470
3765
|
}
|
|
3471
3766
|
return false;
|
|
3472
3767
|
}
|
|
@@ -3485,6 +3780,34 @@ var HELPER_CALL = /(?<![\w$])((?:expect|assert|verify|check|require)[A-Z]\w*|_{0
|
|
|
3485
3780
|
function finding(type, file, line, snippet, reason, commit) {
|
|
3486
3781
|
return { type, file, lineNumber: Math.max(1, line), lineSnippet: snippet, commitHash: commit, commitDate: "", reason, suggestion: RULE_GUIDES[type].suggestion };
|
|
3487
3782
|
}
|
|
3783
|
+
function alignAssertions(before, after) {
|
|
3784
|
+
const table = Array.from({ length: before.length + 1 }, () => new Array(after.length + 1).fill(0));
|
|
3785
|
+
for (let i = before.length - 1; i >= 0; i--) {
|
|
3786
|
+
for (let j = after.length - 1; j >= 0; j--) table[i][j] = before[i] === after[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
|
|
3787
|
+
}
|
|
3788
|
+
const pairs = /* @__PURE__ */ new Map();
|
|
3789
|
+
for (let i = 0, j = 0; i < before.length && j < after.length; ) {
|
|
3790
|
+
if (before[i] === after[j]) {
|
|
3791
|
+
pairs.set(i, j);
|
|
3792
|
+
i++;
|
|
3793
|
+
j++;
|
|
3794
|
+
} else if (table[i + 1][j] >= table[i][j + 1]) i++;
|
|
3795
|
+
else j++;
|
|
3796
|
+
}
|
|
3797
|
+
return pairs;
|
|
3798
|
+
}
|
|
3799
|
+
function unmatchedIndexes(from, against) {
|
|
3800
|
+
const budget = /* @__PURE__ */ new Map();
|
|
3801
|
+
for (const assertion of against) budget.set(assertion, (budget.get(assertion) ?? 0) + 1);
|
|
3802
|
+
return from.map((assertion, i) => ({ assertion, i })).filter(({ assertion }) => {
|
|
3803
|
+
const available = budget.get(assertion) ?? 0;
|
|
3804
|
+
if (available > 0) {
|
|
3805
|
+
budget.set(assertion, available - 1);
|
|
3806
|
+
return false;
|
|
3807
|
+
}
|
|
3808
|
+
return true;
|
|
3809
|
+
}).map(({ i }) => i);
|
|
3810
|
+
}
|
|
3488
3811
|
function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
|
|
3489
3812
|
const language = languageOf(newText === null ? oldPath : newPath);
|
|
3490
3813
|
if (!language || !(isTestFile(newPath) || isTestFile(oldPath))) return EMPTY;
|
|
@@ -3502,6 +3825,10 @@ function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
|
|
|
3502
3825
|
const addedText = after.filter((test) => !beforeKeys.has(test.key)).map((test) => test.text).join("\n");
|
|
3503
3826
|
const result = { immediate: [], removedTests: [], addedHashes: [], rewritten: [] };
|
|
3504
3827
|
for (const test of after) if (!beforeKeys.has(test.key)) result.addedHashes.push(test.bodyHash);
|
|
3828
|
+
const mockedBefore = new Set((oldText === null ? [] : mockedSubjects(oldText, oldPath)).map((item) => item.key));
|
|
3829
|
+
for (const item of newText === null ? [] : mockedSubjects(newText, newPath)) {
|
|
3830
|
+
if (!mockedBefore.has(item.key)) result.immediate.push(finding("trivial-assert", newPath, item.line, item.snippet, item.reason, commit));
|
|
3831
|
+
}
|
|
3505
3832
|
for (const old of before) {
|
|
3506
3833
|
const current = afterByKey.get(old.key);
|
|
3507
3834
|
if (!current) {
|
|
@@ -3522,6 +3849,34 @@ function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
|
|
|
3522
3849
|
if (current.parametrized && !old.parametrized) continue;
|
|
3523
3850
|
const snapshot = (test) => test.raw.some((statement) => /\.toMatch(?:Inline)?Snapshot\b|\.matchSnapshot\b|\bsnapshot\s*\(/.test(statement));
|
|
3524
3851
|
if (snapshot(current) && !snapshot(old)) continue;
|
|
3852
|
+
if (old.assertions.length && current.assertions.length && current.assertions.length !== old.assertions.length && old.assertions.length * current.assertions.length <= 25e4) {
|
|
3853
|
+
const aligned = alignAssertions(old.assertions, current.assertions);
|
|
3854
|
+
const fresh = unmatchedIndexes(current.assertions, old.assertions);
|
|
3855
|
+
const gone = unmatchedIndexes(old.assertions, current.assertions).filter((index) => (newTotals.get(old.assertions[index]) ?? 0) < (oldTotals.get(old.assertions[index]) ?? 0));
|
|
3856
|
+
for (const index of gone) {
|
|
3857
|
+
const was = checkedExpression(old.raw[index], language);
|
|
3858
|
+
if (was === null) continue;
|
|
3859
|
+
let low = -1, high = current.assertions.length;
|
|
3860
|
+
for (const [from, to] of aligned) {
|
|
3861
|
+
if (from < index) low = Math.max(low, to);
|
|
3862
|
+
else if (from > index) high = Math.min(high, to);
|
|
3863
|
+
}
|
|
3864
|
+
const successors = fresh.filter((candidate) => candidate > low && candidate < high);
|
|
3865
|
+
const siblings = successors.map((candidate) => current.raw[candidate]);
|
|
3866
|
+
const partner = successors.find((candidate) => checkedExpression(current.raw[candidate], language) === was && relaxes(old.raw[index], current.raw[candidate], language, siblings));
|
|
3867
|
+
if (partner !== void 0) {
|
|
3868
|
+
result.immediate.push(finding(
|
|
3869
|
+
"assertion-weakened",
|
|
3870
|
+
newPath,
|
|
3871
|
+
current.line,
|
|
3872
|
+
`${old.name}: ${old.raw[index].slice(0, 80)} \u2192 ${current.raw[partner].slice(0, 80)}`,
|
|
3873
|
+
"Assertion replaced by a weaker check",
|
|
3874
|
+
commit
|
|
3875
|
+
));
|
|
3876
|
+
break;
|
|
3877
|
+
}
|
|
3878
|
+
}
|
|
3879
|
+
}
|
|
3525
3880
|
if (current.assertions.length < old.assertions.length) {
|
|
3526
3881
|
if ([...current.text.matchAll(HELPER_CALL)].some((call) => !(oldText ?? "").includes(call[1]))) continue;
|
|
3527
3882
|
const budget = tally([current]);
|
|
@@ -3550,7 +3905,8 @@ ${addedText}`;
|
|
|
3550
3905
|
continue;
|
|
3551
3906
|
}
|
|
3552
3907
|
if (current.assertions.length === old.assertions.length && old.assertions.length > 0) {
|
|
3553
|
-
const
|
|
3908
|
+
const goneIndexes = unmatchedIndexes(old.assertions, current.assertions), freshIndexes = unmatchedIndexes(current.assertions, old.assertions);
|
|
3909
|
+
const changed = goneIndexes.map((index, k) => ({ before: old.assertions[index], after: current.assertions[freshIndexes[k]], rawBefore: old.raw[index], rawAfter: current.raw[freshIndexes[k]] })).filter((pair) => pair.after !== void 0);
|
|
3554
3910
|
const weaker = changed.find((pair) => isWeaker(pair.rawBefore, pair.rawAfter, language));
|
|
3555
3911
|
if (weaker) {
|
|
3556
3912
|
result.immediate.push(finding(
|
|
@@ -3779,7 +4135,9 @@ function analyzeGuardFile(file, oldText, newText, commit) {
|
|
|
3779
4135
|
}
|
|
3780
4136
|
const swallowing = (text) => text.split(/\r?\n/).map((line, index) => ({ line: line.trim(), index })).filter((item) => !item.line.startsWith("#") && CI_RUNNER.test(item.line) && SHELL_SWALLOW.test(item.line));
|
|
3781
4137
|
const was = swallowing(oldText), now = swallowing(newText);
|
|
3782
|
-
|
|
4138
|
+
const tolerance = (text) => text.split(/\r?\n/).filter((line) => !line.trim().startsWith("#") && /continue-on-error:\s*true|allow_failure:\s*true/.test(line)).length;
|
|
4139
|
+
const traded = Math.max(0, tolerance(oldText) - tolerance(newText));
|
|
4140
|
+
if (now.length > was.length + traded) {
|
|
3783
4141
|
const known = new Set(was.map((item) => item.line));
|
|
3784
4142
|
const fresh = now.find((item) => !known.has(item.line)) ?? now[0];
|
|
3785
4143
|
out.push(finding("guard-weakened", file, fresh.index + 1, fresh.line.slice(0, 80), "Test command can no longer fail the CI step", commit));
|
package/dist/cli.js
CHANGED
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-roast",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Stops AI coding agents (and rushed humans) from turning a build green by weakening tests, coverage gates or CI. Hook for Claude Code, Codex and Cursor, pre-commit, GitHub Action. Offline.",
|
|
5
5
|
"mcpName": "io.github.Nari-Ab/agent-roast",
|
|
6
6
|
"type": "module",
|