agent-roast 0.18.0 → 0.19.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 +13 -10
- package/dist/{chunk-2KWC2IAP.js → chunk-OAMVMB5N.js} +220 -38
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -109,23 +109,26 @@ 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 asks about them. On repositories it had not seen,
|
|
113
|
-
blocks were questions with a harmless answer.**
|
|
112
|
+
deliberate, and not specific to AI. agent-roast is a tripwire that asks about them. On repositories it had not seen, between a quarter and
|
|
113
|
+
two thirds of its blocks were questions with a harmless answer, depending on the sample.**
|
|
114
114
|
|
|
115
|
-
- **
|
|
115
|
+
- **145 of 145** hand-labeled cheating edits are caught and **127 of 127** harmless look-alikes (reformats, moves, raised
|
|
116
116
|
thresholds, environment-gated skips) stay silent. The cases were written with the detector, so they guard against regressions;
|
|
117
117
|
they do not prove accuracy. One gap is documented and still open.
|
|
118
118
|
- **12,645 commits** that touch tests or CI/config in 166 public repositories: blocking rules fired on 0.3% of AI-signed commits
|
|
119
119
|
(Claude, Copilot, Cursor, Codex, Devin, aider) and on 0.3% of the others. Inside the same repositories the odds for an
|
|
120
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
|
|
121
121
|
checks more often.
|
|
122
|
-
- **On repositories it was not written from,
|
|
123
|
-
repositories (
|
|
124
|
-
first sample and
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
122
|
+
- **On repositories it was not written from, between a quarter and two thirds of the blocked commits were false alarms.** Six samples of 39
|
|
123
|
+
or 40 public repositories (238 in all, 67,843 commits) were replayed and every blocked commit was read. The share was 69% for 0.17.0 on the
|
|
124
|
+
first sample and on the fourth, 51% for 0.18.0 on the fourth and 27% on the fifth, which it had not seen either. This version, frozen before the
|
|
125
|
+
sixth sample was drawn, blocked 100 of its 11,335 commits: 34 changed a check a reviewer should see (an assertion that got weaker, a gate
|
|
126
|
+
that was relaxed, a test switched off or left focused) and 66 were false alarms, but 51 of the 66 were one defect in one repository, an array of
|
|
127
|
+
mypy overrides read as one table, which is fixed in this release; the released build blocks 44 of those commits, 34 and 10. A new
|
|
128
|
+
test that asserts nothing counts as a block for a reviewer; counted as false alarms, 13 of 33 on the fifth sample and 72 of 100 on the sixth. Most of the rest, in the
|
|
129
|
+
earlier measurements too, were deliberate and explained. One reader (an AI assistant) sorted them, and the fixes were made from the same
|
|
130
|
+
sorting, so treat the figures as a first estimate ([section 9](docs/benchmarks.md#9-repositories-nobody-had-tuned-on),
|
|
131
|
+
[section 10](docs/benchmarks.md#10-two-more-samples-and-what-the-fixes-from-sample-4-did)).
|
|
129
132
|
- **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
133
|
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
134
|
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
|
|
@@ -1166,7 +1166,8 @@ var PYTHON_TEST_SKIP_PATTERNS = [
|
|
|
1166
1166
|
{ regex: /\bself\s*\.\s*skipTest\s*\(/, name: "imperative unittest skip (self.skipTest)", imperative: true },
|
|
1167
1167
|
// A test that is expected to fail cannot fail the run, which is what a skip does too.
|
|
1168
1168
|
{ regex: /(?<![\w.])pytest\s*\.\s*xfail\s*\(/, name: "imperative pytest xfail (pytest.xfail)", imperative: true },
|
|
1169
|
-
|
|
1169
|
+
// `@unittest.expectedFailure`, and the same decorator under another module's name (`@test.expectedFailure` of a test framework that re-exports it).
|
|
1170
|
+
{ regex: /@(?:[A-Za-z_]\w*\s*\.\s*)*expectedFailure\b/, name: "test expected to fail (@unittest.expectedFailure)", imperative: false }
|
|
1170
1171
|
];
|
|
1171
1172
|
var PYTHON_MARKER = /(@\s*|(?<![\w.])marks\s*=\s*\[?\s*|^[ \t]*pytestmark\s*=\s*\[?\s*)pytest\s*\.\s*mark\s*\.\s*(skip|xfail)\b/gm;
|
|
1172
1173
|
function isConditionalXfail(args) {
|
|
@@ -1317,13 +1318,55 @@ function isGuardedBlockSkip(lines, profile) {
|
|
|
1317
1318
|
return compiledConditionally(i + 1);
|
|
1318
1319
|
}
|
|
1319
1320
|
var JS_TEST_API = /(?<![\w$.])(?:(?:it|test|describe|suite)(?:\s*\.\s*(?:concurrent|serial|parallel|describe|failing|sequential))*\s*\.\s*(skip|todo|only|fixme|failing|fails|fail)|((?:xit|xtest|xdescribe|fit|fdescribe)))\b((?:\s*\.\s*(?:each|failing|concurrent|serial|parallel|sequential))*)\s*(?=\(|\u0001)/g;
|
|
1320
|
-
|
|
1321
|
+
var MENTIONS_CI = /\bCI\b|\bisCI\b|GITHUB_ACTIONS/;
|
|
1322
|
+
function isConditionalSkip(source, raw, open2, start) {
|
|
1321
1323
|
const args = callArguments(source, raw, open2);
|
|
1322
1324
|
if (!args) return false;
|
|
1323
1325
|
const isString = (arg) => /^(['"`])[\s\S]*\1$/.test(arg);
|
|
1324
|
-
if (!args.length ||
|
|
1326
|
+
if (!args.length || args[0] === "true") return isUnderRuntimeCondition(source, raw, start);
|
|
1327
|
+
if (isString(args[0])) return false;
|
|
1325
1328
|
if (args.length > 1 && !isString(args.at(-1))) return false;
|
|
1326
|
-
return
|
|
1329
|
+
return !MENTIONS_CI.test(args[0]);
|
|
1330
|
+
}
|
|
1331
|
+
var TEST_REGISTRATION = /^(?:await\s+)?(?:test|it|describe|suite|beforeAll|beforeEach|afterAll|afterEach)\b/;
|
|
1332
|
+
function isUnderRuntimeCondition(source, raw, start) {
|
|
1333
|
+
const alwaysTrue = (text) => /^\s*(?:true|1|!\s*0|!\s*false)\s*$/.test(text);
|
|
1334
|
+
const indentOf = (text) => text.length - text.trimStart().length;
|
|
1335
|
+
const lineStartOf = (offset) => Math.max(source.lastIndexOf("\n", offset - 1), source.lastIndexOf("\0", offset - 1)) + 1;
|
|
1336
|
+
const mentionsCi = (from, to) => MENTIONS_CI.test(raw.slice(from, to));
|
|
1337
|
+
const lineStart = lineStartOf(start);
|
|
1338
|
+
const head = source.slice(lineStart, start).trimEnd();
|
|
1339
|
+
if (head.endsWith(")")) {
|
|
1340
|
+
let depth = 0, opening = -1;
|
|
1341
|
+
for (let i = head.length - 1; i >= 0; i--) {
|
|
1342
|
+
if (head[i] === ")") depth++;
|
|
1343
|
+
else if (head[i] === "(" && --depth === 0) {
|
|
1344
|
+
opening = i;
|
|
1345
|
+
break;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
if (opening >= 0 && /(?:^|[\s}])if\s*$/.test(head.slice(0, opening))) {
|
|
1349
|
+
return !mentionsCi(lineStart + opening + 1, lineStart + head.length - 1) && !alwaysTrue(head.slice(opening + 1, -1));
|
|
1350
|
+
}
|
|
1351
|
+
} else if (/(?:^|[\s}])else$/.test(head)) return true;
|
|
1352
|
+
else if (head.endsWith("&&")) return !mentionsCi(lineStart, start) && !alwaysTrue(head.slice(0, -2).replace(/^[\s{}]+/, ""));
|
|
1353
|
+
const lineEnd = source.slice(start).search(/[\n\0]/);
|
|
1354
|
+
let limit = indentOf(source.slice(lineStart, lineEnd < 0 ? void 0 : start + lineEnd)), at = lineStart;
|
|
1355
|
+
for (let steps = 0; steps < 200 && at > 0; steps++) {
|
|
1356
|
+
if (source[at - 1] === "\0") return false;
|
|
1357
|
+
const previous = lineStartOf(at - 1), line = source.slice(previous, at - 1);
|
|
1358
|
+
const lineRaw = raw.slice(previous, at - 1);
|
|
1359
|
+
at = previous;
|
|
1360
|
+
if (!line.trim() || indentOf(line) >= limit) continue;
|
|
1361
|
+
const header = line.trim();
|
|
1362
|
+
if (/^(?:\}\s*)?(?:else\s+)?if\s*\(/.test(header)) {
|
|
1363
|
+
return !MENTIONS_CI.test(lineRaw) && !alwaysTrue(/\bif\s*\((.*)\)\s*\{?\s*$/.exec(header)?.[1] ?? "");
|
|
1364
|
+
}
|
|
1365
|
+
if (/^(?:\}\s*)?(?:else|catch)\b|^(?:case\b|default\s*:)|^\)\s*\{?\s*$/.test(header)) return true;
|
|
1366
|
+
if (TEST_REGISTRATION.test(header)) return false;
|
|
1367
|
+
limit = indentOf(line);
|
|
1368
|
+
}
|
|
1369
|
+
return false;
|
|
1327
1370
|
}
|
|
1328
1371
|
function callArguments(source, raw, open2) {
|
|
1329
1372
|
if (source[open2] !== "(") return null;
|
|
@@ -1627,7 +1670,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1627
1670
|
if (match[2] && previous >= 0 && (/[(,=:?[&|!+\-*%<]/.test(source[previous]) || /(?:await|return|yield|typeof|void)$/.test(source.slice(Math.max(0, previous - 6), previous + 1)))) continue;
|
|
1628
1671
|
if (/\bfunction$/.test(source.slice(Math.max(0, previous - 8), previous + 1))) continue;
|
|
1629
1672
|
if (source[match.index + match[0].length] === "" && !/\.\s*each\s*$/.test(match[3])) continue;
|
|
1630
|
-
if ((match[1] === "skip" || match[1] === "fixme" || match[1] === "fail") && !match[3] && isConditionalSkip(source, raw, match.index + match[0].length)) continue;
|
|
1673
|
+
if ((match[1] === "skip" || match[1] === "fixme" || match[1] === "fail") && !match[3] && isConditionalSkip(source, raw, match.index + match[0].length, match.index)) continue;
|
|
1631
1674
|
const focus = match[1] === "only" || match[2] === "fit" || match[2] === "fdescribe";
|
|
1632
1675
|
const expectedToFail = match[1] === "failing" || match[1] === "fails" || match[1] === "fail";
|
|
1633
1676
|
infractions.push({
|
|
@@ -3208,6 +3251,12 @@ function javascript(text) {
|
|
|
3208
3251
|
const end = closing(code, open2);
|
|
3209
3252
|
if (end > 0) callbacks.push([open2, end]);
|
|
3210
3253
|
}
|
|
3254
|
+
const tests = [];
|
|
3255
|
+
for (const match of code.matchAll(/(?<![\w$.])(?:it|test|describe)(?:\s*\.\s*\w+)*\s*\(/g)) {
|
|
3256
|
+
const open2 = match.index + match[0].length - 1;
|
|
3257
|
+
const end = closing(code, open2);
|
|
3258
|
+
if (end > 0) tests.push([open2, end]);
|
|
3259
|
+
}
|
|
3211
3260
|
const programLevel = (index) => {
|
|
3212
3261
|
let depth = 0;
|
|
3213
3262
|
for (let i = 0; i < index; i++) {
|
|
@@ -3226,8 +3275,9 @@ function javascript(text) {
|
|
|
3226
3275
|
const end = closing(code, open2);
|
|
3227
3276
|
const args = end > 0 ? raw.slice(open2, end) : "";
|
|
3228
3277
|
if (/requireActual|importActual|importOriginal/.test(args)) continue;
|
|
3229
|
-
const
|
|
3230
|
-
|
|
3278
|
+
const factoryHead = /^\(\s*(['"`])[^'"`\n]+\1\s*,\s*(?:async\s+)?(?:function\s*[\w$]*\s*)?(?:\(\s*([A-Za-z_$][\w$]*)\s*[,)]|([A-Za-z_$][\w$]*)\s*=>)/.exec(args);
|
|
3279
|
+
const loader = factoryHead?.[2] ?? factoryHead?.[3];
|
|
3280
|
+
if (loader && new RegExp(`(?<![\\w$])(?:(?<=\\.\\.\\.)|(?<!\\.))${loader.replace(/\$/g, "\\$")}\\s*(?:<[^\\n]*?>)?\\s*\\(`).test(args.slice(args.indexOf(loader) + loader.length))) continue;
|
|
3231
3281
|
const factory = /^\(\s*(['"`])[^'"`\n]+\1\s*,/.test(args) && !/,\s*\{\s*spy\s*:\s*true\b/.test(args);
|
|
3232
3282
|
if (!mocked.has(specifier)) mocked.set(specifier, { index: match.index, fake: factory });
|
|
3233
3283
|
}
|
|
@@ -3256,7 +3306,8 @@ function javascript(text) {
|
|
|
3256
3306
|
if (match[1].startsWith("{")) bind(match[1], specifier);
|
|
3257
3307
|
else bindings.set(match[1], { specifier, name: "*", namespace: true });
|
|
3258
3308
|
}
|
|
3259
|
-
const
|
|
3309
|
+
const candidates = [];
|
|
3310
|
+
const mockedNames = [...bindings].filter(([, binding]) => mocked.has(binding.specifier)).map(([local]) => local.replace(/\$/g, "\\$"));
|
|
3260
3311
|
for (const match of code.matchAll(/(?<![\w$.])expect\s*\(/g)) {
|
|
3261
3312
|
const open2 = match.index + match[0].length - 1;
|
|
3262
3313
|
const end = closing(code, open2);
|
|
@@ -3277,18 +3328,32 @@ function javascript(text) {
|
|
|
3277
3328
|
const name = binding.namespace ? call[2] : call[2] ? void 0 : binding.name;
|
|
3278
3329
|
if (!name) continue;
|
|
3279
3330
|
const identifier = call[1].replace(/\$/g, "\\$");
|
|
3280
|
-
const
|
|
3331
|
+
const configures = new RegExp(`(?<![\\w$.])${identifier}\\b[^;]{0,80}?\\.\\s*mock(?:Return|Resolved|Rejected|Implementation)\\w*\\s*\\(`, "g");
|
|
3332
|
+
const configured = [...raw.matchAll(configures)].some((config) => tests.every((range) => !within(range, config.index) || within(range, match.index)));
|
|
3281
3333
|
if (!mock.fake && !configured) continue;
|
|
3282
3334
|
const key = `${binding.specifier}::${name}`;
|
|
3283
|
-
if (found.has(key)) continue;
|
|
3284
3335
|
const line = lineOf2(mock.index);
|
|
3285
|
-
|
|
3336
|
+
candidates.push({
|
|
3286
3337
|
key,
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3338
|
+
at: match.index,
|
|
3339
|
+
range: innermost(callbacks, match.index),
|
|
3340
|
+
subject: {
|
|
3341
|
+
key,
|
|
3342
|
+
line,
|
|
3343
|
+
snippet: lines[line - 1].trim().slice(0, 120),
|
|
3344
|
+
reason: `assertion on the output of ${name}(), which this test replaces with a mock of "${binding.specifier}"`
|
|
3345
|
+
}
|
|
3290
3346
|
});
|
|
3291
3347
|
}
|
|
3348
|
+
const candidateAt = new Set(candidates.map((candidate) => candidate.at));
|
|
3349
|
+
const mentionsMock = mockedNames.length ? new RegExp(`(?<![\\w$.])(?:${mockedNames.join("|")})(?![\\w$])`) : null;
|
|
3350
|
+
const checksSomethingElse = (range) => !!range && [...code.matchAll(/(?<![\w$.])expect\s*\(/g)].some((other) => {
|
|
3351
|
+
if (!within(range, other.index) || candidateAt.has(other.index)) return false;
|
|
3352
|
+
const open2 = other.index + other[0].length - 1, end = closing(code, open2);
|
|
3353
|
+
return end > 0 && !mentionsMock?.test(code.slice(open2 + 1, end));
|
|
3354
|
+
});
|
|
3355
|
+
const found = /* @__PURE__ */ new Map();
|
|
3356
|
+
for (const candidate of candidates) if (!found.has(candidate.key) && !checksSomethingElse(candidate.range)) found.set(candidate.key, candidate.subject);
|
|
3292
3357
|
return [...found.values()];
|
|
3293
3358
|
}
|
|
3294
3359
|
var DOTTED = /^[A-Za-z_][\w.]*$/;
|
|
@@ -3398,6 +3463,7 @@ function python(text) {
|
|
|
3398
3463
|
const receiver = new RegExp(`(?<![\\w.])${patch.object.replace(/[.$]/g, "\\$&")}\\s*\\.\\s*(?!${attribute}\\b)\\w+\\s*\\(`);
|
|
3399
3464
|
if (receiver.test(code.slice(patch.end + 1, match.index))) continue;
|
|
3400
3465
|
}
|
|
3466
|
+
if (/(?<![\w$])(?:[A-Za-z_]\w*\s*\.\s*)*_*(?:install|wrap|instrument|inject|hook|swap|rewire|reload)\w*\s*\(/.test(code.slice(patch.end + 1, match.index))) continue;
|
|
3401
3467
|
const line = lineOf2(patch.index);
|
|
3402
3468
|
found.set(key, {
|
|
3403
3469
|
key,
|
|
@@ -3428,13 +3494,15 @@ function openBrackets(text) {
|
|
|
3428
3494
|
function configEntries(text, toml) {
|
|
3429
3495
|
const lines = text.split(/\r?\n/);
|
|
3430
3496
|
const entries = [];
|
|
3431
|
-
|
|
3497
|
+
const elements = /* @__PURE__ */ new Map();
|
|
3498
|
+
let section = "", block = 0;
|
|
3432
3499
|
for (let i = 0; i < lines.length; i++) {
|
|
3433
3500
|
const trimmed = lines[i].trim();
|
|
3434
3501
|
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) continue;
|
|
3435
|
-
const header =
|
|
3502
|
+
const header = /^(\[\[?)\s*([^\]]+?)\s*\]\]?\s*(?:[#;].*)?$/.exec(trimmed);
|
|
3436
3503
|
if (header) {
|
|
3437
|
-
section = header[
|
|
3504
|
+
section = header[2].replace(/["']/g, "");
|
|
3505
|
+
block = header[1] === "[[" ? (elements.set(section, (elements.get(section) ?? 0) + 1), elements.get(section)) : 0;
|
|
3438
3506
|
continue;
|
|
3439
3507
|
}
|
|
3440
3508
|
const pair = /^("[^"]*"|'[^']*'|[\w.-]+)\s*=\s*(.*)$/.exec(trimmed);
|
|
@@ -3446,7 +3514,7 @@ function configEntries(text, toml) {
|
|
|
3446
3514
|
} else {
|
|
3447
3515
|
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1]) && !/^\s*[#;]/.test(lines[i + 1])) value += "\n" + lines[++i].trim();
|
|
3448
3516
|
}
|
|
3449
|
-
entries.push({ section, key: pair[1].replace(/["']/g, ""), value, line: start + 1 });
|
|
3517
|
+
entries.push({ section, key: pair[1].replace(/["']/g, ""), value, line: start + 1, block });
|
|
3450
3518
|
}
|
|
3451
3519
|
return entries;
|
|
3452
3520
|
}
|
|
@@ -3520,10 +3588,16 @@ function analyzePythonConfig(file, oldText, newText, report) {
|
|
|
3520
3588
|
report(line, `${tool} select: ${dropped.slice(0, 4).map((code) => "-" + code).join(", ")}${dropped.length > 4 ? ", \u2026" : ""}`, "Lint rules removed from the selected set");
|
|
3521
3589
|
}
|
|
3522
3590
|
}
|
|
3523
|
-
const
|
|
3591
|
+
const tableOf = (entries) => {
|
|
3592
|
+
const modules = /* @__PURE__ */ new Map();
|
|
3593
|
+
for (const entry of entries) if (entry.block && entry.key === "module") modules.set(`${entry.section}#${entry.block}`, members(entry.value).join(","));
|
|
3594
|
+
return (entry) => entry.block ? `${entry.section}#${modules.get(`${entry.section}#${entry.block}`) ?? entry.block}` : entry.section;
|
|
3595
|
+
};
|
|
3596
|
+
const tableBefore = tableOf(before), tableAfter = tableOf(after);
|
|
3597
|
+
const switches = new Map(before.filter((entry) => toolOf(file, entry.section) === "mypy" && MYPY_SWITCHES.test(entry.key)).map((entry) => [`${tableBefore(entry)}|${entry.key}`, truthy(entry.value)]));
|
|
3524
3598
|
for (const entry of after) {
|
|
3525
3599
|
if (toolOf(file, entry.section) !== "mypy" || !MYPY_SWITCHES.test(entry.key)) continue;
|
|
3526
|
-
if (switches.get(`${entry
|
|
3600
|
+
if (switches.get(`${tableAfter(entry)}|${entry.key}`) === true && !truthy(entry.value)) report(entry.line, `mypy ${entry.key}: true \u2192 false`, "Type checker strictness switched off");
|
|
3527
3601
|
}
|
|
3528
3602
|
const ignoring = (entries) => entries.filter((entry) => toolOf(file, entry.section) === "mypy" && entry.key === "ignore_errors" && truthy(entry.value));
|
|
3529
3603
|
const before_ = ignoring(before), after_ = ignoring(after);
|
|
@@ -3796,6 +3870,12 @@ var EMPTY_PY = /==\s*(?:0(?:\.0+)?(?![\w.])|\[\s*\]|\{\s*\}|""|''|\(\s*\)|(?:set
|
|
|
3796
3870
|
var PY_SPECIFIC = /==|!=|<=|>=|\bin\b|\bis\b|\bisinstance\s*\(|\.startswith\s*\(|\.endswith\s*\(|\bcallable\s*\(/;
|
|
3797
3871
|
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*\(/;
|
|
3798
3872
|
var THROWING_QUERY = /\b(?:get|find)(?:All)?By\w*\s*\(/;
|
|
3873
|
+
var PRESENCE_PREDICATE = /\.(?:exists|isVisible|isExisting|isDisplayed)\(\)$/;
|
|
3874
|
+
function samePresence(oldAssertion, newAssertion) {
|
|
3875
|
+
if (!/\.toBe\s*\(\s*true\s*\)\s*;?\s*$/.test(oldAssertion) || !/\.(?:not\s*\.\s*toBeNull|toBeTruthy|toBeDefined|toBeInTheDocument)\s*\(\s*\)\s*;?\s*$/.test(newAssertion)) return false;
|
|
3876
|
+
const was = checkedExpression(oldAssertion, "js"), now = checkedExpression(newAssertion, "js");
|
|
3877
|
+
return !!was && !!now && PRESENCE_PREDICATE.test(was) && (PRESENCE_PREDICATE.test(now) || /(?:querySelector|queryBy\w*|getBy\w*|findBy\w*|\.find|\.get|\$)\(/.test(now));
|
|
3878
|
+
}
|
|
3799
3879
|
function checkedExpression(assertion, language) {
|
|
3800
3880
|
const squash = (text) => text.replace(/\s+/g, "").replace(/^len\((.*)\)$/, "$1").replace(/\.length$/, "");
|
|
3801
3881
|
if (language === "py") {
|
|
@@ -3887,6 +3967,34 @@ function relaxes(oldAssertion, newAssertion, language, siblings = []) {
|
|
|
3887
3967
|
}
|
|
3888
3968
|
return false;
|
|
3889
3969
|
}
|
|
3970
|
+
function splitArguments(text) {
|
|
3971
|
+
const parts = [];
|
|
3972
|
+
let depth = 0, quote = "", start = 0;
|
|
3973
|
+
for (let i = 0; i < text.length; i++) {
|
|
3974
|
+
const char = text[i];
|
|
3975
|
+
if (quote) {
|
|
3976
|
+
if (char === "\\") i++;
|
|
3977
|
+
else if (char === quote) quote = "";
|
|
3978
|
+
} else if (char === '"' || char === "'") quote = char;
|
|
3979
|
+
else if ("([{".includes(char)) depth++;
|
|
3980
|
+
else if (")]}".includes(char)) depth--;
|
|
3981
|
+
else if (char === "," && depth === 0) {
|
|
3982
|
+
parts.push(text.slice(start, i).trim());
|
|
3983
|
+
start = i + 1;
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
3986
|
+
parts.push(text.slice(start).trim());
|
|
3987
|
+
return parts.filter(Boolean);
|
|
3988
|
+
}
|
|
3989
|
+
function unittestAsAssert(statement) {
|
|
3990
|
+
const call = /^\s*self\s*\.\s*assert(True|IsNotNone|GreaterEqual|Greater)\s*\(([\s\S]*)\)\s*;?\s*$/.exec(statement);
|
|
3991
|
+
if (!call) return null;
|
|
3992
|
+
const [first, second] = splitArguments(call[2]);
|
|
3993
|
+
if (!first) return null;
|
|
3994
|
+
if (call[1] === "True") return `assert ${first}`;
|
|
3995
|
+
if (call[1] === "IsNotNone") return `assert ${first} is not None`;
|
|
3996
|
+
return second === void 0 ? null : `assert ${first} ${call[1] === "Greater" ? ">" : ">="} ${second}`;
|
|
3997
|
+
}
|
|
3890
3998
|
function pinsLess(oldAssertion, newAssertion, language) {
|
|
3891
3999
|
if (language === "js") {
|
|
3892
4000
|
const was = /\.toThrow(?:Error)?\s*\(\s*([^)\s][^)]*)\)/.exec(oldAssertion);
|
|
@@ -3909,6 +4017,7 @@ function pinsLess(oldAssertion, newAssertion, language) {
|
|
|
3909
4017
|
}
|
|
3910
4018
|
function isWeaker(rawOld, rawNew, language) {
|
|
3911
4019
|
const oldAssertion = language === "py" ? withoutOuterParens(rawOld) : rawOld, newAssertion = language === "py" ? withoutOuterParens(rawNew) : rawNew;
|
|
4020
|
+
if (language === "js" && samePresence(oldAssertion, newAssertion)) return false;
|
|
3912
4021
|
if (pinsLess(oldAssertion, newAssertion, language) || relaxes(oldAssertion, newAssertion, language)) return true;
|
|
3913
4022
|
if (language === "js") {
|
|
3914
4023
|
if (THROWING_QUERY.test(newAssertion) || !JS_SPECIFIC.test(oldAssertion) || WEAK_JS.test(oldAssertion) || BOUND_JS.test(oldAssertion)) return false;
|
|
@@ -3916,7 +4025,8 @@ function isWeaker(rawOld, rawNew, language) {
|
|
|
3916
4025
|
return WEAK_JS.test(newAssertion) || BOUND_JS.test(newAssertion) && !ZERO_JS.test(oldAssertion) && (limit === void 0 || /^-?\d+(?:\.\d+)?$/.test(limit.trim()));
|
|
3917
4026
|
}
|
|
3918
4027
|
if (language === "py") {
|
|
3919
|
-
|
|
4028
|
+
const oldBare = unittestAsAssert(oldAssertion) ?? oldAssertion;
|
|
4029
|
+
if (WEAK_PY.test(oldAssertion) || BOUND_PY.test(oldAssertion) || WEAK_PY.test(oldBare) || BOUND_PY.test(oldBare) || !PY_SPECIFIC.test(oldAssertion)) return false;
|
|
3920
4030
|
const limit = /(?:>=|<=|>|<)\s*([^\s,)]+)/.exec(oldAssertion)?.[1];
|
|
3921
4031
|
return WEAK_PY.test(newAssertion) || BOUND_PY.test(newAssertion) && !EMPTY_PY.test(oldAssertion) && /==|[<>]=?/.test(oldAssertion) && (limit === void 0 || /^-?\d+(?:\.\d+)?$/.test(limit));
|
|
3922
4032
|
}
|
|
@@ -4105,10 +4215,10 @@ function markedTest(finding2, lines) {
|
|
|
4105
4215
|
if (/\.py$/i.test(finding2.file ?? "")) {
|
|
4106
4216
|
if (/^\s*pytestmark\b/.test(snippet)) return null;
|
|
4107
4217
|
if (/imperative/.test(finding2.reason)) {
|
|
4108
|
-
const
|
|
4218
|
+
const indent = (lines[index] ?? "").search(/\S/);
|
|
4109
4219
|
for (let i = index - 1; i >= 0 && i > index - 200; i--) {
|
|
4110
4220
|
const def = /^(\s*)(?:async\s+)?def\s+(\w+)\s*\(/.exec(lines[i]);
|
|
4111
|
-
if (def && def[1].length <
|
|
4221
|
+
if (def && def[1].length < indent) return { key: `py:${def[2]}`, title: def[2] };
|
|
4112
4222
|
}
|
|
4113
4223
|
return null;
|
|
4114
4224
|
}
|
|
@@ -4133,18 +4243,25 @@ function markedTest(finding2, lines) {
|
|
|
4133
4243
|
return null;
|
|
4134
4244
|
}
|
|
4135
4245
|
if (/\.each\b/.test(snippet)) return null;
|
|
4136
|
-
const title = /\b(?:it|test|describe|suite|xit|xtest|xdescribe|fit|fdescribe)\b[\w$.\s]*\(\s*(['"`])((?:\\.|(?!\1)[^\\\n])+)\1\s*[,)]/.exec(snippet);
|
|
4246
|
+
const title = /\b(?:it|test|describe|suite|xit|xtest|xdescribe|fit|fdescribe)\b[\w$.\s]*\(\s*(['"`])((?:\\.|(?!\1)[^\\\n])+)\1\s*(?:[,)]|$)/.exec(titleOnNextLine(snippet, lines, index));
|
|
4137
4247
|
if (title) return { key: `js:${title[1]}${title[2]}${title[1]}`, title: `${title[1]}${title[2]}${title[1]}` };
|
|
4138
|
-
|
|
4139
|
-
if (
|
|
4140
|
-
for (let i = index - 1; i >= 0 && i > index - 200; i--) {
|
|
4141
|
-
if (!lines[i].trim() || lines[i].search(/\S/) >=
|
|
4142
|
-
|
|
4143
|
-
|
|
4248
|
+
let limit = (lines[index] ?? "").search(/\S/);
|
|
4249
|
+
if (limit > 0 && /\b(?:test|it)\s*\.\s*(?:skip|fixme|fail)\s*\(/.test(snippet)) {
|
|
4250
|
+
for (let i = index - 1; i >= 0 && i > index - 200 && limit > 0; i--) {
|
|
4251
|
+
if (!lines[i].trim() || lines[i].search(/\S/) >= limit) continue;
|
|
4252
|
+
limit = lines[i].search(/\S/);
|
|
4253
|
+
const owner = /^\s*(?:test|it)(?:\s*\.\s*(?!describe\b|step\b)\w+)*\s*\(\s*(['"`])((?:\\.|(?!\1)[^\\\n])+)\1/.exec(titleOnNextLine(lines[i], lines, i));
|
|
4254
|
+
if (owner) return { key: `js:${owner[1]}${owner[2]}${owner[1]}`, title: `${owner[1]}${owner[2]}${owner[1]}` };
|
|
4255
|
+
if (/^\s*(?:await\s+)?(?:test|it)\s*\.\s*describe\b|^\s*(?:describe|suite)\b/.test(lines[i])) return null;
|
|
4144
4256
|
}
|
|
4145
4257
|
}
|
|
4146
4258
|
return null;
|
|
4147
4259
|
}
|
|
4260
|
+
function titleOnNextLine(text, lines, index) {
|
|
4261
|
+
if (!/\(\s*$/.test(text)) return text;
|
|
4262
|
+
const next = (lines[index + 1] ?? "").trim();
|
|
4263
|
+
return /^(['"`])/.test(next) ? `${text} ${next}` : text;
|
|
4264
|
+
}
|
|
4148
4265
|
function newTestMarkers(before, after, oldText, newText) {
|
|
4149
4266
|
const markers = after.filter(isTestMarker);
|
|
4150
4267
|
if (!markers.length || oldText === null) return [];
|
|
@@ -4200,23 +4317,60 @@ var THRESHOLD_PATTERNS = [
|
|
|
4200
4317
|
var SWITCHES = ["strict", "noImplicitAny", "strictNullChecks", "noImplicitReturns", "useUnknownInCatchVariables"];
|
|
4201
4318
|
var GATE_SCRIPT = /^(?:pre|post)?(?:test|lint|typecheck|type-check|check|ci|verify|validate)(?::[\w:.-]+)?$/;
|
|
4202
4319
|
var CI_RUNNER = new RegExp(
|
|
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/])` + //
|
|
4204
|
-
String.raw`|(?<![\w./-])(?:
|
|
4320
|
+
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/])` + // A package script that runs the tests with coverage: `yarn coverage`, `npm run coverage`.
|
|
4321
|
+
String.raw`|(?<![\w./-])(?:npm|yarn|pnpm|bun)\s+(?:run\s+)?coverage(?![\w/-])` + // Monorepo runners: `nx affected --target=test`, `turbo run test`, `lerna run test`, `bazel test //...`.
|
|
4322
|
+
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/-])` + // Python's own runners and the browser runners: `python3 -m unittest`, `nosetests`, `playwright test`, `cypress run`, `karma start`.
|
|
4323
|
+
String.raw`|(?<![\w./-])python[\d.]*\s+-m\s+(?:unittest|pytest|nose2?)(?![\w/-])` + String.raw`|(?<![\w./-])(?:nosetests|nose2|py\.test|stestr|behave|jasmine|playwright\s+test|cypress\s+run|karma\s+start|hatch\s+(?:run\s+)?test|swift\s+test)(?![\w/-])` + String.raw`|\brun:.*(?<=\s)(?:tests?|unit|e2e)(?![\w/.-])`
|
|
4205
4324
|
);
|
|
4325
|
+
var SHELL_TEST = /(?:^|run:\s*|[;&|({]\s*|\b(?:if|then|do|else|elif|while|until)\s+|!\s+)test\s+!?\s*-[bcdefghkLnOGprsStuwxzN](?=\s)/g;
|
|
4326
|
+
var ciRuns = (line) => CI_RUNNER.test(line.replace(SHELL_TEST, " ").replace(/\s#.*$/, ""));
|
|
4206
4327
|
var CI_FILE = /(?:\.github\/workflows\/|\.gitlab-ci)/;
|
|
4207
4328
|
var CI_REUSABLE = /^\s*-?\s*uses:\s*\S*\.github\/workflows\/\S+/;
|
|
4208
4329
|
var CI_GATE = /^\s*(?:pull_request|pull_request_target|push|merge_group)\s*:|^\s*on\s*:\s*(?:\[[^\]]*\b(?:push|pull_request|pull_request_target|merge_group)\b[^\]]*\]|(?:push|pull_request|pull_request_target|merge_group)\s*$)|^\s*-\s*(?:push|pull_request|pull_request_target|merge_group)\s*$/m;
|
|
4330
|
+
var CI_NON_GATE = /^\s*(?:schedule|release|workflow_dispatch|workflow_run|repository_dispatch)\s*:/m;
|
|
4331
|
+
function gatesChanges(file, text) {
|
|
4332
|
+
const section = triggerSection(text), triggers = gatingTriggers(text) ?? text;
|
|
4333
|
+
if (/\.gitlab-ci/.test(file) || CI_GATE.test(triggers) || /\bworkflow_call\b/.test(triggers)) return true;
|
|
4334
|
+
return section !== null && triggers !== section ? false : !CI_NON_GATE.test(triggers);
|
|
4335
|
+
}
|
|
4336
|
+
function gatingTriggers(text) {
|
|
4337
|
+
const section = triggerSection(text);
|
|
4338
|
+
if (section === null) return null;
|
|
4339
|
+
const lines = section.split(/\r?\n/), kept = [];
|
|
4340
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4341
|
+
const head = /^(\s*)push\s*:\s*(?:#.*)?$/.exec(lines[i]);
|
|
4342
|
+
if (!head) {
|
|
4343
|
+
kept.push(lines[i]);
|
|
4344
|
+
continue;
|
|
4345
|
+
}
|
|
4346
|
+
let end = i + 1;
|
|
4347
|
+
while (end < lines.length && (!lines[end].trim() || lines[end].search(/\S/) > head[1].length)) end++;
|
|
4348
|
+
const block = lines.slice(i + 1, end).join("\n");
|
|
4349
|
+
if (!(/^\s*tags\s*:/m.test(block) && !/^\s*branches(?:-ignore)?\s*:/m.test(block))) kept.push(...lines.slice(i, end));
|
|
4350
|
+
i = end - 1;
|
|
4351
|
+
}
|
|
4352
|
+
return kept.join("\n");
|
|
4353
|
+
}
|
|
4354
|
+
function triggerSection(text) {
|
|
4355
|
+
const lines = text.split(/\r?\n/);
|
|
4356
|
+
const start = lines.findIndex((line) => /^["']?on["']?\s*:/.test(line));
|
|
4357
|
+
if (start < 0) return null;
|
|
4358
|
+
let end = start + 1;
|
|
4359
|
+
while (end < lines.length && (!lines[end].trim() || /^[\s#-]/.test(lines[end]))) end++;
|
|
4360
|
+
return lines.slice(start, end).join("\n");
|
|
4361
|
+
}
|
|
4209
4362
|
function gatingTestLines(file, text) {
|
|
4210
|
-
return /\.gitlab-ci/.test(file) || CI_GATE.test(text) ? matchingLines(text,
|
|
4363
|
+
return /\.gitlab-ci/.test(file) || CI_GATE.test(gatingTriggers(text) ?? text) ? matchingLines(text, ciRuns) : 0;
|
|
4211
4364
|
}
|
|
4212
4365
|
function matchingLines(text, pattern) {
|
|
4213
|
-
|
|
4366
|
+
const matches = typeof pattern === "function" ? pattern : (line) => pattern.test(line);
|
|
4367
|
+
return text.split(/\r?\n/).filter((line) => !line.trim().startsWith("#") && matches(line)).length;
|
|
4214
4368
|
}
|
|
4215
4369
|
function ciTestSteps(file, oldText, newText) {
|
|
4216
4370
|
if (!CI_FILE.test(file) || oldText === null && newText === null) return null;
|
|
4217
4371
|
const was = oldText ?? "", now = newText ?? "";
|
|
4218
4372
|
const after = gatingTestLines(file, now);
|
|
4219
|
-
return { file, before: gatingTestLines(file, was), after, reusableAfter: matchingLines(now, CI_REUSABLE), ungatedAfter: after === 0 ? matchingLines(now,
|
|
4373
|
+
return { file, before: gatingTestLines(file, was), after, reusableAfter: matchingLines(now, CI_REUSABLE), ungatedAfter: after === 0 ? matchingLines(now, ciRuns) : 0 };
|
|
4220
4374
|
}
|
|
4221
4375
|
function resolveCiTestSteps(counts, commit) {
|
|
4222
4376
|
const before = counts.reduce((sum, item) => sum + item.before, 0);
|
|
@@ -4226,6 +4380,23 @@ function resolveCiTestSteps(counts, commit) {
|
|
|
4226
4380
|
}
|
|
4227
4381
|
var LENIENT = /continue-on-error:\s*true|allow_failure:\s*true|^\s*(?:-\s+)?if:\s*(?:\$\{\{\s*)?false\s*(?:\}\})?\s*(?:#.*)?$/;
|
|
4228
4382
|
var SHELL_SWALLOW = /\|\|\s*(?:true|exit\s+0|:|echo\b)|;\s*(?:true|exit\s+0)\b/;
|
|
4383
|
+
var NON_CHECK_ACTION = new RegExp(String.raw`^\s*(?:-\s+)?uses:\s*["']?(?:` + [
|
|
4384
|
+
String.raw`actions/(?:upload-artifact|download-artifact|upload-pages-artifact|deploy-pages|cache|attest[\w-]*|checkout|setup-[\w-]+)`,
|
|
4385
|
+
String.raw`docker/(?:login-action|metadata-action|build-push-action|setup-[\w-]+)`,
|
|
4386
|
+
String.raw`azure/login`,
|
|
4387
|
+
String.raw`aws-actions/(?:configure-aws-credentials|amazon-ecr-login)`,
|
|
4388
|
+
String.raw`google-github-actions/(?:auth|setup-gcloud)`,
|
|
4389
|
+
String.raw`softprops/action-gh-release`,
|
|
4390
|
+
String.raw`ncipollo/release-action`,
|
|
4391
|
+
String.raw`pypa/gh-action-pypi-publish`,
|
|
4392
|
+
String.raw`JS-DevTools/npm-publish`,
|
|
4393
|
+
String.raw`peaceiris/actions-gh-pages`,
|
|
4394
|
+
String.raw`peter-evans/[\w-]+`,
|
|
4395
|
+
String.raw`marocchino/sticky-pull-request-comment`,
|
|
4396
|
+
String.raw`slackapi/slack-github-action`,
|
|
4397
|
+
String.raw`codecov/codecov-action`,
|
|
4398
|
+
String.raw`coverallsapp/github-action`
|
|
4399
|
+
].join("|") + String.raw`)(?:/[\w./-]+)?@`, "i");
|
|
4229
4400
|
function lenientSteps(text) {
|
|
4230
4401
|
const lines = text.split(/\r?\n/);
|
|
4231
4402
|
const indentOf = (line) => line.length - line.trimStart().length;
|
|
@@ -4242,9 +4413,18 @@ function lenientSteps(text) {
|
|
|
4242
4413
|
while (j >= 0 && (!lines[j].trim() || lines[j].trim().startsWith("#") || indentOf(lines[j]) >= indentOf(line))) j--;
|
|
4243
4414
|
identity = j >= 0 ? lines[j].trim() : "";
|
|
4244
4415
|
}
|
|
4416
|
+
const start = /^\s*-\s+if:/.test(line) ? i : Math.max(j, 0), level = indentOf(lines[start]);
|
|
4417
|
+
let end = start + 1;
|
|
4418
|
+
while (end < lines.length && (!lines[end].trim() || lines[end].trim().startsWith("#") || indentOf(lines[end]) > level)) end++;
|
|
4419
|
+
const scope = lines.slice(start, end), runsChecks = matchingLines(scope.join("\n"), ciRuns) > 0;
|
|
4420
|
+
const uses = scope.filter((item) => /^\s*(?:-\s+)?uses:/.test(item));
|
|
4421
|
+
const nonCheck = !runsChecks && uses.length === 1 && NON_CHECK_ACTION.test(uses[0]) && !scope.some((item) => /^\s*(?:-\s+)?run:/.test(item));
|
|
4245
4422
|
const entry = found.get(identity);
|
|
4246
|
-
if (entry)
|
|
4247
|
-
|
|
4423
|
+
if (entry) {
|
|
4424
|
+
entry.count++;
|
|
4425
|
+
entry.runsChecks ||= runsChecks;
|
|
4426
|
+
entry.nonCheck &&= nonCheck;
|
|
4427
|
+
} else found.set(identity, { count: 1, line: i + 1, runsChecks, nonCheck });
|
|
4248
4428
|
});
|
|
4249
4429
|
return found;
|
|
4250
4430
|
}
|
|
@@ -4306,16 +4486,18 @@ function analyzeGuardFile(file, oldText, newText, commit) {
|
|
|
4306
4486
|
});
|
|
4307
4487
|
}
|
|
4308
4488
|
if (CI_FILE.test(file)) {
|
|
4489
|
+
const gating = gatesChanges(file, oldText) || gatesChanges(file, newText);
|
|
4309
4490
|
const before = lenientSteps(oldText), existing = new Set(oldText.split(/\r?\n/).flatMap((line) => [line.trim(), line.trim().replace(/^-\s+/, "")]));
|
|
4310
4491
|
const newLines = newText.split(/\r?\n/);
|
|
4311
4492
|
for (const [identity, now2] of lenientSteps(newText)) {
|
|
4493
|
+
if (now2.nonCheck || !gating && !now2.runsChecks) continue;
|
|
4312
4494
|
if (identity && existing.has(identity) && now2.count > (before.get(identity)?.count ?? 0)) {
|
|
4313
4495
|
const marker = (newLines[now2.line - 1] ?? "").trim();
|
|
4314
4496
|
out.push(finding("guard-weakened", file, now2.line, marker, /^(?:-\s+)?if:/.test(marker) ? "CI step is now switched off" : "CI step is now allowed to fail", commit));
|
|
4315
4497
|
break;
|
|
4316
4498
|
}
|
|
4317
4499
|
}
|
|
4318
|
-
const swallowing = (text) => text.split(/\r?\n/).map((line, index) => ({ line: line.trim(), index })).filter((item) => !item.line.startsWith("#") &&
|
|
4500
|
+
const swallowing = (text) => text.split(/\r?\n/).map((line, index) => ({ line: line.trim(), index })).filter((item) => !item.line.startsWith("#") && ciRuns(item.line) && SHELL_SWALLOW.test(item.line));
|
|
4319
4501
|
const was = swallowing(oldText), now = swallowing(newText);
|
|
4320
4502
|
const tolerance = (text) => text.split(/\r?\n/).filter((line) => !line.trim().startsWith("#") && /continue-on-error:\s*true|allow_failure:\s*true/.test(line)).length;
|
|
4321
4503
|
const traded = Math.max(0, tolerance(oldText) - tolerance(newText));
|
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.19.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",
|