agent-roast 0.8.1 → 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 +93 -24
- package/dist/{chunk-U7OCVA3D.js → chunk-NSGANOZ5.js} +544 -61
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +12 -8
- package/schema.json +41 -1
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,36 +17,105 @@
|
|
|
17
17
|
|
|
18
18
|
</div>
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
**Did this change fix the code, or only make the checks pass?**
|
|
21
|
+
|
|
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.
|
|
24
29
|
|
|
25
30
|
```bash
|
|
26
|
-
|
|
27
|
-
npx agent-roast
|
|
31
|
+
npx agent-roast review --base origin/main # a pull request
|
|
32
|
+
npx agent-roast --staged # what you are about to commit
|
|
33
|
+
```
|
|
28
34
|
|
|
29
|
-
|
|
35
|
+
| Rule | Fires when | By default |
|
|
36
|
+
| --- | --- | --- |
|
|
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.
|
|
48
|
+
|
|
49
|
+
### How well does it work?
|
|
50
|
+
|
|
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
|
|
99
|
+
reads text, not program semantics. A green result is not a quality certificate.
|
|
100
|
+
|
|
101
|
+
### Quick start
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
# Everything that is true of the code you have now
|
|
105
|
+
npx agent-roast check
|
|
30
106
|
npx agent-roast explain swallowed-error
|
|
31
107
|
|
|
32
|
-
# Adopt
|
|
108
|
+
# Adopt in an existing project without blocking on historical debt
|
|
33
109
|
npx agent-roast check --write-baseline .agentroast-baseline.json
|
|
34
|
-
# Review the findings and commit this policy file, then gate only new problems
|
|
35
110
|
npx agent-roast check --baseline .agentroast-baseline.json
|
|
36
111
|
|
|
37
|
-
#
|
|
38
|
-
npx agent-roast --staged
|
|
39
|
-
|
|
40
|
-
# Audit pull request diffs (CI gate)
|
|
41
|
-
npx agent-roast review --base origin/main --head HEAD --format github
|
|
42
|
-
|
|
43
|
-
# Optional historical audit
|
|
112
|
+
# Optional: score a repository's history
|
|
44
113
|
npx agent-roast --all --verbose
|
|
45
114
|
```
|
|
46
115
|
|
|
47
|
-
A finding includes its file and line, the
|
|
48
|
-
|
|
49
|
-
|
|
116
|
+
A finding includes its file and line, the evidence, and a concrete next step.
|
|
117
|
+
`--format json|markdown|sarif|github` are available; terminal output shows the first
|
|
118
|
+
20 findings.
|
|
50
119
|
|
|
51
120
|
### Context review for pull requests
|
|
52
121
|
|
|
@@ -106,12 +175,12 @@ regressions) may appear in the report/summary rather than as an inline PR annota
|
|
|
106
175
|
Use a separate SARIF category for introduced-findings review; it does not represent
|
|
107
176
|
a full scan of all outstanding alerts.
|
|
108
177
|
|
|
109
|
-
Install 0.
|
|
178
|
+
Install 0.10.0: `npx agent-roast@0.10.0 review --base origin/main`. For local development,
|
|
110
179
|
run `npm run build` and use `node dist/cli.js`.
|
|
111
180
|
|
|
112
181
|
### Precise exceptions (since 0.8.0)
|
|
113
182
|
|
|
114
|
-
Use `npx agent-roast@0.
|
|
183
|
+
Use `npx agent-roast@0.10.0 check --format json`, or build this checkout with
|
|
115
184
|
`npm run build` and run `node dist/cli.js`.
|
|
116
185
|
|
|
117
186
|
Some skipped integrations or abstract stubs are intentional. In 0.8.0 you can accept
|
|
@@ -347,7 +416,7 @@ jobs:
|
|
|
347
416
|
|
|
348
417
|
To pilot the Action without blocking on findings, set `advisory: true`. It passes
|
|
349
418
|
an explicit empty fail-on list; invalid configuration, Git errors and incomplete
|
|
350
|
-
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 ''`.
|
|
351
420
|
|
|
352
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.
|
|
353
422
|
|
|
@@ -435,7 +504,7 @@ node scripts/verify-user-workflows.mjs dist/cli.js
|
|
|
435
504
|
against current code. Regression tests use disposable Git repositories. The programmatic
|
|
436
505
|
`auditPullRequest({ staged: true })` API retains the legacy added-lines contract;
|
|
437
506
|
use `reviewStagedChanges()` for the new context comparison and `ReviewResult`.
|
|
438
|
-
The composite Action installs exactly 0.
|
|
507
|
+
The composite Action installs exactly 0.10.0. For local development, use
|
|
439
508
|
`node dist/cli.js --staged` after building.
|
|
440
509
|
|
|
441
510
|
### Author & Support
|
|
@@ -786,6 +786,36 @@ var RULE_GUIDES = {
|
|
|
786
786
|
suggestion: "Implement the path or make its unsupported state explicit to callers. Check whether this is an intentional abstract contract.",
|
|
787
787
|
example: "throw new Error('Not implemented') \u2192 implement the operation and add a behavior test"
|
|
788
788
|
},
|
|
789
|
+
"assertion-weakened": {
|
|
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
|
+
example: "expect(total).toBe(42) \u2192 expect(total).toBeDefined() (restore the exact check)"
|
|
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
|
+
},
|
|
801
|
+
"test-removed": {
|
|
802
|
+
title: "Test removed",
|
|
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.",
|
|
804
|
+
suggestion: "Confirm the behavior was intentionally removed. If it still exists, restore the test and fix the code.",
|
|
805
|
+
example: "it('rejects expired tokens', ...) deleted while token validation remains in src/"
|
|
806
|
+
},
|
|
807
|
+
"expectation-rewritten": {
|
|
808
|
+
title: "Expected value edited alongside production code",
|
|
809
|
+
why: "Changing an expected value while the test inputs stay the same can mean the test was adjusted to match new output instead of the output being corrected.",
|
|
810
|
+
suggestion: "Verify the new expectation against the requirement, not against the program's current output. Intentional behavior changes should say so in the PR.",
|
|
811
|
+
example: "expect(price(cart)).toBe(100) \u2192 toBe(90) with price() changed in the same PR"
|
|
812
|
+
},
|
|
813
|
+
"guard-weakened": {
|
|
814
|
+
title: "Quality gate made easier to pass",
|
|
815
|
+
why: "Lower coverage thresholds, disabled compiler strictness, downgraded lint rules, tolerated CI failures or an always-passing test script let defects through without touching the code.",
|
|
816
|
+
suggestion: "Keep the gate and fix the underlying failure. If the change is deliberate, record the reason and a plan to restore it.",
|
|
817
|
+
example: 'coverageThreshold lines: 80 \u2192 60, "strict": true \u2192 false, continue-on-error: true'
|
|
818
|
+
},
|
|
789
819
|
"panic-loop": {
|
|
790
820
|
title: "Rapid overlapping fix commits",
|
|
791
821
|
why: "Repeated short fix cycles can suggest a missing reproduction or feedback loop; timing alone does not prove poor work.",
|
|
@@ -806,7 +836,12 @@ var VALID_DETECTORS = [
|
|
|
806
836
|
"panic-loop",
|
|
807
837
|
"trivial-assert",
|
|
808
838
|
"stub-abandonment",
|
|
809
|
-
"test-focus"
|
|
839
|
+
"test-focus",
|
|
840
|
+
"assertion-weakened",
|
|
841
|
+
"assertion-removed",
|
|
842
|
+
"test-removed",
|
|
843
|
+
"expectation-rewritten",
|
|
844
|
+
"guard-weakened"
|
|
810
845
|
];
|
|
811
846
|
|
|
812
847
|
// src/file-read.ts
|
|
@@ -839,13 +874,13 @@ function readRegularFile(filename, limit) {
|
|
|
839
874
|
import { createHash, randomUUID as randomUUID2 } from "crypto";
|
|
840
875
|
import * as fs2 from "fs";
|
|
841
876
|
import path from "path";
|
|
842
|
-
function findingFingerprint(
|
|
843
|
-
return createHash("sha256").update(JSON.stringify([
|
|
877
|
+
function findingFingerprint(finding2) {
|
|
878
|
+
return createHash("sha256").update(JSON.stringify([finding2.type, finding2.file, finding2.lineSnippet?.trim()])).digest("hex");
|
|
844
879
|
}
|
|
845
880
|
function createBaseline(findings) {
|
|
846
881
|
const counts = /* @__PURE__ */ new Map();
|
|
847
|
-
for (const
|
|
848
|
-
const fingerprint = findingFingerprint(
|
|
882
|
+
for (const finding2 of findings) {
|
|
883
|
+
const fingerprint = findingFingerprint(finding2);
|
|
849
884
|
counts.set(fingerprint, (counts.get(fingerprint) || 0) + 1);
|
|
850
885
|
}
|
|
851
886
|
return {
|
|
@@ -877,14 +912,14 @@ function applyBaseline(findings, baseline) {
|
|
|
877
912
|
const remaining = new Map(baseline.entries.map((entry) => [entry.fingerprint, entry.count]));
|
|
878
913
|
const fresh = [];
|
|
879
914
|
let baselineCount = 0;
|
|
880
|
-
for (const
|
|
881
|
-
const fingerprint = findingFingerprint(
|
|
915
|
+
for (const finding2 of findings) {
|
|
916
|
+
const fingerprint = findingFingerprint(finding2);
|
|
882
917
|
const count = remaining.get(fingerprint) || 0;
|
|
883
918
|
if (count > 0) {
|
|
884
919
|
remaining.set(fingerprint, count - 1);
|
|
885
920
|
baselineCount++;
|
|
886
921
|
} else {
|
|
887
|
-
fresh.push(
|
|
922
|
+
fresh.push(finding2);
|
|
888
923
|
}
|
|
889
924
|
}
|
|
890
925
|
return { findings: fresh, baselineCount, staleBaselineCount: [...remaining.values()].reduce((a, b) => a + b, 0) };
|
|
@@ -933,11 +968,11 @@ function applyExceptions(findings, exceptions = [], now = /* @__PURE__ */ new Da
|
|
|
933
968
|
const occurrences = new Map(priorOccurrences);
|
|
934
969
|
const actionable = [];
|
|
935
970
|
const suppressedFindings = [];
|
|
936
|
-
for (const
|
|
937
|
-
const fingerprint = findingFingerprint(
|
|
971
|
+
for (const finding2 of findings) {
|
|
972
|
+
const fingerprint = findingFingerprint(finding2);
|
|
938
973
|
const occurrence = (occurrences.get(fingerprint) ?? 0) + 1;
|
|
939
974
|
occurrences.set(fingerprint, occurrence);
|
|
940
|
-
const enriched = { ...
|
|
975
|
+
const enriched = { ...finding2, fingerprint, occurrence };
|
|
941
976
|
const allowance = active.get(fingerprint);
|
|
942
977
|
if (allowance && allowance.remaining > 0) {
|
|
943
978
|
allowance.remaining--;
|
|
@@ -950,7 +985,7 @@ function applyExceptions(findings, exceptions = [], now = /* @__PURE__ */ new Da
|
|
|
950
985
|
// src/config.ts
|
|
951
986
|
var MAX_CONFIG_BYTES = 64 * 1024;
|
|
952
987
|
var DEFAULT_CONFIG = {
|
|
953
|
-
failOn: ["test-skip", "trivial-assert", "test-focus"],
|
|
988
|
+
failOn: ["test-skip", "trivial-assert", "test-focus", "assertion-weakened", "guard-weakened"],
|
|
954
989
|
ignorePaths: [
|
|
955
990
|
"**/fixtures/**",
|
|
956
991
|
"**/__fixtures__/**",
|
|
@@ -966,6 +1001,11 @@ var DEFAULT_CONFIG = {
|
|
|
966
1001
|
"test-skip": "error",
|
|
967
1002
|
"trivial-assert": "error",
|
|
968
1003
|
"test-focus": "error",
|
|
1004
|
+
"assertion-weakened": "error",
|
|
1005
|
+
"assertion-removed": "warn",
|
|
1006
|
+
"guard-weakened": "error",
|
|
1007
|
+
"test-removed": "warn",
|
|
1008
|
+
"expectation-rewritten": "warn",
|
|
969
1009
|
"swallowed-error": "warn",
|
|
970
1010
|
"stub-abandonment": "warn",
|
|
971
1011
|
"type-escape": "warn"
|
|
@@ -1395,7 +1435,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1395
1435
|
});
|
|
1396
1436
|
}
|
|
1397
1437
|
const ordered = infractions.splice(firstFinding).sort((left, right) => (left.lineNumber || 0) - (right.lineNumber || 0));
|
|
1398
|
-
for (const
|
|
1438
|
+
for (const finding2 of ordered) infractions.push(finding2);
|
|
1399
1439
|
}
|
|
1400
1440
|
return { infractions: infractions.filter((inf) => options.rules?.[inf.type] !== "off").map((inf) => ({ ...inf, suggestion: RULE_GUIDES[inf.type].suggestion })), hasSupportedChanges, filesInspected, linesAdded };
|
|
1401
1441
|
}
|
|
@@ -1530,7 +1570,7 @@ var MetricsCollector = class {
|
|
|
1530
1570
|
const counts = {};
|
|
1531
1571
|
for (const infraction of audit.infractions) {
|
|
1532
1572
|
const count = counts[infraction.type] || 0;
|
|
1533
|
-
if (count < MAX_INFRACTIONS_PER_COMMIT) buckets[infraction.type]
|
|
1573
|
+
if (count < MAX_INFRACTIONS_PER_COMMIT) buckets[infraction.type]?.push(infraction);
|
|
1534
1574
|
counts[infraction.type] = count + 1;
|
|
1535
1575
|
}
|
|
1536
1576
|
}
|
|
@@ -1976,8 +2016,8 @@ function exceptionTerminal(result) {
|
|
|
1976
2016
|
const accepted = result.suppressedFindings || [];
|
|
1977
2017
|
if (accepted.length) {
|
|
1978
2018
|
lines.push(` ${accepted.length} findings accepted by explicit exceptions (not fixed)`);
|
|
1979
|
-
for (const
|
|
1980
|
-
` ACCEPTED [${
|
|
2019
|
+
for (const finding2 of accepted.slice(0, 20)) lines.push(
|
|
2020
|
+
` ACCEPTED [${finding2.type}] ${terminalText(finding2.file || "unknown")}:${finding2.lineNumber} \u2014 ${terminalText(finding2.suppression.reason)} (expires ${terminalText(finding2.suppression.expires)})`
|
|
1981
2021
|
);
|
|
1982
2022
|
if (accepted.length > 20) lines.push(` ${accepted.length - 20} more accepted findings; use --format json.`);
|
|
1983
2023
|
}
|
|
@@ -1997,7 +2037,7 @@ function exceptionMarkdown(result) {
|
|
|
1997
2037
|
"| Rule | Location | Reason | Expires |",
|
|
1998
2038
|
"| --- | --- | --- | --- |"
|
|
1999
2039
|
);
|
|
2000
|
-
for (const
|
|
2040
|
+
for (const finding2 of accepted) lines.push(`| ${finding2.type} | ${markdownCell(`${finding2.file}:${finding2.lineNumber}`)} | ${markdownCell(finding2.suppression.reason)} | ${markdownCell(finding2.suppression.expires)} |`);
|
|
2001
2041
|
}
|
|
2002
2042
|
if (result.expiredExceptionCount) lines.push("", `${result.expiredExceptionCount} expired exceptions were not applied; review their findings.`);
|
|
2003
2043
|
return lines;
|
|
@@ -2054,12 +2094,12 @@ function formatReviewTerminal(result) {
|
|
|
2054
2094
|
lines.push(...exceptionTerminal(result));
|
|
2055
2095
|
if (result.policyChanges.length) lines.push(" Candidate policy changes were ignored; adopt them separately after review.");
|
|
2056
2096
|
if (result.excludedFiles || result.unsupportedFiles) lines.push(` ${result.excludedFiles} excluded \xB7 ${result.unsupportedFiles} unsupported/non-source changes`);
|
|
2057
|
-
for (const
|
|
2097
|
+
for (const finding2 of result.infractions.slice(0, 20)) {
|
|
2058
2098
|
lines.push(
|
|
2059
2099
|
"",
|
|
2060
|
-
` ${result.failedRules.includes(
|
|
2061
|
-
` ${terminalText(
|
|
2062
|
-
` Next: ${terminalText(
|
|
2100
|
+
` ${result.failedRules.includes(finding2.type) ? "FAIL" : "WARN"} [${finding2.type}] ${terminalText(finding2.file || "unknown")}:${finding2.lineNumber}`,
|
|
2101
|
+
` ${terminalText(finding2.lineSnippet || finding2.reason)}`,
|
|
2102
|
+
` Next: ${terminalText(finding2.suggestion || "Review this occurrence.")}`
|
|
2063
2103
|
);
|
|
2064
2104
|
}
|
|
2065
2105
|
if (result.infractions.length > 20) lines.push(` ${result.infractions.length - 20} more new findings; use --format json for all results.`);
|
|
@@ -2080,7 +2120,7 @@ function formatReviewMarkdown(result) {
|
|
|
2080
2120
|
];
|
|
2081
2121
|
if (result.policyChanges.length) lines.push("Candidate policy changes were ignored. This review uses the supplied base commit's policy.", "");
|
|
2082
2122
|
lines.push("| New finding | Head location | Evidence | Next step |", "| --- | --- | --- | --- |");
|
|
2083
|
-
for (const
|
|
2123
|
+
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.")} |`);
|
|
2084
2124
|
lines.push(...exceptionMarkdown(result));
|
|
2085
2125
|
if (result.removedFindings.length) {
|
|
2086
2126
|
lines.push(
|
|
@@ -2092,7 +2132,7 @@ function formatReviewMarkdown(result) {
|
|
|
2092
2132
|
"| Rule | Previous location | Previous evidence |",
|
|
2093
2133
|
"| --- | --- | --- |"
|
|
2094
2134
|
);
|
|
2095
|
-
for (const
|
|
2135
|
+
for (const finding2 of result.removedFindings) lines.push(`| ${finding2.type} | ${markdownCell(`${finding2.file}:${finding2.lineNumber}`)} | ${markdownCell(finding2.lineSnippet || finding2.reason)} |`);
|
|
2096
2136
|
}
|
|
2097
2137
|
for (const skipped of result.skippedFiles) lines.push("", `Not compared: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
|
|
2098
2138
|
lines.push("", `Scope: changed supported files only; ${result.excludedFiles} excluded and ${result.unsupportedFiles} unsupported/non-source changes.`);
|
|
@@ -2338,26 +2378,26 @@ var version = require2("../package.json").version;
|
|
|
2338
2378
|
function formatSarif(report) {
|
|
2339
2379
|
const occurrences = /* @__PURE__ */ new Map();
|
|
2340
2380
|
const allFindings = [
|
|
2341
|
-
...report.infractions.map((
|
|
2342
|
-
...(report.suppressedFindings ?? []).map((
|
|
2381
|
+
...report.infractions.map((finding2) => ({ finding: finding2, suppression: void 0 })),
|
|
2382
|
+
...(report.suppressedFindings ?? []).map((finding2) => ({ finding: finding2, suppression: finding2.suppression }))
|
|
2343
2383
|
];
|
|
2344
2384
|
allFindings.sort((a, b) => (a.finding.file ?? "").localeCompare(b.finding.file ?? "") || (a.finding.lineNumber ?? 1) - (b.finding.lineNumber ?? 1) || a.finding.type.localeCompare(b.finding.type));
|
|
2345
|
-
const results = allFindings.map(({ finding, suppression }) => {
|
|
2346
|
-
const filename =
|
|
2385
|
+
const results = allFindings.map(({ finding: finding2, suppression }) => {
|
|
2386
|
+
const filename = finding2.file;
|
|
2347
2387
|
if (!filename || filename.startsWith("/") || filename.split("/").includes("..") || /^[a-z]:/i.test(filename)) {
|
|
2348
2388
|
throw new ConfigError("SARIF needs repository-relative finding paths.");
|
|
2349
2389
|
}
|
|
2350
|
-
const fingerprint = findingFingerprint(
|
|
2351
|
-
const occurrence =
|
|
2390
|
+
const fingerprint = findingFingerprint(finding2);
|
|
2391
|
+
const occurrence = finding2.occurrence ?? (occurrences.get(fingerprint) || 0) + 1;
|
|
2352
2392
|
occurrences.set(fingerprint, occurrence);
|
|
2353
2393
|
return {
|
|
2354
|
-
ruleId:
|
|
2355
|
-
ruleIndex: VALID_DETECTORS.indexOf(
|
|
2356
|
-
level: report.failedRules.includes(
|
|
2357
|
-
message: { text: `${
|
|
2394
|
+
ruleId: finding2.type,
|
|
2395
|
+
ruleIndex: VALID_DETECTORS.indexOf(finding2.type),
|
|
2396
|
+
level: report.failedRules.includes(finding2.type) ? "error" : "warning",
|
|
2397
|
+
message: { text: `${finding2.reason}. ${finding2.suggestion || RULE_GUIDES[finding2.type].suggestion}` },
|
|
2358
2398
|
locations: [{ physicalLocation: {
|
|
2359
2399
|
artifactLocation: { uri: filename.split("/").map((segment) => encodeURIComponent(segment)).join("/") },
|
|
2360
|
-
region: { startLine: Math.max(1,
|
|
2400
|
+
region: { startLine: Math.max(1, finding2.lineNumber || 1) }
|
|
2361
2401
|
} }],
|
|
2362
2402
|
partialFingerprints: { "agentRoastFinding/v1": `${fingerprint}:${occurrence}` },
|
|
2363
2403
|
...suppression ? { suppressions: [{
|
|
@@ -2662,7 +2702,7 @@ async function checkProject(options = {}) {
|
|
|
2662
2702
|
const cwd = path5.resolve(options.cwd || process.cwd());
|
|
2663
2703
|
const root = await fs6.realpath(gitOutputPath(await git(cwd, ["rev-parse", "--show-toplevel"], options.signal)));
|
|
2664
2704
|
const config = loadConfig(root);
|
|
2665
|
-
const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert", "test-focus"];
|
|
2705
|
+
const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert", "test-focus", "assertion-weakened", "guard-weakened"];
|
|
2666
2706
|
for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown rule '${rule}'.`);
|
|
2667
2707
|
const format = options.format || "terminal";
|
|
2668
2708
|
if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported check output format.");
|
|
@@ -2699,14 +2739,14 @@ async function checkProject(options = {}) {
|
|
|
2699
2739
|
const applied = baseline ? applyBaseline(infractions, baseline) : { findings: infractions, baselineCount: 0, staleBaselineCount: 0 };
|
|
2700
2740
|
const notInBaseline = new Set(applied.findings);
|
|
2701
2741
|
const acceptedCounts = /* @__PURE__ */ new Map();
|
|
2702
|
-
for (const
|
|
2703
|
-
if (notInBaseline.has(
|
|
2704
|
-
const fingerprint = findingFingerprint(
|
|
2742
|
+
for (const finding2 of infractions) {
|
|
2743
|
+
if (notInBaseline.has(finding2)) continue;
|
|
2744
|
+
const fingerprint = findingFingerprint(finding2);
|
|
2705
2745
|
acceptedCounts.set(fingerprint, (acceptedCounts.get(fingerprint) ?? 0) + 1);
|
|
2706
2746
|
}
|
|
2707
2747
|
const exceptions = applyExceptions(applied.findings, options.ignoreExceptions ? [] : config.exceptions, /* @__PURE__ */ new Date(), acceptedCounts);
|
|
2708
2748
|
const findings = exceptions.findings;
|
|
2709
|
-
const failedRules = [...new Set(findings.filter((
|
|
2749
|
+
const failedRules = [...new Set(findings.filter((finding2) => failOn.includes(finding2.type)).map((finding2) => finding2.type))];
|
|
2710
2750
|
const complete = skippedFiles.length === 0;
|
|
2711
2751
|
const passed = complete && failedRules.length === 0;
|
|
2712
2752
|
const result = {
|
|
@@ -2738,24 +2778,437 @@ async function checkProject(options = {}) {
|
|
|
2738
2778
|
|
|
2739
2779
|
// src/review.ts
|
|
2740
2780
|
import path6 from "path";
|
|
2781
|
+
|
|
2782
|
+
// src/tampering.ts
|
|
2783
|
+
import { createHash as createHash3 } from "crypto";
|
|
2784
|
+
var EMPTY = { immediate: [], removedTests: [], addedHashes: [], rewritten: [] };
|
|
2785
|
+
function languageOf(file) {
|
|
2786
|
+
if (/\.(?:[cm]?[jt]sx?)$/i.test(file)) return "js";
|
|
2787
|
+
if (/\.py$/i.test(file)) return "py";
|
|
2788
|
+
if (/_test\.go$/i.test(file)) return "go";
|
|
2789
|
+
return null;
|
|
2790
|
+
}
|
|
2791
|
+
function codeLines(text, language) {
|
|
2792
|
+
let block = false;
|
|
2793
|
+
return text.split(/\r?\n/).map((line) => {
|
|
2794
|
+
const trimmed = line.trim();
|
|
2795
|
+
if (language !== "py") {
|
|
2796
|
+
if (block) {
|
|
2797
|
+
if (trimmed.includes("*/")) block = false;
|
|
2798
|
+
return "";
|
|
2799
|
+
}
|
|
2800
|
+
if (trimmed.startsWith("/*")) {
|
|
2801
|
+
if (!trimmed.includes("*/")) block = true;
|
|
2802
|
+
return "";
|
|
2803
|
+
}
|
|
2804
|
+
if (trimmed.startsWith("//")) return "";
|
|
2805
|
+
} else if (trimmed.startsWith("#")) return "";
|
|
2806
|
+
return line;
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
2809
|
+
var JS_TEST_START = /(?<![\w$.])(?:it|test)(?:\s*\.\s*(?:only|skip|concurrent|serial))*\s*\(\s*(['"`])((?:\\.|(?!\1)[^\\\n])*)\1/;
|
|
2810
|
+
var PY_TEST_START = /^\s*(?:async\s+)?def\s+(test\w*)\s*\(/;
|
|
2811
|
+
var GO_TEST_START = /^func\s+(Test\w+)\s*\(/;
|
|
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/;
|
|
2813
|
+
var PY_ASSERTION = /^\s*assert\b|\bself\s*\.\s*assert\w*\s*\(|\bpytest\s*\.\s*raises\b|\bassertRaises\b|(?<!\w)assert_\w+\s*\(/;
|
|
2814
|
+
var GO_ASSERTION = /\bt\s*\.\s*(?:Error|Errorf|Fatal|Fatalf|Fail|FailNow)\b|\b(?:assert|require)\s*\.\s*\w+\s*\(/;
|
|
2815
|
+
function callEnd(text, from, limit) {
|
|
2816
|
+
let depth = 1, quote = "";
|
|
2817
|
+
for (let i = from; i < limit; i++) {
|
|
2818
|
+
const char = text[i];
|
|
2819
|
+
if (quote) {
|
|
2820
|
+
if (char === "\\") i++;
|
|
2821
|
+
else if (char === quote || char === "\n" && quote !== "`") quote = "";
|
|
2822
|
+
} else if (char === '"' || char === "'" || char === "`") quote = char;
|
|
2823
|
+
else if (char === "/" && text[i + 1] === "/" && (i === from || /\s/.test(text[i - 1]))) {
|
|
2824
|
+
const newline = text.indexOf("\n", i);
|
|
2825
|
+
i = newline < 0 || newline > limit ? limit : newline;
|
|
2826
|
+
} else if (char === "(") depth++;
|
|
2827
|
+
else if (char === ")" && --depth === 0) return i;
|
|
2828
|
+
}
|
|
2829
|
+
return limit;
|
|
2830
|
+
}
|
|
2831
|
+
function testBodies(text, language) {
|
|
2832
|
+
const lines = codeLines(text, language);
|
|
2833
|
+
if (language === "js") {
|
|
2834
|
+
const code = lines.join("\n");
|
|
2835
|
+
const matches = [...code.matchAll(new RegExp(JS_TEST_START.source, "g"))];
|
|
2836
|
+
let line = 1, cursor = 0;
|
|
2837
|
+
return matches.map((match, i) => {
|
|
2838
|
+
const at = match.index ?? 0;
|
|
2839
|
+
for (; cursor < at; cursor++) if (code.charCodeAt(cursor) === 10) line++;
|
|
2840
|
+
const from = at + match[0].length;
|
|
2841
|
+
const limit = i + 1 < matches.length ? matches[i + 1].index ?? code.length : code.length;
|
|
2842
|
+
return { name: match[2], line, body: code.slice(from, Math.min(callEnd(code, from, limit) + 1, limit)), parametrized: false };
|
|
2843
|
+
});
|
|
2844
|
+
}
|
|
2845
|
+
const startPattern = language === "py" ? PY_TEST_START : GO_TEST_START;
|
|
2846
|
+
const starts = [];
|
|
2847
|
+
lines.forEach((line, index) => {
|
|
2848
|
+
const match = startPattern.exec(line);
|
|
2849
|
+
if (match) starts.push({ index, name: match[1], length: match[0].length });
|
|
2850
|
+
});
|
|
2851
|
+
const indentOf = (line) => /^\s*/.exec(line)[0].length;
|
|
2852
|
+
return starts.map((start, n) => {
|
|
2853
|
+
const limit = n + 1 < starts.length ? starts[n + 1].index : lines.length;
|
|
2854
|
+
const header = lines[start.index], indent = indentOf(header);
|
|
2855
|
+
let end = start.index + 1, depth = balance(header);
|
|
2856
|
+
while (depth > 0 && end < limit) depth += balance(lines[end++]);
|
|
2857
|
+
while (end < limit && (language === "go" ? lines[end - 1] !== "}" : !lines[end].trim() || indentOf(lines[end]) > indent)) end++;
|
|
2858
|
+
let top = start.index;
|
|
2859
|
+
while (top > 0 && start.index - top < 200 && lines[top - 1].trim() && !/^\s*(?:async\s+)?(?:def|class)\b/.test(lines[top - 1])) top--;
|
|
2860
|
+
return {
|
|
2861
|
+
name: start.name,
|
|
2862
|
+
line: start.index + 1,
|
|
2863
|
+
body: [header.slice(start.length), ...lines.slice(start.index + 1, end)].join("\n"),
|
|
2864
|
+
parametrized: /parametrize/.test(lines.slice(top, start.index).join("\n"))
|
|
2865
|
+
};
|
|
2866
|
+
});
|
|
2867
|
+
}
|
|
2868
|
+
function balance(line) {
|
|
2869
|
+
const bare = line.replace(/(['"`])(?:\\.|(?!\1)[^\\])*\1/g, "");
|
|
2870
|
+
let net = 0;
|
|
2871
|
+
for (const char of bare) net += "([{".includes(char) ? 1 : ")]}".includes(char) ? -1 : 0;
|
|
2872
|
+
return net;
|
|
2873
|
+
}
|
|
2874
|
+
function normalize(statement) {
|
|
2875
|
+
return statement.replace(/\s+/g, "").replace(/'/g, '"').replace(/,([)\]}])/g, "$1").replace(/;$/, "");
|
|
2876
|
+
}
|
|
2877
|
+
function splitStatements(text) {
|
|
2878
|
+
const pieces = [];
|
|
2879
|
+
let quote = "", start = 0;
|
|
2880
|
+
for (let i = 0; i < text.length; i++) {
|
|
2881
|
+
const char = text[i];
|
|
2882
|
+
if (quote) {
|
|
2883
|
+
if (char === "\\") i++;
|
|
2884
|
+
else if (char === quote) quote = "";
|
|
2885
|
+
} else if (char === '"' || char === "'" || char === "`") quote = char;
|
|
2886
|
+
else if (char === ";") {
|
|
2887
|
+
pieces.push(text.slice(start, i));
|
|
2888
|
+
start = i + 1;
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
pieces.push(text.slice(start));
|
|
2892
|
+
return pieces.map((piece) => piece.trim()).filter(Boolean);
|
|
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
|
+
}
|
|
2907
|
+
var CALLBACK_HEADER = /^[,\s]*(?:async\s*)?(?:\([^)]*\)|[\w$]+)\s*=>\s*\{\s*|^[,\s]*(?:async\s+)?function\s*\w*\s*\([^)]*\)\s*\{\s*/;
|
|
2908
|
+
function parseTests(text, language) {
|
|
2909
|
+
const assertionPattern = language === "js" ? JS_ASSERTION : language === "py" ? PY_ASSERTION : GO_ASSERTION;
|
|
2910
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2911
|
+
return testBodies(text, language).map((test) => {
|
|
2912
|
+
const body = test.body.split("\n").map((line) => language === "go" ? line : stripTrailingComment(line, language));
|
|
2913
|
+
const assertions = [], raw = [], setupLines = [];
|
|
2914
|
+
for (let i = 0; i < body.length; i++) {
|
|
2915
|
+
if (!assertionPattern.test(body[i])) {
|
|
2916
|
+
setupLines.push(body[i]);
|
|
2917
|
+
continue;
|
|
2918
|
+
}
|
|
2919
|
+
let statement = body[i].trim(), depth = balance(body[i]);
|
|
2920
|
+
while (depth > 0 && i + 1 < body.length) {
|
|
2921
|
+
i++;
|
|
2922
|
+
statement += " " + body[i].trim();
|
|
2923
|
+
depth += balance(body[i]);
|
|
2924
|
+
}
|
|
2925
|
+
for (const piece of language === "js" ? splitStatements(statement) : [statement]) {
|
|
2926
|
+
const clean = piece.replace(CALLBACK_HEADER, "");
|
|
2927
|
+
if (assertionPattern.test(clean)) {
|
|
2928
|
+
raw.push(clean);
|
|
2929
|
+
assertions.push(normalize(clean));
|
|
2930
|
+
} else setupLines.push(clean);
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
const setup = setupLines.join("\n").replace(/\s+/g, "");
|
|
2934
|
+
const bodyHash = createHash3("sha1").update(test.body.replace(/\s+/g, "")).digest("hex");
|
|
2935
|
+
const count = (seen.get(test.name) ?? 0) + 1;
|
|
2936
|
+
seen.set(test.name, count);
|
|
2937
|
+
return { key: `${test.name}#${count}`, parametrized: test.parametrized, name: test.name, line: test.line, assertions, raw, setup, bodyHash, text: test.body };
|
|
2938
|
+
});
|
|
2939
|
+
}
|
|
2940
|
+
function skeleton(assertion) {
|
|
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, "");
|
|
2942
|
+
}
|
|
2943
|
+
var WEAK_JS = /\.(?:toBeDefined|toBeTruthy|toBeFalsy|toBeUndefined)\s*\(|\.not\s*\.\s*(?:toBeNull|toBeUndefined|toThrow)\s*\(|\.toBeGreaterThanOrEqual\s*\(\s*0\s*\)/;
|
|
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*\(/;
|
|
2948
|
+
function isWeaker(oldAssertion, newAssertion, language) {
|
|
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);
|
|
2951
|
+
return false;
|
|
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;
|
|
2965
|
+
function finding(type, file, line, snippet, reason, commit) {
|
|
2966
|
+
return { type, file, lineNumber: Math.max(1, line), lineSnippet: snippet, commitHash: commit, commitDate: "", reason, suggestion: RULE_GUIDES[type].suggestion };
|
|
2967
|
+
}
|
|
2968
|
+
function analyzeTestFile(oldPath, newPath, oldText, newText, commit) {
|
|
2969
|
+
const language = languageOf(newText === null ? oldPath : newPath);
|
|
2970
|
+
if (!language || !(isTestFile(newPath) || isTestFile(oldPath))) return EMPTY;
|
|
2971
|
+
const before = oldText === null ? [] : parseTests(oldText, language);
|
|
2972
|
+
const after = newText === null ? [] : parseTests(newText, language);
|
|
2973
|
+
const afterByKey = new Map(after.map((test) => [test.key, test]));
|
|
2974
|
+
const newCode = newText === null ? "" : codeLines(newText, language).join("\n");
|
|
2975
|
+
const tally = (tests) => {
|
|
2976
|
+
const totals = /* @__PURE__ */ new Map();
|
|
2977
|
+
for (const test of tests) for (const assertion of test.assertions) totals.set(assertion, (totals.get(assertion) ?? 0) + 1);
|
|
2978
|
+
return totals;
|
|
2979
|
+
};
|
|
2980
|
+
const oldTotals = tally(before), newTotals = tally(after);
|
|
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");
|
|
2983
|
+
const result = { immediate: [], removedTests: [], addedHashes: [], rewritten: [] };
|
|
2984
|
+
for (const test of after) if (!beforeKeys.has(test.key)) result.addedHashes.push(test.bodyHash);
|
|
2985
|
+
for (const old of before) {
|
|
2986
|
+
const current = afterByKey.get(old.key);
|
|
2987
|
+
if (!current) {
|
|
2988
|
+
if (newCode && ['"', "'", "`"].some((quote) => newCode.includes(quote + old.name + quote))) continue;
|
|
2989
|
+
result.removedTests.push({
|
|
2990
|
+
bodyHash: old.bodyHash,
|
|
2991
|
+
finding: finding(
|
|
2992
|
+
"test-removed",
|
|
2993
|
+
newPath,
|
|
2994
|
+
1,
|
|
2995
|
+
`${old.name}: test no longer present`,
|
|
2996
|
+
newText === null ? "Test file deleted" : "Test removed",
|
|
2997
|
+
commit
|
|
2998
|
+
)
|
|
2999
|
+
});
|
|
3000
|
+
continue;
|
|
3001
|
+
}
|
|
3002
|
+
if (current.parametrized && !old.parametrized) continue;
|
|
3003
|
+
const snapshot = (test) => test.raw.some((statement) => /\.toMatch(?:Inline)?Snapshot\b|\.matchSnapshot\b|\bsnapshot\s*\(/.test(statement));
|
|
3004
|
+
if (snapshot(current) && !snapshot(old)) continue;
|
|
3005
|
+
if (current.assertions.length < old.assertions.length) {
|
|
3006
|
+
if ([...current.text.matchAll(HELPER_CALL)].some((call) => !(oldText ?? "").includes(call[1]))) continue;
|
|
3007
|
+
const budget = tally([current]);
|
|
3008
|
+
const lost = [];
|
|
3009
|
+
old.assertions.forEach((assertion, i) => {
|
|
3010
|
+
const left = budget.get(assertion) ?? 0;
|
|
3011
|
+
if (left > 0) budget.set(assertion, left - 1);
|
|
3012
|
+
else lost.push({ normalized: assertion, raw: old.raw[i] });
|
|
3013
|
+
});
|
|
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;
|
|
3022
|
+
result.immediate.push(finding(
|
|
3023
|
+
"assertion-removed",
|
|
3024
|
+
newPath,
|
|
3025
|
+
current.line,
|
|
3026
|
+
`${old.name}: ${old.assertions.length} \u2192 ${current.assertions.length} assertions`,
|
|
3027
|
+
"Test checks less than before",
|
|
3028
|
+
commit
|
|
3029
|
+
));
|
|
3030
|
+
continue;
|
|
3031
|
+
}
|
|
3032
|
+
if (current.assertions.length === old.assertions.length && old.assertions.length > 0) {
|
|
3033
|
+
const changed = old.assertions.map((assertion, i) => ({ before: assertion, after: current.assertions[i], rawBefore: old.raw[i], rawAfter: current.raw[i] })).filter((pair) => pair.before !== pair.after);
|
|
3034
|
+
const weaker = changed.find((pair) => isWeaker(pair.rawBefore, pair.rawAfter, language));
|
|
3035
|
+
if (weaker) {
|
|
3036
|
+
result.immediate.push(finding(
|
|
3037
|
+
"assertion-weakened",
|
|
3038
|
+
newPath,
|
|
3039
|
+
current.line,
|
|
3040
|
+
`${old.name}: ${weaker.rawBefore.slice(0, 80)} \u2192 ${weaker.rawAfter.slice(0, 80)}`,
|
|
3041
|
+
"Assertion replaced by a weaker check",
|
|
3042
|
+
commit
|
|
3043
|
+
));
|
|
3044
|
+
} else if (changed.length && old.setup === current.setup && changed.every((pair) => skeleton(pair.before) === skeleton(pair.after))) {
|
|
3045
|
+
result.rewritten.push(finding(
|
|
3046
|
+
"expectation-rewritten",
|
|
3047
|
+
newPath,
|
|
3048
|
+
current.line,
|
|
3049
|
+
`${old.name}: ${changed[0].rawBefore.slice(0, 80)} \u2192 ${changed[0].rawAfter.slice(0, 80)}`,
|
|
3050
|
+
"Expected value edited while the test inputs stayed the same",
|
|
3051
|
+
commit
|
|
3052
|
+
));
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
return result;
|
|
3057
|
+
}
|
|
3058
|
+
function resolveTampering(files, productionChanged) {
|
|
3059
|
+
const added = new Set(files.flatMap((file) => file.addedHashes));
|
|
3060
|
+
return [
|
|
3061
|
+
...files.flatMap((file) => file.immediate),
|
|
3062
|
+
...files.flatMap((file) => file.removedTests).filter((removed) => !added.has(removed.bodyHash)).map((removed) => removed.finding),
|
|
3063
|
+
...productionChanged ? files.flatMap((file) => file.rewritten) : []
|
|
3064
|
+
];
|
|
3065
|
+
}
|
|
3066
|
+
var GUARD_FILE = /(?:^|\/)(?:package\.json|tsconfig[^/]*\.json|jsconfig\.json|(?:jest|vitest|vite)\.config\.[cm]?[jt]s|\.eslintrc(?:\.[a-z]+)?|eslint\.config\.[cm]?[jt]s|pyproject\.toml|setup\.cfg|pytest\.ini|tox\.ini|\.coveragerc|codecov\.ya?ml|\.gitlab-ci\.ya?ml|\.github\/workflows\/[^/]+\.ya?ml)$/i;
|
|
3067
|
+
function isGuardFile(file) {
|
|
3068
|
+
return GUARD_FILE.test(file);
|
|
3069
|
+
}
|
|
3070
|
+
function lineOf(text, index) {
|
|
3071
|
+
let line = 1;
|
|
3072
|
+
for (let i = 0; i < index && i < text.length; i++) if (text.charCodeAt(i) === 10) line++;
|
|
3073
|
+
return line;
|
|
3074
|
+
}
|
|
3075
|
+
function numbers(text, pattern) {
|
|
3076
|
+
const seen = /* @__PURE__ */ new Map();
|
|
3077
|
+
return [...text.matchAll(pattern)].map((match) => {
|
|
3078
|
+
const label = match[1] ?? "value";
|
|
3079
|
+
const count = (seen.get(label) ?? 0) + 1;
|
|
3080
|
+
seen.set(label, count);
|
|
3081
|
+
return { key: `${label}#${count}`, value: Number(match[match.length - 1]), index: match.index ?? 0 };
|
|
3082
|
+
});
|
|
3083
|
+
}
|
|
3084
|
+
var THRESHOLD_PATTERNS = [
|
|
3085
|
+
/\b(lines|branches|functions|statements)\b["']?\s*[:=]\s*(\d+(?:\.\d+)?)/g,
|
|
3086
|
+
/\b(fail_under)\s*[:=]\s*(\d+(?:\.\d+)?)/g,
|
|
3087
|
+
/(--cov-fail-under)[= ](\d+(?:\.\d+)?)/g
|
|
3088
|
+
];
|
|
3089
|
+
var SWITCHES = ["strict", "noImplicitAny", "strictNullChecks", "noImplicitReturns", "useUnknownInCatchVariables"];
|
|
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
|
+
}
|
|
3130
|
+
function severityOf(value) {
|
|
3131
|
+
return /^(?:error|2)$/.test(value) ? 2 : /^(?:warn|1)$/.test(value) ? 1 : 0;
|
|
3132
|
+
}
|
|
3133
|
+
function analyzeGuardFile(file, oldText, newText, commit) {
|
|
3134
|
+
if (oldText === null || newText === null) return [];
|
|
3135
|
+
const out = [];
|
|
3136
|
+
const add = (index, snippet, reason) => out.push(finding("guard-weakened", file, lineOf(newText, index), snippet, reason, commit));
|
|
3137
|
+
if (/threshold|fail_under|cov-fail-under|coverage/i.test(newText)) {
|
|
3138
|
+
for (const pattern of THRESHOLD_PATTERNS) {
|
|
3139
|
+
const old = new Map(numbers(oldText, pattern).map((item) => [item.key, item.value]));
|
|
3140
|
+
for (const item of numbers(newText, pattern)) {
|
|
3141
|
+
const previous = old.get(item.key);
|
|
3142
|
+
if (previous !== void 0 && item.value < previous) {
|
|
3143
|
+
add(item.index, `${item.key.split("#")[0]}: ${previous} \u2192 ${item.value}`, "Coverage threshold lowered");
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
if (/(?:^|\/)(?:tsconfig|jsconfig)[^/]*\.json$/i.test(file)) {
|
|
3149
|
+
for (const name of SWITCHES) {
|
|
3150
|
+
const pattern = new RegExp(`"${name}"\\s*:\\s*(true|false)`);
|
|
3151
|
+
const before = pattern.exec(oldText), after = pattern.exec(newText);
|
|
3152
|
+
if (before?.[1] === "true" && after?.[1] === "false") add(after.index, `${name}: true \u2192 false`, "Compiler strictness switched off");
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
if (/eslint/i.test(file)) {
|
|
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) {
|
|
3167
|
+
const previous = before.get(name);
|
|
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");
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
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
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
}
|
|
3181
|
+
if (/(?:^|\/)package\.json$/i.test(file)) {
|
|
3182
|
+
try {
|
|
3183
|
+
const before = JSON.parse(oldText)?.scripts?.test, after = JSON.parse(newText)?.scripts?.test;
|
|
3184
|
+
if (typeof before === "string" && typeof after === "string" && before !== after && (/^\s*(?:echo\b|true\b|exit\s+0|:)/.test(after) || /\|\|\s*(?:true|exit\s+0)\b/.test(after) || /--passWithNoTests/.test(after) && !/--passWithNoTests/.test(before))) {
|
|
3185
|
+
add(Math.max(0, newText.indexOf('"test"')), `test: ${after}`, "Test script can no longer fail");
|
|
3186
|
+
}
|
|
3187
|
+
} catch {
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
return out;
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
// src/review.ts
|
|
2741
3194
|
var GENERATED_PATHS2 = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
|
|
2742
3195
|
var MAX_SCAN_BYTES2 = 50 * 1024 * 1024;
|
|
2743
3196
|
function compareFindings(before, after, newPath) {
|
|
2744
3197
|
const old = /* @__PURE__ */ new Map();
|
|
2745
|
-
for (const
|
|
2746
|
-
const key = findingFingerprint({ ...
|
|
3198
|
+
for (const finding2 of before) {
|
|
3199
|
+
const key = findingFingerprint({ ...finding2, file: newPath });
|
|
2747
3200
|
const bucket = old.get(key) || { findings: [], matched: 0 };
|
|
2748
|
-
bucket.findings.push(
|
|
3201
|
+
bucket.findings.push(finding2);
|
|
2749
3202
|
old.set(key, bucket);
|
|
2750
3203
|
}
|
|
2751
3204
|
const introduced = [];
|
|
2752
3205
|
let unchanged = 0;
|
|
2753
|
-
for (const
|
|
2754
|
-
const bucket = old.get(findingFingerprint(
|
|
3206
|
+
for (const finding2 of after) {
|
|
3207
|
+
const bucket = old.get(findingFingerprint(finding2));
|
|
2755
3208
|
if (bucket && bucket.matched < bucket.findings.length) {
|
|
2756
3209
|
bucket.matched++;
|
|
2757
3210
|
unchanged++;
|
|
2758
|
-
} else introduced.push(
|
|
3211
|
+
} else introduced.push(finding2);
|
|
2759
3212
|
}
|
|
2760
3213
|
return { introduced, removed: [...old.values()].flatMap((bucket) => bucket.findings.slice(bucket.matched)), unchanged };
|
|
2761
3214
|
}
|
|
@@ -2773,7 +3226,7 @@ async function compareSourceChanges(options) {
|
|
|
2773
3226
|
const policy = refs.baseSha === "EMPTY" ? { config: configFromText("{}", "default first-commit policy"), filename: null } : await reviewPolicy(cwd, refs.baseSha, options.signal);
|
|
2774
3227
|
const format = options.format ?? "terminal";
|
|
2775
3228
|
if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported review format.");
|
|
2776
|
-
const failOn = options.failOn ?? policy.config.failOn ?? ["test-skip", "trivial-assert", "test-focus"];
|
|
3229
|
+
const failOn = options.failOn ?? policy.config.failOn ?? ["test-skip", "trivial-assert", "test-focus", "assertion-weakened", "guard-weakened"];
|
|
2777
3230
|
for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown review rule '${rule}'.`);
|
|
2778
3231
|
const changes = staged?.changes ?? await changedTreeFiles(cwd, refs.mergeBaseSha, refs.headSha, options.signal);
|
|
2779
3232
|
const policyChanges = changes.filter((change) => CONFIG_FILENAMES.includes(change.oldPath) || CONFIG_FILENAMES.includes(change.newPath)).map((change) => change.newPath);
|
|
@@ -2805,13 +3258,42 @@ async function compareSourceChanges(options) {
|
|
|
2805
3258
|
output: ""
|
|
2806
3259
|
};
|
|
2807
3260
|
let totalBytes = 0;
|
|
3261
|
+
const tamperingFiles = [];
|
|
3262
|
+
const guardFindings = [];
|
|
3263
|
+
const ciCounts = [];
|
|
3264
|
+
let productionChanged = false;
|
|
3265
|
+
const readText = async (mode, oid) => {
|
|
3266
|
+
if (mode === "000000") return null;
|
|
3267
|
+
if (!["100644", "100755"].includes(mode)) throw new ConfigError("Changed source is a symlink, submodule or unsupported Git mode; comparison is incomplete.");
|
|
3268
|
+
const content = await readBlob(cwd, oid, options.signal);
|
|
3269
|
+
totalBytes += content.length;
|
|
3270
|
+
if (totalBytes > MAX_SCAN_BYTES2) throw new ConfigError("Review exceeds the 50 MiB source comparison limit.");
|
|
3271
|
+
if (content.includes(0)) throw new ConfigError("Binary content in a source comparison.");
|
|
3272
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
3273
|
+
};
|
|
2808
3274
|
const existingCounts = /* @__PURE__ */ new Map();
|
|
2809
3275
|
for (const change of changes) {
|
|
2810
3276
|
assertNotAborted(options.signal);
|
|
2811
3277
|
const oldSupported = change.oldMode !== "000000" && isCodeFile(change.oldPath);
|
|
2812
3278
|
const newSupported = change.newMode !== "000000" && isCodeFile(change.newPath);
|
|
2813
3279
|
if (!oldSupported && !newSupported) {
|
|
2814
|
-
|
|
3280
|
+
const guarded = (isGuardFile(change.newPath) || isGuardFile(change.oldPath)) && isCodeFile(change.newPath + ".ts", ignores);
|
|
3281
|
+
if (!guarded) {
|
|
3282
|
+
result.unsupportedFiles++;
|
|
3283
|
+
continue;
|
|
3284
|
+
}
|
|
3285
|
+
try {
|
|
3286
|
+
const oldText = await readText(change.oldMode, change.oldOid);
|
|
3287
|
+
const newText = await readText(change.newMode, change.newOid);
|
|
3288
|
+
guardFindings.push(...analyzeGuardFile(change.newPath, oldText, newText, refs.headSha));
|
|
3289
|
+
const ci = ciTestSteps(change.newPath, oldText, newText);
|
|
3290
|
+
if (ci) ciCounts.push(ci);
|
|
3291
|
+
result.filesInspected++;
|
|
3292
|
+
} catch (error) {
|
|
3293
|
+
assertNotAborted(options.signal);
|
|
3294
|
+
result.skippedFiles.push({ file: change.newPath, reason: error instanceof Error ? error.message : String(error) });
|
|
3295
|
+
if (totalBytes > MAX_SCAN_BYTES2) break;
|
|
3296
|
+
}
|
|
2815
3297
|
continue;
|
|
2816
3298
|
}
|
|
2817
3299
|
const oldInScope = oldSupported && isCodeFile(change.oldPath, ignores);
|
|
@@ -2822,14 +3304,8 @@ async function compareSourceChanges(options) {
|
|
|
2822
3304
|
}
|
|
2823
3305
|
try {
|
|
2824
3306
|
let inspectedLines = 0;
|
|
2825
|
-
const scan =
|
|
2826
|
-
if (
|
|
2827
|
-
if (!["100644", "100755"].includes(mode)) throw new ConfigError("Changed source is a symlink, submodule or unsupported Git mode; comparison is incomplete.");
|
|
2828
|
-
const content = await readBlob(cwd, oid, options.signal);
|
|
2829
|
-
totalBytes += content.length;
|
|
2830
|
-
if (totalBytes > MAX_SCAN_BYTES2) throw new ConfigError("Review exceeds the 50 MiB source comparison limit.");
|
|
2831
|
-
if (content.includes(0)) throw new ConfigError("Binary content in a source comparison.");
|
|
2832
|
-
const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
3307
|
+
const scan = (text, filename, commit) => {
|
|
3308
|
+
if (text === null) return [];
|
|
2833
3309
|
const lines = text.split(/\r?\n/);
|
|
2834
3310
|
if (lines.at(-1) === "") lines.pop();
|
|
2835
3311
|
inspectedLines += lines.length;
|
|
@@ -2838,13 +3314,18 @@ async function compareSourceChanges(options) {
|
|
|
2838
3314
|
if (oldSupported && !newSupported && change.newMode !== "000000") {
|
|
2839
3315
|
throw new ConfigError("Source moved to an unsupported extension; cannot claim its findings were resolved.");
|
|
2840
3316
|
}
|
|
2841
|
-
const
|
|
2842
|
-
const
|
|
3317
|
+
const oldText = await readText(change.oldMode, change.oldOid);
|
|
3318
|
+
const newText = await readText(change.newMode, change.newOid);
|
|
3319
|
+
const before = scan(oldText, change.oldPath, refs.mergeBaseSha);
|
|
3320
|
+
const after = scan(newText, change.newPath, refs.headSha);
|
|
3321
|
+
if (isTestFile(change.oldPath) || isTestFile(change.newPath)) tamperingFiles.push(analyzeTestFile(change.oldPath, change.newPath, oldText, newText, refs.headSha));
|
|
3322
|
+
else if (change.oldOid !== change.newOid) productionChanged = true;
|
|
3323
|
+
if ((isGuardFile(change.newPath) || isGuardFile(change.oldPath)) && oldText !== null && newText !== null) guardFindings.push(...analyzeGuardFile(change.newPath, oldText, newText, refs.headSha));
|
|
2843
3324
|
const compared = compareFindings(before, after, change.newPath);
|
|
2844
3325
|
const introduced = new Set(compared.introduced);
|
|
2845
|
-
for (const
|
|
2846
|
-
if (introduced.has(
|
|
2847
|
-
const fingerprint = findingFingerprint(
|
|
3326
|
+
for (const finding2 of after) {
|
|
3327
|
+
if (introduced.has(finding2)) continue;
|
|
3328
|
+
const fingerprint = findingFingerprint(finding2);
|
|
2848
3329
|
existingCounts.set(fingerprint, (existingCounts.get(fingerprint) ?? 0) + 1);
|
|
2849
3330
|
}
|
|
2850
3331
|
result.infractions.push(...compared.introduced);
|
|
@@ -2863,11 +3344,13 @@ async function compareSourceChanges(options) {
|
|
|
2863
3344
|
assertNotAborted(options.signal);
|
|
2864
3345
|
await staged?.verify();
|
|
2865
3346
|
result.complete = result.skippedFiles.length === 0;
|
|
3347
|
+
const rules = policy.config.rules ?? {};
|
|
3348
|
+
result.infractions.push(...[...resolveTampering(tamperingFiles, productionChanged), ...guardFindings, ...resolveCiTestSteps(ciCounts, refs.headSha)].filter((finding2) => rules[finding2.type] !== "off"));
|
|
2866
3349
|
const exceptions = applyExceptions(result.infractions, options.ignoreExceptions ? [] : policy.config.exceptions, /* @__PURE__ */ new Date(), existingCounts);
|
|
2867
3350
|
result.infractions = exceptions.findings;
|
|
2868
3351
|
result.suppressedFindings = exceptions.suppressedFindings;
|
|
2869
3352
|
result.expiredExceptionCount = exceptions.expiredExceptionCount;
|
|
2870
|
-
result.failedRules = [...new Set(result.infractions.filter((
|
|
3353
|
+
result.failedRules = [...new Set(result.infractions.filter((finding2) => failOn.includes(finding2.type)).map((finding2) => finding2.type))];
|
|
2871
3354
|
result.passed = result.complete && result.failedRules.length === 0;
|
|
2872
3355
|
result.exitCode = !result.complete ? 2 : result.passed ? 0 : 1;
|
|
2873
3356
|
if (format === "json") result.output = JSON.stringify(result, null, 2);
|
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";
|
|
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-roast",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Offline
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Offline review for AI-assisted changes: catches weakened tests, lowered quality gates, skipped tests and swallowed errors",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
@@ -20,16 +20,22 @@
|
|
|
20
20
|
"test": "vitest run",
|
|
21
21
|
"prepublishOnly": "npm run build",
|
|
22
22
|
"pretest": "npm run build",
|
|
23
|
-
"typecheck": "tsc --noEmit"
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"bench": "node scripts/benchmark-recall.mjs",
|
|
25
|
+
"bench:noise": "node scripts/benchmark-noise.mjs"
|
|
24
26
|
},
|
|
25
27
|
"keywords": [
|
|
26
28
|
"ai",
|
|
27
29
|
"agents",
|
|
28
|
-
"
|
|
30
|
+
"ai-agents",
|
|
29
31
|
"claude-code",
|
|
30
|
-
"
|
|
32
|
+
"cursor",
|
|
33
|
+
"codex",
|
|
34
|
+
"code-review",
|
|
35
|
+
"test-quality",
|
|
36
|
+
"ci",
|
|
37
|
+
"sarif",
|
|
31
38
|
"git",
|
|
32
|
-
"code-quality",
|
|
33
39
|
"cli"
|
|
34
40
|
],
|
|
35
41
|
"author": "Nari-Ab",
|
|
@@ -47,9 +53,7 @@
|
|
|
47
53
|
"picocolors": "^1.1.0"
|
|
48
54
|
},
|
|
49
55
|
"devDependencies": {
|
|
50
|
-
"@napi-rs/canvas": "^1.0.10",
|
|
51
56
|
"@types/node": "^20.0.0",
|
|
52
|
-
"gifenc": "^1.0.3",
|
|
53
57
|
"tsup": "^8.0.0",
|
|
54
58
|
"typescript": "^5.0.0",
|
|
55
59
|
"vitest": "^4.1.12"
|
package/schema.json
CHANGED
|
@@ -20,7 +20,12 @@
|
|
|
20
20
|
"panic-loop",
|
|
21
21
|
"trivial-assert",
|
|
22
22
|
"stub-abandonment",
|
|
23
|
-
"test-focus"
|
|
23
|
+
"test-focus",
|
|
24
|
+
"assertion-weakened",
|
|
25
|
+
"assertion-removed",
|
|
26
|
+
"test-removed",
|
|
27
|
+
"expectation-rewritten",
|
|
28
|
+
"guard-weakened"
|
|
24
29
|
]
|
|
25
30
|
}
|
|
26
31
|
},
|
|
@@ -94,6 +99,41 @@
|
|
|
94
99
|
"warn",
|
|
95
100
|
"off"
|
|
96
101
|
]
|
|
102
|
+
},
|
|
103
|
+
"assertion-weakened": {
|
|
104
|
+
"enum": [
|
|
105
|
+
"error",
|
|
106
|
+
"warn",
|
|
107
|
+
"off"
|
|
108
|
+
]
|
|
109
|
+
},
|
|
110
|
+
"test-removed": {
|
|
111
|
+
"enum": [
|
|
112
|
+
"error",
|
|
113
|
+
"warn",
|
|
114
|
+
"off"
|
|
115
|
+
]
|
|
116
|
+
},
|
|
117
|
+
"expectation-rewritten": {
|
|
118
|
+
"enum": [
|
|
119
|
+
"error",
|
|
120
|
+
"warn",
|
|
121
|
+
"off"
|
|
122
|
+
]
|
|
123
|
+
},
|
|
124
|
+
"guard-weakened": {
|
|
125
|
+
"enum": [
|
|
126
|
+
"error",
|
|
127
|
+
"warn",
|
|
128
|
+
"off"
|
|
129
|
+
]
|
|
130
|
+
},
|
|
131
|
+
"assertion-removed": {
|
|
132
|
+
"enum": [
|
|
133
|
+
"error",
|
|
134
|
+
"warn",
|
|
135
|
+
"off"
|
|
136
|
+
]
|
|
97
137
|
}
|
|
98
138
|
}
|
|
99
139
|
},
|