agent-roast 0.5.0 → 0.7.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 +96 -15
- package/dist/{chunk-FUAUIURY.js → chunk-YGHNTWHX.js} +531 -36
- package/dist/cli.js +59 -3
- package/dist/index.d.ts +67 -4
- package/dist/index.js +17 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ npx agent-roast check --baseline .agentroast-baseline.json
|
|
|
38
38
|
npx agent-roast --staged
|
|
39
39
|
|
|
40
40
|
# Audit pull request diffs (CI gate)
|
|
41
|
-
npx agent-roast --base origin/main --head HEAD --format github
|
|
41
|
+
npx agent-roast review --base origin/main --head HEAD --format github
|
|
42
42
|
|
|
43
43
|
# Optional historical audit
|
|
44
44
|
npx agent-roast --all --verbose
|
|
@@ -48,6 +48,67 @@ A finding includes its file and line, the source snippet, and a concrete next st
|
|
|
48
48
|
To see the complete machine-readable result, use `check --format json`; terminal
|
|
49
49
|
output shows the first 20 findings. `check --format markdown` produces a review table.
|
|
50
50
|
|
|
51
|
+
### Context review for pull requests
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Compare committed source at the common ancestor and candidate head
|
|
55
|
+
agent-roast review --base origin/main --head HEAD
|
|
56
|
+
agent-roast review --base origin/main --format markdown
|
|
57
|
+
|
|
58
|
+
# Export a report for tools that consume SARIF 2.1.0
|
|
59
|
+
agent-roast review --base origin/main --format sarif > agentroast.sarif
|
|
60
|
+
agent-roast check --format sarif > current-code.sarif
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`review` compares **full contents of changed source files**, so deleting the only
|
|
64
|
+
handler inside a `catch` is detectable even when no suspicious line was added.
|
|
65
|
+
It reports new findings, existing findings, and patterns no longer detected. Only
|
|
66
|
+
new blocking findings fail the check; no manually generated baseline is required.
|
|
67
|
+
Deleting a file is reported as disappearance of a pattern, not proof of a correct fix.
|
|
68
|
+
|
|
69
|
+
Source is read directly from immutable Git blobs at the merge-base and head. The
|
|
70
|
+
worktree and index are left untouched, and project code, hooks, diff programs and
|
|
71
|
+
package scripts are never executed. Use `check` for uncommitted working files, and
|
|
72
|
+
`--staged` for the index. Source comparison and policy are pinned to the reported SHAs.
|
|
73
|
+
|
|
74
|
+
Policy comes from the explicitly supplied **base ref tip**; source comparison starts
|
|
75
|
+
at the merge-base. Candidate changes to `.agentroastrc.json`, `.agentroastrc` or
|
|
76
|
+
`agent-roast.config.json` are reported and ignored for this review. Adopt policy
|
|
77
|
+
changes through separate human review. An explicit `--fail-on` overrides blocking
|
|
78
|
+
severity but does not enable rules disabled by base policy.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Advisory pilot: inspect findings before deciding what should block the team
|
|
82
|
+
agent-roast review --base origin/main --fail-on ''
|
|
83
|
+
# Gate the shortcuts the team agrees matter
|
|
84
|
+
agent-roast review --base origin/main --fail-on test-skip,trivial-assert,swallowed-error
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Matching uses rule, source snippet and occurrence count within a file pair. Line
|
|
88
|
+
shifts and Git-recognized pure renames do not turn old debt into new debt. A second
|
|
89
|
+
identical occurrence is new. This is not semantic tracking: rewritten snippets or
|
|
90
|
+
language changes can require fresh review, and Git's rename recognition is heuristic.
|
|
91
|
+
|
|
92
|
+
Limits: 1,000 changed files, 2 MiB per source blob, 50 MiB across inspected versions,
|
|
93
|
+
30 seconds per Git command. Symlinked/binary/oversized source and missing comparison
|
|
94
|
+
objects yield exit 2, never a clean pass. Partial clones and ambiguous multiple
|
|
95
|
+
merge-bases are unsupported; fetch complete local history (`fetch-depth: 0`). The
|
|
96
|
+
report counts excluded and unsupported changes explicitly. Only changed supported
|
|
97
|
+
files are compared; this is not a whole-repository safety certification.
|
|
98
|
+
|
|
99
|
+
SARIF includes rule metadata, relative locations, stable occurrence fingerprints
|
|
100
|
+
and whether the scan completed. Source snippets are omitted from SARIF. JSON and
|
|
101
|
+
Markdown reports contain evidence snippets and should be handled as source code.
|
|
102
|
+
SARIF output is generated offline; uploading it is a separate, optional operation.
|
|
103
|
+
GitHub supports SARIF ingestion, but this release has not been tested with a live
|
|
104
|
+
code-scanning upload. Some findings on unchanged lines (including deletion-only
|
|
105
|
+
regressions) may appear in the report/summary rather than as an inline PR annotation.
|
|
106
|
+
Use a separate SARIF category for introduced-findings review; it does not represent
|
|
107
|
+
a full scan of all outstanding alerts.
|
|
108
|
+
|
|
109
|
+
Install 0.7.0: `npx agent-roast@0.7.0 review --base origin/main`. For local development,
|
|
110
|
+
run `npm run build` and use `node dist/cli.js`.
|
|
111
|
+
|
|
51
112
|
### Baseline adoption
|
|
52
113
|
|
|
53
114
|
A baseline explicitly accepts the findings present at adoption time. It does not fix
|
|
@@ -114,7 +175,6 @@ Configure custom ignore paths, rule severities, and CI thresholds with a `.agent
|
|
|
114
175
|
|
|
115
176
|
```json
|
|
116
177
|
{
|
|
117
|
-
"$schema": "https://raw.githubusercontent.com/Nari-Ab/agent-roast/main/schema.json",
|
|
118
178
|
"ignorePaths": [
|
|
119
179
|
"**/*.min.js",
|
|
120
180
|
"**/vendor/**",
|
|
@@ -122,11 +182,10 @@ Configure custom ignore paths, rule severities, and CI thresholds with a `.agent
|
|
|
122
182
|
"**/fixtures/**"
|
|
123
183
|
],
|
|
124
184
|
"rules": {
|
|
125
|
-
"
|
|
126
|
-
"
|
|
185
|
+
"test-focus": "error",
|
|
186
|
+
"swallowed-error": "warn",
|
|
127
187
|
"trivial-assert": "warn",
|
|
128
|
-
"stub-abandonment": "warn"
|
|
129
|
-
"mock-leak": "warn"
|
|
188
|
+
"stub-abandonment": "warn"
|
|
130
189
|
},
|
|
131
190
|
"scoreThreshold": 70
|
|
132
191
|
}
|
|
@@ -142,9 +201,22 @@ npx agent-roast --staged
|
|
|
142
201
|
npx agent-roast init --hook
|
|
143
202
|
```
|
|
144
203
|
|
|
145
|
-
`--staged`
|
|
146
|
-
|
|
147
|
-
|
|
204
|
+
Since 0.7.0, `--staged` (also `review --staged`) compares the **full source**
|
|
205
|
+
of changed files in HEAD and the Git index. It detects deletion-only empty handlers
|
|
206
|
+
and retains comment context. Existing findings do not block; additional occurrences
|
|
207
|
+
do. Unstaged edits, untracked files and `git add -N` intent-to-add entries are excluded.
|
|
208
|
+
The previous released 0.6.0 `--staged` command checks only added index lines.
|
|
209
|
+
|
|
210
|
+
Policy comes from HEAD. Staged/unstaged config edits cannot weaken this check and
|
|
211
|
+
are reported; adopt policy changes in a separately reviewed commit. With no HEAD,
|
|
212
|
+
the first commit uses built-in defaults; use `--fail-on` for an explicit override.
|
|
213
|
+
For example: `node dist/cli.js review --staged --fail-on test-skip,test-focus,swallowed-error`.
|
|
214
|
+
Source is pinned by Git blob IDs. If HEAD or the staged inventory changes before the
|
|
215
|
+
scan finishes, it exits 2 and asks for a rerun. The report's `source` is `index`;
|
|
216
|
+
`headSha` is an `INDEX:` snapshot digest, not a commit SHA, and an unborn base is `EMPTY`.
|
|
217
|
+
The JSON schema version for context reviews is now `1.1.0`.
|
|
218
|
+
|
|
219
|
+
The generated hook uses the locally installed CLI (`--no-install`) and blocks on configured error rules instead of scoring
|
|
148
220
|
old commits. It preserves an existing pre-commit hook.
|
|
149
221
|
|
|
150
222
|
### Configure what blocks a change
|
|
@@ -155,6 +227,7 @@ Create `.agentroastrc.json` (or run `npx agent-roast init`):
|
|
|
155
227
|
{
|
|
156
228
|
"rules": {
|
|
157
229
|
"test-skip": "error",
|
|
230
|
+
"test-focus": "error",
|
|
158
231
|
"trivial-assert": "error",
|
|
159
232
|
"swallowed-error": "warn",
|
|
160
233
|
"type-escape": "off"
|
|
@@ -166,7 +239,7 @@ Create `.agentroastrc.json` (or run `npx agent-roast init`):
|
|
|
166
239
|
```
|
|
167
240
|
|
|
168
241
|
`off` removes findings and score penalties; `warn` reports without blocking a diff;
|
|
169
|
-
`error` blocks PR/staged checks. An explicit `failOn` array overrides which enabled
|
|
242
|
+
`error` blocks PR/staged checks, including `test-focus` by default in 0.7.0. An explicit `failOn` array overrides which enabled
|
|
170
243
|
rules block (`[]` makes the audit advisory). CLI `--fail-on` takes precedence over
|
|
171
244
|
that array. History and badge audits honor disabled rules and the panic-loop window.
|
|
172
245
|
History exit status is controlled by `scoreThreshold` or `--fail-under`.
|
|
@@ -203,7 +276,6 @@ jobs:
|
|
|
203
276
|
- name: Run agent-roast audit
|
|
204
277
|
uses: Nari-Ab/agent-roast@v1
|
|
205
278
|
with:
|
|
206
|
-
fail-on: test-skip,trivial-assert,stub-abandonment
|
|
207
279
|
format: github
|
|
208
280
|
```
|
|
209
281
|
|
|
@@ -213,8 +285,12 @@ jobs:
|
|
|
213
285
|
|
|
214
286
|
### What it checks
|
|
215
287
|
|
|
216
|
-
|
|
288
|
+
`check` scans current source. `review` scans both full versions of changed source;
|
|
289
|
+
the 0.7.0 `--staged` command does the same for HEAD and index. Legacy
|
|
290
|
+
`--base` and history audits inspect added lines. Comment/literal masking reduces
|
|
291
|
+
noise; these are heuristic checks and can have false positives and missed findings.
|
|
217
292
|
|
|
293
|
+
- **Focused tests (`test-focus`):** Exclusive `test.only`, `it.only`, `describe.only`, `suite.only`, Playwright `test.describe.only` and common modifier chains in JS/TS-family sources. `fit` / `fdescribe` aliases are recognized in conventional test paths. Supports contiguous multiline chains and ignores literal/comment examples. Default blocking rule; an existing explicit `failOn` list must include it to block. This does not resolve imports or custom aliases; configure your runner's native forbid-only option too when available ([Playwright](https://playwright.dev/docs/api/class-testconfig#test-config-forbid-only), [Jest API](https://jestjs.io/docs/api#testonlyname-fn-timeout)).
|
|
218
294
|
- **Skipped tests (`test-skip`):** `it.skip`, `describe.skip`, `xit`, `pytest.skip`, `test.todo` added to silence failing tests. (Default blocking gate).
|
|
219
295
|
- **Trivial assertions (`trivial-assert`):** Tautological or dummy assertions added to fabricate test green checks (`expect(true).toBe(true)`, `expect(1).toBe(1)`, `assert True`, `self.assertTrue(True)`).
|
|
220
296
|
- **Stub abandonment (`stub-abandonment`):** Dummy stubs left unimplemented in committed code (`throw new Error("Not implemented")`, `raise NotImplementedError`, Rust `todo!()` / `unimplemented!()`).
|
|
@@ -238,7 +314,7 @@ Only **added lines** (`+`) in source code files are scanned — deleted lines, f
|
|
|
238
314
|
- **Requirements:** Git 2.30+ (for `--end-of-options` argument isolation), Node.js 18+.
|
|
239
315
|
- **100% Local:** Instant streaming parser runs directly on your machine.
|
|
240
316
|
- **Zero Telemetry:** No cloud dependencies, no LLM API calls, no network traffic. Verified by static bundle inspection.
|
|
241
|
-
- **
|
|
317
|
+
- **Git isolation:** Forces stable diff paths, disables fsmonitor/external diff execution, and reads review content by object identity.
|
|
242
318
|
|
|
243
319
|
---
|
|
244
320
|
|
|
@@ -249,7 +325,8 @@ stubs, alongside a compiler, test runner and language-aware linter. Findings ide
|
|
|
249
325
|
patterns, not proof of AI authorship or developer intent. Deliberate skips and abstract
|
|
250
326
|
methods can be legitimate. Contiguous block comments, Python docstrings and multi-line empty handlers are
|
|
251
327
|
recognized. Template interpolation, complex language syntax and context outside
|
|
252
|
-
the added diff are not fully parsed.
|
|
328
|
+
the added diff in legacy audit modes are not fully parsed. Custom test API aliases,
|
|
329
|
+
computed property calls and type/import resolution are not handled. Panic loops are an observational history heuristic.
|
|
253
330
|
The discipline score summarizes added history; it does not certify current code quality.
|
|
254
331
|
`check` reports actionable findings without assigning a personality or quality score.
|
|
255
332
|
|
|
@@ -262,7 +339,11 @@ npm test
|
|
|
262
339
|
```
|
|
263
340
|
|
|
264
341
|
`npm test` builds the distribution first so CLI integration and bundle checks run
|
|
265
|
-
against current code. Regression tests use disposable Git repositories.
|
|
342
|
+
against current code. Regression tests use disposable Git repositories. The programmatic
|
|
343
|
+
`auditPullRequest({ staged: true })` API retains the legacy added-lines contract;
|
|
344
|
+
use `reviewStagedChanges()` for the new context comparison and `ReviewResult`.
|
|
345
|
+
The composite Action installs exactly 0.7.0. For local development, use
|
|
346
|
+
`node dist/cli.js --staged` after building.
|
|
266
347
|
|
|
267
348
|
### Author & Support
|
|
268
349
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import
|
|
4
|
+
import path6 from "path";
|
|
5
5
|
|
|
6
6
|
// src/git.ts
|
|
7
7
|
import { spawn, spawnSync } from "child_process";
|
|
@@ -640,6 +640,12 @@ var SourceMasker = class {
|
|
|
640
640
|
|
|
641
641
|
// src/rules.ts
|
|
642
642
|
var RULE_GUIDES = {
|
|
643
|
+
"test-focus": {
|
|
644
|
+
title: "Focused test narrows the test run",
|
|
645
|
+
why: "An exclusive test or suite can leave other tests unexecuted while the selected tests pass.",
|
|
646
|
+
suggestion: "Remove .only (or replace fit/fdescribe with it/describe) and run the full suite. Enable your test runner's forbid-only setting in CI when available.",
|
|
647
|
+
example: 'test.only("auth", ...) \u2192 test("auth", ...), then run the full suite'
|
|
648
|
+
},
|
|
643
649
|
"test-skip": {
|
|
644
650
|
title: "Skipped or disabled test",
|
|
645
651
|
why: "The test runner may report success without executing this behavior check.",
|
|
@@ -689,12 +695,13 @@ var VALID_DETECTORS = [
|
|
|
689
695
|
"swallowed-error",
|
|
690
696
|
"panic-loop",
|
|
691
697
|
"trivial-assert",
|
|
692
|
-
"stub-abandonment"
|
|
698
|
+
"stub-abandonment",
|
|
699
|
+
"test-focus"
|
|
693
700
|
];
|
|
694
701
|
|
|
695
702
|
// src/config.ts
|
|
696
703
|
var DEFAULT_CONFIG = {
|
|
697
|
-
failOn: ["test-skip", "trivial-assert"],
|
|
704
|
+
failOn: ["test-skip", "trivial-assert", "test-focus"],
|
|
698
705
|
ignorePaths: [
|
|
699
706
|
"**/fixtures/**",
|
|
700
707
|
"**/__fixtures__/**",
|
|
@@ -709,6 +716,7 @@ var DEFAULT_CONFIG = {
|
|
|
709
716
|
rules: {
|
|
710
717
|
"test-skip": "error",
|
|
711
718
|
"trivial-assert": "error",
|
|
719
|
+
"test-focus": "error",
|
|
712
720
|
"swallowed-error": "warn",
|
|
713
721
|
"stub-abandonment": "warn",
|
|
714
722
|
"type-escape": "warn"
|
|
@@ -754,32 +762,36 @@ function validateConfig(value) {
|
|
|
754
762
|
}
|
|
755
763
|
}
|
|
756
764
|
}
|
|
765
|
+
function configFromText(text, source) {
|
|
766
|
+
try {
|
|
767
|
+
const parsed = JSON.parse(text);
|
|
768
|
+
validateConfig(parsed);
|
|
769
|
+
const rules = { ...DEFAULT_CONFIG.rules, ...parsed.rules };
|
|
770
|
+
const failOn = parsed.failOn ?? Object.entries(rules).filter(([, level]) => level === "error").map(([rule]) => rule);
|
|
771
|
+
return {
|
|
772
|
+
...DEFAULT_CONFIG,
|
|
773
|
+
...parsed,
|
|
774
|
+
failOn,
|
|
775
|
+
ignorePaths: [.../* @__PURE__ */ new Set([...DEFAULT_CONFIG.ignorePaths || [], ...parsed.ignorePaths || []])],
|
|
776
|
+
rules
|
|
777
|
+
};
|
|
778
|
+
} catch (error) {
|
|
779
|
+
throw new ConfigError(`Invalid ${source}: ${error instanceof Error ? error.message : String(error)}`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
757
782
|
function loadConfig(cwd = process.cwd()) {
|
|
758
783
|
for (const filename of CONFIG_FILENAMES) {
|
|
759
784
|
const filePath = path.join(cwd, filename);
|
|
760
|
-
if (
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
...DEFAULT_CONFIG,
|
|
768
|
-
...parsed,
|
|
769
|
-
failOn,
|
|
770
|
-
ignorePaths: [.../* @__PURE__ */ new Set([...DEFAULT_CONFIG.ignorePaths || [], ...parsed.ignorePaths || []])],
|
|
771
|
-
rules
|
|
772
|
-
};
|
|
773
|
-
} catch (err) {
|
|
774
|
-
throw new ConfigError(`Invalid ${filename}: ${err instanceof Error ? err.message : String(err)}`);
|
|
785
|
+
if (fs.existsSync(filePath)) {
|
|
786
|
+
try {
|
|
787
|
+
return configFromText(fs.readFileSync(filePath, "utf8"), filename);
|
|
788
|
+
} catch (error) {
|
|
789
|
+
if (error instanceof ConfigError) throw error;
|
|
790
|
+
throw new ConfigError(`Cannot read ${filename}: ${error instanceof Error ? error.message : String(error)}`);
|
|
791
|
+
}
|
|
775
792
|
}
|
|
776
793
|
}
|
|
777
|
-
return {
|
|
778
|
-
...DEFAULT_CONFIG,
|
|
779
|
-
failOn: [...DEFAULT_CONFIG.failOn || []],
|
|
780
|
-
ignorePaths: [...DEFAULT_CONFIG.ignorePaths || []],
|
|
781
|
-
rules: { ...DEFAULT_CONFIG.rules }
|
|
782
|
-
};
|
|
794
|
+
return configFromText("{}", "default configuration");
|
|
783
795
|
}
|
|
784
796
|
function matchIgnorePath(filePath, ignorePatterns) {
|
|
785
797
|
if (!ignorePatterns || ignorePatterns.length === 0) {
|
|
@@ -836,6 +848,7 @@ var TEST_SKIP_PATTERNS = [
|
|
|
836
848
|
{ regex: /\bpytest\.skip\(/, name: "imperative pytest skip (pytest.skip)" },
|
|
837
849
|
{ regex: /\btest\.todo\(/, name: "todo placeholder test" }
|
|
838
850
|
];
|
|
851
|
+
var FOCUSED_TEST = /(?<![\w$.])(?:it|test|describe|suite)(?:\s*\.\s*(?:concurrent|serial|parallel|describe))*\s*\.\s*only\b(?=\s*(?:\(|\.\s*(?:each|failing|concurrent|serial|parallel)\b))|(?<![\w$.])(?:fit|fdescribe)\b(?=\s*(?:\(|\.\s*each\b))/g;
|
|
839
852
|
var COMMENT_DIRECTIVE_PATTERNS = [
|
|
840
853
|
{ regex: /^\s*\/\/\s*@ts-ignore\b/, name: "compiler ignore (@ts-ignore)" },
|
|
841
854
|
{ regex: /^\s*\/\/\s*@ts-expect-error\b/, name: "compiler suppression (@ts-expect-error)" },
|
|
@@ -875,6 +888,8 @@ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
|
875
888
|
".jsx",
|
|
876
889
|
".mjs",
|
|
877
890
|
".cjs",
|
|
891
|
+
".mts",
|
|
892
|
+
".cts",
|
|
878
893
|
".py",
|
|
879
894
|
".go",
|
|
880
895
|
".rs",
|
|
@@ -1020,6 +1035,35 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1020
1035
|
}
|
|
1021
1036
|
}
|
|
1022
1037
|
}
|
|
1038
|
+
if (/\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file.path)) {
|
|
1039
|
+
let source = "";
|
|
1040
|
+
const offsets = [];
|
|
1041
|
+
for (let i = 0; i < maskedLines.length; i++) {
|
|
1042
|
+
if (i) source += maskedLines[i].number === maskedLines[i - 1].number + 1 ? "\n" : "\0";
|
|
1043
|
+
offsets.push(source.length);
|
|
1044
|
+
source += maskedLines[i].code;
|
|
1045
|
+
}
|
|
1046
|
+
let row = 0;
|
|
1047
|
+
for (const match of source.matchAll(FOCUSED_TEST)) {
|
|
1048
|
+
let previous = match.index - 1;
|
|
1049
|
+
while (previous >= 0 && /\s/.test(source[previous])) previous--;
|
|
1050
|
+
if (source[previous] === ".") continue;
|
|
1051
|
+
if (/^(?:fit|fdescribe)$/.test(match[0]) && !isTestFile(file.path)) continue;
|
|
1052
|
+
if (/\bfunction$/.test(source.slice(Math.max(0, previous - 8), previous + 1))) continue;
|
|
1053
|
+
while (row + 1 < offsets.length && offsets[row + 1] <= match.index) row++;
|
|
1054
|
+
let end = row;
|
|
1055
|
+
while (end + 1 < offsets.length && offsets[end + 1] < match.index + match[0].length) end++;
|
|
1056
|
+
infractions.push({
|
|
1057
|
+
type: "test-focus",
|
|
1058
|
+
file: file.path,
|
|
1059
|
+
lineNumber: maskedLines[row].number,
|
|
1060
|
+
lineSnippet: maskedLines.slice(row, end + 1).map((line) => line.raw.trim()).join(" "),
|
|
1061
|
+
commitHash,
|
|
1062
|
+
commitDate,
|
|
1063
|
+
reason: "focused test or suite excludes other tests from the run"
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1023
1067
|
for (let index = 0; index < maskedLines.length; index++) {
|
|
1024
1068
|
const start = maskedLines[index];
|
|
1025
1069
|
const isCatch = /\bcatch\s*(?:\([^)]*\))?\s*\{\s*$/.test(start.code);
|
|
@@ -1063,6 +1107,7 @@ var MetricsCollector = class {
|
|
|
1063
1107
|
totalCommits = 0;
|
|
1064
1108
|
aiCommits = 0;
|
|
1065
1109
|
testSkips = [];
|
|
1110
|
+
focusedTests = [];
|
|
1066
1111
|
typeEscapes = [];
|
|
1067
1112
|
swallowedErrors = [];
|
|
1068
1113
|
panicLoops = [];
|
|
@@ -1169,6 +1214,7 @@ var MetricsCollector = class {
|
|
|
1169
1214
|
});
|
|
1170
1215
|
const buckets = {
|
|
1171
1216
|
"test-skip": this.testSkips,
|
|
1217
|
+
"test-focus": this.focusedTests,
|
|
1172
1218
|
"type-escape": this.typeEscapes,
|
|
1173
1219
|
"swallowed-error": this.swallowedErrors,
|
|
1174
1220
|
"trivial-assert": this.trivialAsserts,
|
|
@@ -1186,7 +1232,7 @@ var MetricsCollector = class {
|
|
|
1186
1232
|
this.flushPanicChain();
|
|
1187
1233
|
const linesSample = this.includeAll ? this.totalLinesAdded : this.aiLinesAdded;
|
|
1188
1234
|
const isSufficientData = linesSample >= MIN_SAMPLE_LOC;
|
|
1189
|
-
const codeInfractionPoints = this.testSkips.length * 15 + this.trivialAsserts.length * 15 + this.swallowedErrors.length * 10 + this.stubAbandonments.length * 8 + this.typeEscapes.length * 3;
|
|
1235
|
+
const codeInfractionPoints = this.testSkips.length * 15 + this.focusedTests.length * 15 + this.trivialAsserts.length * 15 + this.swallowedErrors.length * 10 + this.stubAbandonments.length * 8 + this.typeEscapes.length * 3;
|
|
1190
1236
|
let panicInfractionPoints = 0;
|
|
1191
1237
|
for (const loop of this.panicLoops) {
|
|
1192
1238
|
const countMatch = loop.reason.match(/chain of (\d+) quick fixes/);
|
|
@@ -1255,6 +1301,7 @@ var MetricsCollector = class {
|
|
|
1255
1301
|
totalCommits: this.totalCommits,
|
|
1256
1302
|
aiCommits: this.aiCommits,
|
|
1257
1303
|
testSkips: this.testSkips,
|
|
1304
|
+
focusedTests: this.focusedTests,
|
|
1258
1305
|
typeEscapes: this.typeEscapes,
|
|
1259
1306
|
swallowedErrors: this.swallowedErrors,
|
|
1260
1307
|
panicLoops: this.panicLoops,
|
|
@@ -1311,7 +1358,7 @@ function formatTerminalCard(summary, options) {
|
|
|
1311
1358
|
lines.push(` ${pc.dim(summary.archetypeDescription)}`);
|
|
1312
1359
|
lines.push("");
|
|
1313
1360
|
}
|
|
1314
|
-
const totalInfractions = (summary.testSkips?.length || 0) + (summary.typeEscapes?.length || 0) + (summary.swallowedErrors?.length || 0) + (summary.panicLoops?.length || 0) + (summary.trivialAsserts?.length || 0) + (summary.stubAbandonments?.length || 0);
|
|
1361
|
+
const totalInfractions = (summary.testSkips?.length || 0) + (summary.focusedTests?.length || 0) + (summary.typeEscapes?.length || 0) + (summary.swallowedErrors?.length || 0) + (summary.panicLoops?.length || 0) + (summary.trivialAsserts?.length || 0) + (summary.stubAbandonments?.length || 0);
|
|
1315
1362
|
lines.push(pc.bold(` INFRACTIONS (${totalInfractions})`));
|
|
1316
1363
|
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"));
|
|
1317
1364
|
if (summary.panicLoops && summary.panicLoops.length > 0) {
|
|
@@ -1330,6 +1377,11 @@ function formatTerminalCard(summary, options) {
|
|
|
1330
1377
|
lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
|
|
1331
1378
|
}
|
|
1332
1379
|
}
|
|
1380
|
+
if (summary.focusedTests?.length) {
|
|
1381
|
+
lines.push(` ${pc.red("\u25CF")} ${pc.bold("test-focus")} (${summary.focusedTests.length}) ${pc.dim("exclusive tests narrow the test run")}`);
|
|
1382
|
+
const sample = summary.focusedTests[0];
|
|
1383
|
+
if (sample.file) lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
|
|
1384
|
+
}
|
|
1333
1385
|
if (summary.testSkips && summary.testSkips.length > 0) {
|
|
1334
1386
|
lines.push(
|
|
1335
1387
|
` ${pc.red("\u25CF")} ${pc.bold("test-skip")} (${summary.testSkips.length}) ${pc.dim("skipped or disabled tests")}`
|
|
@@ -1384,6 +1436,7 @@ function formatTerminalCard(summary, options) {
|
|
|
1384
1436
|
}
|
|
1385
1437
|
const allInfractions = [
|
|
1386
1438
|
...summary.testSkips || [],
|
|
1439
|
+
...summary.focusedTests || [],
|
|
1387
1440
|
...summary.trivialAsserts || [],
|
|
1388
1441
|
...summary.swallowedErrors || [],
|
|
1389
1442
|
...summary.stubAbandonments || [],
|
|
@@ -1623,6 +1676,60 @@ function formatCheckMarkdown(result) {
|
|
|
1623
1676
|
for (const skipped of result.skippedFiles) lines.push("", `Not scanned: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
|
|
1624
1677
|
return lines.join("\n");
|
|
1625
1678
|
}
|
|
1679
|
+
function formatReviewTerminal(result) {
|
|
1680
|
+
const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
|
|
1681
|
+
const lines = [
|
|
1682
|
+
"",
|
|
1683
|
+
pc.bold(`agent-roast \u2014 ${result.source === "index" ? "staged context review" : "context review"}: ${status}`),
|
|
1684
|
+
` ${result.mergeBaseSha.slice(0, 7)} \u2192 ${result.headSha.slice(0, result.source === "index" ? 18 : 7)} \xB7 policy: ${result.policySha === "EMPTY" ? "built-in first-commit defaults" : "base " + result.policySha.slice(0, 7)}`,
|
|
1685
|
+
` ${result.filesInspected} changed files compared \xB7 ${result.linesInspected} source lines inspected across both versions`,
|
|
1686
|
+
` ${result.infractions.length} new \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected`
|
|
1687
|
+
];
|
|
1688
|
+
if (result.policyChanges.length) lines.push(" Candidate policy changes were ignored; adopt them separately after review.");
|
|
1689
|
+
if (result.excludedFiles || result.unsupportedFiles) lines.push(` ${result.excludedFiles} excluded \xB7 ${result.unsupportedFiles} unsupported/non-source changes`);
|
|
1690
|
+
for (const finding of result.infractions.slice(0, 20)) {
|
|
1691
|
+
lines.push(
|
|
1692
|
+
"",
|
|
1693
|
+
` ${result.failedRules.includes(finding.type) ? "FAIL" : "WARN"} [${finding.type}] ${terminalText(finding.file || "unknown")}:${finding.lineNumber}`,
|
|
1694
|
+
` ${terminalText(finding.lineSnippet || finding.reason)}`,
|
|
1695
|
+
` Next: ${finding.suggestion || "Review this occurrence."}`
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
if (result.infractions.length > 20) lines.push(` ${result.infractions.length - 20} more new findings; use --format json for all results.`);
|
|
1699
|
+
for (const skipped of result.skippedFiles) lines.push(` NOT COMPARED ${terminalText(skipped.file)}: ${terminalText(skipped.reason)}`);
|
|
1700
|
+
lines.push("", !result.complete ? " INCOMPLETE: some changed source was not compared (exit 2)." : result.passed ? " PASSED: no new blocking findings." : ` BLOCKED: ${result.failedRules.join(", ")}.`, "");
|
|
1701
|
+
if (!result.filesInspected) lines.push(" No supported changed source files compared.", "");
|
|
1702
|
+
return lines.join("\n");
|
|
1703
|
+
}
|
|
1704
|
+
function formatReviewMarkdown(result) {
|
|
1705
|
+
const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
|
|
1706
|
+
const lines = [
|
|
1707
|
+
`## agent-roast ${result.source === "index" ? "staged context review" : "context review"}: ${status}`,
|
|
1708
|
+
"",
|
|
1709
|
+
`Compared ${result.filesInspected} changed files at \`${result.mergeBaseSha.slice(0, 7)}\` \u2192 \`${result.headSha.slice(0, result.source === "index" ? 18 : 7)}\`. Policy: \`${result.policySha === "EMPTY" ? "built-in first-commit defaults" : result.policySha.slice(0, 7)}\`.`,
|
|
1710
|
+
"",
|
|
1711
|
+
`**${result.infractions.length} new** \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected.`,
|
|
1712
|
+
""
|
|
1713
|
+
];
|
|
1714
|
+
if (result.policyChanges.length) lines.push("Candidate policy changes were ignored. This review uses the supplied base commit's policy.", "");
|
|
1715
|
+
lines.push("| New finding | Head location | Evidence | Next step |", "| --- | --- | --- | --- |");
|
|
1716
|
+
for (const finding of result.infractions) lines.push(`| ${finding.type} | ${markdownCell(`${finding.file}:${finding.lineNumber}`)} | ${markdownCell(finding.lineSnippet || finding.reason)} | ${markdownCell(finding.suggestion || "Review this occurrence.")} |`);
|
|
1717
|
+
if (result.removedFindings.length) {
|
|
1718
|
+
lines.push(
|
|
1719
|
+
"",
|
|
1720
|
+
"### No longer detected",
|
|
1721
|
+
"",
|
|
1722
|
+
"These patterns disappeared from the compared source. Deleting code is not proof of a behavioral fix.",
|
|
1723
|
+
"",
|
|
1724
|
+
"| Rule | Previous location | Previous evidence |",
|
|
1725
|
+
"| --- | --- | --- |"
|
|
1726
|
+
);
|
|
1727
|
+
for (const finding of result.removedFindings) lines.push(`| ${finding.type} | ${markdownCell(`${finding.file}:${finding.lineNumber}`)} | ${markdownCell(finding.lineSnippet || finding.reason)} |`);
|
|
1728
|
+
}
|
|
1729
|
+
for (const skipped of result.skippedFiles) lines.push("", `Not compared: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
|
|
1730
|
+
lines.push("", `Scope: changed supported files only; ${result.excludedFiles} excluded and ${result.unsupportedFiles} unsupported/non-source changes.`);
|
|
1731
|
+
return lines.join("\n");
|
|
1732
|
+
}
|
|
1626
1733
|
|
|
1627
1734
|
// src/init.ts
|
|
1628
1735
|
import * as fs2 from "fs";
|
|
@@ -1663,6 +1770,7 @@ jobs:
|
|
|
1663
1770
|
base: \${{ github.event.pull_request.base.sha }}
|
|
1664
1771
|
head: \${{ github.event.pull_request.head.sha }}
|
|
1665
1772
|
format: github
|
|
1773
|
+
mode: review
|
|
1666
1774
|
`;
|
|
1667
1775
|
var PRE_COMMIT_HOOK_TEMPLATE = `#!/bin/sh
|
|
1668
1776
|
# Run agent-roast pre-commit verification
|
|
@@ -1775,11 +1883,8 @@ function generateShieldsJson(score, label = "AI Discipline") {
|
|
|
1775
1883
|
};
|
|
1776
1884
|
}
|
|
1777
1885
|
|
|
1778
|
-
// src/
|
|
1779
|
-
import {
|
|
1780
|
-
import * as fs4 from "fs/promises";
|
|
1781
|
-
import { constants as constants2 } from "fs";
|
|
1782
|
-
import path4 from "path";
|
|
1886
|
+
// src/sarif.ts
|
|
1887
|
+
import { createRequire } from "module";
|
|
1783
1888
|
|
|
1784
1889
|
// src/baseline.ts
|
|
1785
1890
|
import { createHash, randomUUID } from "crypto";
|
|
@@ -1873,7 +1978,79 @@ function writeBaseline(filename, findings) {
|
|
|
1873
1978
|
}
|
|
1874
1979
|
}
|
|
1875
1980
|
|
|
1981
|
+
// src/sarif.ts
|
|
1982
|
+
var require2 = createRequire(import.meta.url);
|
|
1983
|
+
var version = require2("../package.json").version;
|
|
1984
|
+
function formatSarif(report) {
|
|
1985
|
+
const occurrences = /* @__PURE__ */ new Map();
|
|
1986
|
+
const results = report.infractions.map((finding) => {
|
|
1987
|
+
const filename = finding.file;
|
|
1988
|
+
if (!filename || filename.startsWith("/") || filename.split("/").includes("..") || /^[a-z]:/i.test(filename)) {
|
|
1989
|
+
throw new ConfigError("SARIF needs repository-relative finding paths.");
|
|
1990
|
+
}
|
|
1991
|
+
const fingerprint = findingFingerprint(finding);
|
|
1992
|
+
const occurrence = (occurrences.get(fingerprint) || 0) + 1;
|
|
1993
|
+
occurrences.set(fingerprint, occurrence);
|
|
1994
|
+
return {
|
|
1995
|
+
ruleId: finding.type,
|
|
1996
|
+
ruleIndex: VALID_DETECTORS.indexOf(finding.type),
|
|
1997
|
+
level: report.failedRules.includes(finding.type) ? "error" : "warning",
|
|
1998
|
+
message: { text: `${finding.reason}. ${finding.suggestion || RULE_GUIDES[finding.type].suggestion}` },
|
|
1999
|
+
locations: [{ physicalLocation: {
|
|
2000
|
+
artifactLocation: { uri: filename.split("/").map((segment) => encodeURIComponent(segment)).join("/") },
|
|
2001
|
+
region: { startLine: Math.max(1, finding.lineNumber || 1) }
|
|
2002
|
+
} }],
|
|
2003
|
+
partialFingerprints: { "agentRoastFinding/v1": `${fingerprint}:${occurrence}` },
|
|
2004
|
+
...report.mode === "review" ? { baselineState: "new" } : {}
|
|
2005
|
+
};
|
|
2006
|
+
});
|
|
2007
|
+
return JSON.stringify({
|
|
2008
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
2009
|
+
version: "2.1.0",
|
|
2010
|
+
runs: [{
|
|
2011
|
+
tool: { driver: {
|
|
2012
|
+
name: "agent-roast",
|
|
2013
|
+
semanticVersion: version,
|
|
2014
|
+
informationUri: "https://github.com/Nari-Ab/agent-roast",
|
|
2015
|
+
rules: VALID_DETECTORS.map((rule) => ({
|
|
2016
|
+
id: rule,
|
|
2017
|
+
name: rule,
|
|
2018
|
+
shortDescription: { text: RULE_GUIDES[rule].title },
|
|
2019
|
+
fullDescription: { text: RULE_GUIDES[rule].why },
|
|
2020
|
+
help: { text: `${RULE_GUIDES[rule].suggestion}
|
|
2021
|
+
Example: ${RULE_GUIDES[rule].example}` },
|
|
2022
|
+
defaultConfiguration: { level: "warning" },
|
|
2023
|
+
properties: { tags: ["maintainability", "code-review"] }
|
|
2024
|
+
}))
|
|
2025
|
+
} },
|
|
2026
|
+
invocations: [{
|
|
2027
|
+
executionSuccessful: report.complete,
|
|
2028
|
+
exitCode: report.exitCode,
|
|
2029
|
+
toolExecutionNotifications: report.skippedFiles.map((skipped) => ({
|
|
2030
|
+
level: "error",
|
|
2031
|
+
message: { text: `Not inspected: ${skipped.file}. ${skipped.reason}` }
|
|
2032
|
+
}))
|
|
2033
|
+
}],
|
|
2034
|
+
results,
|
|
2035
|
+
properties: report.mode === "review" ? {
|
|
2036
|
+
scope: "introduced findings in changed files",
|
|
2037
|
+
source: report.source ?? "commits",
|
|
2038
|
+
baseSha: report.baseSha,
|
|
2039
|
+
headSha: report.headSha,
|
|
2040
|
+
mergeBaseSha: report.mergeBaseSha,
|
|
2041
|
+
policySha: report.policySha,
|
|
2042
|
+
unchangedFindings: report.unchangedCount,
|
|
2043
|
+
removedFindings: report.removedFindings.length
|
|
2044
|
+
} : { scope: "current source", acceptedBaselineFindings: report.baselineCount }
|
|
2045
|
+
}]
|
|
2046
|
+
}, null, 2);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
1876
2049
|
// src/check.ts
|
|
2050
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
2051
|
+
import * as fs4 from "fs/promises";
|
|
2052
|
+
import { constants as constants2 } from "fs";
|
|
2053
|
+
import path4 from "path";
|
|
1877
2054
|
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
1878
2055
|
var MAX_SCAN_BYTES = 50 * 1024 * 1024;
|
|
1879
2056
|
var GENERATED_PATHS = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
|
|
@@ -1936,10 +2113,10 @@ async function checkProject(options = {}) {
|
|
|
1936
2113
|
const cwd = path4.resolve(options.cwd || process.cwd());
|
|
1937
2114
|
const root = await fs4.realpath(git(cwd, ["rev-parse", "--show-toplevel"]).trim());
|
|
1938
2115
|
const config = loadConfig(root);
|
|
1939
|
-
const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert"];
|
|
2116
|
+
const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert", "test-focus"];
|
|
1940
2117
|
for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown rule '${rule}'.`);
|
|
1941
2118
|
const format = options.format || "terminal";
|
|
1942
|
-
if (!["terminal", "json", "github", "markdown"].includes(format)) throw new ConfigError("Unsupported check output format.");
|
|
2119
|
+
if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported check output format.");
|
|
1943
2120
|
const baseline = options.baseline ? loadBaseline(path4.resolve(cwd, options.baseline)) : void 0;
|
|
1944
2121
|
const filenames = [...new Set(git(root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]).split("\0").filter(Boolean))].sort();
|
|
1945
2122
|
const infractions = [];
|
|
@@ -1992,6 +2169,7 @@ async function checkProject(options = {}) {
|
|
|
1992
2169
|
output: ""
|
|
1993
2170
|
};
|
|
1994
2171
|
if (format === "json") result.output = JSON.stringify(result, null, 2);
|
|
2172
|
+
else if (format === "sarif") result.output = formatSarif(result);
|
|
1995
2173
|
else if (format === "markdown") result.output = formatCheckMarkdown(result);
|
|
1996
2174
|
else if (format === "github") {
|
|
1997
2175
|
result.output = formatGithubAnnotations(result) + (complete ? "" : "\n::error::Project scan incomplete. See the JSON or terminal report for skipped files.");
|
|
@@ -1999,6 +2177,315 @@ async function checkProject(options = {}) {
|
|
|
1999
2177
|
return result;
|
|
2000
2178
|
}
|
|
2001
2179
|
|
|
2180
|
+
// src/review.ts
|
|
2181
|
+
import path5 from "path";
|
|
2182
|
+
|
|
2183
|
+
// src/snapshot.ts
|
|
2184
|
+
import { createHash as createHash2 } from "crypto";
|
|
2185
|
+
import { spawn as spawn2 } from "child_process";
|
|
2186
|
+
function assertNotAborted(signal) {
|
|
2187
|
+
if (signal?.aborted) throw new ConfigError("Review cancelled; no complete result was produced.");
|
|
2188
|
+
}
|
|
2189
|
+
async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024) {
|
|
2190
|
+
assertNotAborted(signal);
|
|
2191
|
+
const env = getHermeticGitEnv({ GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0" });
|
|
2192
|
+
for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_NAMESPACE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"]) delete env[key];
|
|
2193
|
+
return new Promise((resolve2, reject) => {
|
|
2194
|
+
const child = spawn2("git", [...HERMETIC_GIT_FLAGS, ...args], {
|
|
2195
|
+
cwd,
|
|
2196
|
+
env,
|
|
2197
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2198
|
+
windowsHide: true
|
|
2199
|
+
});
|
|
2200
|
+
const chunks = [];
|
|
2201
|
+
let bytes = 0;
|
|
2202
|
+
let stderr = "";
|
|
2203
|
+
let settled = false;
|
|
2204
|
+
const finish = (error) => {
|
|
2205
|
+
if (settled) return;
|
|
2206
|
+
settled = true;
|
|
2207
|
+
clearTimeout(timer);
|
|
2208
|
+
signal?.removeEventListener("abort", abort);
|
|
2209
|
+
if (error) {
|
|
2210
|
+
child.kill();
|
|
2211
|
+
reject(error);
|
|
2212
|
+
} else resolve2(Buffer.concat(chunks));
|
|
2213
|
+
};
|
|
2214
|
+
const abort = () => finish(new ConfigError("Review cancelled; no complete result was produced."));
|
|
2215
|
+
const timer = setTimeout(() => finish(new ConfigError("Git review read timed out after 30 seconds.")), 3e4);
|
|
2216
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
2217
|
+
child.stdout.on("data", (chunk) => {
|
|
2218
|
+
if (settled) return;
|
|
2219
|
+
bytes += chunk.length;
|
|
2220
|
+
if (bytes > limit) finish(new ConfigError(`Git read exceeds the ${limit} byte limit.`));
|
|
2221
|
+
else chunks.push(chunk);
|
|
2222
|
+
});
|
|
2223
|
+
child.stderr.on("data", (chunk) => {
|
|
2224
|
+
if (stderr.length < 64 * 1024) stderr += chunk.toString("utf8").slice(0, 64 * 1024 - stderr.length);
|
|
2225
|
+
});
|
|
2226
|
+
child.on("error", (error) => finish(error));
|
|
2227
|
+
child.on("close", (code) => finish(code === 0 ? void 0 : new GitError(stderr, code)));
|
|
2228
|
+
if (signal?.aborted) abort();
|
|
2229
|
+
});
|
|
2230
|
+
}
|
|
2231
|
+
async function requireLocalObjects(cwd, signal) {
|
|
2232
|
+
try {
|
|
2233
|
+
const promises = (await readGit(cwd, ["config", "--type=bool", "--get-regexp", "^remote\\..*\\.promisor$"], signal, 64 * 1024)).toString();
|
|
2234
|
+
if (promises.split("\n").some((line) => /\s+(?:true|yes|on|1)\s*$/i.test(line))) {
|
|
2235
|
+
throw new ConfigError("Offline review requires a non-partial clone with local objects; promisor remotes are unsupported.");
|
|
2236
|
+
}
|
|
2237
|
+
} catch (error) {
|
|
2238
|
+
if (!(error instanceof GitError && error.code === 1)) throw error;
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
async function reviewRefs(cwd, base, head, signal) {
|
|
2242
|
+
const resolve2 = async (ref) => {
|
|
2243
|
+
if (!ref.trim()) throw new ConfigError("Review refs must not be empty.");
|
|
2244
|
+
try {
|
|
2245
|
+
return (await readGit(cwd, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], signal, 1024)).toString().trim();
|
|
2246
|
+
} catch (error) {
|
|
2247
|
+
assertNotAborted(signal);
|
|
2248
|
+
throw new ConfigError(`Cannot resolve review ref '${ref}'. Use valid commits and fetch complete history (actions/checkout fetch-depth: 0). ${error instanceof Error ? error.message : String(error)}`);
|
|
2249
|
+
}
|
|
2250
|
+
};
|
|
2251
|
+
await requireLocalObjects(cwd, signal);
|
|
2252
|
+
const baseSha = await resolve2(base);
|
|
2253
|
+
const headSha = await resolve2(head);
|
|
2254
|
+
let ancestors;
|
|
2255
|
+
try {
|
|
2256
|
+
ancestors = (await readGit(cwd, ["merge-base", "--all", "--end-of-options", baseSha, headSha], signal, 8192)).toString().trim().split("\n");
|
|
2257
|
+
} catch (error) {
|
|
2258
|
+
assertNotAborted(signal);
|
|
2259
|
+
throw new ConfigError(`Cannot find review merge-base. Fetch full shared history (fetch-depth: 0). ${error instanceof Error ? error.message : String(error)}`);
|
|
2260
|
+
}
|
|
2261
|
+
if (ancestors.length !== 1 || !/^[a-f0-9]{40,64}$/.test(ancestors[0])) throw new ConfigError("Review needs exactly one merge base; ambiguous merge ancestry is unsupported.");
|
|
2262
|
+
return { baseSha, headSha, mergeBaseSha: ancestors[0] };
|
|
2263
|
+
}
|
|
2264
|
+
async function changedTreeFiles(cwd, before, after, signal) {
|
|
2265
|
+
const buffer = await readGit(cwd, ["diff", "--raw", "-z", "--no-relative", "--no-abbrev", "--no-ext-diff", "--no-textconv", "--find-renames=50%", "-l1000", "--end-of-options", before, after, "--"], signal);
|
|
2266
|
+
return parseTreeChanges(buffer);
|
|
2267
|
+
}
|
|
2268
|
+
function parseTreeChanges(buffer) {
|
|
2269
|
+
const tokens = new TextDecoder("utf-8", { fatal: true }).decode(buffer).split("\0");
|
|
2270
|
+
if (tokens.pop() !== "") throw new ConfigError("Truncated Git change inventory.");
|
|
2271
|
+
const changes = [];
|
|
2272
|
+
for (let i = 0; i < tokens.length; ) {
|
|
2273
|
+
const match = /^:(\d{6}) (\d{6}) ([a-f0-9]{40,64}) ([a-f0-9]{40,64}) ([AMDRT]\d*)$/.exec(tokens[i++]);
|
|
2274
|
+
if (!match) throw new ConfigError("Unsupported Git change inventory entry.");
|
|
2275
|
+
const oldPath = tokens[i++];
|
|
2276
|
+
const newPath = match[5].startsWith("R") ? tokens[i++] : oldPath;
|
|
2277
|
+
if (!oldPath || !newPath) throw new ConfigError("Incomplete Git path inventory.");
|
|
2278
|
+
changes.push({ oldMode: match[1], newMode: match[2], oldOid: match[3], newOid: match[4], status: match[5], oldPath, newPath });
|
|
2279
|
+
if (changes.length > 1e3) throw new ConfigError("Review exceeds the 1000 changed-file limit; split the change into smaller reviews.");
|
|
2280
|
+
}
|
|
2281
|
+
return changes;
|
|
2282
|
+
}
|
|
2283
|
+
async function readBlob(cwd, oid, signal, limit = 2 * 1024 * 1024) {
|
|
2284
|
+
if (!/^[a-f0-9]{40,64}$/.test(oid)) throw new ConfigError("Invalid Git blob identity.");
|
|
2285
|
+
const sizeText = (await readGit(cwd, ["cat-file", "-s", oid], signal, 128)).toString().trim();
|
|
2286
|
+
const size = Number(sizeText);
|
|
2287
|
+
if (!Number.isSafeInteger(size) || size < 0 || size > limit) throw new ConfigError(`Source blob exceeds the ${limit} byte limit or has an invalid size.`);
|
|
2288
|
+
const buffer = await readGit(cwd, ["cat-file", "blob", oid], signal, limit);
|
|
2289
|
+
if (buffer.length !== size) throw new ConfigError("Git blob size differs from the recorded object.");
|
|
2290
|
+
return buffer;
|
|
2291
|
+
}
|
|
2292
|
+
async function reviewPolicy(cwd, baseSha, signal) {
|
|
2293
|
+
const raw = await readGit(cwd, ["ls-tree", "--full-tree", "-z", baseSha, "--", ...CONFIG_FILENAMES], signal, 8192);
|
|
2294
|
+
const entries = raw.toString().split("\0").filter(Boolean);
|
|
2295
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
2296
|
+
const entry = entries.find((line) => line.slice(line.indexOf(" ") + 1) === filename);
|
|
2297
|
+
if (!entry) continue;
|
|
2298
|
+
const match = /^(100644|100755) blob ([a-f0-9]{40,64})\t/.exec(entry);
|
|
2299
|
+
if (!match) throw new ConfigError(`Base policy '${filename}' must be a regular Git blob.`);
|
|
2300
|
+
const content = await readBlob(cwd, match[2], signal, 64 * 1024);
|
|
2301
|
+
return { config: configFromText(new TextDecoder("utf-8", { fatal: true }).decode(content), `${filename} in base ${baseSha.slice(0, 7)}`), filename };
|
|
2302
|
+
}
|
|
2303
|
+
return { config: configFromText("{}", "default review policy"), filename: null };
|
|
2304
|
+
}
|
|
2305
|
+
async function stagedSnapshot(cwd, signal) {
|
|
2306
|
+
await requireLocalObjects(cwd, signal);
|
|
2307
|
+
const head = async () => {
|
|
2308
|
+
try {
|
|
2309
|
+
return (await readGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], signal, 1024)).toString().trim();
|
|
2310
|
+
} catch (error) {
|
|
2311
|
+
if (!(error instanceof GitError && error.code === 1)) throw error;
|
|
2312
|
+
const ref = (await readGit(cwd, ["symbolic-ref", "--quiet", "HEAD"], signal, 1024)).toString().trim();
|
|
2313
|
+
try {
|
|
2314
|
+
await readGit(cwd, ["show-ref", "--verify", "--quiet", ref], signal, 1024);
|
|
2315
|
+
} catch (missing) {
|
|
2316
|
+
if (missing instanceof GitError && missing.code === 1) return "EMPTY";
|
|
2317
|
+
throw missing;
|
|
2318
|
+
}
|
|
2319
|
+
throw new ConfigError("HEAD exists but cannot be resolved to a commit.");
|
|
2320
|
+
}
|
|
2321
|
+
};
|
|
2322
|
+
const baseSha = await head();
|
|
2323
|
+
const inventory = () => readGit(cwd, [
|
|
2324
|
+
"diff",
|
|
2325
|
+
"--cached",
|
|
2326
|
+
"--raw",
|
|
2327
|
+
"-z",
|
|
2328
|
+
"--no-relative",
|
|
2329
|
+
"--no-abbrev",
|
|
2330
|
+
"--no-ext-diff",
|
|
2331
|
+
"--no-textconv",
|
|
2332
|
+
"--ignore-submodules=none",
|
|
2333
|
+
"--ita-invisible-in-index",
|
|
2334
|
+
"--find-renames=50%",
|
|
2335
|
+
"-l1000",
|
|
2336
|
+
"--end-of-options",
|
|
2337
|
+
...baseSha === "EMPTY" ? [] : [baseSha],
|
|
2338
|
+
"--"
|
|
2339
|
+
], signal);
|
|
2340
|
+
const conflicts = await readGit(cwd, ["ls-files", "--unmerged", "-z", "--full-name"], signal);
|
|
2341
|
+
if (conflicts.length) throw new ConfigError("Resolve unmerged index entries before reviewing staged changes.");
|
|
2342
|
+
const pinned = await inventory();
|
|
2343
|
+
const changes = parseTreeChanges(pinned);
|
|
2344
|
+
const headSha = "INDEX:" + createHash2("sha256").update(baseSha).update(pinned).digest("hex");
|
|
2345
|
+
return {
|
|
2346
|
+
baseSha,
|
|
2347
|
+
headSha,
|
|
2348
|
+
mergeBaseSha: baseSha,
|
|
2349
|
+
changes,
|
|
2350
|
+
verify: async () => {
|
|
2351
|
+
assertNotAborted(signal);
|
|
2352
|
+
if (await head() !== baseSha || !(await inventory()).equals(pinned)) {
|
|
2353
|
+
throw new ConfigError("HEAD or staged changes changed during review; rerun before committing. No complete result was produced.");
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
};
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
// src/review.ts
|
|
2360
|
+
var GENERATED_PATHS2 = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
|
|
2361
|
+
var MAX_SCAN_BYTES2 = 50 * 1024 * 1024;
|
|
2362
|
+
function compareFindings(before, after, newPath) {
|
|
2363
|
+
const old = /* @__PURE__ */ new Map();
|
|
2364
|
+
for (const finding of before) {
|
|
2365
|
+
const key = findingFingerprint({ ...finding, file: newPath });
|
|
2366
|
+
const bucket = old.get(key) || { findings: [], matched: 0 };
|
|
2367
|
+
bucket.findings.push(finding);
|
|
2368
|
+
old.set(key, bucket);
|
|
2369
|
+
}
|
|
2370
|
+
const introduced = [];
|
|
2371
|
+
let unchanged = 0;
|
|
2372
|
+
for (const finding of after) {
|
|
2373
|
+
const bucket = old.get(findingFingerprint(finding));
|
|
2374
|
+
if (bucket && bucket.matched < bucket.findings.length) {
|
|
2375
|
+
bucket.matched++;
|
|
2376
|
+
unchanged++;
|
|
2377
|
+
} else introduced.push(finding);
|
|
2378
|
+
}
|
|
2379
|
+
return { introduced, removed: [...old.values()].flatMap((bucket) => bucket.findings.slice(bucket.matched)), unchanged };
|
|
2380
|
+
}
|
|
2381
|
+
function reviewChanges(options) {
|
|
2382
|
+
return compareSourceChanges({ ...options, staged: false });
|
|
2383
|
+
}
|
|
2384
|
+
function reviewStagedChanges(options = {}) {
|
|
2385
|
+
return compareSourceChanges({ ...options, staged: true });
|
|
2386
|
+
}
|
|
2387
|
+
async function compareSourceChanges(options) {
|
|
2388
|
+
assertNotAborted(options.signal);
|
|
2389
|
+
const cwd = path5.resolve(options.cwd || process.cwd());
|
|
2390
|
+
const staged = options.staged ? await stagedSnapshot(cwd, options.signal) : void 0;
|
|
2391
|
+
const refs = options.staged ? { baseSha: staged.baseSha, headSha: staged.headSha, mergeBaseSha: staged.mergeBaseSha } : await reviewRefs(cwd, options.base, options.head ?? "HEAD", options.signal);
|
|
2392
|
+
const policy = refs.baseSha === "EMPTY" ? { config: configFromText("{}", "default first-commit policy"), filename: null } : await reviewPolicy(cwd, refs.baseSha, options.signal);
|
|
2393
|
+
const format = options.format ?? "terminal";
|
|
2394
|
+
if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported review format.");
|
|
2395
|
+
const failOn = options.failOn ?? policy.config.failOn ?? ["test-skip", "trivial-assert", "test-focus"];
|
|
2396
|
+
for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown review rule '${rule}'.`);
|
|
2397
|
+
const changes = staged?.changes ?? await changedTreeFiles(cwd, refs.mergeBaseSha, refs.headSha, options.signal);
|
|
2398
|
+
const policyChanges = changes.filter((change) => CONFIG_FILENAMES.includes(change.oldPath) || CONFIG_FILENAMES.includes(change.newPath)).map((change) => change.newPath);
|
|
2399
|
+
const ignores = [...GENERATED_PATHS2, ...policy.config.ignorePaths || []];
|
|
2400
|
+
const result = {
|
|
2401
|
+
schemaVersion: "1.1.0",
|
|
2402
|
+
mode: "review",
|
|
2403
|
+
source: staged ? "index" : "commits",
|
|
2404
|
+
...refs,
|
|
2405
|
+
policySha: refs.baseSha,
|
|
2406
|
+
policyFile: policy.filename,
|
|
2407
|
+
policyChanges,
|
|
2408
|
+
filesChanged: changes.length,
|
|
2409
|
+
filesInspected: 0,
|
|
2410
|
+
unsupportedFiles: 0,
|
|
2411
|
+
excludedFiles: 0,
|
|
2412
|
+
renamedFiles: changes.filter((change) => change.status.startsWith("R")).length,
|
|
2413
|
+
linesInspected: 0,
|
|
2414
|
+
beforeFindings: 0,
|
|
2415
|
+
afterFindings: 0,
|
|
2416
|
+
unchangedCount: 0,
|
|
2417
|
+
infractions: [],
|
|
2418
|
+
removedFindings: [],
|
|
2419
|
+
skippedFiles: [],
|
|
2420
|
+
complete: true,
|
|
2421
|
+
failedRules: [],
|
|
2422
|
+
passed: false,
|
|
2423
|
+
exitCode: 2,
|
|
2424
|
+
output: ""
|
|
2425
|
+
};
|
|
2426
|
+
let totalBytes = 0;
|
|
2427
|
+
for (const change of changes) {
|
|
2428
|
+
assertNotAborted(options.signal);
|
|
2429
|
+
const oldSupported = change.oldMode !== "000000" && isCodeFile(change.oldPath);
|
|
2430
|
+
const newSupported = change.newMode !== "000000" && isCodeFile(change.newPath);
|
|
2431
|
+
if (!oldSupported && !newSupported) {
|
|
2432
|
+
result.unsupportedFiles++;
|
|
2433
|
+
continue;
|
|
2434
|
+
}
|
|
2435
|
+
const oldInScope = oldSupported && isCodeFile(change.oldPath, ignores);
|
|
2436
|
+
const newInScope = newSupported && isCodeFile(change.newPath, ignores);
|
|
2437
|
+
if (!oldInScope && !newInScope) {
|
|
2438
|
+
result.excludedFiles++;
|
|
2439
|
+
continue;
|
|
2440
|
+
}
|
|
2441
|
+
try {
|
|
2442
|
+
let inspectedLines = 0;
|
|
2443
|
+
const scan = async (mode, oid, filename, commit) => {
|
|
2444
|
+
if (mode === "000000") return [];
|
|
2445
|
+
if (!["100644", "100755"].includes(mode)) throw new ConfigError("Changed source is a symlink, submodule or unsupported Git mode; comparison is incomplete.");
|
|
2446
|
+
const content = await readBlob(cwd, oid, options.signal);
|
|
2447
|
+
totalBytes += content.length;
|
|
2448
|
+
if (totalBytes > MAX_SCAN_BYTES2) throw new ConfigError("Review exceeds the 50 MiB source comparison limit.");
|
|
2449
|
+
if (content.includes(0)) throw new ConfigError("Binary content in a source comparison.");
|
|
2450
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
2451
|
+
const lines = text.split(/\r?\n/);
|
|
2452
|
+
if (lines.at(-1) === "") lines.pop();
|
|
2453
|
+
inspectedLines += lines.length;
|
|
2454
|
+
return auditFileDiffs([{ path: filename, addedLines: lines.map((line, index) => ({ line, lineNumber: index + 1 })) }], commit, "", { rules: policy.config.rules }).infractions;
|
|
2455
|
+
};
|
|
2456
|
+
if (oldSupported && !newSupported && change.newMode !== "000000") {
|
|
2457
|
+
throw new ConfigError("Source moved to an unsupported extension; cannot claim its findings were resolved.");
|
|
2458
|
+
}
|
|
2459
|
+
const before = await scan(change.oldMode, change.oldOid, change.oldPath, refs.mergeBaseSha);
|
|
2460
|
+
const after = await scan(change.newMode, change.newOid, change.newPath, refs.headSha);
|
|
2461
|
+
const compared = compareFindings(before, after, change.newPath);
|
|
2462
|
+
result.infractions.push(...compared.introduced);
|
|
2463
|
+
result.removedFindings.push(...compared.removed);
|
|
2464
|
+
result.unchangedCount += compared.unchanged;
|
|
2465
|
+
result.beforeFindings += before.length;
|
|
2466
|
+
result.afterFindings += after.length;
|
|
2467
|
+
result.filesInspected++;
|
|
2468
|
+
result.linesInspected += inspectedLines;
|
|
2469
|
+
} catch (error) {
|
|
2470
|
+
assertNotAborted(options.signal);
|
|
2471
|
+
result.skippedFiles.push({ file: change.newPath, reason: error instanceof Error ? error.message : String(error) });
|
|
2472
|
+
if (totalBytes > MAX_SCAN_BYTES2) break;
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
assertNotAborted(options.signal);
|
|
2476
|
+
await staged?.verify();
|
|
2477
|
+
result.complete = result.skippedFiles.length === 0;
|
|
2478
|
+
result.failedRules = [...new Set(result.infractions.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
|
|
2479
|
+
result.passed = result.complete && result.failedRules.length === 0;
|
|
2480
|
+
result.exitCode = !result.complete ? 2 : result.passed ? 0 : 1;
|
|
2481
|
+
if (format === "json") result.output = JSON.stringify(result, null, 2);
|
|
2482
|
+
else if (format === "markdown") result.output = formatReviewMarkdown(result);
|
|
2483
|
+
else if (format === "sarif") result.output = formatSarif(result);
|
|
2484
|
+
else if (format === "github") result.output = formatGithubAnnotations(result) + (!result.complete ? "\n::error::Context review incomplete. Inspect skipped source files in the JSON report." : "") + (policyChanges.length ? "\n::notice::Candidate policy changes were ignored; review uses policy from the supplied base commit." : "");
|
|
2485
|
+
else result.output = formatReviewTerminal(result);
|
|
2486
|
+
return result;
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2002
2489
|
// src/index.ts
|
|
2003
2490
|
var InvalidDetectorError = class extends Error {
|
|
2004
2491
|
constructor(message) {
|
|
@@ -2007,9 +2494,9 @@ var InvalidDetectorError = class extends Error {
|
|
|
2007
2494
|
}
|
|
2008
2495
|
};
|
|
2009
2496
|
async function roastRepository(options = {}) {
|
|
2010
|
-
const cwd =
|
|
2497
|
+
const cwd = path6.resolve(options.cwd || process.cwd());
|
|
2011
2498
|
const since = options.since || "90 days ago";
|
|
2012
|
-
const repoName =
|
|
2499
|
+
const repoName = path6.basename(cwd);
|
|
2013
2500
|
const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths, rules: options.rules, panicLoopWindowMinutes: options.panicLoopWindowMinutes });
|
|
2014
2501
|
const gitStream = streamGitLog(cwd, since);
|
|
2015
2502
|
await parseGitLogStream(gitStream, (commit, stats) => {
|
|
@@ -2024,7 +2511,7 @@ async function roastRepository(options = {}) {
|
|
|
2024
2511
|
return { summary, output };
|
|
2025
2512
|
}
|
|
2026
2513
|
async function auditPullRequest(options) {
|
|
2027
|
-
const cwd =
|
|
2514
|
+
const cwd = path6.resolve(options.cwd || process.cwd());
|
|
2028
2515
|
const format = options.format || "terminal";
|
|
2029
2516
|
const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
|
|
2030
2517
|
for (const det of failOn) {
|
|
@@ -2109,7 +2596,9 @@ export {
|
|
|
2109
2596
|
RULE_GUIDES,
|
|
2110
2597
|
VALID_DETECTORS,
|
|
2111
2598
|
DEFAULT_CONFIG,
|
|
2599
|
+
CONFIG_FILENAMES,
|
|
2112
2600
|
ConfigError,
|
|
2601
|
+
configFromText,
|
|
2113
2602
|
loadConfig,
|
|
2114
2603
|
matchIgnorePath,
|
|
2115
2604
|
isCodeFile,
|
|
@@ -2125,6 +2614,8 @@ export {
|
|
|
2125
2614
|
formatPrMarkdownComment,
|
|
2126
2615
|
formatCheckTerminal,
|
|
2127
2616
|
formatCheckMarkdown,
|
|
2617
|
+
formatReviewTerminal,
|
|
2618
|
+
formatReviewMarkdown,
|
|
2128
2619
|
DEFAULT_CONFIG_TEMPLATE,
|
|
2129
2620
|
GITHUB_WORKFLOW_TEMPLATE,
|
|
2130
2621
|
PRE_COMMIT_HOOK_TEMPLATE,
|
|
@@ -2138,7 +2629,11 @@ export {
|
|
|
2138
2629
|
loadBaseline,
|
|
2139
2630
|
applyBaseline,
|
|
2140
2631
|
writeBaseline,
|
|
2632
|
+
formatSarif,
|
|
2141
2633
|
checkProject,
|
|
2634
|
+
compareFindings,
|
|
2635
|
+
reviewChanges,
|
|
2636
|
+
reviewStagedChanges,
|
|
2142
2637
|
InvalidDetectorError,
|
|
2143
2638
|
roastRepository,
|
|
2144
2639
|
auditPullRequest
|
package/dist/cli.js
CHANGED
|
@@ -9,14 +9,17 @@ import {
|
|
|
9
9
|
auditPullRequest,
|
|
10
10
|
checkProject,
|
|
11
11
|
formatGithubStepSummary,
|
|
12
|
+
formatReviewMarkdown,
|
|
12
13
|
generateShieldsJson,
|
|
13
14
|
generateShieldsMarkdown,
|
|
14
15
|
generateSvgBadge,
|
|
15
16
|
initProject,
|
|
16
17
|
loadConfig,
|
|
18
|
+
reviewChanges,
|
|
19
|
+
reviewStagedChanges,
|
|
17
20
|
roastRepository,
|
|
18
21
|
writeBaseline
|
|
19
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-YGHNTWHX.js";
|
|
20
23
|
|
|
21
24
|
// src/cli.ts
|
|
22
25
|
import { Command } from "commander";
|
|
@@ -25,8 +28,39 @@ import * as fs from "fs";
|
|
|
25
28
|
import * as path from "path";
|
|
26
29
|
var require2 = createRequire(import.meta.url);
|
|
27
30
|
var pkg = require2("../package.json");
|
|
31
|
+
async function runContextReview(repoPath, options, staged) {
|
|
32
|
+
const controller = new AbortController();
|
|
33
|
+
const abort = () => controller.abort();
|
|
34
|
+
process.once("SIGINT", abort);
|
|
35
|
+
process.once("SIGTERM", abort);
|
|
36
|
+
try {
|
|
37
|
+
const common = {
|
|
38
|
+
cwd: repoPath,
|
|
39
|
+
failOn: options.failOn !== void 0 ? options.failOn.split(",").map((rule) => rule.trim()).filter(Boolean) : void 0,
|
|
40
|
+
format: options.json ? "json" : options.format,
|
|
41
|
+
signal: controller.signal
|
|
42
|
+
};
|
|
43
|
+
const result = staged ? await reviewStagedChanges(common) : await reviewChanges({ ...common, base: options.base, head: options.head });
|
|
44
|
+
if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, formatReviewMarkdown(result) + "\n");
|
|
45
|
+
if (process.env.GITHUB_OUTPUT) fs.appendFileSync(
|
|
46
|
+
process.env.GITHUB_OUTPUT,
|
|
47
|
+
`passed=${result.passed}
|
|
48
|
+
infractions-count=${result.infractions.length}
|
|
49
|
+
has-supported-changes=${result.filesInspected > 0}
|
|
50
|
+
complete=${result.complete}
|
|
51
|
+
existing-count=${result.unchangedCount}
|
|
52
|
+
removed-count=${result.removedFindings.length}
|
|
53
|
+
`
|
|
54
|
+
);
|
|
55
|
+
console.log(result.output);
|
|
56
|
+
process.exitCode = result.exitCode;
|
|
57
|
+
} finally {
|
|
58
|
+
process.removeListener("SIGINT", abort);
|
|
59
|
+
process.removeListener("SIGTERM", abort);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
28
62
|
var program = new Command();
|
|
29
|
-
program.name("agent-roast").description("Audit your git history or PR diffs for AI coding shortcuts and panic loops").version(pkg.version || "0.4.0").argument("[path]", "Path to git repository (default: current directory)", ".").option("-a, --all", "Audit ALL commits regardless of AI attribution tags", false).option("-s, --since <time>", "Time window for git analysis", "90 days ago").option("-v, --verbose", "Show commit SHAs and line snippets for all infractions", false).option("--json", "Output results as raw JSON", false).option("--staged", "
|
|
63
|
+
program.name("agent-roast").description("Audit your git history or PR diffs for AI coding shortcuts and panic loops").version(pkg.version || "0.4.0").argument("[path]", "Path to git repository (default: current directory)", ".").option("-a, --all", "Audit ALL commits regardless of AI attribution tags", false).option("-s, --since <time>", "Time window for git analysis", "90 days ago").option("-v, --verbose", "Show commit SHAs and line snippets for all infractions", false).option("--json", "Output results as raw JSON", false).option("--staged", "Compare full HEAD/index source and gate new staged findings (including the first commit)", false).option("--base <ref>", "Base git ref/branch for PR mode audit (e.g. origin/main)").option("--head <ref>", "Head git ref/SHA for PR mode audit (default: HEAD)", "HEAD").option("--fail-on <rules>", "Comma-separated detectors that trigger CI failure").option("--fail-under <score>", "Fail CI if agent score is below threshold (0-100)").option("--format <format>", "Output format: terminal, json, github, markdown (check/review also support sarif)", "terminal").option("--authors", "Display author names and emails in report", false).action(async (repoPath, options) => {
|
|
30
64
|
try {
|
|
31
65
|
if (process.env.GITHUB_EVENT_NAME === "pull_request_target") {
|
|
32
66
|
console.warn(
|
|
@@ -34,6 +68,13 @@ program.name("agent-roast").description("Audit your git history or PR diffs for
|
|
|
34
68
|
);
|
|
35
69
|
}
|
|
36
70
|
if (options.staged && options.base) throw new ConfigError("--staged cannot be combined with --base.");
|
|
71
|
+
if (options.staged) {
|
|
72
|
+
if (options.all || options.failUnder !== void 0 || program.getOptionValueSource("since") === "cli" || program.getOptionValueSource("head") === "cli") {
|
|
73
|
+
throw new ConfigError("--staged compares HEAD and index; --all, --since, --head and --fail-under do not apply.");
|
|
74
|
+
}
|
|
75
|
+
await runContextReview(repoPath, options, true);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
37
78
|
if (!["terminal", "json", "github", "markdown"].includes(options.format)) {
|
|
38
79
|
throw new ConfigError("--format must be terminal, json, github, or markdown.");
|
|
39
80
|
}
|
|
@@ -120,7 +161,22 @@ has-supported-changes=${prResult.hasSupportedChanges}
|
|
|
120
161
|
}
|
|
121
162
|
}
|
|
122
163
|
});
|
|
123
|
-
program.command("
|
|
164
|
+
program.command("review").description("Compare full changed source at merge-base and head; gate only new findings").argument("[path]", "Path to the Git repository", ".").addHelpText("after", "\nRequired: --base <trusted-ref> Optional: --head <ref> (default HEAD), or use --staged for HEAD/index\nOutput: --format terminal|json|github|markdown|sarif, --json, --fail-on <rules>\nExample: agent-roast review --base origin/main --format markdown").action(async (repoPath) => {
|
|
165
|
+
try {
|
|
166
|
+
const options = program.opts();
|
|
167
|
+
if (options.staged ? options.base || program.getOptionValueSource("head") === "cli" : !options.base) {
|
|
168
|
+
throw new ConfigError("Use review --base <trusted-ref> or review --staged; --base/--head cannot be combined with --staged.");
|
|
169
|
+
}
|
|
170
|
+
if (options.all || options.failUnder !== void 0 || program.getOptionValueSource("since") === "cli") {
|
|
171
|
+
throw new ConfigError("review compares source snapshots; --all, --since and --fail-under do not apply.");
|
|
172
|
+
}
|
|
173
|
+
await runContextReview(repoPath, options, options.staged);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
console.error(`agent-roast review error: ${error instanceof Error ? error.message : String(error)}`);
|
|
176
|
+
process.exitCode = 2;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
program.command("check").description("Check current tracked and non-ignored untracked source files").argument("[path]", "Path to a git repository", ".").option("--baseline <file>", "Report only findings not accepted in this baseline").option("--write-baseline <file>", "Save current findings for adoption (refuses to overwrite)").addHelpText("after", "\nOutput options: --format terminal|json|github|markdown|sarif, --json, --fail-on <rules>\nExamples:\n agent-roast check\n agent-roast check --write-baseline .agentroast-baseline.json\n agent-roast check --baseline .agentroast-baseline.json --format json").action(async (repoPath, opts) => {
|
|
124
180
|
try {
|
|
125
181
|
const global = program.opts();
|
|
126
182
|
if (global.base || global.staged || global.failUnder !== void 0 || global.all || program.getOptionValueSource("head") === "cli" || program.getOptionValueSource("since") === "cli") {
|
package/dist/index.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ interface CommitInfo {
|
|
|
9
9
|
isAiAttributed: boolean;
|
|
10
10
|
aiSignatures: string[];
|
|
11
11
|
}
|
|
12
|
-
type DetectorType = "test-skip" | "type-escape" | "swallowed-error" | "panic-loop" | "trivial-assert" | "stub-abandonment";
|
|
12
|
+
type DetectorType = "test-skip" | "type-escape" | "swallowed-error" | "panic-loop" | "trivial-assert" | "stub-abandonment" | "test-focus";
|
|
13
13
|
declare const VALID_DETECTORS: readonly DetectorType[];
|
|
14
14
|
interface Infraction {
|
|
15
15
|
type: DetectorType;
|
|
@@ -28,6 +28,7 @@ interface MetricSummary {
|
|
|
28
28
|
totalCommits: number;
|
|
29
29
|
aiCommits: number;
|
|
30
30
|
testSkips: Infraction[];
|
|
31
|
+
focusedTests?: Infraction[];
|
|
31
32
|
typeEscapes: Infraction[];
|
|
32
33
|
swallowedErrors: Infraction[];
|
|
33
34
|
panicLoops: Infraction[];
|
|
@@ -86,7 +87,7 @@ interface PrAuditResult {
|
|
|
86
87
|
interface CheckOptions {
|
|
87
88
|
cwd?: string;
|
|
88
89
|
failOn?: string[];
|
|
89
|
-
format?:
|
|
90
|
+
format?: ReviewFormat;
|
|
90
91
|
baseline?: string;
|
|
91
92
|
signal?: AbortSignal;
|
|
92
93
|
}
|
|
@@ -109,6 +110,47 @@ interface CheckResult {
|
|
|
109
110
|
exitCode: number;
|
|
110
111
|
output: string;
|
|
111
112
|
}
|
|
113
|
+
type ReviewFormat = "terminal" | "json" | "github" | "markdown" | "sarif";
|
|
114
|
+
interface ReviewOptions {
|
|
115
|
+
cwd?: string;
|
|
116
|
+
base: string;
|
|
117
|
+
head?: string;
|
|
118
|
+
failOn?: string[];
|
|
119
|
+
format?: ReviewFormat;
|
|
120
|
+
signal?: AbortSignal;
|
|
121
|
+
}
|
|
122
|
+
type StagedReviewOptions = Omit<ReviewOptions, "base" | "head">;
|
|
123
|
+
interface ReviewResult {
|
|
124
|
+
schemaVersion: string;
|
|
125
|
+
mode: "review";
|
|
126
|
+
source?: "commits" | "index";
|
|
127
|
+
baseSha: string;
|
|
128
|
+
headSha: string;
|
|
129
|
+
mergeBaseSha: string;
|
|
130
|
+
policySha: string;
|
|
131
|
+
policyFile: string | null;
|
|
132
|
+
policyChanges: string[];
|
|
133
|
+
filesChanged: number;
|
|
134
|
+
filesInspected: number;
|
|
135
|
+
unsupportedFiles: number;
|
|
136
|
+
excludedFiles: number;
|
|
137
|
+
renamedFiles: number;
|
|
138
|
+
linesInspected: number;
|
|
139
|
+
beforeFindings: number;
|
|
140
|
+
afterFindings: number;
|
|
141
|
+
unchangedCount: number;
|
|
142
|
+
infractions: Infraction[];
|
|
143
|
+
removedFindings: Infraction[];
|
|
144
|
+
skippedFiles: {
|
|
145
|
+
file: string;
|
|
146
|
+
reason: string;
|
|
147
|
+
}[];
|
|
148
|
+
complete: boolean;
|
|
149
|
+
failedRules: string[];
|
|
150
|
+
passed: boolean;
|
|
151
|
+
exitCode: number;
|
|
152
|
+
output: string;
|
|
153
|
+
}
|
|
112
154
|
|
|
113
155
|
interface AiAttributionResult {
|
|
114
156
|
isAiAttributed: boolean;
|
|
@@ -201,6 +243,7 @@ declare class MetricsCollector {
|
|
|
201
243
|
private totalCommits;
|
|
202
244
|
private aiCommits;
|
|
203
245
|
private testSkips;
|
|
246
|
+
private focusedTests;
|
|
204
247
|
private typeEscapes;
|
|
205
248
|
private swallowedErrors;
|
|
206
249
|
private panicLoops;
|
|
@@ -246,6 +289,8 @@ declare function formatPrMarkdownComment(result: PrAuditResult): string;
|
|
|
246
289
|
/** Current-tree report emphasizes next actions and explicitly accounts for baseline debt. */
|
|
247
290
|
declare function formatCheckTerminal(result: CheckResult): string;
|
|
248
291
|
declare function formatCheckMarkdown(result: CheckResult): string;
|
|
292
|
+
declare function formatReviewTerminal(result: ReviewResult): string;
|
|
293
|
+
declare function formatReviewMarkdown(result: ReviewResult): string;
|
|
249
294
|
|
|
250
295
|
interface AgentRoastConfig {
|
|
251
296
|
/** Detector rules to fail CI on in PR mode */
|
|
@@ -260,9 +305,12 @@ interface AgentRoastConfig {
|
|
|
260
305
|
panicLoopWindowMinutes?: number;
|
|
261
306
|
}
|
|
262
307
|
declare const DEFAULT_CONFIG: AgentRoastConfig;
|
|
308
|
+
declare const CONFIG_FILENAMES: string[];
|
|
263
309
|
declare class ConfigError extends Error {
|
|
264
310
|
constructor(message: string);
|
|
265
311
|
}
|
|
312
|
+
/** Parse one explicit policy source; shared by worktree and trusted Git reviews. */
|
|
313
|
+
declare function configFromText(text: string, source: string): AgentRoastConfig;
|
|
266
314
|
/** Invalid policy must stop the audit instead of silently changing CI behavior. */
|
|
267
315
|
declare function loadConfig(cwd?: string): AgentRoastConfig;
|
|
268
316
|
/**
|
|
@@ -287,7 +335,7 @@ declare const DEFAULT_CONFIG_TEMPLATE: {
|
|
|
287
335
|
};
|
|
288
336
|
scoreThreshold: number;
|
|
289
337
|
};
|
|
290
|
-
declare const GITHUB_WORKFLOW_TEMPLATE = "name: Agent Roast\non:\n pull_request:\n branches: [main, master]\n\npermissions:\n contents: read\n\njobs:\n audit:\n name: Audit AI Shortcuts\n runs-on: ubuntu-latest\n steps:\n - name: Checkout repository\n uses: actions/checkout@v4\n with:\n fetch-depth: 0\n\n - name: Run agent-roast PR check\n if: github.event_name == 'pull_request'\n uses: Nari-Ab/agent-roast@v1\n with:\n base: ${{ github.event.pull_request.base.sha }}\n head: ${{ github.event.pull_request.head.sha }}\n format: github\n";
|
|
338
|
+
declare const GITHUB_WORKFLOW_TEMPLATE = "name: Agent Roast\non:\n pull_request:\n branches: [main, master]\n\npermissions:\n contents: read\n\njobs:\n audit:\n name: Audit AI Shortcuts\n runs-on: ubuntu-latest\n steps:\n - name: Checkout repository\n uses: actions/checkout@v4\n with:\n fetch-depth: 0\n\n - name: Run agent-roast PR check\n if: github.event_name == 'pull_request'\n uses: Nari-Ab/agent-roast@v1\n with:\n base: ${{ github.event.pull_request.base.sha }}\n head: ${{ github.event.pull_request.head.sha }}\n format: github\n mode: review\n";
|
|
291
339
|
declare const PRE_COMMIT_HOOK_TEMPLATE = "#!/bin/sh\n# Run agent-roast pre-commit verification\nnpx --no-install agent-roast --staged\n";
|
|
292
340
|
declare function initProject(options?: InitOptions): Promise<InitResult>;
|
|
293
341
|
|
|
@@ -308,6 +356,21 @@ declare function generateShieldsJson(score: number | null, label?: string): Shie
|
|
|
308
356
|
/** Read current tracked and non-ignored untracked source; never run project code. */
|
|
309
357
|
declare function checkProject(options?: CheckOptions): Promise<CheckResult>;
|
|
310
358
|
|
|
359
|
+
/** Match only within the same changed-file pair; Git renames remap old paths.
|
|
360
|
+
* Counts matter: an identical second shortcut is still a new finding. */
|
|
361
|
+
declare function compareFindings(before: Infraction[], after: Infraction[], newPath: string): {
|
|
362
|
+
introduced: Infraction[];
|
|
363
|
+
removed: Infraction[];
|
|
364
|
+
unchanged: number;
|
|
365
|
+
};
|
|
366
|
+
/** Compare full source blobs in changed files without executing or checking out the candidate. */
|
|
367
|
+
declare function reviewChanges(options: ReviewOptions): Promise<ReviewResult>;
|
|
368
|
+
declare function reviewStagedChanges(options?: StagedReviewOptions): Promise<ReviewResult>;
|
|
369
|
+
|
|
370
|
+
/** Offline SARIF 2.1.0, with relative URIs and explicit incomplete-run status.
|
|
371
|
+
* Snippets are omitted to avoid embedding source code in integration artifacts. */
|
|
372
|
+
declare function formatSarif(report: CheckResult | ReviewResult): string;
|
|
373
|
+
|
|
311
374
|
interface FindingBaseline {
|
|
312
375
|
schemaVersion: "1.0.0";
|
|
313
376
|
fingerprintVersion: 1;
|
|
@@ -346,4 +409,4 @@ declare function roastRepository(options?: RoastOptions): Promise<{
|
|
|
346
409
|
}>;
|
|
347
410
|
declare function auditPullRequest(options: PrAuditOptions): Promise<PrAuditResult>;
|
|
348
411
|
|
|
349
|
-
export { type AgentRoastConfig, type AiAttributionResult, type CheckOptions, type CheckResult, type CommitInfo, ConfigError, DEFAULT_CONFIG, DEFAULT_CONFIG_TEMPLATE, type DetectorType, FIELD_SEPARATOR, type FindingBaseline, GITHUB_WORKFLOW_TEMPLATE, GitError, GitResolutionError, HERMETIC_GIT_FLAGS, type Infraction, type InitOptions, type InitResult, InvalidDetectorError, type MetricSummary, MetricsCollector, PRE_COMMIT_HOOK_TEMPLATE, type ParseDiffResult, type ParsedCommit, type ParsedDiffLine, type ParsedFileDiff, type PrAuditOptions, type PrAuditResult, RECORD_SEPARATOR, RULE_GUIDES, type RoastOptions, type RuleGuide, type ShieldsEndpointResponse, VALID_DETECTORS, applyBaseline, auditFileDiffs, auditPullRequest, checkProject, createBaseline, detectAiAttribution, findMergeBase, findingFingerprint, formatCheckMarkdown, formatCheckTerminal, formatGithubAnnotations, formatGithubStepSummary, formatPrMarkdownComment, formatPrTerminalCard, formatTerminalCard, generateShareUrl, generateShieldsJson, generateShieldsMarkdown, generateSvgBadge, getHermeticGitEnv, getScoreColor, initProject, isCodeFile, isTestFile, loadBaseline, loadConfig, matchIgnorePath, parseDiff, parseGitLogStream, resolveCommitSha, roastRepository, streamGitDiff, streamGitLog, stripStringAndRegexLiterals, unquoteGitPath, writeBaseline };
|
|
412
|
+
export { type AgentRoastConfig, type AiAttributionResult, CONFIG_FILENAMES, type CheckOptions, type CheckResult, type CommitInfo, ConfigError, DEFAULT_CONFIG, DEFAULT_CONFIG_TEMPLATE, type DetectorType, FIELD_SEPARATOR, type FindingBaseline, GITHUB_WORKFLOW_TEMPLATE, GitError, GitResolutionError, HERMETIC_GIT_FLAGS, type Infraction, type InitOptions, type InitResult, InvalidDetectorError, type MetricSummary, MetricsCollector, PRE_COMMIT_HOOK_TEMPLATE, type ParseDiffResult, type ParsedCommit, type ParsedDiffLine, type ParsedFileDiff, type PrAuditOptions, type PrAuditResult, RECORD_SEPARATOR, RULE_GUIDES, type ReviewFormat, type ReviewOptions, type ReviewResult, type RoastOptions, type RuleGuide, type ShieldsEndpointResponse, type StagedReviewOptions, VALID_DETECTORS, applyBaseline, auditFileDiffs, auditPullRequest, checkProject, compareFindings, configFromText, createBaseline, detectAiAttribution, findMergeBase, findingFingerprint, formatCheckMarkdown, formatCheckTerminal, formatGithubAnnotations, formatGithubStepSummary, formatPrMarkdownComment, formatPrTerminalCard, formatReviewMarkdown, formatReviewTerminal, formatSarif, formatTerminalCard, generateShareUrl, generateShieldsJson, generateShieldsMarkdown, generateSvgBadge, getHermeticGitEnv, getScoreColor, initProject, isCodeFile, isTestFile, loadBaseline, loadConfig, matchIgnorePath, parseDiff, parseGitLogStream, resolveCommitSha, reviewChanges, reviewStagedChanges, roastRepository, streamGitDiff, streamGitLog, stripStringAndRegexLiterals, unquoteGitPath, writeBaseline };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
CONFIG_FILENAMES,
|
|
3
4
|
ConfigError,
|
|
4
5
|
DEFAULT_CONFIG,
|
|
5
6
|
DEFAULT_CONFIG_TEMPLATE,
|
|
@@ -18,6 +19,8 @@ import {
|
|
|
18
19
|
auditFileDiffs,
|
|
19
20
|
auditPullRequest,
|
|
20
21
|
checkProject,
|
|
22
|
+
compareFindings,
|
|
23
|
+
configFromText,
|
|
21
24
|
createBaseline,
|
|
22
25
|
detectAiAttribution,
|
|
23
26
|
findMergeBase,
|
|
@@ -28,6 +31,9 @@ import {
|
|
|
28
31
|
formatGithubStepSummary,
|
|
29
32
|
formatPrMarkdownComment,
|
|
30
33
|
formatPrTerminalCard,
|
|
34
|
+
formatReviewMarkdown,
|
|
35
|
+
formatReviewTerminal,
|
|
36
|
+
formatSarif,
|
|
31
37
|
formatTerminalCard,
|
|
32
38
|
generateShareUrl,
|
|
33
39
|
generateShieldsJson,
|
|
@@ -44,14 +50,17 @@ import {
|
|
|
44
50
|
parseDiff,
|
|
45
51
|
parseGitLogStream,
|
|
46
52
|
resolveCommitSha,
|
|
53
|
+
reviewChanges,
|
|
54
|
+
reviewStagedChanges,
|
|
47
55
|
roastRepository,
|
|
48
56
|
streamGitDiff,
|
|
49
57
|
streamGitLog,
|
|
50
58
|
stripStringAndRegexLiterals,
|
|
51
59
|
unquoteGitPath,
|
|
52
60
|
writeBaseline
|
|
53
|
-
} from "./chunk-
|
|
61
|
+
} from "./chunk-YGHNTWHX.js";
|
|
54
62
|
export {
|
|
63
|
+
CONFIG_FILENAMES,
|
|
55
64
|
ConfigError,
|
|
56
65
|
DEFAULT_CONFIG,
|
|
57
66
|
DEFAULT_CONFIG_TEMPLATE,
|
|
@@ -70,6 +79,8 @@ export {
|
|
|
70
79
|
auditFileDiffs,
|
|
71
80
|
auditPullRequest,
|
|
72
81
|
checkProject,
|
|
82
|
+
compareFindings,
|
|
83
|
+
configFromText,
|
|
73
84
|
createBaseline,
|
|
74
85
|
detectAiAttribution,
|
|
75
86
|
findMergeBase,
|
|
@@ -80,6 +91,9 @@ export {
|
|
|
80
91
|
formatGithubStepSummary,
|
|
81
92
|
formatPrMarkdownComment,
|
|
82
93
|
formatPrTerminalCard,
|
|
94
|
+
formatReviewMarkdown,
|
|
95
|
+
formatReviewTerminal,
|
|
96
|
+
formatSarif,
|
|
83
97
|
formatTerminalCard,
|
|
84
98
|
generateShareUrl,
|
|
85
99
|
generateShieldsJson,
|
|
@@ -96,6 +110,8 @@ export {
|
|
|
96
110
|
parseDiff,
|
|
97
111
|
parseGitLogStream,
|
|
98
112
|
resolveCommitSha,
|
|
113
|
+
reviewChanges,
|
|
114
|
+
reviewStagedChanges,
|
|
99
115
|
roastRepository,
|
|
100
116
|
streamGitDiff,
|
|
101
117
|
streamGitLog,
|