agent-roast 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,7 +81,7 @@ Policy is read from the **base** branch, so a pull request cannot switch off the
81
81
  | `assertion-weakened` | an exact check is replaced by a weaker one: `toBe(42)` → `toBeDefined()`, `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,9 +111,9 @@ 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 of 114** hand-labeled cheating edits are caught and **80 of 80** harmless look-alikes (reformats, moves, raised
114
+ - **119 of 119** hand-labeled cheating edits are caught and **84 of 84** 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. Two gaps are documented and still open.
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
@@ -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",
@@ -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 javascript = /\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file.path);
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 python = /\.py$/i.test(file.path);
1458
+ const python2 = /\.py$/i.test(file.path);
1459
1459
  const rust = /\.rs$/i.test(file.path);
1460
- const braceCatches = javascript || /\.(?:java|kt|php|cs|cpp)$/i.test(file.path);
1461
- const directives = javascript ? JS_COMMENT_DIRECTIVE_PATTERNS : python ? PYTHON_COMMENT_DIRECTIVE_PATTERNS : [];
1462
- const stubs = javascript ? JS_STUB_PATTERNS : python ? PYTHON_STUB_PATTERNS : rust ? RUST_STUB_PATTERNS : [];
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 (python && !isComment) {
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 = javascript && !foundEscape ? JS_BLOCK_DIRECTIVE_PATTERNS.find((rule) => masked.blockStarts.some((start) => rule.regex.test(rawLine.slice(start)))) : void 0;
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 (python && !isComment && isTestFile(file.path)) {
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 (python) {
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 (javascript) {
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 = python && /^\s*except(?:\*?\s+[^:]+)?:\s*pass\s*;?\s*$/.test(start.code);
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 = python && /^\s*except(?:\*?\s+[^:]+)?:\s*$/.test(start.code);
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) => code.startsWith(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) => code.startsWith(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,7 +3668,7 @@ 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*\)|\.(?:toHaveBeenCalled|toBeCalled)\s*\(\s*\)|\.(?:toBe|toEqual|toStrictEqual)\s*\(\s*expect\s*\.\s*(?:anything\s*\(\s*\)|any\s*\(\s*\w+\s*\))\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/;
@@ -3447,14 +3685,15 @@ function pinsLess(oldAssertion, newAssertion, language) {
3447
3685
  }
3448
3686
  if (language === "py") {
3449
3687
  const raised = (text) => {
3688
+ const captured = /\)\s*as\s+\w+/.test(text);
3450
3689
  const marker = /\bpytest\s*\.\s*raises\s*\(\s*([\w.]+|\([^)]*\))([^)]*)\)/.exec(text);
3451
- if (marker) return { type: marker[1], message: /\bmatch\s*=/.test(marker[2]) };
3690
+ if (marker) return { type: marker[1], message: /\bmatch\s*=/.test(marker[2]), captured };
3452
3691
  const unit = /\bassertRaises(Regex)?\s*\(\s*([\w.]+|\([^)]*\))/.exec(text);
3453
- return unit ? { type: unit[2], message: !!unit[1] } : null;
3692
+ return unit ? { type: unit[2], message: !!unit[1], captured } : null;
3454
3693
  };
3455
3694
  const was = raised(oldAssertion), now = raised(newAssertion);
3456
3695
  const broad = (type) => /^(?:builtins\.)?(?:Base)?Exception$/.test(type);
3457
- return !!was && !!now && (was.message && !now.message || !broad(was.type) && broad(now.type));
3696
+ return !!was && !!now && (was.message && !now.message && !now.captured || !broad(was.type) && broad(now.type));
3458
3697
  }
3459
3698
  return false;
3460
3699
  }
@@ -3466,7 +3705,7 @@ function isWeaker(oldAssertion, newAssertion, language) {
3466
3705
  }
3467
3706
  if (language === "py") {
3468
3707
  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);
3708
+ return WEAK_PY.test(newAssertion) || BOUND_PY.test(newAssertion) && !EMPTY_PY.test(oldAssertion) && /==|\bin\b|[<>]=?/.test(oldAssertion);
3470
3709
  }
3471
3710
  return false;
3472
3711
  }
@@ -3502,6 +3741,10 @@ function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
3502
3741
  const addedText = after.filter((test) => !beforeKeys.has(test.key)).map((test) => test.text).join("\n");
3503
3742
  const result = { immediate: [], removedTests: [], addedHashes: [], rewritten: [] };
3504
3743
  for (const test of after) if (!beforeKeys.has(test.key)) result.addedHashes.push(test.bodyHash);
3744
+ const mockedBefore = new Set((oldText === null ? [] : mockedSubjects(oldText, oldPath)).map((item) => item.key));
3745
+ for (const item of newText === null ? [] : mockedSubjects(newText, newPath)) {
3746
+ if (!mockedBefore.has(item.key)) result.immediate.push(finding("trivial-assert", newPath, item.line, item.snippet, item.reason, commit));
3747
+ }
3505
3748
  for (const old of before) {
3506
3749
  const current = afterByKey.get(old.key);
3507
3750
  if (!current) {
@@ -3550,7 +3793,20 @@ ${addedText}`;
3550
3793
  continue;
3551
3794
  }
3552
3795
  if (current.assertions.length === old.assertions.length && old.assertions.length > 0) {
3553
- const changed = old.assertions.map((assertion, i) => ({ before: assertion, after: current.assertions[i], rawBefore: old.raw[i], rawAfter: current.raw[i] })).filter((pair) => pair.before !== pair.after);
3796
+ const left = (from, against) => {
3797
+ const budget = /* @__PURE__ */ new Map();
3798
+ for (const assertion of against) budget.set(assertion, (budget.get(assertion) ?? 0) + 1);
3799
+ return from.map((assertion, i) => ({ assertion, i })).filter(({ assertion }) => {
3800
+ const available = budget.get(assertion) ?? 0;
3801
+ if (available > 0) {
3802
+ budget.set(assertion, available - 1);
3803
+ return false;
3804
+ }
3805
+ return true;
3806
+ }).map(({ i }) => i);
3807
+ };
3808
+ const goneIndexes = left(old.assertions, current.assertions), freshIndexes = left(current.assertions, old.assertions);
3809
+ 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
3810
  const weaker = changed.find((pair) => isWeaker(pair.rawBefore, pair.rawAfter, language));
3555
3811
  if (weaker) {
3556
3812
  result.immediate.push(finding(
@@ -3779,7 +4035,9 @@ function analyzeGuardFile(file, oldText, newText, commit) {
3779
4035
  }
3780
4036
  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
4037
  const was = swallowing(oldText), now = swallowing(newText);
3782
- if (now.length > was.length) {
4038
+ const tolerance = (text) => text.split(/\r?\n/).filter((line) => !line.trim().startsWith("#") && /continue-on-error:\s*true|allow_failure:\s*true/.test(line)).length;
4039
+ const traded = Math.max(0, tolerance(oldText) - tolerance(newText));
4040
+ if (now.length > was.length + traded) {
3783
4041
  const known = new Set(was.map((item) => item.line));
3784
4042
  const fresh = now.find((item) => !known.has(item.line)) ?? now[0];
3785
4043
  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
@@ -38,7 +38,7 @@ import {
38
38
  terminalText,
39
39
  uninstallHooks,
40
40
  writeBaseline
41
- } from "./chunk-BWGYT2YZ.js";
41
+ } from "./chunk-52FUQ4EP.js";
42
42
 
43
43
  // src/cli.ts
44
44
  import { Command } from "commander";
package/dist/index.js CHANGED
@@ -91,7 +91,7 @@ import {
91
91
  unquoteGitPath,
92
92
  validateExceptions,
93
93
  writeBaseline
94
- } from "./chunk-BWGYT2YZ.js";
94
+ } from "./chunk-52FUQ4EP.js";
95
95
  export {
96
96
  AGENT_LABELS,
97
97
  CONFIG_FILENAMES,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-roast",
3
- "version": "0.15.0",
3
+ "version": "0.16.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",