agent-roast 0.20.0 → 0.21.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
@@ -80,8 +80,8 @@ Policy is read from the **base** branch, so a pull request cannot switch off the
80
80
  | --- | --- | :-: |
81
81
  | `assertion-weakened` | an exact check is replaced by a weaker one: `toBe(42)` → `toBeDefined()`, `toBe(2)` → `toBeLessThanOrEqual(2)`, `toBe(2)` → `not.toBe(0)`, `toEqual(x)` → `toMatchObject(x)`, `assert x == 1` → `assert x` | blocks |
82
82
  | `guard-weakened` | a coverage threshold is lowered, `strict` is switched off, a lint rule is downgraded, a CI step may fail, a workflow stops running tests, a `conftest.py` hook sets a report to `passed` | blocks |
83
- | `test-skip` | a test that was already there is switched off or expected to fail: `it.skip`, `@pytest.mark.skip`, `@pytest.mark.xfail`, `it.fails`, `test.fixme`, `t.Skip`, `#[ignore]`, `@Disabled`, `[Ignore]`, `xit`, ... (a new test that is skipped is not reported) | blocks |
84
- | `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 |
83
+ | `test-skip` | a test that was already there is switched off or expected to fail: `it.skip`, `@pytest.mark.skip`, `@pytest.mark.skipif(True)`, `@pytest.mark.xfail`, `it.fails`, `test.fixme`, `t.Skip`, `#[ignore]`, `@Disabled`, `[Ignore]`, `xit`, ... (a new test that is skipped is not reported) | blocks |
84
+ | `test-focus`, `trivial-assert` | `.only` / `fit`; `expect(true).toBe(true)` and its relatives in ten languages, `assert x or True`; 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
 
@@ -112,15 +112,18 @@ Measured, with the denominators and the caveats in [docs/benchmarks.md](docs/ben
112
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
113
  two thirds of its blocks were questions with a harmless answer, depending on the sample.**
114
114
 
115
- - **152 of 152** hand-labeled cheating edits are caught and **136 of 136** harmless look-alikes (reformats, moves, raised
115
+ - **162 of 162** hand-labeled cheating edits are caught and **150 of 150** 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
+ - **A catalog of cheats that someone else wrote.** Of the nine hacks in [reward-hack-zoo](https://github.com/MatthewWaller/reward-hack-zoo) (hand-written specimens; none from a model) four are reported (a skip, a patched target, a `conftest.py` that rewrites reports,
119
+ an edited expected value as a warning) and none of its six honest solutions. The five that are not (special-casing, swallowed errors, overfitting) live in the code, and a search of 126,486 commits shows why a tool that reads text cannot report them:
120
+ their shape is the shape of ordinary code ([section 13](docs/benchmarks.md#13-a-probe-of-what-no-rule-sees-and-a-catalog-of-cheats-that-someone-else-wrote)).
118
121
  - **12,645 commits** that touch tests or CI/config in 166 public repositories: blocking rules fired on 0.3% of AI-signed commits
119
122
  (Claude, Copilot, Cursor, Codex, Devin, aider) and on 0.3% of the others. Inside the same repositories the odds for an
120
123
  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
124
  checks more often.
122
- - **On repositories it was not written from, between a quarter and two thirds of the blocked commits were false alarms.** Seven samples of 39
123
- or 40 public repositories (278 in all, 79,843 commits) were replayed and every blocked commit was read. The share was 69% for 0.17.0 on the
125
+ - **On repositories it was not written from, between a quarter and two thirds of the blocked commits were false alarms.** Nine samples of 39
126
+ or 40 public repositories (356 in all, 102,210 commits) were replayed and every blocked commit was read. The share was 69% for 0.17.0 on the
124
127
  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
128
  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
129
  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
@@ -128,8 +131,8 @@ two thirds of its blocks were questions with a harmless answer, depending on the
128
131
  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
132
  earlier measurements too, were deliberate and explained. One reader (an AI assistant) sorted them, and the fixes were made from the same
130
133
  sorting, so treat the figures as a first estimate. The published 0.19.0 blocked 31 of the 12,000 commits of the seventh sample: 16 for a reviewer and 15 false alarms (48%, 6 of them one
131
- repository's lint configuration); this release fixes the kinds of false alarm that reading it showed, and blocks 22 of those commits ([section 9](docs/benchmarks.md#9-repositories-nobody-had-tuned-on),
132
- [section 10](docs/benchmarks.md#10-two-more-samples-and-what-the-fixes-from-sample-4-did), [section 11](docs/benchmarks.md#11-a-seventh-sample-and-two-things-that-were-missing)).
134
+ repository's lint configuration); the build with the fixes from reading it, tried on an eighth sample that it had not seen, blocked 26 of 11,081 commits: 16 for a reviewer and 10 false alarms (38%), and the fixes from reading that one are in this release too; this release as it was frozen blocked 46 of 11,286 commits of a ninth sample that it had not seen: 28 for a reviewer and 18 false alarms (39%), and five of those are fixed in it ([section 9](docs/benchmarks.md#9-repositories-nobody-had-tuned-on),
135
+ [section 10](docs/benchmarks.md#10-two-more-samples-and-what-the-fixes-from-sample-4-did), [section 11](docs/benchmarks.md#11-a-seventh-sample-and-two-things-that-were-missing), [section 12](docs/benchmarks.md#12-an-eighth-sample-the-first-measurement-of-the-fixes-from-sample-7), [section 14](docs/benchmarks.md#14-a-ninth-sample-the-shipped-build-on-repositories-it-had-not-seen)).
133
136
  - **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
134
137
  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);
135
138
  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
@@ -1167,7 +1167,9 @@ var PYTHON_TEST_SKIP_PATTERNS = [
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
+ { regex: /@(?:[A-Za-z_]\w*\s*\.\s*)*expectedFailure\b/, name: "test expected to fail (@unittest.expectedFailure)", imperative: false },
1171
+ // `skipif(True)` is a skip whose condition is always true: it switches the test off wherever it runs, which `skipif` with a condition on the environment does not.
1172
+ { regex: /(?:@\s*|(?<![\w.])marks\s*=\s*\[?\s*|^[ \t]*pytestmark\s*=\s*\[?\s*)(?:pytest\s*\.\s*mark\s*\.\s*skipif|(?:unittest\s*\.\s*)?skipIf)\s*\(\s*True\s*[,)]/, name: "unconditionally skipped test (skipif(True))", imperative: false }
1171
1173
  ];
1172
1174
  var PYTHON_MARKER = /(@\s*|(?<![\w.])marks\s*=\s*\[?\s*|^[ \t]*pytestmark\s*=\s*\[?\s*)pytest\s*\.\s*mark\s*\.\s*(skip|xfail)\b/gm;
1173
1175
  function isConditionalXfail(args) {
@@ -1178,20 +1180,20 @@ function isConditionalXfail(args) {
1178
1180
  return !/\bCI\b|\bisCI\b|GITHUB_ACTIONS/.test(condition);
1179
1181
  }
1180
1182
  function isGuardedSkip(lines) {
1181
- const indentOf = (text) => text.length - text.trimStart().length;
1183
+ const indentOf2 = (text) => text.length - text.trimStart().length;
1182
1184
  const mentionsCi = (raw) => /\bCI\b|\bGITHUB_ACTIONS\b/.test(raw);
1183
1185
  const own = lines[lines.length - 1];
1184
1186
  if (/^\s*(?:if|elif|else)\b.*:\s*\S/.test(own.code)) return !mentionsCi(own.raw);
1185
- let limit = indentOf(own.code);
1187
+ let limit = indentOf2(own.code);
1186
1188
  for (let i = lines.length - 2; i >= 0 && lines.length - 1 - i <= 200; i--) {
1187
1189
  if (lines[i].number !== lines[i + 1].number - 1) return false;
1188
1190
  const { code, raw } = lines[i];
1189
- if (!code.trim() || indentOf(code) >= limit) continue;
1191
+ if (!code.trim() || indentOf2(code) >= limit) continue;
1190
1192
  const keyword = /^\s*(async\s+def|def|class|if|elif|else|try|except|finally|with|for|while)\b/.exec(code)?.[1];
1191
1193
  if (keyword === "def" || keyword === "async def") return !/^\s*(?:async\s+)?def\s+test/.test(code);
1192
1194
  if (keyword === "class") return false;
1193
1195
  if (keyword === "if" || keyword === "elif" || keyword === "else" || keyword === "try" || keyword === "except" || keyword === "finally") return !mentionsCi(raw);
1194
- limit = indentOf(code) + (keyword ? 0 : 1);
1196
+ limit = indentOf2(code) + (keyword ? 0 : 1);
1195
1197
  }
1196
1198
  return false;
1197
1199
  }
@@ -1289,7 +1291,7 @@ function inTestAttributeList(lines, index) {
1289
1291
  return false;
1290
1292
  }
1291
1293
  function isGuardedBlockSkip(lines, profile) {
1292
- const indentOf = (text) => text.length - text.trimStart().length;
1294
+ const indentOf2 = (text) => text.length - text.trimStart().length;
1293
1295
  const mentionsCi = (raw) => /\bCI\b|\bGITHUB_ACTIONS\b/.test(raw);
1294
1296
  const own = lines[lines.length - 1];
1295
1297
  if (profile.modifier?.test(own.code) || profile.conditional.test(own.code)) return !mentionsCi(own.raw);
@@ -1303,17 +1305,17 @@ function isGuardedBlockSkip(lines, profile) {
1303
1305
  }
1304
1306
  return depth > 0;
1305
1307
  };
1306
- let limit = indentOf(own.code);
1308
+ let limit = indentOf2(own.code);
1307
1309
  let i = lines.length - 2;
1308
1310
  for (; i >= 0 && lines.length - 1 - i <= 200; i--) {
1309
1311
  if (lines[i].number !== lines[i + 1].number - 1) return compiledConditionally(i + 1);
1310
1312
  const { code, raw } = lines[i];
1311
- if (!code.trim() || indentOf(code) >= limit) continue;
1313
+ if (!code.trim() || indentOf2(code) >= limit) continue;
1312
1314
  if (profile.preprocessor && (profile.preprocessor.open.test(code) || profile.preprocessor.close.test(code) || profile.preprocessor.other.test(code))) continue;
1313
1315
  if (profile.conditional.test(code)) return !mentionsCi(raw);
1314
1316
  if (profile.test.test(code)) return compiledConditionally(i + 1);
1315
1317
  if (profile.helper.test(code)) return true;
1316
- limit = indentOf(code);
1318
+ limit = indentOf2(code);
1317
1319
  }
1318
1320
  return compiledConditionally(i + 1);
1319
1321
  }
@@ -1331,7 +1333,7 @@ function isConditionalSkip(source, raw, open2, start) {
1331
1333
  var TEST_REGISTRATION = /^(?:await\s+)?(?:test|it|describe|suite|beforeAll|beforeEach|afterAll|afterEach)\b/;
1332
1334
  function isUnderRuntimeCondition(source, raw, start) {
1333
1335
  const alwaysTrue = (text) => /^\s*(?:true|1|!\s*0|!\s*false)\s*$/.test(text);
1334
- const indentOf = (text) => text.length - text.trimStart().length;
1336
+ const indentOf2 = (text) => text.length - text.trimStart().length;
1335
1337
  const lineStartOf = (offset) => Math.max(source.lastIndexOf("\n", offset - 1), source.lastIndexOf("\0", offset - 1)) + 1;
1336
1338
  const mentionsCi = (from, to) => MENTIONS_CI.test(raw.slice(from, to));
1337
1339
  const lineStart = lineStartOf(start);
@@ -1351,20 +1353,20 @@ function isUnderRuntimeCondition(source, raw, start) {
1351
1353
  } else if (/(?:^|[\s}])else$/.test(head)) return true;
1352
1354
  else if (head.endsWith("&&")) return !mentionsCi(lineStart, start) && !alwaysTrue(head.slice(0, -2).replace(/^[\s{}]+/, ""));
1353
1355
  const lineEnd = source.slice(start).search(/[\n\0]/);
1354
- let limit = indentOf(source.slice(lineStart, lineEnd < 0 ? void 0 : start + lineEnd)), at = lineStart;
1356
+ let limit = indentOf2(source.slice(lineStart, lineEnd < 0 ? void 0 : start + lineEnd)), at = lineStart;
1355
1357
  for (let steps = 0; steps < 200 && at > 0; steps++) {
1356
1358
  if (source[at - 1] === "\0") return false;
1357
1359
  const previous = lineStartOf(at - 1), line = source.slice(previous, at - 1);
1358
1360
  const lineRaw = raw.slice(previous, at - 1);
1359
1361
  at = previous;
1360
- if (!line.trim() || indentOf(line) >= limit) continue;
1362
+ if (!line.trim() || indentOf2(line) >= limit) continue;
1361
1363
  const header = line.trim();
1362
1364
  if (/^(?:\}\s*)?(?:else\s+)?if\s*\(/.test(header)) {
1363
1365
  return !MENTIONS_CI.test(lineRaw) && !alwaysTrue(/\bif\s*\((.*)\)\s*\{?\s*$/.exec(header)?.[1] ?? "");
1364
1366
  }
1365
1367
  if (/^(?:\}\s*)?(?:else|catch)\b|^(?:case\b|default\s*:)|^\)\s*\{?\s*$/.test(header)) return true;
1366
1368
  if (TEST_REGISTRATION.test(header)) return false;
1367
- limit = indentOf(line);
1369
+ limit = indentOf2(line);
1368
1370
  }
1369
1371
  return false;
1370
1372
  }
@@ -1436,6 +1438,27 @@ var PYTHON_TRIVIAL_ASSERT_PATTERNS = [
1436
1438
  { regex: /\bself\s*\.\s*assertTrue\s*\(\s*(?:True|1)\s*\)/, name: "trivial assert (self.assertTrue(True))" },
1437
1439
  { regex: /\bself\s*\.\s*assertEqual\s*\(\s*(?:1\s*,\s*1|True\s*,\s*True)\s*\)/, name: "trivial assert (self.assertEqual(1, 1))" }
1438
1440
  ];
1441
+ function assertsAlwaysTrue(code) {
1442
+ const body = /^\s*assert\b\s*(.*)$/.exec(code)?.[1] ?? /\bself\s*\.\s*assertTrue\s*\((.*)$/.exec(code)?.[1];
1443
+ if (body === void 0) return false;
1444
+ let depth = 0, outside2 = "";
1445
+ for (const character of body) {
1446
+ if ("([{".includes(character)) {
1447
+ outside2 += depth === 0 ? character : " ";
1448
+ depth++;
1449
+ continue;
1450
+ }
1451
+ if (")]}".includes(character)) {
1452
+ if (depth === 0) break;
1453
+ depth--;
1454
+ outside2 += depth === 0 ? character : " ";
1455
+ continue;
1456
+ }
1457
+ if (depth === 0 && character === ",") break;
1458
+ outside2 += depth === 0 ? character : " ";
1459
+ }
1460
+ return outside2.split(/\bor\b/).some((operand) => /^\s*(?:True|1)\s*$/.test(operand));
1461
+ }
1439
1462
  var JS_STUB_PATTERNS = [
1440
1463
  { regex: /\bthrow\s+new\s+Error\s*\(\s*["'`](?:TODO(?=\s*(?:["'`:]|implement\b))|Not implemented\b|to be implemented\b|stub\b|implement me\b)/i, name: "unimplemented stub (throw new Error('Not implemented'))" }
1441
1464
  ];
@@ -1562,19 +1585,17 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
1562
1585
  foundEscape = true;
1563
1586
  }
1564
1587
  if (python2 && !isComment && isTestFile(file.path)) {
1565
- for (const rule of PYTHON_TRIVIAL_ASSERT_PATTERNS) {
1566
- if (rule.regex.test(cleanedLine)) {
1567
- infractions.push({
1568
- type: "trivial-assert",
1569
- file: file.path,
1570
- lineNumber: added.lineNumber,
1571
- lineSnippet: rawLine.trim(),
1572
- commitHash,
1573
- commitDate,
1574
- reason: rule.name
1575
- });
1576
- break;
1577
- }
1588
+ const trivial = PYTHON_TRIVIAL_ASSERT_PATTERNS.find((rule) => rule.regex.test(cleanedLine))?.name ?? (assertsAlwaysTrue(cleanedLine) ? "assertion that cannot fail (assert ... or True)" : void 0);
1589
+ if (trivial) {
1590
+ infractions.push({
1591
+ type: "trivial-assert",
1592
+ file: file.path,
1593
+ lineNumber: added.lineNumber,
1594
+ lineSnippet: rawLine.trim(),
1595
+ commitHash,
1596
+ commitDate,
1597
+ reason: trivial
1598
+ });
1578
1599
  }
1579
1600
  }
1580
1601
  }
@@ -3339,7 +3360,7 @@ function javascript(text) {
3339
3360
  if (!binding || !mock) continue;
3340
3361
  const name = binding.namespace ? call[2] : call[2] ? void 0 : binding.name;
3341
3362
  if (!name) continue;
3342
- const identifier = call[1].replace(/\$/g, "\\$");
3363
+ const identifier = (binding.namespace ? `${call[1]}\\s*\\.\\s*${call[2]}` : call[1]).replace(/\$/g, "\\$");
3343
3364
  const configures = new RegExp(`(?<![\\w$.])${identifier}\\b[^;]{0,80}?\\.\\s*mock(?:Return|Resolved|Rejected|Implementation)\\w*\\s*\\(`, "g");
3344
3365
  const configured = [...raw.matchAll(configures)].some((config) => tests.every((range) => !within(range, config.index) || within(range, match.index)));
3345
3366
  if (!mock.fake && !configured) continue;
@@ -3605,10 +3626,14 @@ function analyzePythonConfig(file, oldText, newText, report) {
3605
3626
  /^\s*python_version\s*=\s*["']?(\d+(?:\.\d+)*)/gm,
3606
3627
  /\bruff\s*(?:(?:===|==|~=|>=|>)\s*["']?|=\s*["'][\^~>=]*)(\d+(?:\.\d+)*)/g
3607
3628
  ];
3629
+ const effective = (text) => newest(text, versions[1]) ?? newest(text, versions[0]);
3608
3630
  const bumped = versions.some((pattern) => {
3609
3631
  const was2 = newest(oldText, pattern), now = newest(newText, pattern);
3610
3632
  return !!was2 && !!now && higher(was2, now);
3611
- });
3633
+ }) || (() => {
3634
+ const was2 = effective(oldText), now = effective(newText);
3635
+ return !!was2 && !!now && higher(was2, now);
3636
+ })();
3612
3637
  for (const [tool, bucket] of ignored(after)) {
3613
3638
  if (!configured.has(tool) || tool === "ruff" && bumped) continue;
3614
3639
  const old = [...was.get(tool)?.keys() ?? []];
@@ -3713,13 +3738,13 @@ function testBodies(text, language) {
3713
3738
  const match = startPattern.exec(line);
3714
3739
  if (match) starts.push({ index, name: match[1], length: match[0].length });
3715
3740
  });
3716
- const indentOf = (line) => /^\s*/.exec(line)[0].length;
3741
+ const indentOf2 = (line) => /^\s*/.exec(line)[0].length;
3717
3742
  return starts.map((start, n) => {
3718
3743
  const limit = n + 1 < starts.length ? starts[n + 1].index : lines.length;
3719
- const header = lines[start.index], indent = indentOf(header);
3744
+ const header = lines[start.index], indent = indentOf2(header);
3720
3745
  let end = start.index + 1, depth = balance(header);
3721
3746
  while (depth > 0 && end < limit) depth += balance(lines[end++]);
3722
- while (end < limit && (language === "go" ? lines[end - 1] !== "}" : !lines[end].trim() || indentOf(lines[end]) > indent)) end++;
3747
+ while (end < limit && (language === "go" ? lines[end - 1] !== "}" : !lines[end].trim() || indentOf2(lines[end]) > indent)) end++;
3723
3748
  let top = start.index;
3724
3749
  while (top > 0 && start.index - top < 200 && lines[top - 1].trim() && !/^\s*(?:async\s+)?(?:def|class)\b/.test(lines[top - 1])) top--;
3725
3750
  return {
@@ -4088,7 +4113,9 @@ function isWeaker(rawOld, rawNew, language) {
4088
4113
  const oldBare = unittestAsAssert(oldAssertion) ?? oldAssertion;
4089
4114
  if (WEAK_PY.test(oldAssertion) || BOUND_PY.test(oldAssertion) || WEAK_PY.test(oldBare) || BOUND_PY.test(oldBare) || !PY_SPECIFIC.test(oldAssertion)) return false;
4090
4115
  const limit = /(?:>=|<=|>|<)\s*([^\s,)]+)/.exec(oldAssertion)?.[1];
4091
- return WEAK_PY.test(newAssertion) || BOUND_PY.test(newAssertion) && !EMPTY_PY.test(oldAssertion) && /==|[<>]=?/.test(oldAssertion) && (limit === void 0 || /^-?\d+(?:\.\d+)?$/.test(limit));
4116
+ const outsideBrackets = oldAssertion.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, "").replace(/[([{][^()[\]{}]*[)\]}]/g, " ").replace(/[([{][^()[\]{}]*[)\]}]/g, " ").replace(/[([{][^()[\]{}]*[)\]}]/g, " ");
4117
+ const expectsNothing = EMPTY_PY.test(oldAssertion) && !/\b(?:and|or)\b/.test(outsideBrackets);
4118
+ return WEAK_PY.test(newAssertion) && !expectsNothing || BOUND_PY.test(newAssertion) && !EMPTY_PY.test(oldAssertion) && /==|[<>]=?/.test(oldAssertion) && (limit === void 0 || /^-?\d+(?:\.\d+)?$/.test(limit));
4092
4119
  }
4093
4120
  return false;
4094
4121
  }
@@ -4346,7 +4373,10 @@ function newTestMarkers(before, after, oldText, newText) {
4346
4373
  if (nth <= had || had === 0 && (nth > 1 && !test.key.startsWith("js:") || !existed(test))) continue;
4347
4374
  found.push(finding2);
4348
4375
  }
4349
- return [...found, ...unnamed.slice(unnamedBefore)];
4376
+ const plain = (lines) => lines.map((line) => line.trim().replace(/\.(?:skip|fails|failing|todo|fixme|only)\b/g, ""));
4377
+ const oldPlain = plain(oldLines).join("\n"), newPlain = plain(newLines);
4378
+ const newTable = (finding2) => /\.each\b/.test(finding2.lineSnippet ?? "") && !oldPlain.includes(newPlain.slice((finding2.lineNumber ?? 1) - 1, (finding2.lineNumber ?? 1) + 3).join("\n"));
4379
+ return [...found, ...unnamed.slice(unnamedBefore).filter((finding2) => !newTable(finding2))];
4350
4380
  }
4351
4381
  var GUARD_FILE = /(?:^|\/)(?:package\.json|tsconfig[^/]*\.json|jsconfig\.json|(?:jest|vitest|vite)\.config\.[cm]?[jt]s|\.eslintrc(?:\.[a-z]+)?|eslint\.config\.[cm]?[jt]s|pyproject\.toml|setup\.cfg|pytest\.ini|tox\.ini|\.?ruff\.toml|\.flake8|\.?mypy\.ini|\.?pylintrc|\.coveragerc|codecov\.ya?ml|\.gitlab-ci\.ya?ml|\.github\/workflows\/[^/]+\.ya?ml)$/i;
4352
4382
  function isGuardFile(file) {
@@ -4384,8 +4414,68 @@ var CI_RUNNER = new RegExp(
4384
4414
  String.raw`|(?<![\w./-])(?:deno\s+(?:task|run)|just|task|poe|rake|composer(?:\s+run)?|mix|sbt|lein|stack|cabal|(?:\./)?(?:gradlew|mvnw)(?:\.bat|\.cmd)?)\s+(?:-\S+\s+)*test(?![\w/-])` + String.raw`|(?<![\w./-])(?:node|tsx)(?:\s+-\S+)*\s+--test(?![\w-])` + // A project's own test script, run through its interpreter or by path: `python scripts/run_tests.py`, `./scripts/test.sh`, `python tests/test_cli.py`.
4385
4415
  String.raw`|(?<![\w./-])(?:(?:python[\d.]*|node|bash|sh|zsh|ruby|pwsh|tsx|ts-node)(?:\s+-\S+)*\s+|\./)(?:[\w.-]+/)*(?:(?:run[_-]?)?tests?(?:[_-](?:all|suite|runner|unit|ci))?|test_[\w-]+|[\w-]+_test)\.(?:py|sh|bash|js|mjs|cjs|ts|rb|ps1)(?![\w-])` + String.raw`|\brun:.*(?<=\s)(?:tests?|unit|e2e)(?![\w/.-])`
4386
4416
  );
4387
- var SHELL_TEST = /(?:^|run:\s*|[;&|({]\s*|\b(?:if|then|do|else|elif|while|until)\s+|!\s+)test\s+!?\s*-[bcdefghkLnOGprsStuwxzN](?=\s)/g;
4388
- var ciRuns = (line) => CI_RUNNER.test(line.replace(SHELL_TEST, " ").replace(/\s#.*$/, ""));
4417
+ var SHELL_TEST = /(?:^|run:\s*|[;&|({]\s*|\b(?:if|then|do|else|elif|while|until)\s+|!\s+)test\s+!?\s*(?:-[bcdefghkLnOGprsStuwxzN](?=\s)|["$'])/g;
4418
+ var NOT_TESTS = new RegExp(
4419
+ String.raw`(?<![\w./-])(?:uv\s+(?:sync|add|tool\s+install|pip\s+install)|(?:python[\d.]*\s+-m\s+)?pip[\d.]*\s+install|pipx\s+install|poetry\s+(?:install|add)|pdm\s+(?:install|sync|add)|npm\s+(?:ci|i|install|add)|pnpm\s+(?:i|install|add)|bun\s+(?:i|install|add)|yarn\s+(?:install|add)|bundle\s+install|gem\s+install|cargo\s+install|go\s+(?:install|get)|brew\s+install|apt(?:-get)?\s+install|conda\s+(?:install|create))\b[^&;|\n]*` + // The gap before the environment is bounded, so that a line of a hundred thousand characters with `tox` in it again and again is read in linear time.
4420
+ String.raw`|(?<![\w./-])(?:tox|nox)\b[^&;|\n]{0,300}?\s(?:-e|-s|--env|--session)[= ]\s*(?:lint|linting|format|fmt|docs?|types?|typing|mypy|pyright|ruff|black|flake8|pylint|style|isort|bandit|build|package|clean)(?![,\w-])[^&;|\n]*`,
4421
+ "g"
4422
+ );
4423
+ var ciRuns = (line) => !/^\s*(?:-\s+)?name\s*:/.test(line) && CI_RUNNER.test(line.replace(SHELL_TEST, " ").replace(NOT_TESTS, " ").replace(/\s#.*$/, ""));
4424
+ var COMMAND_BLOCK = /^(\s*(?:-\s+)?)(run|script|before_script|after_script)\s*:\s*([|>])?[+-]?\d*\s*(?:#.*)?$/;
4425
+ var indentOf = (line) => line.length - line.trimStart().length;
4426
+ var PRINTS = /^(?:echo|printf)\b/;
4427
+ var NOT_A_TOOL = "mkdir|rmdir|cd|rm|ls|cat|cp|mv|touch|chmod|chown|echo|printf|export|source|git|docker|find|tar|zip|unzip|grep|sed|awk|diff|head|tail|wc|stat|du|tree|set|test|curl|wget|ssh|scp|rsync|ping|open|sleep|kill|for|while|until|if|elif|else|then|do|done|case|in|assert|return|import|from|def|class|print|raise|yield|with|local|declare|readonly|unset|eval|await|let|const|var|function|new|delete|throw|and|or|not|is|lambda|try|except|finally|pass|del";
4428
+ var TOOL_TEST = new RegExp(
4429
+ String.raw`^(?:(?:sudo|time|exec|command|env)(?:\s+-\S+)*\s+|\w+=(?:"[^"]*"|'[^']*'|[^\s"']*)\s+)*(?!(?:${NOT_A_TOOL})\b)(?!-)[\w./-]+\s+test(?![\w/.=\[(-])`
4430
+ );
4431
+ function blockCommands(lines, at, key) {
4432
+ const keyIndent = key[1].length, found = [];
4433
+ if (key[3] === ">") {
4434
+ for (let next = at + 1; next < lines.length; next++) {
4435
+ const text = lines[next].trim();
4436
+ if (!text) continue;
4437
+ if (indentOf(lines[next]) <= keyIndent) break;
4438
+ if (!text.startsWith("#")) found.push(text);
4439
+ }
4440
+ return [found.join(" ")];
4441
+ }
4442
+ if (key[3] === "|") {
4443
+ for (let next = at + 1; next < lines.length; next++) {
4444
+ const text = lines[next].trim().replace(/\s*\\$/, "");
4445
+ if (!text) continue;
4446
+ if (indentOf(lines[next]) <= keyIndent) break;
4447
+ if (TOOL_TEST.test(text)) found.push(text);
4448
+ }
4449
+ return found;
4450
+ }
4451
+ if (key[3] || key[2] === "run") return [];
4452
+ let itemIndent = -1;
4453
+ for (let next = at + 1; next < lines.length; next++) {
4454
+ const text = lines[next].trim();
4455
+ if (!text || text.startsWith("#")) continue;
4456
+ const indent = indentOf(lines[next]);
4457
+ if (itemIndent < 0) {
4458
+ if (!text.startsWith("- ") || indent < keyIndent) break;
4459
+ itemIndent = indent;
4460
+ }
4461
+ if (indent < itemIndent || indent === itemIndent && !text.startsWith("- ")) break;
4462
+ if (indent === itemIndent) found.push(text.slice(2).trim());
4463
+ }
4464
+ return found;
4465
+ }
4466
+ function ciTestEntries(text) {
4467
+ const lines = text.split(/\r?\n/), found = [];
4468
+ for (let i = 0; i < lines.length; i++) {
4469
+ const line = lines[i];
4470
+ if (!line.trim() || line.trim().startsWith("#")) continue;
4471
+ if (ciRuns(line)) found.push({ index: i, command: line });
4472
+ const key = COMMAND_BLOCK.exec(line);
4473
+ if (!key) continue;
4474
+ for (const command of blockCommands(lines, i, key)) if (command && !PRINTS.test(command) && !ciRuns(command) && ciRuns(`run: ${command}`)) found.push({ index: i, command });
4475
+ }
4476
+ return found;
4477
+ }
4478
+ var ciTestLines = (text) => ciTestEntries(text).map((entry) => entry.command);
4389
4479
  var CI_FILE = /(?:\.github\/workflows\/|\.gitlab-ci)/;
4390
4480
  var CI_REUSABLE = /^\s*-?\s*uses:\s*\S*\.github\/workflows\/\S+/;
4391
4481
  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;
@@ -4421,10 +4511,14 @@ function triggerSection(text) {
4421
4511
  while (end < lines.length && (!lines[end].trim() || /^[\s#-]/.test(lines[end]))) end++;
4422
4512
  return lines.slice(start, end).join("\n");
4423
4513
  }
4424
- function gatingTestCommands(file, text) {
4514
+ function gatingTestCommands(file, text, skip) {
4425
4515
  if (!(/\.gitlab-ci/.test(file) || CI_GATE.test(gatingTriggers(text) ?? text))) return [];
4426
4516
  const directories = [...new Set([...text.matchAll(/^\s*(?:-\s+)?working-directory\s*:\s*["']?([^\s"'#]+)/gm)].map((match) => match[1]))].sort().join(",");
4427
- return text.split(/\r?\n/).filter((line) => !line.trim().startsWith("#") && ciRuns(line)).map((line) => `${line.replace(/\s#.*$/, "").replace(/^\s*-?\s*(?:run\s*:\s*)?/, "").replace(/\s+/g, " ").trim()} @${directories}`);
4517
+ return ciTestEntries(text).filter((entry) => !skip?.(entry.index)).map((entry) => `${entry.command.replace(/\s#.*$/, "").replace(/^\s*-?\s*(?:run\s*:\s*)?/, "").replace(/\s+/g, " ").trim()} @${directories}`);
4518
+ }
4519
+ function enforcedTestCommands(file, text) {
4520
+ const tolerated = [...lenientSteps(text).values()].filter((step) => step.runsChecks).flatMap((step) => step.ranges);
4521
+ return gatingTestCommands(file, text, (line) => tolerated.some(([from, to]) => line >= from && line < to));
4428
4522
  }
4429
4523
  function matchingLines(text, pattern) {
4430
4524
  const matches = typeof pattern === "function" ? pattern : (line) => pattern.test(line);
@@ -4433,9 +4527,9 @@ function matchingLines(text, pattern) {
4433
4527
  function ciTestSteps(file, oldText, newText) {
4434
4528
  if (!CI_FILE.test(file) || oldText === null && newText === null) return null;
4435
4529
  const was = oldText ?? "", now = newText ?? "";
4436
- const commandsBefore = gatingTestCommands(file, was), commandsAfter = gatingTestCommands(file, now);
4530
+ const commandsBefore = enforcedTestCommands(file, was), commandsAfter = gatingTestCommands(file, now);
4437
4531
  const after = commandsAfter.length;
4438
- return { file, before: commandsBefore.length, after, reusableAfter: matchingLines(now, CI_REUSABLE), ungatedAfter: after === 0 ? matchingLines(now, ciRuns) : 0, commandsBefore, commandsAfter };
4532
+ return { file, before: commandsBefore.length, after, reusableAfter: matchingLines(now, CI_REUSABLE), ungatedAfter: after === 0 ? ciTestLines(now).length : 0, commandsBefore, commandsAfter };
4439
4533
  }
4440
4534
  function resolveCiTestSteps(counts, commit, elsewhere = /* @__PURE__ */ new Set()) {
4441
4535
  const before = counts.reduce((sum, item) => sum + item.before, 0);
@@ -4484,7 +4578,6 @@ function scopeCommands(scope) {
4484
4578
  }
4485
4579
  function lenientSteps(text) {
4486
4580
  const lines = text.split(/\r?\n/);
4487
- const indentOf = (line) => line.length - line.trimStart().length;
4488
4581
  const found = /* @__PURE__ */ new Map();
4489
4582
  lines.forEach((line, i) => {
4490
4583
  if (line.trim().startsWith("#") || !LENIENT.test(line)) return;
@@ -4501,7 +4594,7 @@ function lenientSteps(text) {
4501
4594
  const start = /^\s*-\s+if:/.test(line) ? i : Math.max(j, 0), level = indentOf(lines[start]);
4502
4595
  let end = start + 1;
4503
4596
  while (end < lines.length && (!lines[end].trim() || lines[end].trim().startsWith("#") || indentOf(lines[end]) > level)) end++;
4504
- const scope = lines.slice(start, end), runsChecks = matchingLines(scope.join("\n"), ciRuns) > 0;
4597
+ const scope = lines.slice(start, end), runsChecks = ciTestLines(scope.join("\n")).length > 0;
4505
4598
  const uses = scope.filter((item) => /^\s*(?:-\s+)?uses:/.test(item));
4506
4599
  const nonCheck = !runsChecks && uses.length === 1 && NON_CHECK_ACTION.test(uses[0]) && !scope.some((item) => /^\s*(?:-\s+)?run:/.test(item));
4507
4600
  const commands = scopeCommands(scope);
@@ -4511,7 +4604,8 @@ function lenientSteps(text) {
4511
4604
  entry.runsChecks ||= runsChecks;
4512
4605
  entry.nonCheck &&= nonCheck;
4513
4606
  entry.commands.push(...commands);
4514
- } else found.set(identity, { count: 1, line: i + 1, runsChecks, nonCheck, commands });
4607
+ entry.ranges.push([start, end]);
4608
+ } else found.set(identity, { count: 1, line: i + 1, runsChecks, nonCheck, commands, ranges: [[start, end]] });
4515
4609
  });
4516
4610
  return found;
4517
4611
  }
@@ -4800,7 +4894,7 @@ async function compareSnapshot(options, cwd, snapshot) {
4800
4894
  try {
4801
4895
  for (const file of (await ciFilesAt(cwd, tree, options.signal)).filter((candidate) => !touched.has(candidate.path)).slice(0, 100)) {
4802
4896
  try {
4803
- for (const command of gatingTestCommands(file.path, new TextDecoder().decode(await readBlob(cwd, file.oid, options.signal, 512 * 1024)))) elsewhere.add(command);
4897
+ for (const command of enforcedTestCommands(file.path, new TextDecoder().decode(await readBlob(cwd, file.oid, options.signal, 512 * 1024)))) elsewhere.add(command);
4804
4898
  } catch (error) {
4805
4899
  assertNotAborted(options.signal);
4806
4900
  }
@@ -5760,6 +5854,7 @@ export {
5760
5854
  GitResolutionError,
5761
5855
  readGit,
5762
5856
  RULE_GUIDES,
5857
+ assertsAlwaysTrue,
5763
5858
  isCodeFile,
5764
5859
  isTestFile,
5765
5860
  auditFileDiffs,
package/dist/cli.js CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  terminalText,
39
39
  uninstallHooks,
40
40
  writeBaseline
41
- } from "./chunk-BNDNXKBM.js";
41
+ } from "./chunk-XVV5FNDV.js";
42
42
 
43
43
  // src/cli.ts
44
44
  import { Command } from "commander";
package/dist/index.d.ts CHANGED
@@ -257,6 +257,10 @@ declare class GitResolutionError extends Error {
257
257
  constructor(message: string);
258
258
  }
259
259
 
260
+ /** `assert check or True` and `self.assertTrue(check or True)` cannot fail: an operand of the `or` is the constant, and the check beside it decides nothing. Only the operands outside every
261
+ * bracket are read: `lambda paths: seen.append(paths) or True` inside a call is a callback that says it handled the call, and `identity(x or True)` is a value. `code` has its strings and
262
+ * comments blanked. */
263
+ declare function assertsAlwaysTrue(code: string): boolean;
260
264
  declare function isCodeFile(filePath: string, customIgnorePaths?: string[]): boolean;
261
265
  declare function isTestFile(filePath: string): boolean;
262
266
  /**
@@ -741,4 +745,4 @@ declare function roastRepository(options?: RoastOptions): Promise<{
741
745
  }>;
742
746
  declare function auditPullRequest(options: PrAuditOptions): Promise<PrAuditResult>;
743
747
 
744
- export { AGENT_LABELS, type AgentRoastConfig, type AiAttributionResult, CONFIG_FILENAMES, type CheckOptions, type CheckResult, type CommitInfo, ConfigError, DEFAULT_CONFIG, DEFAULT_CONFIG_TEMPLATE, type DemoResult, type DetectorType, FIELD_SEPARATOR, type FindingBaseline, type FindingException, GITHUB_WORKFLOW_TEMPLATE, GitError, GitResolutionError, HERMETIC_GIT_FLAGS, HOOK_AGENTS, type HookAgent, type HookInstallChange, type HookInstallOptions, type HookOptions, type HookOutcome, type HookScope, INSTRUCTIONS_BLOCK, INSTRUCTIONS_END, INSTRUCTIONS_START, type Infraction, type InitOptions, type InitResult, InvalidDetectorError, MAX_CONFIG_BYTES, MCP_TOOLS, type McpServer, type McpServerOptions, type MetricSummary, MetricsCollector, PRE_COMMIT_HOOK_TEMPLATE, type ParseDiffResult, type ParsedCommit, type ParsedDiffLine, type ParsedFileDiff, type PrAuditOptions, type PrAuditResult, RECORD_SEPARATOR, RULE_GUIDES, type ReplayCommit, type ReplayOptions, type ReplayResult, type ReplayStatus, type ReviewFormat, type ReviewOptions, type ReviewResult, type RoastOptions, type RuleGuide, type ServeMcpOptions, type ShieldsEndpointResponse, type StagedReviewOptions, type SuppressedFinding, VALID_DETECTORS, applyBaseline, applyExceptions, auditFileDiffs, auditPullRequest, checkProject, compareFindings, configFromText, createBaseline, createMcpServer, detectAiAttribution, findMergeBase, findingFingerprint, formatAgentFeedback, formatCheckMarkdown, formatCheckTerminal, formatGithubAnnotations, formatGithubStepSummary, formatPrMarkdownComment, formatPrTerminalCard, formatReplayTerminal, formatReviewMarkdown, formatReviewTerminal, formatSarif, formatTerminalCard, generateShareUrl, generateShieldsJson, generateShieldsMarkdown, generateSvgBadge, getHermeticGitEnv, getScoreColor, gitOutputPath, hookCommand, hookConfigPath, hookPausedUntil, hookStatus, initProject, installHooks, installInstructions, isCheckRule, isCodeFile, isTestFile, loadBaseline, loadConfig, matchIgnorePath, mostImportantFirst, packageVersion, parseDiff, parseDuration, parseGitLogStream, pauseHook, replayHistory, repositoryRoot, resolveCommitSha, resumeHook, reviewChanges, reviewStagedChanges, reviewWorktreeChanges, roastRepository, runDemo, runHook, serveMcp, streamGitDiff, streamGitLog, stripStringAndRegexLiterals, terminalText, uninstallHooks, unquoteGitPath, validateExceptions, writeBaseline };
748
+ export { AGENT_LABELS, type AgentRoastConfig, type AiAttributionResult, CONFIG_FILENAMES, type CheckOptions, type CheckResult, type CommitInfo, ConfigError, DEFAULT_CONFIG, DEFAULT_CONFIG_TEMPLATE, type DemoResult, type DetectorType, FIELD_SEPARATOR, type FindingBaseline, type FindingException, GITHUB_WORKFLOW_TEMPLATE, GitError, GitResolutionError, HERMETIC_GIT_FLAGS, HOOK_AGENTS, type HookAgent, type HookInstallChange, type HookInstallOptions, type HookOptions, type HookOutcome, type HookScope, INSTRUCTIONS_BLOCK, INSTRUCTIONS_END, INSTRUCTIONS_START, type Infraction, type InitOptions, type InitResult, InvalidDetectorError, MAX_CONFIG_BYTES, MCP_TOOLS, type McpServer, type McpServerOptions, type MetricSummary, MetricsCollector, PRE_COMMIT_HOOK_TEMPLATE, type ParseDiffResult, type ParsedCommit, type ParsedDiffLine, type ParsedFileDiff, type PrAuditOptions, type PrAuditResult, RECORD_SEPARATOR, RULE_GUIDES, type ReplayCommit, type ReplayOptions, type ReplayResult, type ReplayStatus, type ReviewFormat, type ReviewOptions, type ReviewResult, type RoastOptions, type RuleGuide, type ServeMcpOptions, type ShieldsEndpointResponse, type StagedReviewOptions, type SuppressedFinding, VALID_DETECTORS, applyBaseline, applyExceptions, assertsAlwaysTrue, auditFileDiffs, auditPullRequest, checkProject, compareFindings, configFromText, createBaseline, createMcpServer, detectAiAttribution, findMergeBase, findingFingerprint, formatAgentFeedback, formatCheckMarkdown, formatCheckTerminal, formatGithubAnnotations, formatGithubStepSummary, formatPrMarkdownComment, formatPrTerminalCard, formatReplayTerminal, formatReviewMarkdown, formatReviewTerminal, formatSarif, formatTerminalCard, generateShareUrl, generateShieldsJson, generateShieldsMarkdown, generateSvgBadge, getHermeticGitEnv, getScoreColor, gitOutputPath, hookCommand, hookConfigPath, hookPausedUntil, hookStatus, initProject, installHooks, installInstructions, isCheckRule, isCodeFile, isTestFile, loadBaseline, loadConfig, matchIgnorePath, mostImportantFirst, packageVersion, parseDiff, parseDuration, parseGitLogStream, pauseHook, replayHistory, repositoryRoot, resolveCommitSha, resumeHook, reviewChanges, reviewStagedChanges, reviewWorktreeChanges, roastRepository, runDemo, runHook, serveMcp, streamGitDiff, streamGitLog, stripStringAndRegexLiterals, terminalText, uninstallHooks, unquoteGitPath, validateExceptions, writeBaseline };
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  VALID_DETECTORS,
25
25
  applyBaseline,
26
26
  applyExceptions,
27
+ assertsAlwaysTrue,
27
28
  auditFileDiffs,
28
29
  auditPullRequest,
29
30
  checkProject,
@@ -91,7 +92,7 @@ import {
91
92
  unquoteGitPath,
92
93
  validateExceptions,
93
94
  writeBaseline
94
- } from "./chunk-BNDNXKBM.js";
95
+ } from "./chunk-XVV5FNDV.js";
95
96
  export {
96
97
  AGENT_LABELS,
97
98
  CONFIG_FILENAMES,
@@ -117,6 +118,7 @@ export {
117
118
  VALID_DETECTORS,
118
119
  applyBaseline,
119
120
  applyExceptions,
121
+ assertsAlwaysTrue,
120
122
  auditFileDiffs,
121
123
  auditPullRequest,
122
124
  checkProject,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-roast",
3
- "version": "0.20.0",
3
+ "version": "0.21.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",