agent-roast 0.19.0 → 0.20.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 +8 -7
- package/dist/{chunk-OAMVMB5N.js → chunk-BNDNXKBM.js} +120 -13
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -78,7 +78,7 @@ 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
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
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 |
|
|
@@ -112,23 +112,24 @@ 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
|
+
- **152 of 152** hand-labeled cheating edits are caught and **136 of 136** harmless look-alikes (reformats, moves, raised
|
|
116
116
|
thresholds, environment-gated skips) stay silent. The cases were written with the detector, so they guard against regressions;
|
|
117
117
|
they do not prove accuracy. One gap is documented and still open.
|
|
118
118
|
- **12,645 commits** that touch tests or CI/config in 166 public repositories: blocking rules fired on 0.3% of AI-signed commits
|
|
119
119
|
(Claude, Copilot, Cursor, Codex, Devin, aider) and on 0.3% of the others. Inside the same repositories the odds for an
|
|
120
120
|
AI-signed commit were 0.81 times those of another commit (95% CI 0.40–1.67). There is no sign that AI-signed commits weaken
|
|
121
121
|
checks more often.
|
|
122
|
-
- **On repositories it was not written from, between a quarter and two thirds of the blocked commits were false alarms.**
|
|
123
|
-
or 40 public repositories (
|
|
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
|
|
124
124
|
first sample and on the fourth, 51% for 0.18.0 on the fourth and 27% on the fifth, which it had not seen either. This version, frozen before the
|
|
125
125
|
sixth sample was drawn, blocked 100 of its 11,335 commits: 34 changed a check a reviewer should see (an assertion that got weaker, a gate
|
|
126
126
|
that was relaxed, a test switched off or left focused) and 66 were false alarms, but 51 of the 66 were one defect in one repository, an array of
|
|
127
127
|
mypy overrides read as one table, which is fixed in this release; the released build blocks 44 of those commits, 34 and 10. A new
|
|
128
128
|
test that asserts nothing counts as a block for a reviewer; counted as false alarms, 13 of 33 on the fifth sample and 72 of 100 on the sixth. Most of the rest, in the
|
|
129
129
|
earlier measurements too, were deliberate and explained. One reader (an AI assistant) sorted them, and the fixes were made from the same
|
|
130
|
-
sorting, so treat the figures as a first estimate
|
|
131
|
-
[section
|
|
130
|
+
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)).
|
|
132
133
|
- **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
134
|
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
135
|
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 +162,7 @@ It is a review aid. It cannot prove who wrote a change or that code is correct,
|
|
|
161
162
|
|
|
162
163
|
[Rules](docs/rules.md) · [Integrations](docs/integrations.md) · [Reference](docs/reference.md) (commands, exit codes,
|
|
163
164
|
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)
|
|
165
|
+
[Real blocks](docs/examples.md) · [Try it on your repository](docs/pilot.md) · [Contributing](CONTRIBUTING.md) · [Changelog](CHANGELOG.md)
|
|
165
166
|
|
|
166
167
|
Found a finding that was wrong, or a shortcut it missed? That is the most useful report there is:
|
|
167
168
|
[open an issue](https://github.com/Nari-Ab/agent-roast/issues/new/choose).
|
|
@@ -1667,6 +1667,10 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1667
1667
|
if (source[previous] === ".") continue;
|
|
1668
1668
|
if (match[2] && !isTestFile(file.path)) continue;
|
|
1669
1669
|
if (match[2] && callArguments(source, raw, match.index + match[0].length)?.length === 0) continue;
|
|
1670
|
+
if ((match[2] === "fit" || match[2] === "fdescribe") && !match[3]) {
|
|
1671
|
+
const first = callArguments(source, raw, match.index + match[0].length)?.[0];
|
|
1672
|
+
if (first !== void 0 && !/^[`'"]/.test(first)) continue;
|
|
1673
|
+
}
|
|
1670
1674
|
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
1675
|
if (/\bfunction$/.test(source.slice(Math.max(0, previous - 8), previous + 1))) continue;
|
|
1672
1676
|
if (source[match.index + match[0].length] === "" && !/\.\s*each\s*$/.test(match[3])) continue;
|
|
@@ -2902,6 +2906,14 @@ async function readBlob(cwd, oid, signal, limit = 2 * 1024 * 1024) {
|
|
|
2902
2906
|
if (buffer.length !== size) throw new ConfigError("Git blob size differs from the recorded object.");
|
|
2903
2907
|
return buffer;
|
|
2904
2908
|
}
|
|
2909
|
+
async function ciFilesAt(cwd, sha, signal) {
|
|
2910
|
+
if (!/^[a-f0-9]{40,64}$/.test(sha)) return [];
|
|
2911
|
+
const raw = await readGit(cwd, ["ls-tree", "-r", "-z", "--full-tree", sha, "--", ".github/workflows", ".gitlab-ci.yml"], signal, 1024 * 1024);
|
|
2912
|
+
return raw.toString().split("\0").filter(Boolean).flatMap((entry) => {
|
|
2913
|
+
const match = /^100(?:644|755) blob ([a-f0-9]{40,64})\t(.+\.ya?ml)$/.exec(entry);
|
|
2914
|
+
return match ? [{ path: match[2], oid: match[1] }] : [];
|
|
2915
|
+
});
|
|
2916
|
+
}
|
|
2905
2917
|
async function reviewPolicy(cwd, baseSha, signal) {
|
|
2906
2918
|
const raw = await readGit(cwd, ["ls-tree", "--full-tree", "-z", baseSha, "--", ...CONFIG_FILENAMES], signal, 8192);
|
|
2907
2919
|
const entries = raw.toString().split("\0").filter(Boolean);
|
|
@@ -3518,10 +3530,12 @@ function configEntries(text, toml) {
|
|
|
3518
3530
|
}
|
|
3519
3531
|
return entries;
|
|
3520
3532
|
}
|
|
3533
|
+
var withoutComments = (value) => value.replace(/"(?:\\.|[^"\\\n])*"|'[^'\n]*'|[#;][^\n]*/g, (found) => found[0] === "#" || found[0] === ";" ? "" : found);
|
|
3521
3534
|
function members(value) {
|
|
3522
|
-
const
|
|
3535
|
+
const code = withoutComments(value);
|
|
3536
|
+
const quoted = [...code.matchAll(/"((?:\\.|[^"\\\n])*)"|'([^'\n]*)'/g)].map((match) => (match[1] ?? match[2]).trim()).filter(Boolean);
|
|
3523
3537
|
if (quoted.length) return quoted;
|
|
3524
|
-
return
|
|
3538
|
+
return code.split(/[\s,]+/).map((word) => word.trim()).filter(Boolean);
|
|
3525
3539
|
}
|
|
3526
3540
|
function covers(selector, code) {
|
|
3527
3541
|
if (selector === "ALL") return true;
|
|
@@ -3572,8 +3586,31 @@ function analyzePythonConfig(file, oldText, newText, report) {
|
|
|
3572
3586
|
const base = explicit.length ? explicit : tool === "ruff" ? ["E4", "E7", "E9", "F"] : ["E", "F", "W", "C90"];
|
|
3573
3587
|
return [...base, ...extra].some((selected2) => covers(selected2, code) || covers(code, selected2));
|
|
3574
3588
|
};
|
|
3589
|
+
const versionParts = (text) => /^\d{2,}$/.test(text) ? [Number(text[0]), Number(text.slice(1))] : text.split(".").map(Number);
|
|
3590
|
+
const higher = (was2, now) => {
|
|
3591
|
+
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);
|
|
3592
|
+
return false;
|
|
3593
|
+
};
|
|
3594
|
+
const newest = (text, pattern) => {
|
|
3595
|
+
let best = null;
|
|
3596
|
+
for (const match of text.matchAll(pattern)) {
|
|
3597
|
+
const parts = versionParts(match[1]);
|
|
3598
|
+
if (!best || higher(best, parts)) best = parts;
|
|
3599
|
+
}
|
|
3600
|
+
return best;
|
|
3601
|
+
};
|
|
3602
|
+
const versions = [
|
|
3603
|
+
/^\s*requires-python\s*=\s*["'][<>=!~^\s]*(\d+(?:\.\d+)*)/gm,
|
|
3604
|
+
/^\s*target-version\s*=\s*["']?(?:py)?(\d+(?:\.\d+)*)/gm,
|
|
3605
|
+
/^\s*python_version\s*=\s*["']?(\d+(?:\.\d+)*)/gm,
|
|
3606
|
+
/\bruff\s*(?:(?:===|==|~=|>=|>)\s*["']?|=\s*["'][\^~>=]*)(\d+(?:\.\d+)*)/g
|
|
3607
|
+
];
|
|
3608
|
+
const bumped = versions.some((pattern) => {
|
|
3609
|
+
const was2 = newest(oldText, pattern), now = newest(newText, pattern);
|
|
3610
|
+
return !!was2 && !!now && higher(was2, now);
|
|
3611
|
+
});
|
|
3575
3612
|
for (const [tool, bucket] of ignored(after)) {
|
|
3576
|
-
if (!configured.has(tool)) continue;
|
|
3613
|
+
if (!configured.has(tool) || tool === "ruff" && bumped) continue;
|
|
3577
3614
|
const old = [...was.get(tool)?.keys() ?? []];
|
|
3578
3615
|
const fresh = [...bucket].filter(([code]) => !old.some((known) => covers(known, code)) && enforced(tool, code));
|
|
3579
3616
|
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");
|
|
@@ -3926,12 +3963,33 @@ function admits(value, operator, limit) {
|
|
|
3926
3963
|
return true;
|
|
3927
3964
|
}
|
|
3928
3965
|
}
|
|
3966
|
+
var EXACT_MATCHERS = ["toBe", "toEqual", "toStrictEqual", "toHaveLength"];
|
|
3967
|
+
function comparedValue(assertion, language, negated) {
|
|
3968
|
+
if (language === "js") {
|
|
3969
|
+
for (const matcher of EXACT_MATCHERS) {
|
|
3970
|
+
const value = matcherArgument(assertion, negated ? `not\\s*\\.\\s*${matcher}` : matcher);
|
|
3971
|
+
if (value !== null) return value;
|
|
3972
|
+
}
|
|
3973
|
+
return null;
|
|
3974
|
+
}
|
|
3975
|
+
const call = new RegExp(`\\bself\\s*\\.\\s*assert${negated ? "NotEqual" : "Equal"}\\s*\\(([\\s\\S]*)\\)\\s*;?\\s*$`).exec(assertion);
|
|
3976
|
+
if (call) return splitArguments(call[1])[1]?.replace(/\s+/g, "") ?? null;
|
|
3977
|
+
const at = assertion.indexOf(negated ? "!=" : "==");
|
|
3978
|
+
return at < 0 ? null : splitArguments(assertion.slice(at + 2))[0]?.replace(/\s+/g, "") ?? null;
|
|
3979
|
+
}
|
|
3980
|
+
var NOTHING_OR_BOOLEAN = /^(?:true|false|True|False|null|undefined|None|NaN|0(?:\.0+)?|""|''|``|\[\]|\{\}|\(\))$/;
|
|
3981
|
+
var LITERAL_VALUE = /^(?:-?\d+(?:\.\d+)?|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|null|undefined|None|\[\]|\{\}|\(\))$/;
|
|
3982
|
+
function exactBecameExclusion(oldAssertion, newAssertion, language) {
|
|
3983
|
+
const pinned = comparedValue(oldAssertion, language, false), ruledOut = comparedValue(newAssertion, language, true);
|
|
3984
|
+
return pinned !== null && ruledOut !== null && pinned !== "" && !NOTHING_OR_BOOLEAN.test(pinned) && LITERAL_VALUE.test(ruledOut) && pinned !== ruledOut;
|
|
3985
|
+
}
|
|
3929
3986
|
function relaxes(oldAssertion, newAssertion, language, siblings = []) {
|
|
3930
3987
|
const subject = checkedExpression(oldAssertion, language);
|
|
3931
3988
|
if (!subject || subject !== checkedExpression(newAssertion, language)) return false;
|
|
3932
3989
|
if (siblings.some((other) => isExact(other, language) && checkedExpression(other, language) === subject)) return false;
|
|
3933
3990
|
if (language === "js") {
|
|
3934
|
-
|
|
3991
|
+
const negated = (text) => /\.\s*not\s*\./.test(text);
|
|
3992
|
+
if (negated(oldAssertion) || negated(newAssertion)) return !negated(oldAssertion) && exactBecameExclusion(oldAssertion, newAssertion, language);
|
|
3935
3993
|
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
3994
|
const bound = /\.(?:toBeGreaterThan|toBeGreaterThanOrEqual|toBeLessThan|toBeLessThanOrEqual)\s*\(/.test(newAssertion) || /assert\s*\.\s*ok\s*\([^;]*(?:>=|<=|>|<)/.test(newAssertion);
|
|
3937
3995
|
if (exact !== void 0 && Number(exact) !== 0 && bound) {
|
|
@@ -3951,6 +4009,8 @@ function relaxes(oldAssertion, newAssertion, language, siblings = []) {
|
|
|
3951
4009
|
const operator = (text) => /^\s*assert\s+(?:not\s+)?.+?\s*(==|!=|>=|<=|>|<|\bin\b|\bis\b)/.exec(text)?.[1] ?? "";
|
|
3952
4010
|
const exact = operator(oldAssertion) === "==" && plain(oldAssertion) || /\bself\s*\.\s*assertEqual\s*\(/.test(oldAssertion);
|
|
3953
4011
|
const bound = [">=", "<=", ">", "<"].includes(operator(newAssertion)) && plain(newAssertion) || /\bself\s*\.\s*assert(?:Greater|Less)(?:Equal)?\s*\(/.test(newAssertion);
|
|
4012
|
+
const unequal = operator(newAssertion) === "!=" && plain(newAssertion) && !/^\s*assert\s+not\b/.test(newAssertion) || /\bself\s*\.\s*assertNotEqual\s*\(/.test(newAssertion);
|
|
4013
|
+
if (exact && unequal) return exactBecameExclusion(oldAssertion, newAssertion, language);
|
|
3954
4014
|
if (!exact || !bound) return false;
|
|
3955
4015
|
const value = (text) => /==\s*(-?\d+(?:\.\d+)?|[\w.]+(?:\([^)]*\))?)\s*(?:,|$)/.exec(text)?.[1] ?? /\bassertEqual\s*\([^,]+,\s*(-?\d+(?:\.\d+)?|[\w.]+)\s*[,)]/.exec(text)?.[1];
|
|
3956
4016
|
const limit = (text) => {
|
|
@@ -4320,7 +4380,9 @@ var CI_RUNNER = new RegExp(
|
|
|
4320
4380
|
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
4381
|
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
4382
|
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/-])` +
|
|
4383
|
+
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`.
|
|
4384
|
+
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
|
+
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/.-])`
|
|
4324
4386
|
);
|
|
4325
4387
|
var SHELL_TEST = /(?:^|run:\s*|[;&|({]\s*|\b(?:if|then|do|else|elif|while|until)\s+|!\s+)test\s+!?\s*-[bcdefghkLnOGprsStuwxzN](?=\s)/g;
|
|
4326
4388
|
var ciRuns = (line) => CI_RUNNER.test(line.replace(SHELL_TEST, " ").replace(/\s#.*$/, ""));
|
|
@@ -4359,8 +4421,10 @@ function triggerSection(text) {
|
|
|
4359
4421
|
while (end < lines.length && (!lines[end].trim() || /^[\s#-]/.test(lines[end]))) end++;
|
|
4360
4422
|
return lines.slice(start, end).join("\n");
|
|
4361
4423
|
}
|
|
4362
|
-
function
|
|
4363
|
-
|
|
4424
|
+
function gatingTestCommands(file, text) {
|
|
4425
|
+
if (!(/\.gitlab-ci/.test(file) || CI_GATE.test(gatingTriggers(text) ?? text))) return [];
|
|
4426
|
+
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}`);
|
|
4364
4428
|
}
|
|
4365
4429
|
function matchingLines(text, pattern) {
|
|
4366
4430
|
const matches = typeof pattern === "function" ? pattern : (line) => pattern.test(line);
|
|
@@ -4369,14 +4433,16 @@ function matchingLines(text, pattern) {
|
|
|
4369
4433
|
function ciTestSteps(file, oldText, newText) {
|
|
4370
4434
|
if (!CI_FILE.test(file) || oldText === null && newText === null) return null;
|
|
4371
4435
|
const was = oldText ?? "", now = newText ?? "";
|
|
4372
|
-
const
|
|
4373
|
-
|
|
4436
|
+
const commandsBefore = gatingTestCommands(file, was), commandsAfter = gatingTestCommands(file, now);
|
|
4437
|
+
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 };
|
|
4374
4439
|
}
|
|
4375
|
-
function resolveCiTestSteps(counts, commit) {
|
|
4440
|
+
function resolveCiTestSteps(counts, commit, elsewhere = /* @__PURE__ */ new Set()) {
|
|
4376
4441
|
const before = counts.reduce((sum, item) => sum + item.before, 0);
|
|
4377
4442
|
const after = counts.reduce((sum, item) => sum + item.after + item.reusableAfter, 0);
|
|
4378
4443
|
if (after >= before) return [];
|
|
4379
|
-
|
|
4444
|
+
const running = /* @__PURE__ */ new Set([...counts.flatMap((item) => item.commandsAfter ?? []), ...elsewhere]);
|
|
4445
|
+
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
4446
|
}
|
|
4381
4447
|
var LENIENT = /continue-on-error:\s*true|allow_failure:\s*true|^\s*(?:-\s+)?if:\s*(?:\$\{\{\s*)?false\s*(?:\}\})?\s*(?:#.*)?$/;
|
|
4382
4448
|
var SHELL_SWALLOW = /\|\|\s*(?:true|exit\s+0|:|echo\b)|;\s*(?:true|exit\s+0)\b/;
|
|
@@ -4397,6 +4463,25 @@ var NON_CHECK_ACTION = new RegExp(String.raw`^\s*(?:-\s+)?uses:\s*["']?(?:` + [
|
|
|
4397
4463
|
String.raw`codecov/codecov-action`,
|
|
4398
4464
|
String.raw`coverallsapp/github-action`
|
|
4399
4465
|
].join("|") + String.raw`)(?:/[\w./-]+)?@`, "i");
|
|
4466
|
+
function scopeCommands(scope) {
|
|
4467
|
+
const found = [];
|
|
4468
|
+
scope.forEach((line, index) => {
|
|
4469
|
+
const head = /^(\s*(?:-\s+)?)(?:run|uses)\s*:\s*(.*)$/.exec(line);
|
|
4470
|
+
if (!head || line.trim().startsWith("#")) return;
|
|
4471
|
+
const text = head[2].replace(/\s#.*$/, "").trim();
|
|
4472
|
+
if (text && !/^[|>][+-]?$/.test(text)) {
|
|
4473
|
+
found.push(text.replace(/\s+/g, " "));
|
|
4474
|
+
return;
|
|
4475
|
+
}
|
|
4476
|
+
const keyAt = head[1].length;
|
|
4477
|
+
for (let next = index + 1; next < scope.length; next++) {
|
|
4478
|
+
if (!scope[next].trim()) continue;
|
|
4479
|
+
if (scope[next].length - scope[next].trimStart().length <= keyAt) break;
|
|
4480
|
+
if (!scope[next].trim().startsWith("#")) found.push(scope[next].trim().replace(/\s+/g, " "));
|
|
4481
|
+
}
|
|
4482
|
+
});
|
|
4483
|
+
return found;
|
|
4484
|
+
}
|
|
4400
4485
|
function lenientSteps(text) {
|
|
4401
4486
|
const lines = text.split(/\r?\n/);
|
|
4402
4487
|
const indentOf = (line) => line.length - line.trimStart().length;
|
|
@@ -4419,12 +4504,14 @@ function lenientSteps(text) {
|
|
|
4419
4504
|
const scope = lines.slice(start, end), runsChecks = matchingLines(scope.join("\n"), ciRuns) > 0;
|
|
4420
4505
|
const uses = scope.filter((item) => /^\s*(?:-\s+)?uses:/.test(item));
|
|
4421
4506
|
const nonCheck = !runsChecks && uses.length === 1 && NON_CHECK_ACTION.test(uses[0]) && !scope.some((item) => /^\s*(?:-\s+)?run:/.test(item));
|
|
4507
|
+
const commands = scopeCommands(scope);
|
|
4422
4508
|
const entry = found.get(identity);
|
|
4423
4509
|
if (entry) {
|
|
4424
4510
|
entry.count++;
|
|
4425
4511
|
entry.runsChecks ||= runsChecks;
|
|
4426
4512
|
entry.nonCheck &&= nonCheck;
|
|
4427
|
-
|
|
4513
|
+
entry.commands.push(...commands);
|
|
4514
|
+
} else found.set(identity, { count: 1, line: i + 1, runsChecks, nonCheck, commands });
|
|
4428
4515
|
});
|
|
4429
4516
|
return found;
|
|
4430
4517
|
}
|
|
@@ -4489,8 +4576,12 @@ function analyzeGuardFile(file, oldText, newText, commit) {
|
|
|
4489
4576
|
const gating = gatesChanges(file, oldText) || gatesChanges(file, newText);
|
|
4490
4577
|
const before = lenientSteps(oldText), existing = new Set(oldText.split(/\r?\n/).flatMap((line) => [line.trim(), line.trim().replace(/^-\s+/, "")]));
|
|
4491
4578
|
const newLines = newText.split(/\r?\n/);
|
|
4579
|
+
const toleratedPlaces = (text) => text.split(/\r?\n/).filter((line) => !line.trim().startsWith("#") && /continue-on-error:\s*true|allow_failure:\s*true/.test(line)).length;
|
|
4580
|
+
const toleratedBefore = new Set([...before.values()].flatMap((step) => step.commands));
|
|
4581
|
+
const moved = toleratedPlaces(newText) <= toleratedPlaces(oldText);
|
|
4492
4582
|
for (const [identity, now2] of lenientSteps(newText)) {
|
|
4493
4583
|
if (now2.nonCheck || !gating && !now2.runsChecks) continue;
|
|
4584
|
+
if (moved && now2.commands.length > 0 && now2.commands.every((command) => toleratedBefore.has(command))) continue;
|
|
4494
4585
|
if (identity && existing.has(identity) && now2.count > (before.get(identity)?.count ?? 0)) {
|
|
4495
4586
|
const marker = (newLines[now2.line - 1] ?? "").trim();
|
|
4496
4587
|
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 +4793,23 @@ async function compareSnapshot(options, cwd, snapshot) {
|
|
|
4702
4793
|
await snapshot?.verify();
|
|
4703
4794
|
result.complete = result.skippedFiles.length === 0;
|
|
4704
4795
|
const rules = policy.config.rules ?? {};
|
|
4705
|
-
|
|
4796
|
+
const elsewhere = /* @__PURE__ */ new Set();
|
|
4797
|
+
if (ciCounts.some((item) => item.before > 0 && item.after === 0 && item.reusableAfter === 0)) {
|
|
4798
|
+
const touched = new Set(changes.flatMap((change) => [change.oldPath, change.newPath]));
|
|
4799
|
+
const tree = /^[a-f0-9]{40,64}$/.test(refs.headSha) ? refs.headSha : refs.baseSha;
|
|
4800
|
+
try {
|
|
4801
|
+
for (const file of (await ciFilesAt(cwd, tree, options.signal)).filter((candidate) => !touched.has(candidate.path)).slice(0, 100)) {
|
|
4802
|
+
try {
|
|
4803
|
+
for (const command of gatingTestCommands(file.path, new TextDecoder().decode(await readBlob(cwd, file.oid, options.signal, 512 * 1024)))) elsewhere.add(command);
|
|
4804
|
+
} catch (error) {
|
|
4805
|
+
assertNotAborted(options.signal);
|
|
4806
|
+
}
|
|
4807
|
+
}
|
|
4808
|
+
} catch (error) {
|
|
4809
|
+
assertNotAborted(options.signal);
|
|
4810
|
+
}
|
|
4811
|
+
}
|
|
4812
|
+
result.infractions.push(...[...resolveTampering(tamperingFiles, productionChanged), ...guardFindings, ...resolveCiTestSteps(ciCounts, refs.headSha, elsewhere)].filter((finding2) => rules[finding2.type] !== "off"));
|
|
4706
4813
|
const exceptions = applyExceptions(result.infractions, options.ignoreExceptions ? [] : policy.config.exceptions, /* @__PURE__ */ new Date(), existingCounts);
|
|
4707
4814
|
result.infractions = exceptions.findings;
|
|
4708
4815
|
result.suppressedFindings = exceptions.suppressedFindings;
|
package/dist/cli.js
CHANGED
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-roast",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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",
|