agent-roast 0.9.0 → 0.10.1
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 +80 -45
- package/dist/{chunk-GMBQNL6Z.js → chunk-FFKEBW6D.js} +150 -44
- 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,57 +17,91 @@
|
|
|
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
|
|
|
101
|
+
**Want to see what it says about your repository?** [docs/pilot.md](docs/pilot.md) has a
|
|
102
|
+
five-minute look back, an advisory workflow that never fails a pull request, and a feedback form.
|
|
103
|
+
Counts are enough; no code is needed.
|
|
104
|
+
|
|
71
105
|
### Quick start
|
|
72
106
|
|
|
73
107
|
```bash
|
|
@@ -84,8 +118,9 @@ npx agent-roast --all --verbose
|
|
|
84
118
|
```
|
|
85
119
|
|
|
86
120
|
A finding includes its file and line, the evidence, and a concrete next step.
|
|
87
|
-
`--format json|markdown|sarif|github` are available; terminal output shows the
|
|
88
|
-
|
|
121
|
+
`--format json|markdown|sarif|github` are available; terminal output shows the 20 most
|
|
122
|
+
important findings (rules that block first, then the before/after rules, then the noisier
|
|
123
|
+
line patterns).
|
|
89
124
|
|
|
90
125
|
### Context review for pull requests
|
|
91
126
|
|
|
@@ -145,12 +180,12 @@ regressions) may appear in the report/summary rather than as an inline PR annota
|
|
|
145
180
|
Use a separate SARIF category for introduced-findings review; it does not represent
|
|
146
181
|
a full scan of all outstanding alerts.
|
|
147
182
|
|
|
148
|
-
Install 0.
|
|
183
|
+
Install 0.10.1: `npx agent-roast@0.10.1 review --base origin/main`. For local development,
|
|
149
184
|
run `npm run build` and use `node dist/cli.js`.
|
|
150
185
|
|
|
151
186
|
### Precise exceptions (since 0.8.0)
|
|
152
187
|
|
|
153
|
-
Use `npx agent-roast@0.
|
|
188
|
+
Use `npx agent-roast@0.10.1 check --format json`, or build this checkout with
|
|
154
189
|
`npm run build` and run `node dist/cli.js`.
|
|
155
190
|
|
|
156
191
|
Some skipped integrations or abstract stubs are intentional. In 0.8.0 you can accept
|
|
@@ -386,7 +421,7 @@ jobs:
|
|
|
386
421
|
|
|
387
422
|
To pilot the Action without blocking on findings, set `advisory: true`. It passes
|
|
388
423
|
an explicit empty fail-on list; invalid configuration, Git errors and incomplete
|
|
389
|
-
scans still fail. The `v1` Action installs 0.
|
|
424
|
+
scans still fail. The `v1` Action installs 0.10.1. The CLI equivalent is `--fail-on ''`.
|
|
390
425
|
|
|
391
426
|
> **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
427
|
|
|
@@ -474,7 +509,7 @@ node scripts/verify-user-workflows.mjs dist/cli.js
|
|
|
474
509
|
against current code. Regression tests use disposable Git repositories. The programmatic
|
|
475
510
|
`auditPullRequest({ staged: true })` API retains the legacy added-lines contract;
|
|
476
511
|
use `reviewStagedChanges()` for the new context comparison and `ReviewResult`.
|
|
477
|
-
The composite Action installs exactly 0.
|
|
512
|
+
The composite Action installs exactly 0.10.1. For local development, use
|
|
478
513
|
`node dist/cli.js --staged` after building.
|
|
479
514
|
|
|
480
515
|
### 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",
|
|
@@ -1845,7 +1853,7 @@ function formatPrTerminalCard(result) {
|
|
|
1845
1853
|
} else {
|
|
1846
1854
|
lines.push(pc.bold(` INFRACTIONS DETECTED (${result.infractions.length})`));
|
|
1847
1855
|
lines.push(pc.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
1848
|
-
for (const inf of result.infractions) {
|
|
1856
|
+
for (const inf of mostImportantFirst(result.infractions, result.failedRules)) {
|
|
1849
1857
|
const isFailedRule = result.failedRules.includes(inf.type);
|
|
1850
1858
|
const badge = isFailedRule ? pc.red("FAIL") : pc.yellow("WARN");
|
|
1851
1859
|
const loc = `${inf.file || "unknown"}:${inf.lineNumber || 1}`;
|
|
@@ -1869,12 +1877,25 @@ function formatPrTerminalCard(result) {
|
|
|
1869
1877
|
lines.push("");
|
|
1870
1878
|
return lines.join("\n");
|
|
1871
1879
|
}
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1880
|
+
var RULE_PRIORITY = {
|
|
1881
|
+
"assertion-weakened": 0,
|
|
1882
|
+
"guard-weakened": 0,
|
|
1883
|
+
"test-skip": 0,
|
|
1884
|
+
"test-focus": 0,
|
|
1885
|
+
"trivial-assert": 0,
|
|
1886
|
+
"assertion-removed": 1,
|
|
1887
|
+
"expectation-rewritten": 1,
|
|
1888
|
+
"test-removed": 1
|
|
1889
|
+
};
|
|
1890
|
+
function findingRank(type, failedRules) {
|
|
1891
|
+
return failedRules.includes(type) ? -1 : RULE_PRIORITY[type] ?? 2;
|
|
1892
|
+
}
|
|
1893
|
+
function mostImportantFirst(findings, failedRules) {
|
|
1894
|
+
return [...findings].sort((a, b) => findingRank(a.type, failedRules) - findingRank(b.type, failedRules));
|
|
1895
|
+
}
|
|
1896
|
+
function sortInfractionsDeterministic(infractions, failedRules) {
|
|
1876
1897
|
return [...infractions].sort((a, b) => {
|
|
1877
|
-
const rankDiff =
|
|
1898
|
+
const rankDiff = findingRank(a.type, failedRules) - findingRank(b.type, failedRules);
|
|
1878
1899
|
if (rankDiff !== 0) return rankDiff;
|
|
1879
1900
|
const fileA = a.file || "";
|
|
1880
1901
|
const fileB = b.file || "";
|
|
@@ -1888,7 +1909,7 @@ function escapeCommand(value, property = false) {
|
|
|
1888
1909
|
}
|
|
1889
1910
|
function formatGithubAnnotations(result) {
|
|
1890
1911
|
const outputLines = [];
|
|
1891
|
-
const sorted = sortInfractionsDeterministic(result.infractions);
|
|
1912
|
+
const sorted = sortInfractionsDeterministic(result.infractions, result.failedRules);
|
|
1892
1913
|
const MAX_PER_LEVEL = 10;
|
|
1893
1914
|
let errorCount = 0;
|
|
1894
1915
|
let warnCount = 0;
|
|
@@ -1943,7 +1964,7 @@ function formatGithubStepSummary(result) {
|
|
|
1943
1964
|
lines.push("");
|
|
1944
1965
|
lines.push("| Type | File:Line | Description | Snippet |");
|
|
1945
1966
|
lines.push("| --- | --- | --- | --- |");
|
|
1946
|
-
const sorted = sortInfractionsDeterministic(result.infractions);
|
|
1967
|
+
const sorted = sortInfractionsDeterministic(result.infractions, result.failedRules);
|
|
1947
1968
|
for (const inf of sorted) {
|
|
1948
1969
|
const loc = inf.file ? markdownCell(`${inf.file}:${inf.lineNumber || 1}`) : "history";
|
|
1949
1970
|
const snippet = inf.lineSnippet ? markdownCell(inf.lineSnippet) : "-";
|
|
@@ -1983,7 +2004,7 @@ function formatPrMarkdownComment(result) {
|
|
|
1983
2004
|
lines.push("");
|
|
1984
2005
|
lines.push("| Type | File:Line | Description | Snippet |");
|
|
1985
2006
|
lines.push("| :--- | :--- | :--- | :--- |");
|
|
1986
|
-
const sorted = sortInfractionsDeterministic(result.infractions);
|
|
2007
|
+
const sorted = sortInfractionsDeterministic(result.infractions, result.failedRules);
|
|
1987
2008
|
for (const inf of sorted) {
|
|
1988
2009
|
const loc = inf.file ? markdownCell(`${inf.file}:${inf.lineNumber || 1}`) : "history";
|
|
1989
2010
|
const snippet = inf.lineSnippet ? markdownCell(inf.lineSnippet) : "-";
|
|
@@ -2043,7 +2064,7 @@ function formatCheckTerminal(result) {
|
|
|
2043
2064
|
];
|
|
2044
2065
|
lines.push(...exceptionTerminal(result));
|
|
2045
2066
|
if (result.staleBaselineCount) lines.push(` ${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
|
|
2046
|
-
for (const inf of result.infractions.slice(0, 20)) {
|
|
2067
|
+
for (const inf of mostImportantFirst(result.infractions, result.failedRules).slice(0, 20)) {
|
|
2047
2068
|
lines.push("", ` ${result.failedRules.includes(inf.type) ? pc.red("FAIL") : pc.yellow("WARN")} [${inf.type}] ${terminalText(inf.file || "unknown")}:${inf.lineNumber}`, ` ${terminalText(inf.lineSnippet || inf.reason)}`);
|
|
2048
2069
|
if (inf.suggestion) lines.push(` Next: ${terminalText(inf.suggestion)}`);
|
|
2049
2070
|
}
|
|
@@ -2068,7 +2089,7 @@ function formatCheckMarkdown(result) {
|
|
|
2068
2089
|
"| Rule | Location | Finding | Next step |",
|
|
2069
2090
|
"| --- | --- | --- | --- |"
|
|
2070
2091
|
];
|
|
2071
|
-
for (const inf of result.infractions) lines.push(`| ${inf.type} | ${markdownCell(`${inf.file}:${inf.lineNumber}`)} | ${markdownCell(inf.lineSnippet || inf.reason)} | ${markdownCell(inf.suggestion || "Review this occurrence.")} |`);
|
|
2092
|
+
for (const inf of mostImportantFirst(result.infractions, result.failedRules)) lines.push(`| ${inf.type} | ${markdownCell(`${inf.file}:${inf.lineNumber}`)} | ${markdownCell(inf.lineSnippet || inf.reason)} | ${markdownCell(inf.suggestion || "Review this occurrence.")} |`);
|
|
2072
2093
|
lines.push(...exceptionMarkdown(result));
|
|
2073
2094
|
if (result.staleBaselineCount) lines.push("", `${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
|
|
2074
2095
|
for (const skipped of result.skippedFiles) lines.push("", `Not scanned: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
|
|
@@ -2086,7 +2107,7 @@ function formatReviewTerminal(result) {
|
|
|
2086
2107
|
lines.push(...exceptionTerminal(result));
|
|
2087
2108
|
if (result.policyChanges.length) lines.push(" Candidate policy changes were ignored; adopt them separately after review.");
|
|
2088
2109
|
if (result.excludedFiles || result.unsupportedFiles) lines.push(` ${result.excludedFiles} excluded \xB7 ${result.unsupportedFiles} unsupported/non-source changes`);
|
|
2089
|
-
for (const finding2 of result.infractions.slice(0, 20)) {
|
|
2110
|
+
for (const finding2 of mostImportantFirst(result.infractions, result.failedRules).slice(0, 20)) {
|
|
2090
2111
|
lines.push(
|
|
2091
2112
|
"",
|
|
2092
2113
|
` ${result.failedRules.includes(finding2.type) ? "FAIL" : "WARN"} [${finding2.type}] ${terminalText(finding2.file || "unknown")}:${finding2.lineNumber}`,
|
|
@@ -2112,7 +2133,7 @@ function formatReviewMarkdown(result) {
|
|
|
2112
2133
|
];
|
|
2113
2134
|
if (result.policyChanges.length) lines.push("Candidate policy changes were ignored. This review uses the supplied base commit's policy.", "");
|
|
2114
2135
|
lines.push("| New finding | Head location | Evidence | Next step |", "| --- | --- | --- | --- |");
|
|
2115
|
-
for (const finding2 of result.infractions) lines.push(`| ${finding2.type} | ${markdownCell(`${finding2.file}:${finding2.lineNumber}`)} | ${markdownCell(finding2.lineSnippet || finding2.reason)} | ${markdownCell(finding2.suggestion || "Review this occurrence.")} |`);
|
|
2136
|
+
for (const finding2 of mostImportantFirst(result.infractions, result.failedRules)) lines.push(`| ${finding2.type} | ${markdownCell(`${finding2.file}:${finding2.lineNumber}`)} | ${markdownCell(finding2.lineSnippet || finding2.reason)} | ${markdownCell(finding2.suggestion || "Review this occurrence.")} |`);
|
|
2116
2137
|
lines.push(...exceptionMarkdown(result));
|
|
2117
2138
|
if (result.removedFindings.length) {
|
|
2118
2139
|
lines.push(
|
|
@@ -2802,7 +2823,7 @@ var JS_TEST_START = /(?<![\w$.])(?:it|test)(?:\s*\.\s*(?:only|skip|concurrent|se
|
|
|
2802
2823
|
var PY_TEST_START = /^\s*(?:async\s+)?def\s+(test\w*)\s*\(/;
|
|
2803
2824
|
var GO_TEST_START = /^func\s+(Test\w+)\s*\(/;
|
|
2804
2825
|
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/;
|
|
2826
|
+
var PY_ASSERTION = /^\s*assert\b|\bself\s*\.\s*assert\w*\s*\(|\bpytest\s*\.\s*raises\b|\bassertRaises\b|(?<!\w)assert_\w+\s*\(/;
|
|
2806
2827
|
var GO_ASSERTION = /\bt\s*\.\s*(?:Error|Errorf|Fatal|Fatalf|Fail|FailNow)\b|\b(?:assert|require)\s*\.\s*\w+\s*\(/;
|
|
2807
2828
|
function callEnd(text, from, limit) {
|
|
2808
2829
|
let depth = 1, quote = "";
|
|
@@ -2883,12 +2904,25 @@ function splitStatements(text) {
|
|
|
2883
2904
|
pieces.push(text.slice(start));
|
|
2884
2905
|
return pieces.map((piece) => piece.trim()).filter(Boolean);
|
|
2885
2906
|
}
|
|
2907
|
+
function stripTrailingComment(line, language) {
|
|
2908
|
+
const marker = language === "py" ? "#" : "//";
|
|
2909
|
+
let quote = "";
|
|
2910
|
+
for (let i = 0; i < line.length; i++) {
|
|
2911
|
+
const char = line[i];
|
|
2912
|
+
if (quote) {
|
|
2913
|
+
if (char === "\\") i++;
|
|
2914
|
+
else if (char === quote) quote = "";
|
|
2915
|
+
} else if (char === '"' || char === "'" || char === "`") quote = char;
|
|
2916
|
+
else if (line.startsWith(marker, i) && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i).trimEnd();
|
|
2917
|
+
}
|
|
2918
|
+
return line;
|
|
2919
|
+
}
|
|
2886
2920
|
var CALLBACK_HEADER = /^[,\s]*(?:async\s*)?(?:\([^)]*\)|[\w$]+)\s*=>\s*\{\s*|^[,\s]*(?:async\s+)?function\s*\w*\s*\([^)]*\)\s*\{\s*/;
|
|
2887
2921
|
function parseTests(text, language) {
|
|
2888
2922
|
const assertionPattern = language === "js" ? JS_ASSERTION : language === "py" ? PY_ASSERTION : GO_ASSERTION;
|
|
2889
2923
|
const seen = /* @__PURE__ */ new Map();
|
|
2890
2924
|
return testBodies(text, language).map((test) => {
|
|
2891
|
-
const body = test.body.split("\n");
|
|
2925
|
+
const body = test.body.split("\n").map((line) => language === "go" ? line : stripTrailingComment(line, language));
|
|
2892
2926
|
const assertions = [], raw = [], setupLines = [];
|
|
2893
2927
|
for (let i = 0; i < body.length; i++) {
|
|
2894
2928
|
if (!assertionPattern.test(body[i])) {
|
|
@@ -2913,19 +2947,34 @@ function parseTests(text, language) {
|
|
|
2913
2947
|
const bodyHash = createHash3("sha1").update(test.body.replace(/\s+/g, "")).digest("hex");
|
|
2914
2948
|
const count = (seen.get(test.name) ?? 0) + 1;
|
|
2915
2949
|
seen.set(test.name, count);
|
|
2916
|
-
return { key: `${test.name}#${count}`, parametrized: test.parametrized, name: test.name, line: test.line, assertions, raw, setup, bodyHash };
|
|
2950
|
+
return { key: `${test.name}#${count}`, parametrized: test.parametrized, name: test.name, line: test.line, assertions, raw, setup, bodyHash, text: test.body };
|
|
2917
2951
|
});
|
|
2918
2952
|
}
|
|
2919
2953
|
function skeleton(assertion) {
|
|
2920
2954
|
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
2955
|
}
|
|
2922
2956
|
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
|
|
2957
|
+
var WEAK_PY = /^\s*assert\s+[\w.\[\]]+\s*(?:,.*)?$|^\s*assert\s+.+\bis\s+not\s+None\s*(?:,.*)?$|^\s*assert\s+True\b/;
|
|
2958
|
+
var PY_SPECIFIC = /==|!=|<=|>=|\bin\b|\bis\b|\bisinstance\s*\(|\.startswith\s*\(|\.endswith\s*\(|\bcallable\s*\(/;
|
|
2959
|
+
var JS_SPECIFIC = /\.(?:toBe|toEqual|toStrictEqual|toHaveLength|toContain|toContainEqual|toMatch|toMatchObject|toHaveProperty|toHaveBeenCalledWith|toHaveBeenCalledTimes|toHaveTextContent|toHaveAttribute|toHaveValue|toThrow\w*|toBeGreaterThan\w*|toBeLessThan\w*)\s*\(/;
|
|
2960
|
+
var THROWING_QUERY = /\b(?:get|find)(?:All)?By\w*\s*\(/;
|
|
2924
2961
|
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);
|
|
2962
|
+
if (language === "js") return !THROWING_QUERY.test(newAssertion) && WEAK_JS.test(newAssertion) && !WEAK_JS.test(oldAssertion) && JS_SPECIFIC.test(oldAssertion);
|
|
2963
|
+
if (language === "py") return WEAK_PY.test(newAssertion) && !WEAK_PY.test(oldAssertion) && PY_SPECIFIC.test(oldAssertion);
|
|
2927
2964
|
return false;
|
|
2928
2965
|
}
|
|
2966
|
+
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+)*$/;
|
|
2967
|
+
function literalTokens(statement) {
|
|
2968
|
+
const tokens = /* @__PURE__ */ new Set();
|
|
2969
|
+
const literal = /(['"`])((?:\\.|(?!\1)[^\\\n])*)\1|(?<=[(,]\s*)\/((?:\\.|[^/\\\n])+)\/[a-z]*/g;
|
|
2970
|
+
for (const match of statement.matchAll(literal)) {
|
|
2971
|
+
for (const token of (match[2] ?? match[3] ?? "").match(/[A-Za-z0-9_:.=@#%-]{4,}/g) ?? []) {
|
|
2972
|
+
if (!GENERIC_ARGUMENT.test(token)) tokens.add(token);
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
return [...tokens];
|
|
2976
|
+
}
|
|
2977
|
+
var HELPER_CALL = /(?<![\w$])((?:expect|assert|verify|check|require)[A-Z]\w*|_{0,2}(?:assert|check|verify|expect|require)_\w+)\s*\(/g;
|
|
2929
2978
|
function finding(type, file, line, snippet, reason, commit) {
|
|
2930
2979
|
return { type, file, lineNumber: Math.max(1, line), lineSnippet: snippet, commitHash: commit, commitDate: "", reason, suggestion: RULE_GUIDES[type].suggestion };
|
|
2931
2980
|
}
|
|
@@ -2943,6 +2992,7 @@ function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
|
|
|
2943
2992
|
};
|
|
2944
2993
|
const oldTotals = tally(before), newTotals = tally(after);
|
|
2945
2994
|
const beforeKeys = new Set(before.map((test) => test.key));
|
|
2995
|
+
const addedText = after.filter((test) => !beforeKeys.has(test.key)).map((test) => test.text).join("\n");
|
|
2946
2996
|
const result = { immediate: [], removedTests: [], addedHashes: [], rewritten: [] };
|
|
2947
2997
|
for (const test of after) if (!beforeKeys.has(test.key)) result.addedHashes.push(test.bodyHash);
|
|
2948
2998
|
for (const old of before) {
|
|
@@ -2966,18 +3016,24 @@ function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
|
|
|
2966
3016
|
const snapshot = (test) => test.raw.some((statement) => /\.toMatch(?:Inline)?Snapshot\b|\.matchSnapshot\b|\bsnapshot\s*\(/.test(statement));
|
|
2967
3017
|
if (snapshot(current) && !snapshot(old)) continue;
|
|
2968
3018
|
if (current.assertions.length < old.assertions.length) {
|
|
3019
|
+
if ([...current.text.matchAll(HELPER_CALL)].some((call) => !(oldText ?? "").includes(call[1]))) continue;
|
|
2969
3020
|
const budget = tally([current]);
|
|
2970
|
-
const lost =
|
|
3021
|
+
const lost = [];
|
|
3022
|
+
old.assertions.forEach((assertion, i) => {
|
|
2971
3023
|
const left = budget.get(assertion) ?? 0;
|
|
2972
|
-
if (left > 0)
|
|
2973
|
-
|
|
2974
|
-
return false;
|
|
2975
|
-
}
|
|
2976
|
-
return true;
|
|
3024
|
+
if (left > 0) budget.set(assertion, left - 1);
|
|
3025
|
+
else lost.push({ normalized: assertion, raw: old.raw[i] });
|
|
2977
3026
|
});
|
|
2978
|
-
|
|
3027
|
+
const introduced = `${current.text}
|
|
3028
|
+
${addedText}`;
|
|
3029
|
+
const accounted = (item) => {
|
|
3030
|
+
if ((newTotals.get(item.normalized) ?? 0) >= (oldTotals.get(item.normalized) ?? 0)) return true;
|
|
3031
|
+
const tokens = literalTokens(item.raw);
|
|
3032
|
+
return tokens.length > 0 && tokens.every((token) => introduced.includes(token));
|
|
3033
|
+
};
|
|
3034
|
+
if (lost.every(accounted)) continue;
|
|
2979
3035
|
result.immediate.push(finding(
|
|
2980
|
-
"assertion-
|
|
3036
|
+
"assertion-removed",
|
|
2981
3037
|
newPath,
|
|
2982
3038
|
current.line,
|
|
2983
3039
|
`${old.name}: ${old.assertions.length} \u2192 ${current.assertions.length} assertions`,
|
|
@@ -3044,7 +3100,46 @@ var THRESHOLD_PATTERNS = [
|
|
|
3044
3100
|
/(--cov-fail-under)[= ](\d+(?:\.\d+)?)/g
|
|
3045
3101
|
];
|
|
3046
3102
|
var SWITCHES = ["strict", "noImplicitAny", "strictNullChecks", "noImplicitReturns", "useUnknownInCatchVariables"];
|
|
3047
|
-
var
|
|
3103
|
+
var CI_RUNNER = new RegExp(
|
|
3104
|
+
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/.-])`
|
|
3105
|
+
);
|
|
3106
|
+
var CI_FILE = /(?:\.github\/workflows\/|\.gitlab-ci)/;
|
|
3107
|
+
var CI_REUSABLE = /^\s*-?\s*uses:\s*\S*\.github\/workflows\/\S+/;
|
|
3108
|
+
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;
|
|
3109
|
+
function gatingTestLines(file, text) {
|
|
3110
|
+
return /\.gitlab-ci/.test(file) || CI_GATE.test(text) ? matchingLines(text, CI_RUNNER) : 0;
|
|
3111
|
+
}
|
|
3112
|
+
function matchingLines(text, pattern) {
|
|
3113
|
+
return text.split(/\r?\n/).filter((line) => !line.trim().startsWith("#") && pattern.test(line)).length;
|
|
3114
|
+
}
|
|
3115
|
+
function ciTestSteps(file, oldText, newText) {
|
|
3116
|
+
if (!CI_FILE.test(file) || oldText === null && newText === null) return null;
|
|
3117
|
+
const was = oldText ?? "", now = newText ?? "";
|
|
3118
|
+
const after = gatingTestLines(file, now);
|
|
3119
|
+
return { file, before: gatingTestLines(file, was), after, reusableAfter: matchingLines(now, CI_REUSABLE), ungatedAfter: after === 0 ? matchingLines(now, CI_RUNNER) : 0 };
|
|
3120
|
+
}
|
|
3121
|
+
function resolveCiTestSteps(counts, commit) {
|
|
3122
|
+
const before = counts.reduce((sum, item) => sum + item.before, 0);
|
|
3123
|
+
const after = counts.reduce((sum, item) => sum + item.after + item.reusableAfter, 0);
|
|
3124
|
+
if (after >= before) return [];
|
|
3125
|
+
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));
|
|
3126
|
+
}
|
|
3127
|
+
var LENIENT = /continue-on-error:\s*true|allow_failure:\s*true/;
|
|
3128
|
+
function lenientSteps(text) {
|
|
3129
|
+
const lines = text.split(/\r?\n/);
|
|
3130
|
+
const indentOf = (line) => line.length - line.trimStart().length;
|
|
3131
|
+
const found = /* @__PURE__ */ new Map();
|
|
3132
|
+
lines.forEach((line, i) => {
|
|
3133
|
+
if (line.trim().startsWith("#") || !LENIENT.test(line)) return;
|
|
3134
|
+
let j = i - 1;
|
|
3135
|
+
while (j >= 0 && (!lines[j].trim() || lines[j].trim().startsWith("#") || indentOf(lines[j]) >= indentOf(line))) j--;
|
|
3136
|
+
const identity = j >= 0 ? lines[j].trim() : "";
|
|
3137
|
+
const entry = found.get(identity);
|
|
3138
|
+
if (entry) entry.count++;
|
|
3139
|
+
else found.set(identity, { count: 1, line: i + 1 });
|
|
3140
|
+
});
|
|
3141
|
+
return found;
|
|
3142
|
+
}
|
|
3048
3143
|
function severityOf(value) {
|
|
3049
3144
|
return /^(?:error|2)$/.test(value) ? 2 : /^(?:warn|1)$/.test(value) ? 1 : 0;
|
|
3050
3145
|
}
|
|
@@ -3071,21 +3166,29 @@ function analyzeGuardFile(file, oldText, newText, commit) {
|
|
|
3071
3166
|
}
|
|
3072
3167
|
}
|
|
3073
3168
|
if (/eslint/i.test(file)) {
|
|
3074
|
-
const rules = (text) =>
|
|
3075
|
-
|
|
3076
|
-
|
|
3169
|
+
const rules = (text) => {
|
|
3170
|
+
const found = /* @__PURE__ */ new Map();
|
|
3171
|
+
for (const match of text.matchAll(/["']([@\w/.-]+)["']\s*:\s*(?:\[\s*)?["']?(error|warn|off|[012])["']?/g)) {
|
|
3172
|
+
const level = severityOf(match[2]);
|
|
3173
|
+
const known = found.get(match[1]);
|
|
3174
|
+
if (!known || level > known.level) found.set(match[1], { level, index: match.index ?? 0 });
|
|
3175
|
+
}
|
|
3176
|
+
return found;
|
|
3177
|
+
};
|
|
3178
|
+
const before = rules(oldText), after = rules(newText);
|
|
3179
|
+
for (const [name, now] of after) {
|
|
3077
3180
|
const previous = before.get(name);
|
|
3078
|
-
|
|
3181
|
+
const replaced = [...after].some(([other, setting]) => other !== name && other.endsWith(`/${name}`) && setting.level === 2);
|
|
3182
|
+
if (previous && previous.level === 2 && now.level < 2 && !replaced) add(now.index, `${name}: error \u2192 ${now.level ? "warn" : "off"}`, "Lint rule downgraded");
|
|
3079
3183
|
}
|
|
3080
3184
|
}
|
|
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");
|
|
3185
|
+
if (CI_FILE.test(file)) {
|
|
3186
|
+
const before = lenientSteps(oldText), existing = new Set(oldText.split(/\r?\n/).map((line) => line.trim()));
|
|
3187
|
+
for (const [identity, now] of lenientSteps(newText)) {
|
|
3188
|
+
if (identity && existing.has(identity) && now.count > (before.get(identity)?.count ?? 0)) {
|
|
3189
|
+
out.push(finding("guard-weakened", file, now.line, "continue-on-error: true", "CI step is now allowed to fail", commit));
|
|
3190
|
+
break;
|
|
3191
|
+
}
|
|
3089
3192
|
}
|
|
3090
3193
|
}
|
|
3091
3194
|
if (/(?:^|\/)package\.json$/i.test(file)) {
|
|
@@ -3170,6 +3273,7 @@ async function compareSourceChanges(options) {
|
|
|
3170
3273
|
let totalBytes = 0;
|
|
3171
3274
|
const tamperingFiles = [];
|
|
3172
3275
|
const guardFindings = [];
|
|
3276
|
+
const ciCounts = [];
|
|
3173
3277
|
let productionChanged = false;
|
|
3174
3278
|
const readText = async (mode, oid) => {
|
|
3175
3279
|
if (mode === "000000") return null;
|
|
@@ -3195,6 +3299,8 @@ async function compareSourceChanges(options) {
|
|
|
3195
3299
|
const oldText = await readText(change.oldMode, change.oldOid);
|
|
3196
3300
|
const newText = await readText(change.newMode, change.newOid);
|
|
3197
3301
|
guardFindings.push(...analyzeGuardFile(change.newPath, oldText, newText, refs.headSha));
|
|
3302
|
+
const ci = ciTestSteps(change.newPath, oldText, newText);
|
|
3303
|
+
if (ci) ciCounts.push(ci);
|
|
3198
3304
|
result.filesInspected++;
|
|
3199
3305
|
} catch (error) {
|
|
3200
3306
|
assertNotAborted(options.signal);
|
|
@@ -3252,7 +3358,7 @@ async function compareSourceChanges(options) {
|
|
|
3252
3358
|
await staged?.verify();
|
|
3253
3359
|
result.complete = result.skippedFiles.length === 0;
|
|
3254
3360
|
const rules = policy.config.rules ?? {};
|
|
3255
|
-
result.infractions.push(...[...resolveTampering(tamperingFiles, productionChanged), ...guardFindings].filter((finding2) => rules[finding2.type] !== "off"));
|
|
3361
|
+
result.infractions.push(...[...resolveTampering(tamperingFiles, productionChanged), ...guardFindings, ...resolveCiTestSteps(ciCounts, refs.headSha)].filter((finding2) => rules[finding2.type] !== "off"));
|
|
3256
3362
|
const exceptions = applyExceptions(result.infractions, options.ignoreExceptions ? [] : policy.config.exceptions, /* @__PURE__ */ new Date(), existingCounts);
|
|
3257
3363
|
result.infractions = exceptions.findings;
|
|
3258
3364
|
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
|
},
|