agent-roast 0.5.0 → 0.6.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 +62 -1
- package/dist/{chunk-FUAUIURY.js → chunk-RU5OYHLR.js} +406 -31
- package/dist/cli.js +47 -3
- package/dist/index.d.ts +61 -3
- package/dist/index.js +15 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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 with `npx agent-roast@0.6.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
|
|
@@ -238,7 +299,7 @@ Only **added lines** (`+`) in source code files are scanned — deleted lines, f
|
|
|
238
299
|
- **Requirements:** Git 2.30+ (for `--end-of-options` argument isolation), Node.js 18+.
|
|
239
300
|
- **100% Local:** Instant streaming parser runs directly on your machine.
|
|
240
301
|
- **Zero Telemetry:** No cloud dependencies, no LLM API calls, no network traffic. Verified by static bundle inspection.
|
|
241
|
-
- **
|
|
302
|
+
- **Git isolation:** Forces stable diff paths, disables fsmonitor/external diff execution, and reads review content by object identity.
|
|
242
303
|
|
|
243
304
|
---
|
|
244
305
|
|
|
@@ -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";
|
|
@@ -754,32 +754,36 @@ function validateConfig(value) {
|
|
|
754
754
|
}
|
|
755
755
|
}
|
|
756
756
|
}
|
|
757
|
+
function configFromText(text, source) {
|
|
758
|
+
try {
|
|
759
|
+
const parsed = JSON.parse(text);
|
|
760
|
+
validateConfig(parsed);
|
|
761
|
+
const rules = { ...DEFAULT_CONFIG.rules, ...parsed.rules };
|
|
762
|
+
const failOn = parsed.failOn ?? Object.entries(rules).filter(([, level]) => level === "error").map(([rule]) => rule);
|
|
763
|
+
return {
|
|
764
|
+
...DEFAULT_CONFIG,
|
|
765
|
+
...parsed,
|
|
766
|
+
failOn,
|
|
767
|
+
ignorePaths: [.../* @__PURE__ */ new Set([...DEFAULT_CONFIG.ignorePaths || [], ...parsed.ignorePaths || []])],
|
|
768
|
+
rules
|
|
769
|
+
};
|
|
770
|
+
} catch (error) {
|
|
771
|
+
throw new ConfigError(`Invalid ${source}: ${error instanceof Error ? error.message : String(error)}`);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
757
774
|
function loadConfig(cwd = process.cwd()) {
|
|
758
775
|
for (const filename of CONFIG_FILENAMES) {
|
|
759
776
|
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)}`);
|
|
777
|
+
if (fs.existsSync(filePath)) {
|
|
778
|
+
try {
|
|
779
|
+
return configFromText(fs.readFileSync(filePath, "utf8"), filename);
|
|
780
|
+
} catch (error) {
|
|
781
|
+
if (error instanceof ConfigError) throw error;
|
|
782
|
+
throw new ConfigError(`Cannot read ${filename}: ${error instanceof Error ? error.message : String(error)}`);
|
|
783
|
+
}
|
|
775
784
|
}
|
|
776
785
|
}
|
|
777
|
-
return {
|
|
778
|
-
...DEFAULT_CONFIG,
|
|
779
|
-
failOn: [...DEFAULT_CONFIG.failOn || []],
|
|
780
|
-
ignorePaths: [...DEFAULT_CONFIG.ignorePaths || []],
|
|
781
|
-
rules: { ...DEFAULT_CONFIG.rules }
|
|
782
|
-
};
|
|
786
|
+
return configFromText("{}", "default configuration");
|
|
783
787
|
}
|
|
784
788
|
function matchIgnorePath(filePath, ignorePatterns) {
|
|
785
789
|
if (!ignorePatterns || ignorePatterns.length === 0) {
|
|
@@ -1623,6 +1627,60 @@ function formatCheckMarkdown(result) {
|
|
|
1623
1627
|
for (const skipped of result.skippedFiles) lines.push("", `Not scanned: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
|
|
1624
1628
|
return lines.join("\n");
|
|
1625
1629
|
}
|
|
1630
|
+
function formatReviewTerminal(result) {
|
|
1631
|
+
const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
|
|
1632
|
+
const lines = [
|
|
1633
|
+
"",
|
|
1634
|
+
pc.bold(`agent-roast \u2014 context review: ${status}`),
|
|
1635
|
+
` ${result.mergeBaseSha.slice(0, 7)} \u2192 ${result.headSha.slice(0, 7)} \xB7 policy from base ${result.policySha.slice(0, 7)}`,
|
|
1636
|
+
` ${result.filesInspected} changed files compared \xB7 ${result.linesInspected} source lines inspected across both versions`,
|
|
1637
|
+
` ${result.infractions.length} new \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected`
|
|
1638
|
+
];
|
|
1639
|
+
if (result.policyChanges.length) lines.push(" Candidate policy changes were ignored; adopt them separately after review.");
|
|
1640
|
+
if (result.excludedFiles || result.unsupportedFiles) lines.push(` ${result.excludedFiles} excluded \xB7 ${result.unsupportedFiles} unsupported/non-source changes`);
|
|
1641
|
+
for (const finding of result.infractions.slice(0, 20)) {
|
|
1642
|
+
lines.push(
|
|
1643
|
+
"",
|
|
1644
|
+
` ${result.failedRules.includes(finding.type) ? "FAIL" : "WARN"} [${finding.type}] ${terminalText(finding.file || "unknown")}:${finding.lineNumber}`,
|
|
1645
|
+
` ${terminalText(finding.lineSnippet || finding.reason)}`,
|
|
1646
|
+
` Next: ${finding.suggestion || "Review this occurrence."}`
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
if (result.infractions.length > 20) lines.push(` ${result.infractions.length - 20} more new findings; use --format json for all results.`);
|
|
1650
|
+
for (const skipped of result.skippedFiles) lines.push(` NOT COMPARED ${terminalText(skipped.file)}: ${terminalText(skipped.reason)}`);
|
|
1651
|
+
lines.push("", !result.complete ? " INCOMPLETE: some changed source was not compared (exit 2)." : result.passed ? " PASSED: no new blocking findings." : ` BLOCKED: ${result.failedRules.join(", ")}.`, "");
|
|
1652
|
+
if (!result.filesInspected) lines.push(" No supported changed source files compared.", "");
|
|
1653
|
+
return lines.join("\n");
|
|
1654
|
+
}
|
|
1655
|
+
function formatReviewMarkdown(result) {
|
|
1656
|
+
const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
|
|
1657
|
+
const lines = [
|
|
1658
|
+
`## agent-roast context review: ${status}`,
|
|
1659
|
+
"",
|
|
1660
|
+
`Compared ${result.filesInspected} changed files at \`${result.mergeBaseSha.slice(0, 7)}\` \u2192 \`${result.headSha.slice(0, 7)}\`. Policy: \`${result.policySha.slice(0, 7)}\`.`,
|
|
1661
|
+
"",
|
|
1662
|
+
`**${result.infractions.length} new** \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected.`,
|
|
1663
|
+
""
|
|
1664
|
+
];
|
|
1665
|
+
if (result.policyChanges.length) lines.push("Candidate policy changes were ignored. This review uses the supplied base commit's policy.", "");
|
|
1666
|
+
lines.push("| New finding | Head location | Evidence | Next step |", "| --- | --- | --- | --- |");
|
|
1667
|
+
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.")} |`);
|
|
1668
|
+
if (result.removedFindings.length) {
|
|
1669
|
+
lines.push(
|
|
1670
|
+
"",
|
|
1671
|
+
"### No longer detected",
|
|
1672
|
+
"",
|
|
1673
|
+
"These patterns disappeared from the compared source. Deleting code is not proof of a behavioral fix.",
|
|
1674
|
+
"",
|
|
1675
|
+
"| Rule | Previous location | Previous evidence |",
|
|
1676
|
+
"| --- | --- | --- |"
|
|
1677
|
+
);
|
|
1678
|
+
for (const finding of result.removedFindings) lines.push(`| ${finding.type} | ${markdownCell(`${finding.file}:${finding.lineNumber}`)} | ${markdownCell(finding.lineSnippet || finding.reason)} |`);
|
|
1679
|
+
}
|
|
1680
|
+
for (const skipped of result.skippedFiles) lines.push("", `Not compared: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
|
|
1681
|
+
lines.push("", `Scope: changed supported files only; ${result.excludedFiles} excluded and ${result.unsupportedFiles} unsupported/non-source changes.`);
|
|
1682
|
+
return lines.join("\n");
|
|
1683
|
+
}
|
|
1626
1684
|
|
|
1627
1685
|
// src/init.ts
|
|
1628
1686
|
import * as fs2 from "fs";
|
|
@@ -1663,6 +1721,7 @@ jobs:
|
|
|
1663
1721
|
base: \${{ github.event.pull_request.base.sha }}
|
|
1664
1722
|
head: \${{ github.event.pull_request.head.sha }}
|
|
1665
1723
|
format: github
|
|
1724
|
+
mode: review
|
|
1666
1725
|
`;
|
|
1667
1726
|
var PRE_COMMIT_HOOK_TEMPLATE = `#!/bin/sh
|
|
1668
1727
|
# Run agent-roast pre-commit verification
|
|
@@ -1775,11 +1834,8 @@ function generateShieldsJson(score, label = "AI Discipline") {
|
|
|
1775
1834
|
};
|
|
1776
1835
|
}
|
|
1777
1836
|
|
|
1778
|
-
// src/
|
|
1779
|
-
import {
|
|
1780
|
-
import * as fs4 from "fs/promises";
|
|
1781
|
-
import { constants as constants2 } from "fs";
|
|
1782
|
-
import path4 from "path";
|
|
1837
|
+
// src/sarif.ts
|
|
1838
|
+
import { createRequire } from "module";
|
|
1783
1839
|
|
|
1784
1840
|
// src/baseline.ts
|
|
1785
1841
|
import { createHash, randomUUID } from "crypto";
|
|
@@ -1873,7 +1929,78 @@ function writeBaseline(filename, findings) {
|
|
|
1873
1929
|
}
|
|
1874
1930
|
}
|
|
1875
1931
|
|
|
1932
|
+
// src/sarif.ts
|
|
1933
|
+
var require2 = createRequire(import.meta.url);
|
|
1934
|
+
var version = require2("../package.json").version;
|
|
1935
|
+
function formatSarif(report) {
|
|
1936
|
+
const occurrences = /* @__PURE__ */ new Map();
|
|
1937
|
+
const results = report.infractions.map((finding) => {
|
|
1938
|
+
const filename = finding.file;
|
|
1939
|
+
if (!filename || filename.startsWith("/") || filename.split("/").includes("..") || /^[a-z]:/i.test(filename)) {
|
|
1940
|
+
throw new ConfigError("SARIF needs repository-relative finding paths.");
|
|
1941
|
+
}
|
|
1942
|
+
const fingerprint = findingFingerprint(finding);
|
|
1943
|
+
const occurrence = (occurrences.get(fingerprint) || 0) + 1;
|
|
1944
|
+
occurrences.set(fingerprint, occurrence);
|
|
1945
|
+
return {
|
|
1946
|
+
ruleId: finding.type,
|
|
1947
|
+
ruleIndex: VALID_DETECTORS.indexOf(finding.type),
|
|
1948
|
+
level: report.failedRules.includes(finding.type) ? "error" : "warning",
|
|
1949
|
+
message: { text: `${finding.reason}. ${finding.suggestion || RULE_GUIDES[finding.type].suggestion}` },
|
|
1950
|
+
locations: [{ physicalLocation: {
|
|
1951
|
+
artifactLocation: { uri: filename.split("/").map((segment) => encodeURIComponent(segment)).join("/") },
|
|
1952
|
+
region: { startLine: Math.max(1, finding.lineNumber || 1) }
|
|
1953
|
+
} }],
|
|
1954
|
+
partialFingerprints: { "agentRoastFinding/v1": `${fingerprint}:${occurrence}` },
|
|
1955
|
+
...report.mode === "review" ? { baselineState: "new" } : {}
|
|
1956
|
+
};
|
|
1957
|
+
});
|
|
1958
|
+
return JSON.stringify({
|
|
1959
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
1960
|
+
version: "2.1.0",
|
|
1961
|
+
runs: [{
|
|
1962
|
+
tool: { driver: {
|
|
1963
|
+
name: "agent-roast",
|
|
1964
|
+
semanticVersion: version,
|
|
1965
|
+
informationUri: "https://github.com/Nari-Ab/agent-roast",
|
|
1966
|
+
rules: VALID_DETECTORS.map((rule) => ({
|
|
1967
|
+
id: rule,
|
|
1968
|
+
name: rule,
|
|
1969
|
+
shortDescription: { text: RULE_GUIDES[rule].title },
|
|
1970
|
+
fullDescription: { text: RULE_GUIDES[rule].why },
|
|
1971
|
+
help: { text: `${RULE_GUIDES[rule].suggestion}
|
|
1972
|
+
Example: ${RULE_GUIDES[rule].example}` },
|
|
1973
|
+
defaultConfiguration: { level: "warning" },
|
|
1974
|
+
properties: { tags: ["maintainability", "code-review"] }
|
|
1975
|
+
}))
|
|
1976
|
+
} },
|
|
1977
|
+
invocations: [{
|
|
1978
|
+
executionSuccessful: report.complete,
|
|
1979
|
+
exitCode: report.exitCode,
|
|
1980
|
+
toolExecutionNotifications: report.skippedFiles.map((skipped) => ({
|
|
1981
|
+
level: "error",
|
|
1982
|
+
message: { text: `Not inspected: ${skipped.file}. ${skipped.reason}` }
|
|
1983
|
+
}))
|
|
1984
|
+
}],
|
|
1985
|
+
results,
|
|
1986
|
+
properties: report.mode === "review" ? {
|
|
1987
|
+
scope: "introduced findings in changed files",
|
|
1988
|
+
baseSha: report.baseSha,
|
|
1989
|
+
headSha: report.headSha,
|
|
1990
|
+
mergeBaseSha: report.mergeBaseSha,
|
|
1991
|
+
policySha: report.policySha,
|
|
1992
|
+
unchangedFindings: report.unchangedCount,
|
|
1993
|
+
removedFindings: report.removedFindings.length
|
|
1994
|
+
} : { scope: "current source", acceptedBaselineFindings: report.baselineCount }
|
|
1995
|
+
}]
|
|
1996
|
+
}, null, 2);
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1876
1999
|
// src/check.ts
|
|
2000
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
2001
|
+
import * as fs4 from "fs/promises";
|
|
2002
|
+
import { constants as constants2 } from "fs";
|
|
2003
|
+
import path4 from "path";
|
|
1877
2004
|
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
1878
2005
|
var MAX_SCAN_BYTES = 50 * 1024 * 1024;
|
|
1879
2006
|
var GENERATED_PATHS = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
|
|
@@ -1939,7 +2066,7 @@ async function checkProject(options = {}) {
|
|
|
1939
2066
|
const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert"];
|
|
1940
2067
|
for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown rule '${rule}'.`);
|
|
1941
2068
|
const format = options.format || "terminal";
|
|
1942
|
-
if (!["terminal", "json", "github", "markdown"].includes(format)) throw new ConfigError("Unsupported check output format.");
|
|
2069
|
+
if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported check output format.");
|
|
1943
2070
|
const baseline = options.baseline ? loadBaseline(path4.resolve(cwd, options.baseline)) : void 0;
|
|
1944
2071
|
const filenames = [...new Set(git(root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]).split("\0").filter(Boolean))].sort();
|
|
1945
2072
|
const infractions = [];
|
|
@@ -1992,6 +2119,7 @@ async function checkProject(options = {}) {
|
|
|
1992
2119
|
output: ""
|
|
1993
2120
|
};
|
|
1994
2121
|
if (format === "json") result.output = JSON.stringify(result, null, 2);
|
|
2122
|
+
else if (format === "sarif") result.output = formatSarif(result);
|
|
1995
2123
|
else if (format === "markdown") result.output = formatCheckMarkdown(result);
|
|
1996
2124
|
else if (format === "github") {
|
|
1997
2125
|
result.output = formatGithubAnnotations(result) + (complete ? "" : "\n::error::Project scan incomplete. See the JSON or terminal report for skipped files.");
|
|
@@ -1999,6 +2127,246 @@ async function checkProject(options = {}) {
|
|
|
1999
2127
|
return result;
|
|
2000
2128
|
}
|
|
2001
2129
|
|
|
2130
|
+
// src/review.ts
|
|
2131
|
+
import path5 from "path";
|
|
2132
|
+
|
|
2133
|
+
// src/snapshot.ts
|
|
2134
|
+
import { spawn as spawn2 } from "child_process";
|
|
2135
|
+
function assertNotAborted(signal) {
|
|
2136
|
+
if (signal?.aborted) throw new ConfigError("Review cancelled; no complete result was produced.");
|
|
2137
|
+
}
|
|
2138
|
+
async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024) {
|
|
2139
|
+
assertNotAborted(signal);
|
|
2140
|
+
const env = getHermeticGitEnv({ GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0" });
|
|
2141
|
+
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];
|
|
2142
|
+
return new Promise((resolve2, reject) => {
|
|
2143
|
+
const child = spawn2("git", [...HERMETIC_GIT_FLAGS, ...args], {
|
|
2144
|
+
cwd,
|
|
2145
|
+
env,
|
|
2146
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2147
|
+
windowsHide: true
|
|
2148
|
+
});
|
|
2149
|
+
const chunks = [];
|
|
2150
|
+
let bytes = 0;
|
|
2151
|
+
let stderr = "";
|
|
2152
|
+
let settled = false;
|
|
2153
|
+
const finish = (error) => {
|
|
2154
|
+
if (settled) return;
|
|
2155
|
+
settled = true;
|
|
2156
|
+
clearTimeout(timer);
|
|
2157
|
+
signal?.removeEventListener("abort", abort);
|
|
2158
|
+
if (error) {
|
|
2159
|
+
child.kill();
|
|
2160
|
+
reject(error);
|
|
2161
|
+
} else resolve2(Buffer.concat(chunks));
|
|
2162
|
+
};
|
|
2163
|
+
const abort = () => finish(new ConfigError("Review cancelled; no complete result was produced."));
|
|
2164
|
+
const timer = setTimeout(() => finish(new ConfigError("Git review read timed out after 30 seconds.")), 3e4);
|
|
2165
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
2166
|
+
child.stdout.on("data", (chunk) => {
|
|
2167
|
+
if (settled) return;
|
|
2168
|
+
bytes += chunk.length;
|
|
2169
|
+
if (bytes > limit) finish(new ConfigError(`Git read exceeds the ${limit} byte limit.`));
|
|
2170
|
+
else chunks.push(chunk);
|
|
2171
|
+
});
|
|
2172
|
+
child.stderr.on("data", (chunk) => {
|
|
2173
|
+
if (stderr.length < 64 * 1024) stderr += chunk.toString("utf8").slice(0, 64 * 1024 - stderr.length);
|
|
2174
|
+
});
|
|
2175
|
+
child.on("error", (error) => finish(error));
|
|
2176
|
+
child.on("close", (code) => finish(code === 0 ? void 0 : new GitError(stderr, code)));
|
|
2177
|
+
if (signal?.aborted) abort();
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
async function reviewRefs(cwd, base, head, signal) {
|
|
2181
|
+
const resolve2 = async (ref) => {
|
|
2182
|
+
if (!ref.trim()) throw new ConfigError("Review refs must not be empty.");
|
|
2183
|
+
try {
|
|
2184
|
+
return (await readGit(cwd, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], signal, 1024)).toString().trim();
|
|
2185
|
+
} catch (error) {
|
|
2186
|
+
assertNotAborted(signal);
|
|
2187
|
+
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)}`);
|
|
2188
|
+
}
|
|
2189
|
+
};
|
|
2190
|
+
try {
|
|
2191
|
+
const promises = (await readGit(cwd, ["config", "--type=bool", "--get-regexp", "^remote\\..*\\.promisor$"], signal, 64 * 1024)).toString();
|
|
2192
|
+
if (promises.split("\n").some((line) => /\s+(?:true|yes|on|1)\s*$/i.test(line))) {
|
|
2193
|
+
throw new ConfigError("Offline review requires a non-partial clone with local objects; promisor remotes are unsupported.");
|
|
2194
|
+
}
|
|
2195
|
+
} catch (error) {
|
|
2196
|
+
if (!(error instanceof GitError && error.code === 1)) throw error;
|
|
2197
|
+
}
|
|
2198
|
+
const baseSha = await resolve2(base);
|
|
2199
|
+
const headSha = await resolve2(head);
|
|
2200
|
+
let ancestors;
|
|
2201
|
+
try {
|
|
2202
|
+
ancestors = (await readGit(cwd, ["merge-base", "--all", "--end-of-options", baseSha, headSha], signal, 8192)).toString().trim().split("\n");
|
|
2203
|
+
} catch (error) {
|
|
2204
|
+
assertNotAborted(signal);
|
|
2205
|
+
throw new ConfigError(`Cannot find review merge-base. Fetch full shared history (fetch-depth: 0). ${error instanceof Error ? error.message : String(error)}`);
|
|
2206
|
+
}
|
|
2207
|
+
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.");
|
|
2208
|
+
return { baseSha, headSha, mergeBaseSha: ancestors[0] };
|
|
2209
|
+
}
|
|
2210
|
+
async function changedTreeFiles(cwd, before, after, signal) {
|
|
2211
|
+
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);
|
|
2212
|
+
const tokens = new TextDecoder("utf-8", { fatal: true }).decode(buffer).split("\0");
|
|
2213
|
+
if (tokens.pop() !== "") throw new ConfigError("Truncated Git change inventory.");
|
|
2214
|
+
const changes = [];
|
|
2215
|
+
for (let i = 0; i < tokens.length; ) {
|
|
2216
|
+
const match = /^:(\d{6}) (\d{6}) ([a-f0-9]{40,64}) ([a-f0-9]{40,64}) ([AMDRT]\d*)$/.exec(tokens[i++]);
|
|
2217
|
+
if (!match) throw new ConfigError("Unsupported Git change inventory entry.");
|
|
2218
|
+
const oldPath = tokens[i++];
|
|
2219
|
+
const newPath = match[5].startsWith("R") ? tokens[i++] : oldPath;
|
|
2220
|
+
if (!oldPath || !newPath) throw new ConfigError("Incomplete Git path inventory.");
|
|
2221
|
+
changes.push({ oldMode: match[1], newMode: match[2], oldOid: match[3], newOid: match[4], status: match[5], oldPath, newPath });
|
|
2222
|
+
if (changes.length > 1e3) throw new ConfigError("Review exceeds the 1000 changed-file limit; split the change into smaller reviews.");
|
|
2223
|
+
}
|
|
2224
|
+
return changes;
|
|
2225
|
+
}
|
|
2226
|
+
async function readBlob(cwd, oid, signal, limit = 2 * 1024 * 1024) {
|
|
2227
|
+
if (!/^[a-f0-9]{40,64}$/.test(oid)) throw new ConfigError("Invalid Git blob identity.");
|
|
2228
|
+
const sizeText = (await readGit(cwd, ["cat-file", "-s", oid], signal, 128)).toString().trim();
|
|
2229
|
+
const size = Number(sizeText);
|
|
2230
|
+
if (!Number.isSafeInteger(size) || size < 0 || size > limit) throw new ConfigError(`Source blob exceeds the ${limit} byte limit or has an invalid size.`);
|
|
2231
|
+
const buffer = await readGit(cwd, ["cat-file", "blob", oid], signal, limit);
|
|
2232
|
+
if (buffer.length !== size) throw new ConfigError("Git blob size differs from the recorded object.");
|
|
2233
|
+
return buffer;
|
|
2234
|
+
}
|
|
2235
|
+
async function reviewPolicy(cwd, baseSha, signal) {
|
|
2236
|
+
const raw = await readGit(cwd, ["ls-tree", "--full-tree", "-z", baseSha, "--", ...CONFIG_FILENAMES], signal, 8192);
|
|
2237
|
+
const entries = raw.toString().split("\0").filter(Boolean);
|
|
2238
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
2239
|
+
const entry = entries.find((line) => line.slice(line.indexOf(" ") + 1) === filename);
|
|
2240
|
+
if (!entry) continue;
|
|
2241
|
+
const match = /^(100644|100755) blob ([a-f0-9]{40,64})\t/.exec(entry);
|
|
2242
|
+
if (!match) throw new ConfigError(`Base policy '${filename}' must be a regular Git blob.`);
|
|
2243
|
+
const content = await readBlob(cwd, match[2], signal, 64 * 1024);
|
|
2244
|
+
return { config: configFromText(new TextDecoder("utf-8", { fatal: true }).decode(content), `${filename} in base ${baseSha.slice(0, 7)}`), filename };
|
|
2245
|
+
}
|
|
2246
|
+
return { config: configFromText("{}", "default review policy"), filename: null };
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
// src/review.ts
|
|
2250
|
+
var GENERATED_PATHS2 = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
|
|
2251
|
+
var MAX_SCAN_BYTES2 = 50 * 1024 * 1024;
|
|
2252
|
+
function compareFindings(before, after, newPath) {
|
|
2253
|
+
const old = /* @__PURE__ */ new Map();
|
|
2254
|
+
for (const finding of before) {
|
|
2255
|
+
const key = findingFingerprint({ ...finding, file: newPath });
|
|
2256
|
+
const bucket = old.get(key) || { findings: [], matched: 0 };
|
|
2257
|
+
bucket.findings.push(finding);
|
|
2258
|
+
old.set(key, bucket);
|
|
2259
|
+
}
|
|
2260
|
+
const introduced = [];
|
|
2261
|
+
let unchanged = 0;
|
|
2262
|
+
for (const finding of after) {
|
|
2263
|
+
const bucket = old.get(findingFingerprint(finding));
|
|
2264
|
+
if (bucket && bucket.matched < bucket.findings.length) {
|
|
2265
|
+
bucket.matched++;
|
|
2266
|
+
unchanged++;
|
|
2267
|
+
} else introduced.push(finding);
|
|
2268
|
+
}
|
|
2269
|
+
return { introduced, removed: [...old.values()].flatMap((bucket) => bucket.findings.slice(bucket.matched)), unchanged };
|
|
2270
|
+
}
|
|
2271
|
+
async function reviewChanges(options) {
|
|
2272
|
+
assertNotAborted(options.signal);
|
|
2273
|
+
const cwd = path5.resolve(options.cwd || process.cwd());
|
|
2274
|
+
const refs = await reviewRefs(cwd, options.base, options.head ?? "HEAD", options.signal);
|
|
2275
|
+
const policy = await reviewPolicy(cwd, refs.baseSha, options.signal);
|
|
2276
|
+
const format = options.format ?? "terminal";
|
|
2277
|
+
if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported review format.");
|
|
2278
|
+
const failOn = options.failOn ?? policy.config.failOn ?? ["test-skip", "trivial-assert"];
|
|
2279
|
+
for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown review rule '${rule}'.`);
|
|
2280
|
+
const changes = await changedTreeFiles(cwd, refs.mergeBaseSha, refs.headSha, options.signal);
|
|
2281
|
+
const policyChanges = changes.filter((change) => CONFIG_FILENAMES.includes(change.oldPath) || CONFIG_FILENAMES.includes(change.newPath)).map((change) => change.newPath);
|
|
2282
|
+
const ignores = [...GENERATED_PATHS2, ...policy.config.ignorePaths || []];
|
|
2283
|
+
const result = {
|
|
2284
|
+
schemaVersion: "1.0.0",
|
|
2285
|
+
mode: "review",
|
|
2286
|
+
...refs,
|
|
2287
|
+
policySha: refs.baseSha,
|
|
2288
|
+
policyFile: policy.filename,
|
|
2289
|
+
policyChanges,
|
|
2290
|
+
filesChanged: changes.length,
|
|
2291
|
+
filesInspected: 0,
|
|
2292
|
+
unsupportedFiles: 0,
|
|
2293
|
+
excludedFiles: 0,
|
|
2294
|
+
renamedFiles: changes.filter((change) => change.status.startsWith("R")).length,
|
|
2295
|
+
linesInspected: 0,
|
|
2296
|
+
beforeFindings: 0,
|
|
2297
|
+
afterFindings: 0,
|
|
2298
|
+
unchangedCount: 0,
|
|
2299
|
+
infractions: [],
|
|
2300
|
+
removedFindings: [],
|
|
2301
|
+
skippedFiles: [],
|
|
2302
|
+
complete: true,
|
|
2303
|
+
failedRules: [],
|
|
2304
|
+
passed: false,
|
|
2305
|
+
exitCode: 2,
|
|
2306
|
+
output: ""
|
|
2307
|
+
};
|
|
2308
|
+
let totalBytes = 0;
|
|
2309
|
+
for (const change of changes) {
|
|
2310
|
+
assertNotAborted(options.signal);
|
|
2311
|
+
const oldSupported = change.oldMode !== "000000" && isCodeFile(change.oldPath);
|
|
2312
|
+
const newSupported = change.newMode !== "000000" && isCodeFile(change.newPath);
|
|
2313
|
+
if (!oldSupported && !newSupported) {
|
|
2314
|
+
result.unsupportedFiles++;
|
|
2315
|
+
continue;
|
|
2316
|
+
}
|
|
2317
|
+
const oldInScope = oldSupported && isCodeFile(change.oldPath, ignores);
|
|
2318
|
+
const newInScope = newSupported && isCodeFile(change.newPath, ignores);
|
|
2319
|
+
if (!oldInScope && !newInScope) {
|
|
2320
|
+
result.excludedFiles++;
|
|
2321
|
+
continue;
|
|
2322
|
+
}
|
|
2323
|
+
try {
|
|
2324
|
+
let inspectedLines = 0;
|
|
2325
|
+
const scan = async (mode, oid, filename, commit) => {
|
|
2326
|
+
if (mode === "000000") return [];
|
|
2327
|
+
if (!["100644", "100755"].includes(mode)) throw new ConfigError("Changed source is a symlink, submodule or unsupported Git mode; comparison is incomplete.");
|
|
2328
|
+
const content = await readBlob(cwd, oid, options.signal);
|
|
2329
|
+
totalBytes += content.length;
|
|
2330
|
+
if (totalBytes > MAX_SCAN_BYTES2) throw new ConfigError("Review exceeds the 50 MiB source comparison limit.");
|
|
2331
|
+
if (content.includes(0)) throw new ConfigError("Binary content in a source comparison.");
|
|
2332
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
2333
|
+
const lines = text.split(/\r?\n/);
|
|
2334
|
+
if (lines.at(-1) === "") lines.pop();
|
|
2335
|
+
inspectedLines += lines.length;
|
|
2336
|
+
return auditFileDiffs([{ path: filename, addedLines: lines.map((line, index) => ({ line, lineNumber: index + 1 })) }], commit, "", { rules: policy.config.rules }).infractions;
|
|
2337
|
+
};
|
|
2338
|
+
if (oldSupported && !newSupported && change.newMode !== "000000") {
|
|
2339
|
+
throw new ConfigError("Source moved to an unsupported extension; cannot claim its findings were resolved.");
|
|
2340
|
+
}
|
|
2341
|
+
const before = await scan(change.oldMode, change.oldOid, change.oldPath, refs.mergeBaseSha);
|
|
2342
|
+
const after = await scan(change.newMode, change.newOid, change.newPath, refs.headSha);
|
|
2343
|
+
const compared = compareFindings(before, after, change.newPath);
|
|
2344
|
+
result.infractions.push(...compared.introduced);
|
|
2345
|
+
result.removedFindings.push(...compared.removed);
|
|
2346
|
+
result.unchangedCount += compared.unchanged;
|
|
2347
|
+
result.beforeFindings += before.length;
|
|
2348
|
+
result.afterFindings += after.length;
|
|
2349
|
+
result.filesInspected++;
|
|
2350
|
+
result.linesInspected += inspectedLines;
|
|
2351
|
+
} catch (error) {
|
|
2352
|
+
assertNotAborted(options.signal);
|
|
2353
|
+
result.skippedFiles.push({ file: change.newPath, reason: error instanceof Error ? error.message : String(error) });
|
|
2354
|
+
if (totalBytes > MAX_SCAN_BYTES2) break;
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
assertNotAborted(options.signal);
|
|
2358
|
+
result.complete = result.skippedFiles.length === 0;
|
|
2359
|
+
result.failedRules = [...new Set(result.infractions.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
|
|
2360
|
+
result.passed = result.complete && result.failedRules.length === 0;
|
|
2361
|
+
result.exitCode = !result.complete ? 2 : result.passed ? 0 : 1;
|
|
2362
|
+
if (format === "json") result.output = JSON.stringify(result, null, 2);
|
|
2363
|
+
else if (format === "markdown") result.output = formatReviewMarkdown(result);
|
|
2364
|
+
else if (format === "sarif") result.output = formatSarif(result);
|
|
2365
|
+
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." : "");
|
|
2366
|
+
else result.output = formatReviewTerminal(result);
|
|
2367
|
+
return result;
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2002
2370
|
// src/index.ts
|
|
2003
2371
|
var InvalidDetectorError = class extends Error {
|
|
2004
2372
|
constructor(message) {
|
|
@@ -2007,9 +2375,9 @@ var InvalidDetectorError = class extends Error {
|
|
|
2007
2375
|
}
|
|
2008
2376
|
};
|
|
2009
2377
|
async function roastRepository(options = {}) {
|
|
2010
|
-
const cwd =
|
|
2378
|
+
const cwd = path6.resolve(options.cwd || process.cwd());
|
|
2011
2379
|
const since = options.since || "90 days ago";
|
|
2012
|
-
const repoName =
|
|
2380
|
+
const repoName = path6.basename(cwd);
|
|
2013
2381
|
const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths, rules: options.rules, panicLoopWindowMinutes: options.panicLoopWindowMinutes });
|
|
2014
2382
|
const gitStream = streamGitLog(cwd, since);
|
|
2015
2383
|
await parseGitLogStream(gitStream, (commit, stats) => {
|
|
@@ -2024,7 +2392,7 @@ async function roastRepository(options = {}) {
|
|
|
2024
2392
|
return { summary, output };
|
|
2025
2393
|
}
|
|
2026
2394
|
async function auditPullRequest(options) {
|
|
2027
|
-
const cwd =
|
|
2395
|
+
const cwd = path6.resolve(options.cwd || process.cwd());
|
|
2028
2396
|
const format = options.format || "terminal";
|
|
2029
2397
|
const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
|
|
2030
2398
|
for (const det of failOn) {
|
|
@@ -2109,7 +2477,9 @@ export {
|
|
|
2109
2477
|
RULE_GUIDES,
|
|
2110
2478
|
VALID_DETECTORS,
|
|
2111
2479
|
DEFAULT_CONFIG,
|
|
2480
|
+
CONFIG_FILENAMES,
|
|
2112
2481
|
ConfigError,
|
|
2482
|
+
configFromText,
|
|
2113
2483
|
loadConfig,
|
|
2114
2484
|
matchIgnorePath,
|
|
2115
2485
|
isCodeFile,
|
|
@@ -2125,6 +2495,8 @@ export {
|
|
|
2125
2495
|
formatPrMarkdownComment,
|
|
2126
2496
|
formatCheckTerminal,
|
|
2127
2497
|
formatCheckMarkdown,
|
|
2498
|
+
formatReviewTerminal,
|
|
2499
|
+
formatReviewMarkdown,
|
|
2128
2500
|
DEFAULT_CONFIG_TEMPLATE,
|
|
2129
2501
|
GITHUB_WORKFLOW_TEMPLATE,
|
|
2130
2502
|
PRE_COMMIT_HOOK_TEMPLATE,
|
|
@@ -2138,7 +2510,10 @@ export {
|
|
|
2138
2510
|
loadBaseline,
|
|
2139
2511
|
applyBaseline,
|
|
2140
2512
|
writeBaseline,
|
|
2513
|
+
formatSarif,
|
|
2141
2514
|
checkProject,
|
|
2515
|
+
compareFindings,
|
|
2516
|
+
reviewChanges,
|
|
2142
2517
|
InvalidDetectorError,
|
|
2143
2518
|
roastRepository,
|
|
2144
2519
|
auditPullRequest
|
package/dist/cli.js
CHANGED
|
@@ -9,14 +9,16 @@ 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,
|
|
17
19
|
roastRepository,
|
|
18
20
|
writeBaseline
|
|
19
|
-
} from "./chunk-
|
|
21
|
+
} from "./chunk-RU5OYHLR.js";
|
|
20
22
|
|
|
21
23
|
// src/cli.ts
|
|
22
24
|
import { Command } from "commander";
|
|
@@ -26,7 +28,7 @@ import * as path from "path";
|
|
|
26
28
|
var require2 = createRequire(import.meta.url);
|
|
27
29
|
var pkg = require2("../package.json");
|
|
28
30
|
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", "Audit staged changes before committing (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", "terminal").option("--authors", "Display author names and emails in report", false).action(async (repoPath, options) => {
|
|
31
|
+
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", "Audit staged changes before committing (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
32
|
try {
|
|
31
33
|
if (process.env.GITHUB_EVENT_NAME === "pull_request_target") {
|
|
32
34
|
console.warn(
|
|
@@ -120,7 +122,49 @@ has-supported-changes=${prResult.hasSupportedChanges}
|
|
|
120
122
|
}
|
|
121
123
|
}
|
|
122
124
|
});
|
|
123
|
-
program.command("
|
|
125
|
+
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)\nOutput: --format terminal|json|github|markdown|sarif, --json, --fail-on <rules>\nExample: agent-roast review --base origin/main --format markdown").action(async (repoPath) => {
|
|
126
|
+
try {
|
|
127
|
+
const options = program.opts();
|
|
128
|
+
if (!options.base) throw new ConfigError("review requires --base <trusted-ref>.");
|
|
129
|
+
if (options.staged || options.all || options.failUnder !== void 0 || program.getOptionValueSource("since") === "cli") {
|
|
130
|
+
throw new ConfigError("review compares committed refs; --staged, --all, --since and --fail-under do not apply.");
|
|
131
|
+
}
|
|
132
|
+
const controller = new AbortController();
|
|
133
|
+
const abort = () => controller.abort();
|
|
134
|
+
process.once("SIGINT", abort);
|
|
135
|
+
process.once("SIGTERM", abort);
|
|
136
|
+
try {
|
|
137
|
+
const result = await reviewChanges({
|
|
138
|
+
cwd: repoPath,
|
|
139
|
+
base: options.base,
|
|
140
|
+
head: options.head,
|
|
141
|
+
failOn: options.failOn !== void 0 ? options.failOn.split(",").map((rule) => rule.trim()).filter(Boolean) : void 0,
|
|
142
|
+
format: options.json ? "json" : options.format,
|
|
143
|
+
signal: controller.signal
|
|
144
|
+
});
|
|
145
|
+
if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, formatReviewMarkdown(result) + "\n");
|
|
146
|
+
if (process.env.GITHUB_OUTPUT) fs.appendFileSync(
|
|
147
|
+
process.env.GITHUB_OUTPUT,
|
|
148
|
+
`passed=${result.passed}
|
|
149
|
+
infractions-count=${result.infractions.length}
|
|
150
|
+
has-supported-changes=${result.filesInspected > 0}
|
|
151
|
+
complete=${result.complete}
|
|
152
|
+
existing-count=${result.unchangedCount}
|
|
153
|
+
removed-count=${result.removedFindings.length}
|
|
154
|
+
`
|
|
155
|
+
);
|
|
156
|
+
console.log(result.output);
|
|
157
|
+
process.exitCode = result.exitCode;
|
|
158
|
+
} finally {
|
|
159
|
+
process.removeListener("SIGINT", abort);
|
|
160
|
+
process.removeListener("SIGTERM", abort);
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error(`agent-roast review error: ${error instanceof Error ? error.message : String(error)}`);
|
|
164
|
+
process.exitCode = 2;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
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
168
|
try {
|
|
125
169
|
const global = program.opts();
|
|
126
170
|
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
|
@@ -86,7 +86,7 @@ interface PrAuditResult {
|
|
|
86
86
|
interface CheckOptions {
|
|
87
87
|
cwd?: string;
|
|
88
88
|
failOn?: string[];
|
|
89
|
-
format?:
|
|
89
|
+
format?: ReviewFormat;
|
|
90
90
|
baseline?: string;
|
|
91
91
|
signal?: AbortSignal;
|
|
92
92
|
}
|
|
@@ -109,6 +109,45 @@ interface CheckResult {
|
|
|
109
109
|
exitCode: number;
|
|
110
110
|
output: string;
|
|
111
111
|
}
|
|
112
|
+
type ReviewFormat = "terminal" | "json" | "github" | "markdown" | "sarif";
|
|
113
|
+
interface ReviewOptions {
|
|
114
|
+
cwd?: string;
|
|
115
|
+
base: string;
|
|
116
|
+
head?: string;
|
|
117
|
+
failOn?: string[];
|
|
118
|
+
format?: ReviewFormat;
|
|
119
|
+
signal?: AbortSignal;
|
|
120
|
+
}
|
|
121
|
+
interface ReviewResult {
|
|
122
|
+
schemaVersion: string;
|
|
123
|
+
mode: "review";
|
|
124
|
+
baseSha: string;
|
|
125
|
+
headSha: string;
|
|
126
|
+
mergeBaseSha: string;
|
|
127
|
+
policySha: string;
|
|
128
|
+
policyFile: string | null;
|
|
129
|
+
policyChanges: string[];
|
|
130
|
+
filesChanged: number;
|
|
131
|
+
filesInspected: number;
|
|
132
|
+
unsupportedFiles: number;
|
|
133
|
+
excludedFiles: number;
|
|
134
|
+
renamedFiles: number;
|
|
135
|
+
linesInspected: number;
|
|
136
|
+
beforeFindings: number;
|
|
137
|
+
afterFindings: number;
|
|
138
|
+
unchangedCount: number;
|
|
139
|
+
infractions: Infraction[];
|
|
140
|
+
removedFindings: Infraction[];
|
|
141
|
+
skippedFiles: {
|
|
142
|
+
file: string;
|
|
143
|
+
reason: string;
|
|
144
|
+
}[];
|
|
145
|
+
complete: boolean;
|
|
146
|
+
failedRules: string[];
|
|
147
|
+
passed: boolean;
|
|
148
|
+
exitCode: number;
|
|
149
|
+
output: string;
|
|
150
|
+
}
|
|
112
151
|
|
|
113
152
|
interface AiAttributionResult {
|
|
114
153
|
isAiAttributed: boolean;
|
|
@@ -246,6 +285,8 @@ declare function formatPrMarkdownComment(result: PrAuditResult): string;
|
|
|
246
285
|
/** Current-tree report emphasizes next actions and explicitly accounts for baseline debt. */
|
|
247
286
|
declare function formatCheckTerminal(result: CheckResult): string;
|
|
248
287
|
declare function formatCheckMarkdown(result: CheckResult): string;
|
|
288
|
+
declare function formatReviewTerminal(result: ReviewResult): string;
|
|
289
|
+
declare function formatReviewMarkdown(result: ReviewResult): string;
|
|
249
290
|
|
|
250
291
|
interface AgentRoastConfig {
|
|
251
292
|
/** Detector rules to fail CI on in PR mode */
|
|
@@ -260,9 +301,12 @@ interface AgentRoastConfig {
|
|
|
260
301
|
panicLoopWindowMinutes?: number;
|
|
261
302
|
}
|
|
262
303
|
declare const DEFAULT_CONFIG: AgentRoastConfig;
|
|
304
|
+
declare const CONFIG_FILENAMES: string[];
|
|
263
305
|
declare class ConfigError extends Error {
|
|
264
306
|
constructor(message: string);
|
|
265
307
|
}
|
|
308
|
+
/** Parse one explicit policy source; shared by worktree and trusted Git reviews. */
|
|
309
|
+
declare function configFromText(text: string, source: string): AgentRoastConfig;
|
|
266
310
|
/** Invalid policy must stop the audit instead of silently changing CI behavior. */
|
|
267
311
|
declare function loadConfig(cwd?: string): AgentRoastConfig;
|
|
268
312
|
/**
|
|
@@ -287,7 +331,7 @@ declare const DEFAULT_CONFIG_TEMPLATE: {
|
|
|
287
331
|
};
|
|
288
332
|
scoreThreshold: number;
|
|
289
333
|
};
|
|
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";
|
|
334
|
+
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
335
|
declare const PRE_COMMIT_HOOK_TEMPLATE = "#!/bin/sh\n# Run agent-roast pre-commit verification\nnpx --no-install agent-roast --staged\n";
|
|
292
336
|
declare function initProject(options?: InitOptions): Promise<InitResult>;
|
|
293
337
|
|
|
@@ -308,6 +352,20 @@ declare function generateShieldsJson(score: number | null, label?: string): Shie
|
|
|
308
352
|
/** Read current tracked and non-ignored untracked source; never run project code. */
|
|
309
353
|
declare function checkProject(options?: CheckOptions): Promise<CheckResult>;
|
|
310
354
|
|
|
355
|
+
/** Match only within the same changed-file pair; Git renames remap old paths.
|
|
356
|
+
* Counts matter: an identical second shortcut is still a new finding. */
|
|
357
|
+
declare function compareFindings(before: Infraction[], after: Infraction[], newPath: string): {
|
|
358
|
+
introduced: Infraction[];
|
|
359
|
+
removed: Infraction[];
|
|
360
|
+
unchanged: number;
|
|
361
|
+
};
|
|
362
|
+
/** Compare full source blobs in changed files without executing or checking out the candidate. */
|
|
363
|
+
declare function reviewChanges(options: ReviewOptions): Promise<ReviewResult>;
|
|
364
|
+
|
|
365
|
+
/** Offline SARIF 2.1.0, with relative URIs and explicit incomplete-run status.
|
|
366
|
+
* Snippets are omitted to avoid embedding source code in integration artifacts. */
|
|
367
|
+
declare function formatSarif(report: CheckResult | ReviewResult): string;
|
|
368
|
+
|
|
311
369
|
interface FindingBaseline {
|
|
312
370
|
schemaVersion: "1.0.0";
|
|
313
371
|
fingerprintVersion: 1;
|
|
@@ -346,4 +404,4 @@ declare function roastRepository(options?: RoastOptions): Promise<{
|
|
|
346
404
|
}>;
|
|
347
405
|
declare function auditPullRequest(options: PrAuditOptions): Promise<PrAuditResult>;
|
|
348
406
|
|
|
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 };
|
|
407
|
+
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, 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, 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,16 @@ import {
|
|
|
44
50
|
parseDiff,
|
|
45
51
|
parseGitLogStream,
|
|
46
52
|
resolveCommitSha,
|
|
53
|
+
reviewChanges,
|
|
47
54
|
roastRepository,
|
|
48
55
|
streamGitDiff,
|
|
49
56
|
streamGitLog,
|
|
50
57
|
stripStringAndRegexLiterals,
|
|
51
58
|
unquoteGitPath,
|
|
52
59
|
writeBaseline
|
|
53
|
-
} from "./chunk-
|
|
60
|
+
} from "./chunk-RU5OYHLR.js";
|
|
54
61
|
export {
|
|
62
|
+
CONFIG_FILENAMES,
|
|
55
63
|
ConfigError,
|
|
56
64
|
DEFAULT_CONFIG,
|
|
57
65
|
DEFAULT_CONFIG_TEMPLATE,
|
|
@@ -70,6 +78,8 @@ export {
|
|
|
70
78
|
auditFileDiffs,
|
|
71
79
|
auditPullRequest,
|
|
72
80
|
checkProject,
|
|
81
|
+
compareFindings,
|
|
82
|
+
configFromText,
|
|
73
83
|
createBaseline,
|
|
74
84
|
detectAiAttribution,
|
|
75
85
|
findMergeBase,
|
|
@@ -80,6 +90,9 @@ export {
|
|
|
80
90
|
formatGithubStepSummary,
|
|
81
91
|
formatPrMarkdownComment,
|
|
82
92
|
formatPrTerminalCard,
|
|
93
|
+
formatReviewMarkdown,
|
|
94
|
+
formatReviewTerminal,
|
|
95
|
+
formatSarif,
|
|
83
96
|
formatTerminalCard,
|
|
84
97
|
generateShareUrl,
|
|
85
98
|
generateShieldsJson,
|
|
@@ -96,6 +109,7 @@ export {
|
|
|
96
109
|
parseDiff,
|
|
97
110
|
parseGitLogStream,
|
|
98
111
|
resolveCommitSha,
|
|
112
|
+
reviewChanges,
|
|
99
113
|
roastRepository,
|
|
100
114
|
streamGitDiff,
|
|
101
115
|
streamGitLog,
|