agent-roast 0.9.0 → 0.10.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 +73 -43
- package/dist/{chunk-GMBQNL6Z.js → chunk-NSGANOZ5.js} +124 -31
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/schema.json +8 -0
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<img src="docs/assets/logo.svg" alt="agent-roast" width="380">
|
|
6
6
|
</picture>
|
|
7
7
|
|
|
8
|
-
<h2>Catch
|
|
8
|
+
<h2>Catch weakened tests and quality gates before they ship</h2>
|
|
9
9
|
|
|
10
10
|
<p>
|
|
11
11
|
<a href="https://github.com/Nari-Ab/agent-roast/actions/workflows/test.yml"><img src="https://github.com/Nari-Ab/agent-roast/actions/workflows/test.yml/badge.svg" alt="CLI tests"></a>
|
|
@@ -17,55 +17,85 @@
|
|
|
17
17
|
|
|
18
18
|
</div>
|
|
19
19
|
|
|
20
|
-
**Did
|
|
20
|
+
**Did this change fix the code, or only make the checks pass?**
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
A red build can be turned green by changing the *checks* instead of the *code*: loosening or
|
|
23
|
+
deleting the failing assertion, removing the test, lowering the coverage threshold, switching
|
|
24
|
+
off `strict`, or letting a CI step fail. Nothing in the new file looks wrong, so a linter
|
|
25
|
+
cannot see it. `agent-roast review` compares the **before and after** of a change and reports
|
|
26
|
+
exactly these moves. It is offline, runs no project code, and needs only Git. It was built to
|
|
27
|
+
review AI-assisted changes, but it cannot tell who wrote a change, and the measurements below
|
|
28
|
+
show these moves are not specific to AI.
|
|
28
29
|
|
|
29
30
|
```bash
|
|
30
31
|
npx agent-roast review --base origin/main # a pull request
|
|
31
32
|
npx agent-roast --staged # what you are about to commit
|
|
32
33
|
```
|
|
33
34
|
|
|
34
|
-
| Rule | Fires when |
|
|
35
|
+
| Rule | Fires when | By default |
|
|
35
36
|
| --- | --- | --- |
|
|
36
|
-
| `assertion-weakened` |
|
|
37
|
-
| `guard-weakened` | coverage threshold lowered, `strict`/`noImplicitAny` off, lint rule downgraded, CI step
|
|
38
|
-
| `
|
|
39
|
-
| `
|
|
40
|
-
| `
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
37
|
+
| `assertion-weakened` | an exact check is replaced by a weaker one: `toBe(42)` → `toBeDefined()`, `assert x == 1` → `assert x`, `isinstance(...)` → `is not None` | blocks |
|
|
38
|
+
| `guard-weakened` | coverage threshold lowered, `strict`/`noImplicitAny` off, lint rule downgraded, an existing CI step allowed to fail, a workflow that stops running tests or stops running on push and pull requests, a test script that cannot fail | blocks |
|
|
39
|
+
| `assertion-removed` | a test keeps its name but loses assertions | warns |
|
|
40
|
+
| `test-removed` | a test (or test file) disappears and no identical body appears elsewhere | warns |
|
|
41
|
+
| `expectation-rewritten` | an expected value changed, test inputs did not, and production code changed in the same PR | warns |
|
|
42
|
+
| `test-skip`, `test-focus`, `trivial-assert`, `swallowed-error`, `type-escape`, `stub-abandonment` | the classic shortcuts, compared before/after so old debt does not block | skip, focus and trivial-assert block |
|
|
43
|
+
|
|
44
|
+
Renames, moved tests, parametrization, assertions moved into a new helper, and formatter
|
|
45
|
+
rewraps (prettier, black) are recognized and not reported. Intentional cases can be accepted
|
|
46
|
+
one by one with an expiring, reviewed exception (see below). Policy is read from the base
|
|
47
|
+
branch, so a PR cannot switch the rules off for itself.
|
|
46
48
|
|
|
47
49
|
### How well does it work?
|
|
48
50
|
|
|
49
|
-
Measured, not promised
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
51
|
+
Measured, not promised; method, corpus and raw numbers are in
|
|
52
|
+
[docs/benchmarks.md](docs/benchmarks.md). **Short version: weakened checks are rare, usually
|
|
53
|
+
deliberate, and not specific to AI. The tool finds them; a person decides.**
|
|
54
|
+
|
|
55
|
+
- **Labeled changes** (`benchmarks/cases.mjs`): 40 cheating edits across JS/TS, Python, Go and
|
|
56
|
+
CI/config files are caught and 33 harmless look-alikes (reformat, rename, move, parametrize,
|
|
57
|
+
helper extraction, raised thresholds, CI restructuring) stay silent. The cases were written
|
|
58
|
+
together with the detector, so they guard against regressions; they do not prove accuracy.
|
|
59
|
+
Three known gaps are listed in the file and still open: mocking the module under test,
|
|
60
|
+
hardcoding the tested value, and vacuous matchers such as `expect.anything()`.
|
|
61
|
+
- **Five mature open-source projects** (commander.js, execa, click, requests, zod; 606 commits
|
|
62
|
+
that touch tests or CI/config): the blocking rules fired on 3 commits (0.5%), all of them
|
|
63
|
+
deliberate. `assertion-removed` fired on 5 (0.8%), including one real defect:
|
|
64
|
+
a refactor in `requests` dropped the `assert` keyword and left a comparison with no effect.
|
|
65
|
+
- **AI-signed commits in 166 public repositories** (signatures from Claude, Copilot, Cursor,
|
|
66
|
+
Codex, Devin and aider; 12,645 commits that touch tests or CI/config):
|
|
67
|
+
|
|
68
|
+
| | AI-signed | Other commits |
|
|
69
|
+
| --- | ---: | ---: |
|
|
70
|
+
| Repositories | 160 | 141 |
|
|
71
|
+
| Commits reviewed | 6,895 | 5,750 |
|
|
72
|
+
| Blocking rules | 0.3% (20) | 0.3% (16) |
|
|
73
|
+
| `assertion-removed` (warns) | 2.4% | 2.2% |
|
|
74
|
+
| `test-removed` (warns) | 15.4% | 12.7% |
|
|
75
|
+
| `expectation-rewritten` (warns) | 5.7% | 5.5% |
|
|
76
|
+
|
|
77
|
+
Compared inside the same repository (135 repositories with both kinds), the odds of a
|
|
78
|
+
blocking finding in an AI-signed commit are 0.81 times those of another commit (95% CI
|
|
79
|
+
0.40-1.67): no sign that AI-signed commits weaken checks more often. Claude accounts for
|
|
80
|
+
5,380 of the AI-signed commits; the other agents are too few to compare.
|
|
81
|
+
- **Hand-read precision.** Findings were read without knowing who wrote the commit (one
|
|
82
|
+
reader, an AI assistant; not independently verified). On repositories the detector had never
|
|
83
|
+
seen, the 11 blocking findings were 2 unexplained relaxations (TypeScript strictness switched
|
|
84
|
+
off in commits titled "Changes"), 7 deliberate and explained changes, and 2 false alarms. Of
|
|
85
|
+
24 `assertion-removed` warnings, 1 was an unexplained weakening, 12 deliberate, 6 unclear and
|
|
86
|
+
5 false alarms. So most findings are decisions a reviewer should see rather than mistakes,
|
|
87
|
+
and roughly one in five is a false alarm. Earlier rounds (76 findings) were used to fix the
|
|
88
|
+
detector and are not counted here.
|
|
89
|
+
|
|
90
|
+
Caveats. A signature shows a tool was involved, not how much, and unsigned AI help in the
|
|
91
|
+
"other" group would hide a difference. The repositories came from searching for those
|
|
92
|
+
signatures, so many are small personal projects. This measures what was committed, not what an
|
|
93
|
+
agent tries mid-session: a weakened check fixed before the commit is invisible. Labels are one
|
|
94
|
+
reader's opinion. Three small fixes (assertion helpers, throwing test-library queries,
|
|
95
|
+
scheduled-only workflows) were added after the labeling and have regression cases, but were not
|
|
96
|
+
re-labeled.
|
|
97
|
+
|
|
98
|
+
It is a **review aid**: it cannot prove who wrote a change or that code is correct, and it
|
|
69
99
|
reads text, not program semantics. A green result is not a quality certificate.
|
|
70
100
|
|
|
71
101
|
### Quick start
|
|
@@ -145,12 +175,12 @@ regressions) may appear in the report/summary rather than as an inline PR annota
|
|
|
145
175
|
Use a separate SARIF category for introduced-findings review; it does not represent
|
|
146
176
|
a full scan of all outstanding alerts.
|
|
147
177
|
|
|
148
|
-
Install 0.
|
|
178
|
+
Install 0.10.0: `npx agent-roast@0.10.0 review --base origin/main`. For local development,
|
|
149
179
|
run `npm run build` and use `node dist/cli.js`.
|
|
150
180
|
|
|
151
181
|
### Precise exceptions (since 0.8.0)
|
|
152
182
|
|
|
153
|
-
Use `npx agent-roast@0.
|
|
183
|
+
Use `npx agent-roast@0.10.0 check --format json`, or build this checkout with
|
|
154
184
|
`npm run build` and run `node dist/cli.js`.
|
|
155
185
|
|
|
156
186
|
Some skipped integrations or abstract stubs are intentional. In 0.8.0 you can accept
|
|
@@ -386,7 +416,7 @@ jobs:
|
|
|
386
416
|
|
|
387
417
|
To pilot the Action without blocking on findings, set `advisory: true`. It passes
|
|
388
418
|
an explicit empty fail-on list; invalid configuration, Git errors and incomplete
|
|
389
|
-
scans still fail. The `v1` Action installs 0.
|
|
419
|
+
scans still fail. The `v1` Action installs 0.10.0. The CLI equivalent is `--fail-on ''`.
|
|
390
420
|
|
|
391
421
|
> **Security Note:** `agent-roast` runs in an isolated runner directory (`$RUNNER_TEMP`) with `--ignore-scripts` to neutralize malicious `.npmrc` files in untrusted pull requests. `pull_request_target` is not supported.
|
|
392
422
|
|
|
@@ -474,7 +504,7 @@ node scripts/verify-user-workflows.mjs dist/cli.js
|
|
|
474
504
|
against current code. Regression tests use disposable Git repositories. The programmatic
|
|
475
505
|
`auditPullRequest({ staged: true })` API retains the legacy added-lines contract;
|
|
476
506
|
use `reviewStagedChanges()` for the new context comparison and `ReviewResult`.
|
|
477
|
-
The composite Action installs exactly 0.
|
|
507
|
+
The composite Action installs exactly 0.10.0. For local development, use
|
|
478
508
|
`node dist/cli.js --staged` after building.
|
|
479
509
|
|
|
480
510
|
### Author & Support
|
|
@@ -787,11 +787,17 @@ var RULE_GUIDES = {
|
|
|
787
787
|
example: "throw new Error('Not implemented') \u2192 implement the operation and add a behavior test"
|
|
788
788
|
},
|
|
789
789
|
"assertion-weakened": {
|
|
790
|
-
title: "
|
|
791
|
-
why: "
|
|
792
|
-
suggestion: "Restore the
|
|
790
|
+
title: "Exact check replaced by a weaker one",
|
|
791
|
+
why: "Swapping an exact assertion for toBeDefined, a truthiness check or `is not None` keeps the test green while it verifies far less.",
|
|
792
|
+
suggestion: "Restore the exact check, or assert the specific value the code should produce.",
|
|
793
793
|
example: "expect(total).toBe(42) \u2192 expect(total).toBeDefined() (restore the exact check)"
|
|
794
794
|
},
|
|
795
|
+
"assertion-removed": {
|
|
796
|
+
title: "Test checks less than before",
|
|
797
|
+
why: "A test that keeps its name but loses assertions verifies less behavior. Often the behavior changed on purpose; sometimes a check was dropped to get the suite green.",
|
|
798
|
+
suggestion: "Confirm the removed checks no longer apply, or restore them. Say so in the PR when the behavior was removed on purpose.",
|
|
799
|
+
example: "expect(a).toBe(1); expect(b).toBe(2) \u2192 expect(a).toBe(1) (is the second check still needed?)"
|
|
800
|
+
},
|
|
795
801
|
"test-removed": {
|
|
796
802
|
title: "Test removed",
|
|
797
803
|
why: "Deleting a failing test makes a suite green without fixing the behavior it covered. Removals are legitimate when the feature itself is gone.",
|
|
@@ -832,6 +838,7 @@ var VALID_DETECTORS = [
|
|
|
832
838
|
"stub-abandonment",
|
|
833
839
|
"test-focus",
|
|
834
840
|
"assertion-weakened",
|
|
841
|
+
"assertion-removed",
|
|
835
842
|
"test-removed",
|
|
836
843
|
"expectation-rewritten",
|
|
837
844
|
"guard-weakened"
|
|
@@ -995,6 +1002,7 @@ var DEFAULT_CONFIG = {
|
|
|
995
1002
|
"trivial-assert": "error",
|
|
996
1003
|
"test-focus": "error",
|
|
997
1004
|
"assertion-weakened": "error",
|
|
1005
|
+
"assertion-removed": "warn",
|
|
998
1006
|
"guard-weakened": "error",
|
|
999
1007
|
"test-removed": "warn",
|
|
1000
1008
|
"expectation-rewritten": "warn",
|
|
@@ -2802,7 +2810,7 @@ var JS_TEST_START = /(?<![\w$.])(?:it|test)(?:\s*\.\s*(?:only|skip|concurrent|se
|
|
|
2802
2810
|
var PY_TEST_START = /^\s*(?:async\s+)?def\s+(test\w*)\s*\(/;
|
|
2803
2811
|
var GO_TEST_START = /^func\s+(Test\w+)\s*\(/;
|
|
2804
2812
|
var JS_ASSERTION = /(?<![\w$.])(?:expect|assert|expectTypeOf)\b(?!\s*\.\s*(?:assertions|hasAssertions|extend|anything|any|objectContaining|arrayContaining|stringContaining|stringMatching)\b)(?:\s*\.\s*\w+)*\s*\(|\.should\b/;
|
|
2805
|
-
var PY_ASSERTION = /^\s*assert\b|\bself\s*\.\s*assert\w*\s*\(|\bpytest\s*\.\s*raises\b|\bassertRaises\b/;
|
|
2813
|
+
var PY_ASSERTION = /^\s*assert\b|\bself\s*\.\s*assert\w*\s*\(|\bpytest\s*\.\s*raises\b|\bassertRaises\b|(?<!\w)assert_\w+\s*\(/;
|
|
2806
2814
|
var GO_ASSERTION = /\bt\s*\.\s*(?:Error|Errorf|Fatal|Fatalf|Fail|FailNow)\b|\b(?:assert|require)\s*\.\s*\w+\s*\(/;
|
|
2807
2815
|
function callEnd(text, from, limit) {
|
|
2808
2816
|
let depth = 1, quote = "";
|
|
@@ -2883,12 +2891,25 @@ function splitStatements(text) {
|
|
|
2883
2891
|
pieces.push(text.slice(start));
|
|
2884
2892
|
return pieces.map((piece) => piece.trim()).filter(Boolean);
|
|
2885
2893
|
}
|
|
2894
|
+
function stripTrailingComment(line, language) {
|
|
2895
|
+
const marker = language === "py" ? "#" : "//";
|
|
2896
|
+
let quote = "";
|
|
2897
|
+
for (let i = 0; i < line.length; i++) {
|
|
2898
|
+
const char = line[i];
|
|
2899
|
+
if (quote) {
|
|
2900
|
+
if (char === "\\") i++;
|
|
2901
|
+
else if (char === quote) quote = "";
|
|
2902
|
+
} else if (char === '"' || char === "'" || char === "`") quote = char;
|
|
2903
|
+
else if (line.startsWith(marker, i) && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i).trimEnd();
|
|
2904
|
+
}
|
|
2905
|
+
return line;
|
|
2906
|
+
}
|
|
2886
2907
|
var CALLBACK_HEADER = /^[,\s]*(?:async\s*)?(?:\([^)]*\)|[\w$]+)\s*=>\s*\{\s*|^[,\s]*(?:async\s+)?function\s*\w*\s*\([^)]*\)\s*\{\s*/;
|
|
2887
2908
|
function parseTests(text, language) {
|
|
2888
2909
|
const assertionPattern = language === "js" ? JS_ASSERTION : language === "py" ? PY_ASSERTION : GO_ASSERTION;
|
|
2889
2910
|
const seen = /* @__PURE__ */ new Map();
|
|
2890
2911
|
return testBodies(text, language).map((test) => {
|
|
2891
|
-
const body = test.body.split("\n");
|
|
2912
|
+
const body = test.body.split("\n").map((line) => language === "go" ? line : stripTrailingComment(line, language));
|
|
2892
2913
|
const assertions = [], raw = [], setupLines = [];
|
|
2893
2914
|
for (let i = 0; i < body.length; i++) {
|
|
2894
2915
|
if (!assertionPattern.test(body[i])) {
|
|
@@ -2913,19 +2934,34 @@ function parseTests(text, language) {
|
|
|
2913
2934
|
const bodyHash = createHash3("sha1").update(test.body.replace(/\s+/g, "")).digest("hex");
|
|
2914
2935
|
const count = (seen.get(test.name) ?? 0) + 1;
|
|
2915
2936
|
seen.set(test.name, count);
|
|
2916
|
-
return { key: `${test.name}#${count}`, parametrized: test.parametrized, name: test.name, line: test.line, assertions, raw, setup, bodyHash };
|
|
2937
|
+
return { key: `${test.name}#${count}`, parametrized: test.parametrized, name: test.name, line: test.line, assertions, raw, setup, bodyHash, text: test.body };
|
|
2917
2938
|
});
|
|
2918
2939
|
}
|
|
2919
2940
|
function skeleton(assertion) {
|
|
2920
2941
|
return assertion.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, "S").replace(/\b\d+(?:\.\d+)?\b/g, "N").replace(/\b(?:true|false|null|undefined|None|True|False|nil)\b/g, "V").replace(/\s+/g, "");
|
|
2921
2942
|
}
|
|
2922
2943
|
var WEAK_JS = /\.(?:toBeDefined|toBeTruthy|toBeFalsy|toBeUndefined)\s*\(|\.not\s*\.\s*(?:toBeNull|toBeUndefined|toThrow)\s*\(|\.toBeGreaterThanOrEqual\s*\(\s*0\s*\)/;
|
|
2923
|
-
var WEAK_PY = /^\s*assert\s+[\w
|
|
2944
|
+
var WEAK_PY = /^\s*assert\s+[\w.\[\]]+\s*(?:,.*)?$|^\s*assert\s+.+\bis\s+not\s+None\s*(?:,.*)?$|^\s*assert\s+True\b/;
|
|
2945
|
+
var PY_SPECIFIC = /==|!=|<=|>=|\bin\b|\bis\b|\bisinstance\s*\(|\.startswith\s*\(|\.endswith\s*\(|\bcallable\s*\(/;
|
|
2946
|
+
var JS_SPECIFIC = /\.(?:toBe|toEqual|toStrictEqual|toHaveLength|toContain|toContainEqual|toMatch|toMatchObject|toHaveProperty|toHaveBeenCalledWith|toHaveBeenCalledTimes|toHaveTextContent|toHaveAttribute|toHaveValue|toThrow\w*|toBeGreaterThan\w*|toBeLessThan\w*)\s*\(/;
|
|
2947
|
+
var THROWING_QUERY = /\b(?:get|find)(?:All)?By\w*\s*\(/;
|
|
2924
2948
|
function isWeaker(oldAssertion, newAssertion, language) {
|
|
2925
|
-
if (language === "js") return WEAK_JS.test(newAssertion) && !WEAK_JS.test(oldAssertion);
|
|
2926
|
-
if (language === "py") return WEAK_PY.test(newAssertion) && !WEAK_PY.test(oldAssertion);
|
|
2949
|
+
if (language === "js") return !THROWING_QUERY.test(newAssertion) && WEAK_JS.test(newAssertion) && !WEAK_JS.test(oldAssertion) && JS_SPECIFIC.test(oldAssertion);
|
|
2950
|
+
if (language === "py") return WEAK_PY.test(newAssertion) && !WEAK_PY.test(oldAssertion) && PY_SPECIFIC.test(oldAssertion);
|
|
2927
2951
|
return false;
|
|
2928
2952
|
}
|
|
2953
|
+
var GENERIC_ARGUMENT = /^(?:not\.)?(?:be|have|contain|include|exist|match|eq|equal|length|text|value|attr|class|css|visible|checked|disabled|enabled|selected|focus|empty|true|false|null|undefined|deep)(?:\.\w+)*$/;
|
|
2954
|
+
function literalTokens(statement) {
|
|
2955
|
+
const tokens = /* @__PURE__ */ new Set();
|
|
2956
|
+
const literal = /(['"`])((?:\\.|(?!\1)[^\\\n])*)\1|(?<=[(,]\s*)\/((?:\\.|[^/\\\n])+)\/[a-z]*/g;
|
|
2957
|
+
for (const match of statement.matchAll(literal)) {
|
|
2958
|
+
for (const token of (match[2] ?? match[3] ?? "").match(/[A-Za-z0-9_:.=@#%-]{4,}/g) ?? []) {
|
|
2959
|
+
if (!GENERIC_ARGUMENT.test(token)) tokens.add(token);
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
return [...tokens];
|
|
2963
|
+
}
|
|
2964
|
+
var HELPER_CALL = /(?<![\w$])((?:expect|assert|verify|check|require)[A-Z]\w*|_{0,2}(?:assert|check|verify|expect|require)_\w+)\s*\(/g;
|
|
2929
2965
|
function finding(type, file, line, snippet, reason, commit) {
|
|
2930
2966
|
return { type, file, lineNumber: Math.max(1, line), lineSnippet: snippet, commitHash: commit, commitDate: "", reason, suggestion: RULE_GUIDES[type].suggestion };
|
|
2931
2967
|
}
|
|
@@ -2943,6 +2979,7 @@ function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
|
|
|
2943
2979
|
};
|
|
2944
2980
|
const oldTotals = tally(before), newTotals = tally(after);
|
|
2945
2981
|
const beforeKeys = new Set(before.map((test) => test.key));
|
|
2982
|
+
const addedText = after.filter((test) => !beforeKeys.has(test.key)).map((test) => test.text).join("\n");
|
|
2946
2983
|
const result = { immediate: [], removedTests: [], addedHashes: [], rewritten: [] };
|
|
2947
2984
|
for (const test of after) if (!beforeKeys.has(test.key)) result.addedHashes.push(test.bodyHash);
|
|
2948
2985
|
for (const old of before) {
|
|
@@ -2966,18 +3003,24 @@ function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
|
|
|
2966
3003
|
const snapshot = (test) => test.raw.some((statement) => /\.toMatch(?:Inline)?Snapshot\b|\.matchSnapshot\b|\bsnapshot\s*\(/.test(statement));
|
|
2967
3004
|
if (snapshot(current) && !snapshot(old)) continue;
|
|
2968
3005
|
if (current.assertions.length < old.assertions.length) {
|
|
3006
|
+
if ([...current.text.matchAll(HELPER_CALL)].some((call) => !(oldText ?? "").includes(call[1]))) continue;
|
|
2969
3007
|
const budget = tally([current]);
|
|
2970
|
-
const lost =
|
|
3008
|
+
const lost = [];
|
|
3009
|
+
old.assertions.forEach((assertion, i) => {
|
|
2971
3010
|
const left = budget.get(assertion) ?? 0;
|
|
2972
|
-
if (left > 0)
|
|
2973
|
-
|
|
2974
|
-
return false;
|
|
2975
|
-
}
|
|
2976
|
-
return true;
|
|
3011
|
+
if (left > 0) budget.set(assertion, left - 1);
|
|
3012
|
+
else lost.push({ normalized: assertion, raw: old.raw[i] });
|
|
2977
3013
|
});
|
|
2978
|
-
|
|
3014
|
+
const introduced = `${current.text}
|
|
3015
|
+
${addedText}`;
|
|
3016
|
+
const accounted = (item) => {
|
|
3017
|
+
if ((newTotals.get(item.normalized) ?? 0) >= (oldTotals.get(item.normalized) ?? 0)) return true;
|
|
3018
|
+
const tokens = literalTokens(item.raw);
|
|
3019
|
+
return tokens.length > 0 && tokens.every((token) => introduced.includes(token));
|
|
3020
|
+
};
|
|
3021
|
+
if (lost.every(accounted)) continue;
|
|
2979
3022
|
result.immediate.push(finding(
|
|
2980
|
-
"assertion-
|
|
3023
|
+
"assertion-removed",
|
|
2981
3024
|
newPath,
|
|
2982
3025
|
current.line,
|
|
2983
3026
|
`${old.name}: ${old.assertions.length} \u2192 ${current.assertions.length} assertions`,
|
|
@@ -3044,7 +3087,46 @@ var THRESHOLD_PATTERNS = [
|
|
|
3044
3087
|
/(--cov-fail-under)[= ](\d+(?:\.\d+)?)/g
|
|
3045
3088
|
];
|
|
3046
3089
|
var SWITCHES = ["strict", "noImplicitAny", "strictNullChecks", "noImplicitReturns", "useUnknownInCatchVariables"];
|
|
3047
|
-
var
|
|
3090
|
+
var CI_RUNNER = new RegExp(
|
|
3091
|
+
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/])` + String.raw`|\brun:.*(?<=\s)(?:tests?|unit|e2e)(?![\w/.-])`
|
|
3092
|
+
);
|
|
3093
|
+
var CI_FILE = /(?:\.github\/workflows\/|\.gitlab-ci)/;
|
|
3094
|
+
var CI_REUSABLE = /^\s*-?\s*uses:\s*\S*\.github\/workflows\/\S+/;
|
|
3095
|
+
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;
|
|
3096
|
+
function gatingTestLines(file, text) {
|
|
3097
|
+
return /\.gitlab-ci/.test(file) || CI_GATE.test(text) ? matchingLines(text, CI_RUNNER) : 0;
|
|
3098
|
+
}
|
|
3099
|
+
function matchingLines(text, pattern) {
|
|
3100
|
+
return text.split(/\r?\n/).filter((line) => !line.trim().startsWith("#") && pattern.test(line)).length;
|
|
3101
|
+
}
|
|
3102
|
+
function ciTestSteps(file, oldText, newText) {
|
|
3103
|
+
if (!CI_FILE.test(file) || oldText === null && newText === null) return null;
|
|
3104
|
+
const was = oldText ?? "", now = newText ?? "";
|
|
3105
|
+
const after = gatingTestLines(file, now);
|
|
3106
|
+
return { file, before: gatingTestLines(file, was), after, reusableAfter: matchingLines(now, CI_REUSABLE), ungatedAfter: after === 0 ? matchingLines(now, CI_RUNNER) : 0 };
|
|
3107
|
+
}
|
|
3108
|
+
function resolveCiTestSteps(counts, commit) {
|
|
3109
|
+
const before = counts.reduce((sum, item) => sum + item.before, 0);
|
|
3110
|
+
const after = counts.reduce((sum, item) => sum + item.after + item.reusableAfter, 0);
|
|
3111
|
+
if (after >= before) return [];
|
|
3112
|
+
return counts.filter((item) => item.before > 0 && item.after === 0 && item.reusableAfter === 0).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));
|
|
3113
|
+
}
|
|
3114
|
+
var LENIENT = /continue-on-error:\s*true|allow_failure:\s*true/;
|
|
3115
|
+
function lenientSteps(text) {
|
|
3116
|
+
const lines = text.split(/\r?\n/);
|
|
3117
|
+
const indentOf = (line) => line.length - line.trimStart().length;
|
|
3118
|
+
const found = /* @__PURE__ */ new Map();
|
|
3119
|
+
lines.forEach((line, i) => {
|
|
3120
|
+
if (line.trim().startsWith("#") || !LENIENT.test(line)) return;
|
|
3121
|
+
let j = i - 1;
|
|
3122
|
+
while (j >= 0 && (!lines[j].trim() || lines[j].trim().startsWith("#") || indentOf(lines[j]) >= indentOf(line))) j--;
|
|
3123
|
+
const identity = j >= 0 ? lines[j].trim() : "";
|
|
3124
|
+
const entry = found.get(identity);
|
|
3125
|
+
if (entry) entry.count++;
|
|
3126
|
+
else found.set(identity, { count: 1, line: i + 1 });
|
|
3127
|
+
});
|
|
3128
|
+
return found;
|
|
3129
|
+
}
|
|
3048
3130
|
function severityOf(value) {
|
|
3049
3131
|
return /^(?:error|2)$/.test(value) ? 2 : /^(?:warn|1)$/.test(value) ? 1 : 0;
|
|
3050
3132
|
}
|
|
@@ -3071,21 +3153,29 @@ function analyzeGuardFile(file, oldText, newText, commit) {
|
|
|
3071
3153
|
}
|
|
3072
3154
|
}
|
|
3073
3155
|
if (/eslint/i.test(file)) {
|
|
3074
|
-
const rules = (text) =>
|
|
3075
|
-
|
|
3076
|
-
|
|
3156
|
+
const rules = (text) => {
|
|
3157
|
+
const found = /* @__PURE__ */ new Map();
|
|
3158
|
+
for (const match of text.matchAll(/["']([@\w/.-]+)["']\s*:\s*(?:\[\s*)?["']?(error|warn|off|[012])["']?/g)) {
|
|
3159
|
+
const level = severityOf(match[2]);
|
|
3160
|
+
const known = found.get(match[1]);
|
|
3161
|
+
if (!known || level > known.level) found.set(match[1], { level, index: match.index ?? 0 });
|
|
3162
|
+
}
|
|
3163
|
+
return found;
|
|
3164
|
+
};
|
|
3165
|
+
const before = rules(oldText), after = rules(newText);
|
|
3166
|
+
for (const [name, now] of after) {
|
|
3077
3167
|
const previous = before.get(name);
|
|
3078
|
-
|
|
3168
|
+
const replaced = [...after].some(([other, setting]) => other !== name && other.endsWith(`/${name}`) && setting.level === 2);
|
|
3169
|
+
if (previous && previous.level === 2 && now.level < 2 && !replaced) add(now.index, `${name}: error \u2192 ${now.level ? "warn" : "off"}`, "Lint rule downgraded");
|
|
3079
3170
|
}
|
|
3080
3171
|
}
|
|
3081
|
-
if (
|
|
3082
|
-
const
|
|
3083
|
-
const
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
add(0, "test command no longer runs in CI", "Test step removed from CI");
|
|
3172
|
+
if (CI_FILE.test(file)) {
|
|
3173
|
+
const before = lenientSteps(oldText), existing = new Set(oldText.split(/\r?\n/).map((line) => line.trim()));
|
|
3174
|
+
for (const [identity, now] of lenientSteps(newText)) {
|
|
3175
|
+
if (identity && existing.has(identity) && now.count > (before.get(identity)?.count ?? 0)) {
|
|
3176
|
+
out.push(finding("guard-weakened", file, now.line, "continue-on-error: true", "CI step is now allowed to fail", commit));
|
|
3177
|
+
break;
|
|
3178
|
+
}
|
|
3089
3179
|
}
|
|
3090
3180
|
}
|
|
3091
3181
|
if (/(?:^|\/)package\.json$/i.test(file)) {
|
|
@@ -3170,6 +3260,7 @@ async function compareSourceChanges(options) {
|
|
|
3170
3260
|
let totalBytes = 0;
|
|
3171
3261
|
const tamperingFiles = [];
|
|
3172
3262
|
const guardFindings = [];
|
|
3263
|
+
const ciCounts = [];
|
|
3173
3264
|
let productionChanged = false;
|
|
3174
3265
|
const readText = async (mode, oid) => {
|
|
3175
3266
|
if (mode === "000000") return null;
|
|
@@ -3195,6 +3286,8 @@ async function compareSourceChanges(options) {
|
|
|
3195
3286
|
const oldText = await readText(change.oldMode, change.oldOid);
|
|
3196
3287
|
const newText = await readText(change.newMode, change.newOid);
|
|
3197
3288
|
guardFindings.push(...analyzeGuardFile(change.newPath, oldText, newText, refs.headSha));
|
|
3289
|
+
const ci = ciTestSteps(change.newPath, oldText, newText);
|
|
3290
|
+
if (ci) ciCounts.push(ci);
|
|
3198
3291
|
result.filesInspected++;
|
|
3199
3292
|
} catch (error) {
|
|
3200
3293
|
assertNotAborted(options.signal);
|
|
@@ -3252,7 +3345,7 @@ async function compareSourceChanges(options) {
|
|
|
3252
3345
|
await staged?.verify();
|
|
3253
3346
|
result.complete = result.skippedFiles.length === 0;
|
|
3254
3347
|
const rules = policy.config.rules ?? {};
|
|
3255
|
-
result.infractions.push(...[...resolveTampering(tamperingFiles, productionChanged), ...guardFindings].filter((finding2) => rules[finding2.type] !== "off"));
|
|
3348
|
+
result.infractions.push(...[...resolveTampering(tamperingFiles, productionChanged), ...guardFindings, ...resolveCiTestSteps(ciCounts, refs.headSha)].filter((finding2) => rules[finding2.type] !== "off"));
|
|
3256
3349
|
const exceptions = applyExceptions(result.infractions, options.ignoreExceptions ? [] : policy.config.exceptions, /* @__PURE__ */ new Date(), existingCounts);
|
|
3257
3350
|
result.infractions = exceptions.findings;
|
|
3258
3351
|
result.suppressedFindings = exceptions.suppressedFindings;
|
package/dist/cli.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -32,7 +32,7 @@ interface CommitInfo {
|
|
|
32
32
|
isAiAttributed: boolean;
|
|
33
33
|
aiSignatures: string[];
|
|
34
34
|
}
|
|
35
|
-
type DetectorType = "test-skip" | "type-escape" | "swallowed-error" | "panic-loop" | "trivial-assert" | "stub-abandonment" | "test-focus" | "assertion-weakened" | "test-removed" | "expectation-rewritten" | "guard-weakened";
|
|
35
|
+
type DetectorType = "test-skip" | "type-escape" | "swallowed-error" | "panic-loop" | "trivial-assert" | "stub-abandonment" | "test-focus" | "assertion-weakened" | "assertion-removed" | "test-removed" | "expectation-rewritten" | "guard-weakened";
|
|
36
36
|
declare const VALID_DETECTORS: readonly DetectorType[];
|
|
37
37
|
interface Infraction {
|
|
38
38
|
fingerprint?: string;
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
package/schema.json
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"stub-abandonment",
|
|
23
23
|
"test-focus",
|
|
24
24
|
"assertion-weakened",
|
|
25
|
+
"assertion-removed",
|
|
25
26
|
"test-removed",
|
|
26
27
|
"expectation-rewritten",
|
|
27
28
|
"guard-weakened"
|
|
@@ -126,6 +127,13 @@
|
|
|
126
127
|
"warn",
|
|
127
128
|
"off"
|
|
128
129
|
]
|
|
130
|
+
},
|
|
131
|
+
"assertion-removed": {
|
|
132
|
+
"enum": [
|
|
133
|
+
"error",
|
|
134
|
+
"warn",
|
|
135
|
+
"off"
|
|
136
|
+
]
|
|
129
137
|
}
|
|
130
138
|
}
|
|
131
139
|
},
|