agent-roast 0.19.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 +13 -9
- package/dist/{chunk-OAMVMB5N.js → chunk-XVV5FNDV.js} +251 -49
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -78,10 +78,10 @@ Policy is read from the **base** branch, so a pull request cannot switch off the
|
|
|
78
78
|
|
|
79
79
|
| Rule | Fires when | By default |
|
|
80
80
|
| --- | --- | :-: |
|
|
81
|
-
| `assertion-weakened` | an exact check is replaced by a weaker one: `toBe(42)` → `toBeDefined()`, `toBe(2)` → `toBeLessThanOrEqual(2)`, `toEqual(x)` → `toMatchObject(x)`, `assert x == 1` → `assert x` | blocks |
|
|
81
|
+
| `assertion-weakened` | an exact check is replaced by a weaker one: `toBe(42)` → `toBeDefined()`, `toBe(2)` → `toBeLessThanOrEqual(2)`, `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
|
|
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,23 +112,27 @@ 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
|
-
- **
|
|
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.**
|
|
123
|
-
or 40 public repositories (
|
|
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
|
|
127
130
|
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
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
|
-
sorting, so treat the figures as a first estimate
|
|
131
|
-
[section
|
|
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
|
|
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)).
|
|
132
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
|
|
133
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);
|
|
134
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
|
|
@@ -161,7 +165,7 @@ It is a review aid. It cannot prove who wrote a change or that code is correct,
|
|
|
161
165
|
|
|
162
166
|
[Rules](docs/rules.md) · [Integrations](docs/integrations.md) · [Reference](docs/reference.md) (commands, exit codes,
|
|
163
167
|
configuration, exceptions, baselines, limits) · [Comparison](docs/comparison.md) · [Measurements](docs/benchmarks.md) ·
|
|
164
|
-
[Try it on your repository](docs/pilot.md) · [Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md)
|
|
168
|
+
[Real blocks](docs/examples.md) · [Try it on your repository](docs/pilot.md) · [Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md)
|
|
165
169
|
|
|
166
170
|
Found a finding that was wrong, or a shortcut it missed? That is the most useful report there is:
|
|
167
171
|
[open an issue](https://github.com/Nari-Ab/agent-roast/issues/new/choose).
|
|
@@ -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
|
|
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 =
|
|
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() ||
|
|
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 =
|
|
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
|
|
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 =
|
|
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() ||
|
|
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 =
|
|
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
|
|
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 =
|
|
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() ||
|
|
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 =
|
|
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
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
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
|
}
|
|
@@ -1667,6 +1688,10 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1667
1688
|
if (source[previous] === ".") continue;
|
|
1668
1689
|
if (match[2] && !isTestFile(file.path)) continue;
|
|
1669
1690
|
if (match[2] && callArguments(source, raw, match.index + match[0].length)?.length === 0) continue;
|
|
1691
|
+
if ((match[2] === "fit" || match[2] === "fdescribe") && !match[3]) {
|
|
1692
|
+
const first = callArguments(source, raw, match.index + match[0].length)?.[0];
|
|
1693
|
+
if (first !== void 0 && !/^[`'"]/.test(first)) continue;
|
|
1694
|
+
}
|
|
1670
1695
|
if (match[2] && previous >= 0 && (/[(,=:?[&|!+\-*%<]/.test(source[previous]) || /(?:await|return|yield|typeof|void)$/.test(source.slice(Math.max(0, previous - 6), previous + 1)))) continue;
|
|
1671
1696
|
if (/\bfunction$/.test(source.slice(Math.max(0, previous - 8), previous + 1))) continue;
|
|
1672
1697
|
if (source[match.index + match[0].length] === "" && !/\.\s*each\s*$/.test(match[3])) continue;
|
|
@@ -2902,6 +2927,14 @@ async function readBlob(cwd, oid, signal, limit = 2 * 1024 * 1024) {
|
|
|
2902
2927
|
if (buffer.length !== size) throw new ConfigError("Git blob size differs from the recorded object.");
|
|
2903
2928
|
return buffer;
|
|
2904
2929
|
}
|
|
2930
|
+
async function ciFilesAt(cwd, sha, signal) {
|
|
2931
|
+
if (!/^[a-f0-9]{40,64}$/.test(sha)) return [];
|
|
2932
|
+
const raw = await readGit(cwd, ["ls-tree", "-r", "-z", "--full-tree", sha, "--", ".github/workflows", ".gitlab-ci.yml"], signal, 1024 * 1024);
|
|
2933
|
+
return raw.toString().split("\0").filter(Boolean).flatMap((entry) => {
|
|
2934
|
+
const match = /^100(?:644|755) blob ([a-f0-9]{40,64})\t(.+\.ya?ml)$/.exec(entry);
|
|
2935
|
+
return match ? [{ path: match[2], oid: match[1] }] : [];
|
|
2936
|
+
});
|
|
2937
|
+
}
|
|
2905
2938
|
async function reviewPolicy(cwd, baseSha, signal) {
|
|
2906
2939
|
const raw = await readGit(cwd, ["ls-tree", "--full-tree", "-z", baseSha, "--", ...CONFIG_FILENAMES], signal, 8192);
|
|
2907
2940
|
const entries = raw.toString().split("\0").filter(Boolean);
|
|
@@ -3327,7 +3360,7 @@ function javascript(text) {
|
|
|
3327
3360
|
if (!binding || !mock) continue;
|
|
3328
3361
|
const name = binding.namespace ? call[2] : call[2] ? void 0 : binding.name;
|
|
3329
3362
|
if (!name) continue;
|
|
3330
|
-
const identifier = call[1].replace(/\$/g, "\\$");
|
|
3363
|
+
const identifier = (binding.namespace ? `${call[1]}\\s*\\.\\s*${call[2]}` : call[1]).replace(/\$/g, "\\$");
|
|
3331
3364
|
const configures = new RegExp(`(?<![\\w$.])${identifier}\\b[^;]{0,80}?\\.\\s*mock(?:Return|Resolved|Rejected|Implementation)\\w*\\s*\\(`, "g");
|
|
3332
3365
|
const configured = [...raw.matchAll(configures)].some((config) => tests.every((range) => !within(range, config.index) || within(range, match.index)));
|
|
3333
3366
|
if (!mock.fake && !configured) continue;
|
|
@@ -3518,10 +3551,12 @@ function configEntries(text, toml) {
|
|
|
3518
3551
|
}
|
|
3519
3552
|
return entries;
|
|
3520
3553
|
}
|
|
3554
|
+
var withoutComments = (value) => value.replace(/"(?:\\.|[^"\\\n])*"|'[^'\n]*'|[#;][^\n]*/g, (found) => found[0] === "#" || found[0] === ";" ? "" : found);
|
|
3521
3555
|
function members(value) {
|
|
3522
|
-
const
|
|
3556
|
+
const code = withoutComments(value);
|
|
3557
|
+
const quoted = [...code.matchAll(/"((?:\\.|[^"\\\n])*)"|'([^'\n]*)'/g)].map((match) => (match[1] ?? match[2]).trim()).filter(Boolean);
|
|
3523
3558
|
if (quoted.length) return quoted;
|
|
3524
|
-
return
|
|
3559
|
+
return code.split(/[\s,]+/).map((word) => word.trim()).filter(Boolean);
|
|
3525
3560
|
}
|
|
3526
3561
|
function covers(selector, code) {
|
|
3527
3562
|
if (selector === "ALL") return true;
|
|
@@ -3572,8 +3607,35 @@ function analyzePythonConfig(file, oldText, newText, report) {
|
|
|
3572
3607
|
const base = explicit.length ? explicit : tool === "ruff" ? ["E4", "E7", "E9", "F"] : ["E", "F", "W", "C90"];
|
|
3573
3608
|
return [...base, ...extra].some((selected2) => covers(selected2, code) || covers(code, selected2));
|
|
3574
3609
|
};
|
|
3610
|
+
const versionParts = (text) => /^\d{2,}$/.test(text) ? [Number(text[0]), Number(text.slice(1))] : text.split(".").map(Number);
|
|
3611
|
+
const higher = (was2, now) => {
|
|
3612
|
+
for (let index = 0; index < Math.max(was2.length, now.length); index++) if ((now[index] ?? 0) !== (was2[index] ?? 0)) return (now[index] ?? 0) > (was2[index] ?? 0);
|
|
3613
|
+
return false;
|
|
3614
|
+
};
|
|
3615
|
+
const newest = (text, pattern) => {
|
|
3616
|
+
let best = null;
|
|
3617
|
+
for (const match of text.matchAll(pattern)) {
|
|
3618
|
+
const parts = versionParts(match[1]);
|
|
3619
|
+
if (!best || higher(best, parts)) best = parts;
|
|
3620
|
+
}
|
|
3621
|
+
return best;
|
|
3622
|
+
};
|
|
3623
|
+
const versions = [
|
|
3624
|
+
/^\s*requires-python\s*=\s*["'][<>=!~^\s]*(\d+(?:\.\d+)*)/gm,
|
|
3625
|
+
/^\s*target-version\s*=\s*["']?(?:py)?(\d+(?:\.\d+)*)/gm,
|
|
3626
|
+
/^\s*python_version\s*=\s*["']?(\d+(?:\.\d+)*)/gm,
|
|
3627
|
+
/\bruff\s*(?:(?:===|==|~=|>=|>)\s*["']?|=\s*["'][\^~>=]*)(\d+(?:\.\d+)*)/g
|
|
3628
|
+
];
|
|
3629
|
+
const effective = (text) => newest(text, versions[1]) ?? newest(text, versions[0]);
|
|
3630
|
+
const bumped = versions.some((pattern) => {
|
|
3631
|
+
const was2 = newest(oldText, pattern), now = newest(newText, pattern);
|
|
3632
|
+
return !!was2 && !!now && higher(was2, now);
|
|
3633
|
+
}) || (() => {
|
|
3634
|
+
const was2 = effective(oldText), now = effective(newText);
|
|
3635
|
+
return !!was2 && !!now && higher(was2, now);
|
|
3636
|
+
})();
|
|
3575
3637
|
for (const [tool, bucket] of ignored(after)) {
|
|
3576
|
-
if (!configured.has(tool)) continue;
|
|
3638
|
+
if (!configured.has(tool) || tool === "ruff" && bumped) continue;
|
|
3577
3639
|
const old = [...was.get(tool)?.keys() ?? []];
|
|
3578
3640
|
const fresh = [...bucket].filter(([code]) => !old.some((known) => covers(known, code)) && enforced(tool, code));
|
|
3579
3641
|
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");
|
|
@@ -3676,13 +3738,13 @@ function testBodies(text, language) {
|
|
|
3676
3738
|
const match = startPattern.exec(line);
|
|
3677
3739
|
if (match) starts.push({ index, name: match[1], length: match[0].length });
|
|
3678
3740
|
});
|
|
3679
|
-
const
|
|
3741
|
+
const indentOf2 = (line) => /^\s*/.exec(line)[0].length;
|
|
3680
3742
|
return starts.map((start, n) => {
|
|
3681
3743
|
const limit = n + 1 < starts.length ? starts[n + 1].index : lines.length;
|
|
3682
|
-
const header = lines[start.index], indent =
|
|
3744
|
+
const header = lines[start.index], indent = indentOf2(header);
|
|
3683
3745
|
let end = start.index + 1, depth = balance(header);
|
|
3684
3746
|
while (depth > 0 && end < limit) depth += balance(lines[end++]);
|
|
3685
|
-
while (end < limit && (language === "go" ? lines[end - 1] !== "}" : !lines[end].trim() ||
|
|
3747
|
+
while (end < limit && (language === "go" ? lines[end - 1] !== "}" : !lines[end].trim() || indentOf2(lines[end]) > indent)) end++;
|
|
3686
3748
|
let top = start.index;
|
|
3687
3749
|
while (top > 0 && start.index - top < 200 && lines[top - 1].trim() && !/^\s*(?:async\s+)?(?:def|class)\b/.test(lines[top - 1])) top--;
|
|
3688
3750
|
return {
|
|
@@ -3926,12 +3988,33 @@ function admits(value, operator, limit) {
|
|
|
3926
3988
|
return true;
|
|
3927
3989
|
}
|
|
3928
3990
|
}
|
|
3991
|
+
var EXACT_MATCHERS = ["toBe", "toEqual", "toStrictEqual", "toHaveLength"];
|
|
3992
|
+
function comparedValue(assertion, language, negated) {
|
|
3993
|
+
if (language === "js") {
|
|
3994
|
+
for (const matcher of EXACT_MATCHERS) {
|
|
3995
|
+
const value = matcherArgument(assertion, negated ? `not\\s*\\.\\s*${matcher}` : matcher);
|
|
3996
|
+
if (value !== null) return value;
|
|
3997
|
+
}
|
|
3998
|
+
return null;
|
|
3999
|
+
}
|
|
4000
|
+
const call = new RegExp(`\\bself\\s*\\.\\s*assert${negated ? "NotEqual" : "Equal"}\\s*\\(([\\s\\S]*)\\)\\s*;?\\s*$`).exec(assertion);
|
|
4001
|
+
if (call) return splitArguments(call[1])[1]?.replace(/\s+/g, "") ?? null;
|
|
4002
|
+
const at = assertion.indexOf(negated ? "!=" : "==");
|
|
4003
|
+
return at < 0 ? null : splitArguments(assertion.slice(at + 2))[0]?.replace(/\s+/g, "") ?? null;
|
|
4004
|
+
}
|
|
4005
|
+
var NOTHING_OR_BOOLEAN = /^(?:true|false|True|False|null|undefined|None|NaN|0(?:\.0+)?|""|''|``|\[\]|\{\}|\(\))$/;
|
|
4006
|
+
var LITERAL_VALUE = /^(?:-?\d+(?:\.\d+)?|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|null|undefined|None|\[\]|\{\}|\(\))$/;
|
|
4007
|
+
function exactBecameExclusion(oldAssertion, newAssertion, language) {
|
|
4008
|
+
const pinned = comparedValue(oldAssertion, language, false), ruledOut = comparedValue(newAssertion, language, true);
|
|
4009
|
+
return pinned !== null && ruledOut !== null && pinned !== "" && !NOTHING_OR_BOOLEAN.test(pinned) && LITERAL_VALUE.test(ruledOut) && pinned !== ruledOut;
|
|
4010
|
+
}
|
|
3929
4011
|
function relaxes(oldAssertion, newAssertion, language, siblings = []) {
|
|
3930
4012
|
const subject = checkedExpression(oldAssertion, language);
|
|
3931
4013
|
if (!subject || subject !== checkedExpression(newAssertion, language)) return false;
|
|
3932
4014
|
if (siblings.some((other) => isExact(other, language) && checkedExpression(other, language) === subject)) return false;
|
|
3933
4015
|
if (language === "js") {
|
|
3934
|
-
|
|
4016
|
+
const negated = (text) => /\.\s*not\s*\./.test(text);
|
|
4017
|
+
if (negated(oldAssertion) || negated(newAssertion)) return !negated(oldAssertion) && exactBecameExclusion(oldAssertion, newAssertion, language);
|
|
3935
4018
|
const exact = /\.(?:toBe|toEqual|toStrictEqual|toHaveLength)\s*\(\s*(-?\d+(?:\.\d+)?)\s*\)/.exec(oldAssertion)?.[1] ?? /assert\s*\.\s*(?:equal|strictEqual|deepEqual|deepStrictEqual)\s*\([^,]+,\s*(-?\d+(?:\.\d+)?)\s*[,)]/.exec(oldAssertion)?.[1];
|
|
3936
4019
|
const bound = /\.(?:toBeGreaterThan|toBeGreaterThanOrEqual|toBeLessThan|toBeLessThanOrEqual)\s*\(/.test(newAssertion) || /assert\s*\.\s*ok\s*\([^;]*(?:>=|<=|>|<)/.test(newAssertion);
|
|
3937
4020
|
if (exact !== void 0 && Number(exact) !== 0 && bound) {
|
|
@@ -3951,6 +4034,8 @@ function relaxes(oldAssertion, newAssertion, language, siblings = []) {
|
|
|
3951
4034
|
const operator = (text) => /^\s*assert\s+(?:not\s+)?.+?\s*(==|!=|>=|<=|>|<|\bin\b|\bis\b)/.exec(text)?.[1] ?? "";
|
|
3952
4035
|
const exact = operator(oldAssertion) === "==" && plain(oldAssertion) || /\bself\s*\.\s*assertEqual\s*\(/.test(oldAssertion);
|
|
3953
4036
|
const bound = [">=", "<=", ">", "<"].includes(operator(newAssertion)) && plain(newAssertion) || /\bself\s*\.\s*assert(?:Greater|Less)(?:Equal)?\s*\(/.test(newAssertion);
|
|
4037
|
+
const unequal = operator(newAssertion) === "!=" && plain(newAssertion) && !/^\s*assert\s+not\b/.test(newAssertion) || /\bself\s*\.\s*assertNotEqual\s*\(/.test(newAssertion);
|
|
4038
|
+
if (exact && unequal) return exactBecameExclusion(oldAssertion, newAssertion, language);
|
|
3954
4039
|
if (!exact || !bound) return false;
|
|
3955
4040
|
const value = (text) => /==\s*(-?\d+(?:\.\d+)?|[\w.]+(?:\([^)]*\))?)\s*(?:,|$)/.exec(text)?.[1] ?? /\bassertEqual\s*\([^,]+,\s*(-?\d+(?:\.\d+)?|[\w.]+)\s*[,)]/.exec(text)?.[1];
|
|
3956
4041
|
const limit = (text) => {
|
|
@@ -4028,7 +4113,9 @@ function isWeaker(rawOld, rawNew, language) {
|
|
|
4028
4113
|
const oldBare = unittestAsAssert(oldAssertion) ?? oldAssertion;
|
|
4029
4114
|
if (WEAK_PY.test(oldAssertion) || BOUND_PY.test(oldAssertion) || WEAK_PY.test(oldBare) || BOUND_PY.test(oldBare) || !PY_SPECIFIC.test(oldAssertion)) return false;
|
|
4030
4115
|
const limit = /(?:>=|<=|>|<)\s*([^\s,)]+)/.exec(oldAssertion)?.[1];
|
|
4031
|
-
|
|
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));
|
|
4032
4119
|
}
|
|
4033
4120
|
return false;
|
|
4034
4121
|
}
|
|
@@ -4286,7 +4373,10 @@ function newTestMarkers(before, after, oldText, newText) {
|
|
|
4286
4373
|
if (nth <= had || had === 0 && (nth > 1 && !test.key.startsWith("js:") || !existed(test))) continue;
|
|
4287
4374
|
found.push(finding2);
|
|
4288
4375
|
}
|
|
4289
|
-
|
|
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))];
|
|
4290
4380
|
}
|
|
4291
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;
|
|
4292
4382
|
function isGuardFile(file) {
|
|
@@ -4320,10 +4410,72 @@ var CI_RUNNER = new RegExp(
|
|
|
4320
4410
|
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
4411
|
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
4412
|
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/-])` +
|
|
4413
|
+
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/-])` + // Task runners: `deno task test`, `just test`, `rake test`, `composer test`, `mix test`, `sbt test`, `./gradlew test`, and Node's own `node --test`.
|
|
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`.
|
|
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/.-])`
|
|
4416
|
+
);
|
|
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"
|
|
4324
4422
|
);
|
|
4325
|
-
var
|
|
4326
|
-
var
|
|
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);
|
|
4327
4479
|
var CI_FILE = /(?:\.github\/workflows\/|\.gitlab-ci)/;
|
|
4328
4480
|
var CI_REUSABLE = /^\s*-?\s*uses:\s*\S*\.github\/workflows\/\S+/;
|
|
4329
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;
|
|
@@ -4359,8 +4511,14 @@ function triggerSection(text) {
|
|
|
4359
4511
|
while (end < lines.length && (!lines[end].trim() || /^[\s#-]/.test(lines[end]))) end++;
|
|
4360
4512
|
return lines.slice(start, end).join("\n");
|
|
4361
4513
|
}
|
|
4362
|
-
function
|
|
4363
|
-
|
|
4514
|
+
function gatingTestCommands(file, text, skip) {
|
|
4515
|
+
if (!(/\.gitlab-ci/.test(file) || CI_GATE.test(gatingTriggers(text) ?? text))) return [];
|
|
4516
|
+
const directories = [...new Set([...text.matchAll(/^\s*(?:-\s+)?working-directory\s*:\s*["']?([^\s"'#]+)/gm)].map((match) => match[1]))].sort().join(",");
|
|
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));
|
|
4364
4522
|
}
|
|
4365
4523
|
function matchingLines(text, pattern) {
|
|
4366
4524
|
const matches = typeof pattern === "function" ? pattern : (line) => pattern.test(line);
|
|
@@ -4369,14 +4527,16 @@ function matchingLines(text, pattern) {
|
|
|
4369
4527
|
function ciTestSteps(file, oldText, newText) {
|
|
4370
4528
|
if (!CI_FILE.test(file) || oldText === null && newText === null) return null;
|
|
4371
4529
|
const was = oldText ?? "", now = newText ?? "";
|
|
4372
|
-
const
|
|
4373
|
-
|
|
4530
|
+
const commandsBefore = enforcedTestCommands(file, was), commandsAfter = gatingTestCommands(file, now);
|
|
4531
|
+
const after = commandsAfter.length;
|
|
4532
|
+
return { file, before: commandsBefore.length, after, reusableAfter: matchingLines(now, CI_REUSABLE), ungatedAfter: after === 0 ? ciTestLines(now).length : 0, commandsBefore, commandsAfter };
|
|
4374
4533
|
}
|
|
4375
|
-
function resolveCiTestSteps(counts, commit) {
|
|
4534
|
+
function resolveCiTestSteps(counts, commit, elsewhere = /* @__PURE__ */ new Set()) {
|
|
4376
4535
|
const before = counts.reduce((sum, item) => sum + item.before, 0);
|
|
4377
4536
|
const after = counts.reduce((sum, item) => sum + item.after + item.reusableAfter, 0);
|
|
4378
4537
|
if (after >= before) return [];
|
|
4379
|
-
|
|
4538
|
+
const running = /* @__PURE__ */ new Set([...counts.flatMap((item) => item.commandsAfter ?? []), ...elsewhere]);
|
|
4539
|
+
return counts.filter((item) => item.before > 0 && item.after === 0 && item.reusableAfter === 0).filter((item) => item.commandsBefore === void 0 || item.commandsBefore.some((command) => !running.has(command))).map((item) => item.ungatedAfter > 0 ? finding("guard-weakened", item.file, 1, "tests no longer run on push or pull_request", "Workflow keeps its tests but no longer runs on pushes or pull requests", commit) : finding("guard-weakened", item.file, 1, "test command no longer runs in CI", "Test step removed from CI", commit));
|
|
4380
4540
|
}
|
|
4381
4541
|
var LENIENT = /continue-on-error:\s*true|allow_failure:\s*true|^\s*(?:-\s+)?if:\s*(?:\$\{\{\s*)?false\s*(?:\}\})?\s*(?:#.*)?$/;
|
|
4382
4542
|
var SHELL_SWALLOW = /\|\|\s*(?:true|exit\s+0|:|echo\b)|;\s*(?:true|exit\s+0)\b/;
|
|
@@ -4397,9 +4557,27 @@ var NON_CHECK_ACTION = new RegExp(String.raw`^\s*(?:-\s+)?uses:\s*["']?(?:` + [
|
|
|
4397
4557
|
String.raw`codecov/codecov-action`,
|
|
4398
4558
|
String.raw`coverallsapp/github-action`
|
|
4399
4559
|
].join("|") + String.raw`)(?:/[\w./-]+)?@`, "i");
|
|
4560
|
+
function scopeCommands(scope) {
|
|
4561
|
+
const found = [];
|
|
4562
|
+
scope.forEach((line, index) => {
|
|
4563
|
+
const head = /^(\s*(?:-\s+)?)(?:run|uses)\s*:\s*(.*)$/.exec(line);
|
|
4564
|
+
if (!head || line.trim().startsWith("#")) return;
|
|
4565
|
+
const text = head[2].replace(/\s#.*$/, "").trim();
|
|
4566
|
+
if (text && !/^[|>][+-]?$/.test(text)) {
|
|
4567
|
+
found.push(text.replace(/\s+/g, " "));
|
|
4568
|
+
return;
|
|
4569
|
+
}
|
|
4570
|
+
const keyAt = head[1].length;
|
|
4571
|
+
for (let next = index + 1; next < scope.length; next++) {
|
|
4572
|
+
if (!scope[next].trim()) continue;
|
|
4573
|
+
if (scope[next].length - scope[next].trimStart().length <= keyAt) break;
|
|
4574
|
+
if (!scope[next].trim().startsWith("#")) found.push(scope[next].trim().replace(/\s+/g, " "));
|
|
4575
|
+
}
|
|
4576
|
+
});
|
|
4577
|
+
return found;
|
|
4578
|
+
}
|
|
4400
4579
|
function lenientSteps(text) {
|
|
4401
4580
|
const lines = text.split(/\r?\n/);
|
|
4402
|
-
const indentOf = (line) => line.length - line.trimStart().length;
|
|
4403
4581
|
const found = /* @__PURE__ */ new Map();
|
|
4404
4582
|
lines.forEach((line, i) => {
|
|
4405
4583
|
if (line.trim().startsWith("#") || !LENIENT.test(line)) return;
|
|
@@ -4416,15 +4594,18 @@ function lenientSteps(text) {
|
|
|
4416
4594
|
const start = /^\s*-\s+if:/.test(line) ? i : Math.max(j, 0), level = indentOf(lines[start]);
|
|
4417
4595
|
let end = start + 1;
|
|
4418
4596
|
while (end < lines.length && (!lines[end].trim() || lines[end].trim().startsWith("#") || indentOf(lines[end]) > level)) end++;
|
|
4419
|
-
const scope = lines.slice(start, end), runsChecks =
|
|
4597
|
+
const scope = lines.slice(start, end), runsChecks = ciTestLines(scope.join("\n")).length > 0;
|
|
4420
4598
|
const uses = scope.filter((item) => /^\s*(?:-\s+)?uses:/.test(item));
|
|
4421
4599
|
const nonCheck = !runsChecks && uses.length === 1 && NON_CHECK_ACTION.test(uses[0]) && !scope.some((item) => /^\s*(?:-\s+)?run:/.test(item));
|
|
4600
|
+
const commands = scopeCommands(scope);
|
|
4422
4601
|
const entry = found.get(identity);
|
|
4423
4602
|
if (entry) {
|
|
4424
4603
|
entry.count++;
|
|
4425
4604
|
entry.runsChecks ||= runsChecks;
|
|
4426
4605
|
entry.nonCheck &&= nonCheck;
|
|
4427
|
-
|
|
4606
|
+
entry.commands.push(...commands);
|
|
4607
|
+
entry.ranges.push([start, end]);
|
|
4608
|
+
} else found.set(identity, { count: 1, line: i + 1, runsChecks, nonCheck, commands, ranges: [[start, end]] });
|
|
4428
4609
|
});
|
|
4429
4610
|
return found;
|
|
4430
4611
|
}
|
|
@@ -4489,8 +4670,12 @@ function analyzeGuardFile(file, oldText, newText, commit) {
|
|
|
4489
4670
|
const gating = gatesChanges(file, oldText) || gatesChanges(file, newText);
|
|
4490
4671
|
const before = lenientSteps(oldText), existing = new Set(oldText.split(/\r?\n/).flatMap((line) => [line.trim(), line.trim().replace(/^-\s+/, "")]));
|
|
4491
4672
|
const newLines = newText.split(/\r?\n/);
|
|
4673
|
+
const toleratedPlaces = (text) => text.split(/\r?\n/).filter((line) => !line.trim().startsWith("#") && /continue-on-error:\s*true|allow_failure:\s*true/.test(line)).length;
|
|
4674
|
+
const toleratedBefore = new Set([...before.values()].flatMap((step) => step.commands));
|
|
4675
|
+
const moved = toleratedPlaces(newText) <= toleratedPlaces(oldText);
|
|
4492
4676
|
for (const [identity, now2] of lenientSteps(newText)) {
|
|
4493
4677
|
if (now2.nonCheck || !gating && !now2.runsChecks) continue;
|
|
4678
|
+
if (moved && now2.commands.length > 0 && now2.commands.every((command) => toleratedBefore.has(command))) continue;
|
|
4494
4679
|
if (identity && existing.has(identity) && now2.count > (before.get(identity)?.count ?? 0)) {
|
|
4495
4680
|
const marker = (newLines[now2.line - 1] ?? "").trim();
|
|
4496
4681
|
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));
|
|
@@ -4702,7 +4887,23 @@ async function compareSnapshot(options, cwd, snapshot) {
|
|
|
4702
4887
|
await snapshot?.verify();
|
|
4703
4888
|
result.complete = result.skippedFiles.length === 0;
|
|
4704
4889
|
const rules = policy.config.rules ?? {};
|
|
4705
|
-
|
|
4890
|
+
const elsewhere = /* @__PURE__ */ new Set();
|
|
4891
|
+
if (ciCounts.some((item) => item.before > 0 && item.after === 0 && item.reusableAfter === 0)) {
|
|
4892
|
+
const touched = new Set(changes.flatMap((change) => [change.oldPath, change.newPath]));
|
|
4893
|
+
const tree = /^[a-f0-9]{40,64}$/.test(refs.headSha) ? refs.headSha : refs.baseSha;
|
|
4894
|
+
try {
|
|
4895
|
+
for (const file of (await ciFilesAt(cwd, tree, options.signal)).filter((candidate) => !touched.has(candidate.path)).slice(0, 100)) {
|
|
4896
|
+
try {
|
|
4897
|
+
for (const command of enforcedTestCommands(file.path, new TextDecoder().decode(await readBlob(cwd, file.oid, options.signal, 512 * 1024)))) elsewhere.add(command);
|
|
4898
|
+
} catch (error) {
|
|
4899
|
+
assertNotAborted(options.signal);
|
|
4900
|
+
}
|
|
4901
|
+
}
|
|
4902
|
+
} catch (error) {
|
|
4903
|
+
assertNotAborted(options.signal);
|
|
4904
|
+
}
|
|
4905
|
+
}
|
|
4906
|
+
result.infractions.push(...[...resolveTampering(tamperingFiles, productionChanged), ...guardFindings, ...resolveCiTestSteps(ciCounts, refs.headSha, elsewhere)].filter((finding2) => rules[finding2.type] !== "off"));
|
|
4706
4907
|
const exceptions = applyExceptions(result.infractions, options.ignoreExceptions ? [] : policy.config.exceptions, /* @__PURE__ */ new Date(), existingCounts);
|
|
4707
4908
|
result.infractions = exceptions.findings;
|
|
4708
4909
|
result.suppressedFindings = exceptions.suppressedFindings;
|
|
@@ -5653,6 +5854,7 @@ export {
|
|
|
5653
5854
|
GitResolutionError,
|
|
5654
5855
|
readGit,
|
|
5655
5856
|
RULE_GUIDES,
|
|
5857
|
+
assertsAlwaysTrue,
|
|
5656
5858
|
isCodeFile,
|
|
5657
5859
|
isTestFile,
|
|
5658
5860
|
auditFileDiffs,
|
package/dist/cli.js
CHANGED
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-
|
|
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.
|
|
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",
|