agent-roast 0.22.0 → 0.24.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 +3 -3
- package/dist/{chunk-HDC5C2D3.js → chunk-U5MFYERX.js} +66 -12
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -112,7 +112,7 @@ 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
|
+
- **165 of 165** hand-labeled cheating edits are caught and **154 of 154** 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
|
- **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,
|
|
@@ -131,8 +131,8 @@ two thirds of its blocks were questions with a harmless answer, depending on the
|
|
|
131
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
|
|
132
132
|
earlier measurements too, were deliberate and explained. One reader (an AI assistant) sorted them, and the fixes were made from the same
|
|
133
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;
|
|
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), [section 15](docs/benchmarks.md#15-a-tenth-sample-the-build-that-ships-on-repositories-it-had-not-seen)).
|
|
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; 0.21.0 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; 0.21.0 as it shipped blocked 40 of the 11,191 commits of a tenth sample that it had not seen either: 27 for a reviewer and 13 false alarms (33%), and three of those 13 are fixed in this release ([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), [section 15](docs/benchmarks.md#15-a-tenth-sample-the-build-that-ships-on-repositories-it-had-not-seen), [section 16](docs/benchmarks.md#16-what-sample-10-led-to-three-fixes-fitted-and-a-guard-that-did-not-pay)).
|
|
136
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
|
|
137
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);
|
|
138
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
|
|
@@ -1508,6 +1508,7 @@ function isCodeFile(filePath, customIgnorePaths) {
|
|
|
1508
1508
|
function isTestFile(filePath) {
|
|
1509
1509
|
return TEST_PATH_REGEX.test(filePath);
|
|
1510
1510
|
}
|
|
1511
|
+
var PYTHON_TEST_PATH = /(?:^|[/._-])(?:tests?|testing|specs?|conftest)(?:[/._-]|$)/i;
|
|
1511
1512
|
function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ */ new Date()).toISOString(), options = {}) {
|
|
1512
1513
|
const infractions = [];
|
|
1513
1514
|
let hasSupportedChanges = false;
|
|
@@ -1538,7 +1539,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1538
1539
|
for (const start of masked.templateStarts) testCode[start] = "";
|
|
1539
1540
|
maskedLines.push({ code: cleanedLine, testCode: testCode.join(""), raw: rawLine, number: added.lineNumber });
|
|
1540
1541
|
const isComment = !cleanedLine.trim();
|
|
1541
|
-
if (python2 && !isComment) {
|
|
1542
|
+
if (python2 && !isComment && PYTHON_TEST_PATH.test(file.path)) {
|
|
1542
1543
|
for (const rule of PYTHON_TEST_SKIP_PATTERNS) {
|
|
1543
1544
|
if (rule.regex.test(cleanedLine)) {
|
|
1544
1545
|
if (rule.imperative && isGuardedSkip(maskedLines)) break;
|
|
@@ -1644,7 +1645,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1644
1645
|
}
|
|
1645
1646
|
if (python2) {
|
|
1646
1647
|
const reportedSkips = new Set(infractions.slice(firstFinding).filter((f) => f.type === "test-skip").map((f) => f.lineNumber));
|
|
1647
|
-
for (const match of source.matchAll(PYTHON_MARKER)) {
|
|
1648
|
+
for (const match of PYTHON_TEST_PATH.test(file.path) ? source.matchAll(PYTHON_MARKER) : []) {
|
|
1648
1649
|
const xfail = match[2] === "xfail";
|
|
1649
1650
|
let open2 = match.index + match[0].length;
|
|
1650
1651
|
while (source[open2] === " " || source[open2] === " ") open2++;
|
|
@@ -4407,11 +4408,13 @@ var THRESHOLD_PATTERNS = [
|
|
|
4407
4408
|
var SWITCHES = ["strict", "noImplicitAny", "strictNullChecks", "noImplicitReturns", "useUnknownInCatchVariables"];
|
|
4408
4409
|
var GATE_SCRIPT = /^(?:pre|post)?(?:test|lint|typecheck|type-check|check|ci|verify|validate)(?::[\w:.-]+)?$/;
|
|
4409
4410
|
var CI_RUNNER = new RegExp(
|
|
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`.
|
|
4411
|
-
String.raw`|(?<![\w./-])(?:npm|yarn|pnpm|bun)\s+(?:run\s+)?
|
|
4411
|
+
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`, `npm run cover`.
|
|
4412
|
+
String.raw`|(?<![\w./-])(?:npm|yarn|pnpm|bun)\s+(?:run\s+)?cover(?:age)?(?![\w/-])` + // Monorepo runners: `nx affected --target=test`, `turbo run test`, `lerna run test`, `bazel test //...`.
|
|
4412
4413
|
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`.
|
|
4413
4414
|
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
|
|
4415
|
+
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 target of a task runner that has the word as one of its own words and not as the first: `make unit-test`, `just test-asyncio py314`, `pnpm run docs:test`. A target that sets up, installs, builds or type-checks
|
|
4416
|
+
// (`make setup-tests`, `pnpm run typecheck:tests`, `pnpm run build:native:test`) runs no tests.
|
|
4417
|
+
String.raw`|(?<![\w./-])(?:make|just|task|(?:npm|pnpm|bun)\s+run|yarn(?:\s+run)?|pnpm)\s+(?:-\S+\s+)*(?![\w.:-]*(?:setup|teardown|install|deps|typecheck|lint|build|docker|clean|prepare|publish|deploy|release|format|image|download|data))[\w.:-]*(?<![A-Za-z0-9])tests?(?![A-Za-z0-9])` + // 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
4418
|
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
4419
|
);
|
|
4417
4420
|
var SHELL_TEST = /(?:^|run:\s*|[;&|({]\s*|\b(?:if|then|do|else|elif|while|until)\s+|!\s+)test\s+!?\s*(?:-[bcdefghkLnOGprsStuwxzN](?=\s)|["$'])/g;
|
|
@@ -4531,12 +4534,42 @@ function ciTestSteps(file, oldText, newText) {
|
|
|
4531
4534
|
const after = commandsAfter.length;
|
|
4532
4535
|
return { file, before: commandsBefore.length, after, reusableAfter: matchingLines(now, CI_REUSABLE), ungatedAfter: after === 0 ? ciTestLines(now).length : 0, commandsBefore, commandsAfter };
|
|
4533
4536
|
}
|
|
4534
|
-
function
|
|
4537
|
+
function gatingCalls(file, text) {
|
|
4538
|
+
if (!(/\.gitlab-ci/.test(file) || CI_GATE.test(gatingTriggers(text) ?? text))) return [];
|
|
4539
|
+
return [...text.matchAll(/^\s*-?\s*uses:\s*["']?\.\/(\.github\/workflows\/[^\s"'#@]+\.ya?ml)/gm)].map((match) => match[1]);
|
|
4540
|
+
}
|
|
4541
|
+
function lostCalls(file, oldText, newText) {
|
|
4542
|
+
if (!CI_FILE.test(file)) return [];
|
|
4543
|
+
const before = gatingCalls(file, oldText ?? "");
|
|
4544
|
+
if (!before.length) return [];
|
|
4545
|
+
const after = new Set(gatingCalls(file, newText ?? ""));
|
|
4546
|
+
return before.filter((workflow) => !after.has(workflow));
|
|
4547
|
+
}
|
|
4548
|
+
function calledTestCommands(text) {
|
|
4549
|
+
const directories = [...new Set([...text.matchAll(/^\s*(?:-\s+)?working-directory\s*:\s*["']?([^\s"'#]+)/gm)].map((match) => match[1]))].sort().join(",");
|
|
4550
|
+
return ciTestEntries(text).map((entry) => `${entry.command.replace(/\s#.*$/, "").replace(/^\s*-?\s*(?:run\s*:\s*)?/, "").replace(/\s+/g, " ").trim()} @${directories}`);
|
|
4551
|
+
}
|
|
4552
|
+
function resolveLostCallers(lost, commit) {
|
|
4553
|
+
const out = [];
|
|
4554
|
+
for (const [file, { content, stillCalled }] of lost) {
|
|
4555
|
+
if (stillCalled || gatingTestCommands(file, content).length > 0 || calledTestCommands(content).length === 0) continue;
|
|
4556
|
+
out.push(finding(
|
|
4557
|
+
"guard-weakened",
|
|
4558
|
+
file,
|
|
4559
|
+
1,
|
|
4560
|
+
"nothing calls this workflow on a push or pull request any more",
|
|
4561
|
+
"Workflow keeps its tests, but the workflow that called it is gone",
|
|
4562
|
+
commit
|
|
4563
|
+
));
|
|
4564
|
+
}
|
|
4565
|
+
return out;
|
|
4566
|
+
}
|
|
4567
|
+
function resolveCiTestSteps(counts, commit, elsewhere = /* @__PURE__ */ new Set(), called = /* @__PURE__ */ new Set()) {
|
|
4535
4568
|
const before = counts.reduce((sum, item) => sum + item.before, 0);
|
|
4536
4569
|
const after = counts.reduce((sum, item) => sum + item.after + item.reusableAfter, 0);
|
|
4537
4570
|
if (after >= before) return [];
|
|
4538
4571
|
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));
|
|
4572
|
+
return counts.filter((item) => item.before > 0 && item.after === 0 && item.reusableAfter === 0).filter((item) => !(item.ungatedAfter > 0 && called.has(item.file))).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));
|
|
4540
4573
|
}
|
|
4541
4574
|
var LENIENT = /continue-on-error:\s*true|allow_failure:\s*true|^\s*(?:-\s+)?if:\s*(?:\$\{\{\s*)?false\s*(?:\}\})?\s*(?:#.*)?$/;
|
|
4542
4575
|
var SHELL_SWALLOW = /\|\|\s*(?:true|exit\s+0|:|echo\b)|;\s*(?:true|exit\s+0)\b/;
|
|
@@ -4792,6 +4825,9 @@ async function compareSnapshot(options, cwd, snapshot) {
|
|
|
4792
4825
|
const tamperingFiles = [];
|
|
4793
4826
|
const guardFindings = [];
|
|
4794
4827
|
const ciCounts = [];
|
|
4828
|
+
const calledInChange = /* @__PURE__ */ new Set();
|
|
4829
|
+
const lostCalledWorkflows = /* @__PURE__ */ new Set();
|
|
4830
|
+
const touchedCiContent = /* @__PURE__ */ new Map();
|
|
4795
4831
|
let productionChanged = false;
|
|
4796
4832
|
const decode = (content) => {
|
|
4797
4833
|
totalBytes += content.length;
|
|
@@ -4829,7 +4865,12 @@ async function compareSnapshot(options, cwd, snapshot) {
|
|
|
4829
4865
|
const newText = await readNew(change);
|
|
4830
4866
|
guardFindings.push(...analyzeGuardFile(change.newPath, oldText, newText, refs.headSha));
|
|
4831
4867
|
const ci = ciTestSteps(change.newPath, oldText, newText);
|
|
4832
|
-
if (ci)
|
|
4868
|
+
if (ci) {
|
|
4869
|
+
ciCounts.push(ci);
|
|
4870
|
+
touchedCiContent.set(change.newPath, newText);
|
|
4871
|
+
for (const workflow of lostCalls(change.newPath, oldText, newText)) lostCalledWorkflows.add(workflow);
|
|
4872
|
+
if (newText !== null) for (const workflow of gatingCalls(change.newPath, newText)) calledInChange.add(workflow);
|
|
4873
|
+
}
|
|
4833
4874
|
result.filesInspected++;
|
|
4834
4875
|
} catch (error) {
|
|
4835
4876
|
assertNotAborted(options.signal);
|
|
@@ -4887,14 +4928,18 @@ async function compareSnapshot(options, cwd, snapshot) {
|
|
|
4887
4928
|
await snapshot?.verify();
|
|
4888
4929
|
result.complete = result.skippedFiles.length === 0;
|
|
4889
4930
|
const rules = policy.config.rules ?? {};
|
|
4890
|
-
const elsewhere = /* @__PURE__ */ new Set();
|
|
4891
|
-
|
|
4931
|
+
const elsewhere = /* @__PURE__ */ new Set(), called = new Set(calledInChange);
|
|
4932
|
+
const untouchedLostCallerContent = /* @__PURE__ */ new Map();
|
|
4933
|
+
if (ciCounts.some((item) => item.before > 0 && item.after === 0 && item.reusableAfter === 0) || lostCalledWorkflows.size > 0) {
|
|
4892
4934
|
const touched = new Set(changes.flatMap((change) => [change.oldPath, change.newPath]));
|
|
4893
4935
|
const tree = /^[a-f0-9]{40,64}$/.test(refs.headSha) ? refs.headSha : refs.baseSha;
|
|
4894
4936
|
try {
|
|
4895
4937
|
for (const file of (await ciFilesAt(cwd, tree, options.signal)).filter((candidate) => !touched.has(candidate.path)).slice(0, 100)) {
|
|
4896
4938
|
try {
|
|
4897
|
-
|
|
4939
|
+
const text = new TextDecoder().decode(await readBlob(cwd, file.oid, options.signal, 512 * 1024));
|
|
4940
|
+
for (const command of enforcedTestCommands(file.path, text)) elsewhere.add(command);
|
|
4941
|
+
for (const workflow of gatingCalls(file.path, text)) called.add(workflow);
|
|
4942
|
+
if (lostCalledWorkflows.has(file.path)) untouchedLostCallerContent.set(file.path, text);
|
|
4898
4943
|
} catch (error) {
|
|
4899
4944
|
assertNotAborted(options.signal);
|
|
4900
4945
|
}
|
|
@@ -4903,7 +4948,16 @@ async function compareSnapshot(options, cwd, snapshot) {
|
|
|
4903
4948
|
assertNotAborted(options.signal);
|
|
4904
4949
|
}
|
|
4905
4950
|
}
|
|
4906
|
-
|
|
4951
|
+
const ciFindings = resolveCiTestSteps(ciCounts, refs.headSha, elsewhere, called);
|
|
4952
|
+
const alreadyFlagged = new Set(ciFindings.map((found) => found.file));
|
|
4953
|
+
const lostCallers = /* @__PURE__ */ new Map();
|
|
4954
|
+
for (const workflow of lostCalledWorkflows) {
|
|
4955
|
+
if (alreadyFlagged.has(workflow)) continue;
|
|
4956
|
+
const content = touchedCiContent.has(workflow) ? touchedCiContent.get(workflow) ?? null : untouchedLostCallerContent.get(workflow) ?? null;
|
|
4957
|
+
if (content === null) continue;
|
|
4958
|
+
lostCallers.set(workflow, { content, stillCalled: called.has(workflow) });
|
|
4959
|
+
}
|
|
4960
|
+
result.infractions.push(...[...resolveTampering(tamperingFiles, productionChanged), ...guardFindings, ...ciFindings, ...resolveLostCallers(lostCallers, refs.headSha)].filter((finding2) => rules[finding2.type] !== "off"));
|
|
4907
4961
|
const exceptions = applyExceptions(result.infractions, options.ignoreExceptions ? [] : policy.config.exceptions, /* @__PURE__ */ new Date(), existingCounts);
|
|
4908
4962
|
result.infractions = exceptions.findings;
|
|
4909
4963
|
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.24.0",
|
|
4
4
|
"description": "Stops AI agents (and rushed humans) weakening tests, coverage gates or CI to get a green build. Hook for Claude Code, Codex, Cursor, Gemini CLI, Copilot, Droid; pre-commit; GitHub Action. Offline.",
|
|
5
5
|
"mcpName": "io.github.Nari-Ab/agent-roast",
|
|
6
6
|
"type": "module",
|