agent-roast 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -492
- package/dist/{chunk-WMHB65AL.js → chunk-AP5Y4CH6.js} +712 -63
- package/dist/cli.js +243 -14
- package/dist/index.d.ts +105 -2
- package/dist/index.js +43 -1
- package/package.json +12 -4
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import
|
|
4
|
+
import path11 from "path";
|
|
5
5
|
|
|
6
6
|
// src/git.ts
|
|
7
7
|
import { spawn, spawnSync } from "child_process";
|
|
@@ -847,12 +847,12 @@ var VALID_DETECTORS = [
|
|
|
847
847
|
// src/file-read.ts
|
|
848
848
|
import * as fs from "fs";
|
|
849
849
|
function readRegularFile(filename, limit) {
|
|
850
|
-
const
|
|
851
|
-
if (!
|
|
850
|
+
const stat3 = fs.lstatSync(filename);
|
|
851
|
+
if (!stat3.isFile() || stat3.isSymbolicLink() || stat3.size > limit) throw new Error(`Expected a regular file no larger than ${limit} bytes.`);
|
|
852
852
|
const fd = fs.openSync(filename, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
|
|
853
853
|
try {
|
|
854
854
|
const opened = fs.fstatSync(fd);
|
|
855
|
-
if (!opened.isFile() || opened.ino !==
|
|
855
|
+
if (!opened.isFile() || opened.ino !== stat3.ino || opened.dev !== stat3.dev) throw new Error("File changed while opening.");
|
|
856
856
|
const buffer = Buffer.alloc(limit + 1);
|
|
857
857
|
let size = 0;
|
|
858
858
|
while (size < buffer.length) {
|
|
@@ -1183,6 +1183,98 @@ function isGuardedSkip(lines) {
|
|
|
1183
1183
|
}
|
|
1184
1184
|
return false;
|
|
1185
1185
|
}
|
|
1186
|
+
var GO_GUARD = {
|
|
1187
|
+
conditional: /^\s*(?:\}\s*)?(?:if|else|switch|case|select|for)\b/,
|
|
1188
|
+
test: /^\s*func\s+(?:\([^)]*\)\s*)?(?:Test|Benchmark|Fuzz|Example)\w*\s*\(|\.Run\s*\(|\bfunc\s*\(\s*\w+\s+\*testing\.[TB]\s*\)/,
|
|
1189
|
+
helper: /^\s*func\s+(?:\([^)]*\)\s*)?\w+\s*\(/
|
|
1190
|
+
};
|
|
1191
|
+
var RUBY_GUARD = {
|
|
1192
|
+
modifier: /^\s*(?:skip|pending)\b.*\b(?:if|unless)\b/,
|
|
1193
|
+
conditional: /^\s*(?:if|unless|else|elsif|case|when|begin|rescue|ensure|while|until)\b/,
|
|
1194
|
+
test: /^\s*(?:it|specify|example|scenario|test|before|after|around)\b|^\s*def\s+test_/,
|
|
1195
|
+
helper: /^\s*def\s+\w+/
|
|
1196
|
+
};
|
|
1197
|
+
var PHP_GUARD = {
|
|
1198
|
+
modifier: /\b(?:or|and)\b.*markTest|(?:&&|\|\||\?).*markTest/,
|
|
1199
|
+
conditional: /^\s*(?:\}\s*)?(?:if|else|elseif|switch|case|foreach|for|while|try|catch)\b/,
|
|
1200
|
+
test: /^\s*(?:(?:public|protected|private|static)\s+)*function\s+test\w*\s*\(/,
|
|
1201
|
+
helper: /^\s*(?:(?:public|protected|private|static)\s+)*function\s+\w+\s*\(/
|
|
1202
|
+
};
|
|
1203
|
+
var SWIFT_GUARD = {
|
|
1204
|
+
conditional: /^\s*(?:\}\s*)?(?:if|else|guard|switch|case|for|while|do|catch)\b/,
|
|
1205
|
+
test: /^\s*(?:@\w+\s+)*(?:(?:private|fileprivate|static)\s+)*func\s+test\w*\s*\(/,
|
|
1206
|
+
helper: /^\s*(?:@\w+\s+)*(?:(?:private|fileprivate|static)\s+)*func\s+\w+\s*\(/
|
|
1207
|
+
};
|
|
1208
|
+
var LANGUAGE_LINE_RULES = [
|
|
1209
|
+
{ files: /_test\.go$/i, rules: [
|
|
1210
|
+
{ type: "test-skip", regex: /\b(?:t|tb|b)\s*\.\s*(?:Skip|Skipf|SkipNow)\s*\(|\.T\(\)\s*\.\s*(?:Skip|Skipf|SkipNow)\s*\(/, name: "skipped Go test (t.Skip)", guard: GO_GUARD },
|
|
1211
|
+
{ type: "trivial-assert", regex: /\b(?:assert|require)\s*\.\s*True\s*\(\s*\w+\s*,\s*true\s*\)/, name: "trivial assert (assert.True(t, true))" },
|
|
1212
|
+
{ type: "trivial-assert", regex: /\b(?:assert|require)\s*\.\s*False\s*\(\s*\w+\s*,\s*false\s*\)/, name: "trivial assert (assert.False(t, false))" },
|
|
1213
|
+
{ type: "trivial-assert", regex: /\b(?:assert|require)\s*\.\s*Equal\s*\(\s*\w+\s*,\s*(\d+|true|false|nil)\s*,\s*\1\s*\)/, name: "trivial assert (assert.Equal(t, 1, 1))" }
|
|
1214
|
+
] },
|
|
1215
|
+
{ files: /\.rs$/i, rules: [
|
|
1216
|
+
{ type: "test-skip", regex: /^\s*#\[\s*ignore\b/, name: "ignored Rust test (#[ignore])" },
|
|
1217
|
+
{ type: "trivial-assert", regex: /(?<![\w!])assert!\s*\(\s*true\s*\)/, name: "trivial assert (assert!(true))" },
|
|
1218
|
+
{ type: "trivial-assert", regex: /(?<![\w!])assert_eq!\s*\(\s*(\d+|true|false)\s*,\s*\1\s*\)/, name: "trivial assert (assert_eq!(1, 1))" }
|
|
1219
|
+
] },
|
|
1220
|
+
{ files: /\.(?:java|kt)$/i, rules: [
|
|
1221
|
+
{ type: "test-skip", regex: /(?:^|\s)@(?:Ignore|Disabled)\b/, name: "ignored JUnit test (@Ignore / @Disabled)" },
|
|
1222
|
+
{ type: "test-skip", regex: /@Test\s*\([^)]*\benabled\s*=\s*false\b/, name: "disabled TestNG test (enabled = false)" },
|
|
1223
|
+
{ type: "trivial-assert", regex: /\bassertTrue\s*\(\s*true\s*\)/, name: "trivial assert (assertTrue(true))" },
|
|
1224
|
+
{ type: "trivial-assert", regex: /\bassertFalse\s*\(\s*false\s*\)/, name: "trivial assert (assertFalse(false))" },
|
|
1225
|
+
{ type: "trivial-assert", regex: /\bassertEquals\s*\(\s*(\d+|true|false|null)\s*,\s*\1\s*\)/, name: "trivial assert (assertEquals(1, 1))" },
|
|
1226
|
+
{ type: "trivial-assert", regex: /\bassertThat\s*\(\s*true\s*\)\s*\.\s*isTrue\s*\(/, name: "trivial assert (assertThat(true).isTrue())" }
|
|
1227
|
+
] },
|
|
1228
|
+
{ files: /\.cs$/i, rules: [
|
|
1229
|
+
{ type: "test-skip", regex: /\[\s*Ignore\b|\b(?:Fact|Theory)\s*\(\s*(?:[^)]*,\s*)?Skip\s*=/, name: "ignored .NET test ([Ignore] / Skip =)" },
|
|
1230
|
+
{ type: "trivial-assert", regex: /\bAssert\s*\.\s*(?:True|IsTrue)\s*\(\s*true\s*\)/, name: "trivial assert (Assert.True(true))" },
|
|
1231
|
+
{ type: "trivial-assert", regex: /\bAssert\s*\.\s*(?:False|IsFalse)\s*\(\s*false\s*\)/, name: "trivial assert (Assert.False(false))" },
|
|
1232
|
+
{ type: "trivial-assert", regex: /\bAssert\s*\.\s*(?:AreEqual|Equal)\s*\(\s*(\d+|true|false|null)\s*,\s*\1\s*\)/, name: "trivial assert (Assert.Equal(1, 1))" }
|
|
1233
|
+
] },
|
|
1234
|
+
// Ruby has no file-name convention as strict as the others, and `skip`/`pending` are ordinary method names: only spec and test files.
|
|
1235
|
+
{ files: /(?:^|\/)(?:spec|test)\/.*\.rb$|_(?:spec|test)\.rb$/i, rules: [
|
|
1236
|
+
{ type: "test-focus", regex: /^\s*(?:fit|fdescribe|fcontext|fspecify|fexample|fscenario)\b/, name: "focused RSpec example (fit / fdescribe)" },
|
|
1237
|
+
{ type: "test-focus", regex: /\b(?:it|describe|context|specify|scenario)\b[^\n]*[,(]\s*(?::focus\b|focus:\s*true\b)/, name: "focused RSpec example (focus: true)" },
|
|
1238
|
+
{ type: "test-skip", regex: /^\s*(?:xit|xdescribe|xcontext|xspecify|xexample|xscenario|xfeature)\b/, name: "disabled RSpec example (xit / xdescribe)" },
|
|
1239
|
+
{ type: "test-skip", regex: /\b(?:it|describe|context|specify|scenario)\b[^\n]*[,(]\s*(?::skip\b|skip:\s*(?:true\b|["']))/, name: "disabled RSpec example (skip: true)" },
|
|
1240
|
+
{ type: "test-skip", regex: /^\s*skip(?:\s|\(|$)/, name: "skipped Ruby test (skip)", guard: RUBY_GUARD },
|
|
1241
|
+
{ type: "test-skip", regex: /^\s*pending(?:\s+["'(]|\s*$)/, name: "pending Ruby example (pending)", guard: RUBY_GUARD },
|
|
1242
|
+
{ type: "trivial-assert", regex: /\bexpect\s*\(\s*(true|false|1|0|nil)\s*\)\s*\.\s*to\s+(?:eq|eql|be|equal)\b\s*\(?\s*\1\b/, name: "trivial expectation (expect(true).to eq(true))" },
|
|
1243
|
+
{ type: "trivial-assert", regex: /^\s*assert\s*\(?\s*true\s*\)?\s*$/, name: "trivial assert (assert true)" },
|
|
1244
|
+
{ type: "trivial-assert", regex: /\bassert_equal\s*\(?\s*(\d+|true|false|nil)\s*,\s*\1\s*\)?\s*$/, name: "trivial assert (assert_equal 1, 1)" }
|
|
1245
|
+
] },
|
|
1246
|
+
{ files: /\.php$/i, rules: [
|
|
1247
|
+
{ type: "test-skip", regex: /\$this\s*->\s*markTest(?:Skipped|Incomplete)\s*\(/, name: "skipped PHPUnit test (markTestSkipped)", guard: PHP_GUARD },
|
|
1248
|
+
{ type: "trivial-assert", regex: /\$this\s*->\s*assertTrue\s*\(\s*true\s*\)/, name: "trivial assert ($this->assertTrue(true))" },
|
|
1249
|
+
{ type: "trivial-assert", regex: /\$this\s*->\s*assertFalse\s*\(\s*false\s*\)/, name: "trivial assert ($this->assertFalse(false))" },
|
|
1250
|
+
{ type: "trivial-assert", regex: /\$this\s*->\s*assert(?:Equals|Same)\s*\(\s*(\d+|true|false|null)\s*,\s*\1\s*\)/, name: "trivial assert ($this->assertEquals(1, 1))" }
|
|
1251
|
+
] },
|
|
1252
|
+
{ files: /\.swift$/i, rules: [
|
|
1253
|
+
{ type: "test-skip", regex: /\bthrow\s+XCTSkip\s*\(/, name: "skipped XCTest (XCTSkip)", guard: SWIFT_GUARD },
|
|
1254
|
+
{ type: "test-skip", regex: /@(?:Test|Suite)\s*\([^)]*\.disabled\b/, name: "disabled Swift Testing test (.disabled)" },
|
|
1255
|
+
{ type: "trivial-assert", regex: /\bXCTAssertTrue\s*\(\s*true\s*\)/, name: "trivial assert (XCTAssertTrue(true))" },
|
|
1256
|
+
{ type: "trivial-assert", regex: /\bXCTAssertFalse\s*\(\s*false\s*\)/, name: "trivial assert (XCTAssertFalse(false))" },
|
|
1257
|
+
{ type: "trivial-assert", regex: /\bXCTAssertEqual\s*\(\s*(\d+|true|false|nil)\s*,\s*\1\s*\)/, name: "trivial assert (XCTAssertEqual(1, 1))" },
|
|
1258
|
+
{ type: "trivial-assert", regex: /#expect\s*\(\s*true\s*\)/, name: "trivial expectation (#expect(true))" }
|
|
1259
|
+
] }
|
|
1260
|
+
];
|
|
1261
|
+
function isGuardedBlockSkip(lines, profile) {
|
|
1262
|
+
const indentOf = (text) => text.length - text.trimStart().length;
|
|
1263
|
+
const mentionsCi = (raw) => /\bCI\b|\bGITHUB_ACTIONS\b/.test(raw);
|
|
1264
|
+
const own = lines[lines.length - 1];
|
|
1265
|
+
if (profile.modifier?.test(own.code) || profile.conditional.test(own.code)) return !mentionsCi(own.raw);
|
|
1266
|
+
let limit = indentOf(own.code);
|
|
1267
|
+
for (let i = lines.length - 2; i >= 0 && lines.length - 1 - i <= 200; i--) {
|
|
1268
|
+
if (lines[i].number !== lines[i + 1].number - 1) return false;
|
|
1269
|
+
const { code, raw } = lines[i];
|
|
1270
|
+
if (!code.trim() || indentOf(code) >= limit) continue;
|
|
1271
|
+
if (profile.conditional.test(code)) return !mentionsCi(raw);
|
|
1272
|
+
if (profile.test.test(code)) return false;
|
|
1273
|
+
if (profile.helper.test(code)) return true;
|
|
1274
|
+
limit = indentOf(code);
|
|
1275
|
+
}
|
|
1276
|
+
return false;
|
|
1277
|
+
}
|
|
1186
1278
|
var JS_TEST_API = /(?<![\w$.])(?:(?:it|test|describe|suite)(?:\s*\.\s*(?:concurrent|serial|parallel|describe|failing|sequential))*\s*\.\s*(skip|todo|only)|((?:xit|xtest|xdescribe|fit|fdescribe)))\b((?:\s*\.\s*(?:each|failing|concurrent|serial|parallel|sequential))*)\s*(?=\(|\u0001)/g;
|
|
1187
1279
|
function isConditionalSkip(source, raw, open2) {
|
|
1188
1280
|
if (source[open2] !== "(") return false;
|
|
@@ -1268,7 +1360,8 @@ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
|
1268
1360
|
".rb",
|
|
1269
1361
|
".cs",
|
|
1270
1362
|
".cpp",
|
|
1271
|
-
".c"
|
|
1363
|
+
".c",
|
|
1364
|
+
".swift"
|
|
1272
1365
|
]);
|
|
1273
1366
|
var IGNORED_PATH_REGEX = /(?:^|\/)(?:fixtures|__fixtures__|testdata|mocks|test_data|__snapshots__|sample[s]?)\//i;
|
|
1274
1367
|
var TEST_PATH_REGEX = /(?:^|\/)test_[^/]+\.py$|(?:^|\/)(?:tests?|__tests__|specs?)\/|[._-](?:test|spec)\.[a-z0-9]+$/i;
|
|
@@ -1304,6 +1397,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1304
1397
|
const directives = javascript ? JS_COMMENT_DIRECTIVE_PATTERNS : python ? PYTHON_COMMENT_DIRECTIVE_PATTERNS : [];
|
|
1305
1398
|
const stubs = javascript ? JS_STUB_PATTERNS : python ? PYTHON_STUB_PATTERNS : rust ? RUST_STUB_PATTERNS : [];
|
|
1306
1399
|
const firstFinding = infractions.length;
|
|
1400
|
+
const languageRules = LANGUAGE_LINE_RULES.find((entry) => entry.files.test(file.path))?.rules;
|
|
1307
1401
|
const masker = new SourceMasker(file.path);
|
|
1308
1402
|
const maskedLines = [];
|
|
1309
1403
|
for (const added of file.addedLines) {
|
|
@@ -1331,6 +1425,14 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1331
1425
|
}
|
|
1332
1426
|
}
|
|
1333
1427
|
}
|
|
1428
|
+
if (languageRules && !isComment) {
|
|
1429
|
+
for (const rule of languageRules) {
|
|
1430
|
+
if (!rule.regex.test(cleanedLine)) continue;
|
|
1431
|
+
if (rule.guard && isGuardedBlockSkip(maskedLines, rule.guard)) break;
|
|
1432
|
+
infractions.push({ type: rule.type, file: file.path, lineNumber: added.lineNumber, lineSnippet: rawLine.trim(), commitHash, commitDate, reason: rule.name });
|
|
1433
|
+
break;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1334
1436
|
let foundEscape = false;
|
|
1335
1437
|
for (const rule of directives) {
|
|
1336
1438
|
if (rule.regex.test(masked.comments)) {
|
|
@@ -2146,12 +2248,15 @@ function formatCheckMarkdown(result) {
|
|
|
2146
2248
|
for (const skipped of result.skippedFiles) lines.push("", `Not scanned: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
|
|
2147
2249
|
return lines.join("\n");
|
|
2148
2250
|
}
|
|
2251
|
+
var REVIEW_TITLES = { commits: "context review", index: "staged context review", worktree: "working tree review" };
|
|
2252
|
+
var reviewTitle = (result) => REVIEW_TITLES[result.source ?? "commits"];
|
|
2253
|
+
var reviewHead = (result) => result.headSha.slice(0, (result.source ?? "commits") === "commits" ? 7 : 18);
|
|
2149
2254
|
function formatReviewTerminal(result) {
|
|
2150
2255
|
const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
|
|
2151
2256
|
const lines = [
|
|
2152
2257
|
"",
|
|
2153
|
-
pc.bold(`agent-roast \u2014 ${result
|
|
2154
|
-
` ${result.mergeBaseSha.slice(0, 7)} \u2192 ${
|
|
2258
|
+
pc.bold(`agent-roast \u2014 ${reviewTitle(result)}: ${status}`),
|
|
2259
|
+
` ${result.mergeBaseSha.slice(0, 7)} \u2192 ${reviewHead(result)} \xB7 policy: ${result.policySha === "EMPTY" ? "built-in first-commit defaults" : "base " + result.policySha.slice(0, 7)}`,
|
|
2155
2260
|
` ${result.filesInspected} changed files compared \xB7 ${result.linesInspected} source lines inspected across both versions`,
|
|
2156
2261
|
` ${result.infractions.length} new \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected`
|
|
2157
2262
|
];
|
|
@@ -2159,25 +2264,26 @@ function formatReviewTerminal(result) {
|
|
|
2159
2264
|
if (result.policyChanges.length) lines.push(" Candidate policy changes were ignored; adopt them separately after review.");
|
|
2160
2265
|
if (result.excludedFiles || result.unsupportedFiles) lines.push(` ${result.excludedFiles} excluded \xB7 ${result.unsupportedFiles} unsupported/non-source changes`);
|
|
2161
2266
|
for (const finding2 of mostImportantFirst(result.infractions, result.failedRules).slice(0, 20)) {
|
|
2267
|
+
const failing = result.failedRules.includes(finding2.type);
|
|
2162
2268
|
lines.push(
|
|
2163
2269
|
"",
|
|
2164
|
-
` ${
|
|
2270
|
+
(failing ? pc.red : pc.yellow)(` ${failing ? "FAIL" : "WARN"} [${finding2.type}] ${terminalText(finding2.file || "unknown")}:${finding2.lineNumber}`),
|
|
2165
2271
|
` ${terminalText(finding2.lineSnippet || finding2.reason)}`,
|
|
2166
|
-
` Next: ${terminalText(finding2.suggestion || "Review this occurrence.")}`
|
|
2272
|
+
pc.dim(` Next: ${terminalText(finding2.suggestion || "Review this occurrence.")}`)
|
|
2167
2273
|
);
|
|
2168
2274
|
}
|
|
2169
2275
|
if (result.infractions.length > 20) lines.push(` ${result.infractions.length - 20} more new findings; use --format json for all results.`);
|
|
2170
2276
|
for (const skipped of result.skippedFiles) lines.push(` NOT COMPARED ${terminalText(skipped.file)}: ${terminalText(skipped.reason)}`);
|
|
2171
|
-
lines.push("", !result.complete ? " INCOMPLETE: some changed source was not compared (exit 2)." : result.passed ? " PASSED: no new blocking findings." : ` BLOCKED: ${result.failedRules.join(", ")}
|
|
2277
|
+
lines.push("", !result.complete ? pc.red(" INCOMPLETE: some changed source was not compared (exit 2).") : result.passed ? pc.green(" PASSED: no new blocking findings.") : pc.red(` BLOCKED: ${result.failedRules.join(", ")}.`), "");
|
|
2172
2278
|
if (!result.filesInspected) lines.push(" No supported changed source files compared.", "");
|
|
2173
2279
|
return lines.join("\n");
|
|
2174
2280
|
}
|
|
2175
2281
|
function formatReviewMarkdown(result) {
|
|
2176
2282
|
const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
|
|
2177
2283
|
const lines = [
|
|
2178
|
-
`## agent-roast ${result
|
|
2284
|
+
`## agent-roast ${reviewTitle(result)}: ${status}`,
|
|
2179
2285
|
"",
|
|
2180
|
-
`Compared ${result.filesInspected} changed files at \`${result.mergeBaseSha.slice(0, 7)}\` \u2192 \`${
|
|
2286
|
+
`Compared ${result.filesInspected} changed files at \`${result.mergeBaseSha.slice(0, 7)}\` \u2192 \`${reviewHead(result)}\`. Policy: \`${result.policySha === "EMPTY" ? "built-in first-commit defaults" : result.policySha.slice(0, 7)}\`.`,
|
|
2181
2287
|
"",
|
|
2182
2288
|
`**${result.infractions.length} new** \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected.`,
|
|
2183
2289
|
""
|
|
@@ -2257,9 +2363,9 @@ function createNewFile(root, relative2, content, mode = 420) {
|
|
|
2257
2363
|
let directory = root;
|
|
2258
2364
|
for (const segment of path3.dirname(inside).split(path3.sep).filter((part) => part !== ".")) {
|
|
2259
2365
|
directory = path3.join(directory, segment);
|
|
2260
|
-
let
|
|
2366
|
+
let stat3;
|
|
2261
2367
|
try {
|
|
2262
|
-
|
|
2368
|
+
stat3 = fs4.lstatSync(directory);
|
|
2263
2369
|
} catch (error) {
|
|
2264
2370
|
if (error.code !== "ENOENT") throw error;
|
|
2265
2371
|
try {
|
|
@@ -2267,9 +2373,9 @@ function createNewFile(root, relative2, content, mode = 420) {
|
|
|
2267
2373
|
} catch (mkdirError) {
|
|
2268
2374
|
if (mkdirError.code !== "EEXIST") throw mkdirError;
|
|
2269
2375
|
}
|
|
2270
|
-
|
|
2376
|
+
stat3 = fs4.lstatSync(directory);
|
|
2271
2377
|
}
|
|
2272
|
-
if (!
|
|
2378
|
+
if (!stat3.isDirectory() || stat3.isSymbolicLink()) throw new ConfigError("Initialization refuses symlinked or non-directory parent paths.");
|
|
2273
2379
|
}
|
|
2274
2380
|
const temporary = path3.join(directory, `.agent-roast-init-${randomUUID3()}.tmp`);
|
|
2275
2381
|
let fd;
|
|
@@ -2522,14 +2628,15 @@ import { constants as constants2 } from "fs";
|
|
|
2522
2628
|
import path5 from "path";
|
|
2523
2629
|
|
|
2524
2630
|
// src/snapshot.ts
|
|
2525
|
-
import { createHash as createHash2 } from "crypto";
|
|
2631
|
+
import { createHash as createHash2, randomBytes } from "crypto";
|
|
2526
2632
|
import * as fs5 from "fs/promises";
|
|
2633
|
+
import { constants as fsConstants } from "fs";
|
|
2527
2634
|
import path4 from "path";
|
|
2528
2635
|
import { spawn as spawn2 } from "child_process";
|
|
2529
2636
|
function assertNotAborted(signal) {
|
|
2530
2637
|
if (signal?.aborted) throw new ConfigError("Review cancelled; no complete result was produced.");
|
|
2531
2638
|
}
|
|
2532
|
-
async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024, indexFile) {
|
|
2639
|
+
async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024, indexFile, input) {
|
|
2533
2640
|
assertNotAborted(signal);
|
|
2534
2641
|
const env = getHermeticGitEnv({ GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0" });
|
|
2535
2642
|
if (indexFile) env.GIT_INDEX_FILE = indexFile;
|
|
@@ -2537,9 +2644,12 @@ async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024, indexFile) {
|
|
|
2537
2644
|
const child = spawn2("git", [...HERMETIC_GIT_FLAGS, ...args], {
|
|
2538
2645
|
cwd,
|
|
2539
2646
|
env,
|
|
2540
|
-
stdio: ["
|
|
2647
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2541
2648
|
windowsHide: true
|
|
2542
2649
|
});
|
|
2650
|
+
child.stdin.on("error", () => {
|
|
2651
|
+
});
|
|
2652
|
+
child.stdin.end(input);
|
|
2543
2653
|
const chunks = [];
|
|
2544
2654
|
let bytes = 0;
|
|
2545
2655
|
let stderr = "";
|
|
@@ -2659,35 +2769,36 @@ async function reviewPolicy(cwd, baseSha, signal) {
|
|
|
2659
2769
|
}
|
|
2660
2770
|
return { config: configFromText("{}", "default review policy"), filename: null };
|
|
2661
2771
|
}
|
|
2772
|
+
async function resolveHead(cwd, signal) {
|
|
2773
|
+
try {
|
|
2774
|
+
return (await readGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], signal, 1024)).toString().trim();
|
|
2775
|
+
} catch (error) {
|
|
2776
|
+
if (!(error instanceof GitError && error.code === 1)) throw error;
|
|
2777
|
+
const ref = (await readGit(cwd, ["symbolic-ref", "--quiet", "HEAD"], signal, 1024)).toString().trim();
|
|
2778
|
+
try {
|
|
2779
|
+
await readGit(cwd, ["show-ref", "--verify", "--quiet", ref], signal, 1024);
|
|
2780
|
+
} catch (missing) {
|
|
2781
|
+
if (missing instanceof GitError && missing.code === 1) return "EMPTY";
|
|
2782
|
+
throw missing;
|
|
2783
|
+
}
|
|
2784
|
+
throw new ConfigError("HEAD exists but cannot be resolved to a commit.");
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2662
2787
|
async function stagedSnapshot(cwd, signal) {
|
|
2663
2788
|
await requireLocalObjects2(cwd, signal);
|
|
2664
2789
|
let indexFile;
|
|
2665
2790
|
if (process.env.GIT_INDEX_FILE) {
|
|
2666
|
-
const
|
|
2791
|
+
const gitDirectory2 = await fs5.realpath(gitOutputPath((await readGit(cwd, ["rev-parse", "--absolute-git-dir"], signal, 8192)).toString()));
|
|
2667
2792
|
const requested = path4.resolve(cwd, process.env.GIT_INDEX_FILE);
|
|
2668
|
-
const
|
|
2793
|
+
const stat3 = await fs5.lstat(requested);
|
|
2669
2794
|
const resolved = await fs5.realpath(requested);
|
|
2670
|
-
const relative2 = path4.relative(
|
|
2671
|
-
if (!
|
|
2795
|
+
const relative2 = path4.relative(gitDirectory2, resolved);
|
|
2796
|
+
if (!stat3.isFile() || stat3.isSymbolicLink() || !relative2 || relative2 === ".." || relative2.startsWith(".." + path4.sep) || path4.isAbsolute(relative2)) {
|
|
2672
2797
|
throw new ConfigError("GIT_INDEX_FILE must be a regular candidate index inside this repository's Git directory; refusing to review another index.");
|
|
2673
2798
|
}
|
|
2674
2799
|
indexFile = resolved;
|
|
2675
2800
|
}
|
|
2676
|
-
const head =
|
|
2677
|
-
try {
|
|
2678
|
-
return (await readGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], signal, 1024)).toString().trim();
|
|
2679
|
-
} catch (error) {
|
|
2680
|
-
if (!(error instanceof GitError && error.code === 1)) throw error;
|
|
2681
|
-
const ref = (await readGit(cwd, ["symbolic-ref", "--quiet", "HEAD"], signal, 1024)).toString().trim();
|
|
2682
|
-
try {
|
|
2683
|
-
await readGit(cwd, ["show-ref", "--verify", "--quiet", ref], signal, 1024);
|
|
2684
|
-
} catch (missing) {
|
|
2685
|
-
if (missing instanceof GitError && missing.code === 1) return "EMPTY";
|
|
2686
|
-
throw missing;
|
|
2687
|
-
}
|
|
2688
|
-
throw new ConfigError("HEAD exists but cannot be resolved to a commit.");
|
|
2689
|
-
}
|
|
2690
|
-
};
|
|
2801
|
+
const head = () => resolveHead(cwd, signal);
|
|
2691
2802
|
const baseSha = await head();
|
|
2692
2803
|
const inventory = () => readGit(cwd, [
|
|
2693
2804
|
"diff",
|
|
@@ -2724,6 +2835,99 @@ async function stagedSnapshot(cwd, signal) {
|
|
|
2724
2835
|
}
|
|
2725
2836
|
};
|
|
2726
2837
|
}
|
|
2838
|
+
var MAX_WORKTREE_FILE = 2 * 1024 * 1024;
|
|
2839
|
+
var outside = (relative2) => !relative2 || relative2 === ".." || relative2.startsWith(".." + path4.sep) || path4.isAbsolute(relative2);
|
|
2840
|
+
async function worktreeSnapshot(cwd, signal) {
|
|
2841
|
+
await requireLocalObjects2(cwd, signal);
|
|
2842
|
+
const text = async (...args) => gitOutputPath((await readGit(cwd, args, signal, 8192)).toString());
|
|
2843
|
+
const [root, gitDirectory2, indexPath, baseSha] = await Promise.all([
|
|
2844
|
+
text("rev-parse", "--show-toplevel").then((file) => fs5.realpath(file)),
|
|
2845
|
+
text("rev-parse", "--absolute-git-dir").then((file) => fs5.realpath(file)),
|
|
2846
|
+
text("rev-parse", "--git-path", "index"),
|
|
2847
|
+
resolveHead(cwd, signal)
|
|
2848
|
+
]);
|
|
2849
|
+
const realIndex = path4.resolve(cwd, indexPath);
|
|
2850
|
+
const indexFile = path4.join(gitDirectory2, `agent-roast-${process.pid}-${randomBytes(6).toString("hex")}.index`);
|
|
2851
|
+
const dispose = async () => {
|
|
2852
|
+
await Promise.all([indexFile, indexFile + ".lock"].map((file) => fs5.rm(file, { force: true })));
|
|
2853
|
+
};
|
|
2854
|
+
try {
|
|
2855
|
+
let restoreIndexTime = async () => {
|
|
2856
|
+
};
|
|
2857
|
+
try {
|
|
2858
|
+
const original = await fs5.stat(realIndex);
|
|
2859
|
+
await fs5.copyFile(realIndex, indexFile, fsConstants.COPYFILE_EXCL);
|
|
2860
|
+
const seconds = Math.floor(original.mtimeMs) / 1e3;
|
|
2861
|
+
restoreIndexTime = () => fs5.utimes(indexFile, seconds, seconds);
|
|
2862
|
+
await restoreIndexTime();
|
|
2863
|
+
} catch (error) {
|
|
2864
|
+
if (error.code !== "ENOENT") throw error;
|
|
2865
|
+
}
|
|
2866
|
+
const conflicts = await readGit(root, ["ls-files", "--unmerged", "-z", "--full-name"], signal, 8 * 1024 * 1024, indexFile);
|
|
2867
|
+
if (conflicts.length) throw new ConfigError("Resolve unmerged index entries before reviewing the working tree.");
|
|
2868
|
+
await readGit(root, ["update-index", "-q", "--refresh"], signal, 1024 * 1024, indexFile);
|
|
2869
|
+
await restoreIndexTime();
|
|
2870
|
+
await readGit(root, ["add", "--intent-to-add", "--", "."], signal, 1024 * 1024, indexFile);
|
|
2871
|
+
await restoreIndexTime();
|
|
2872
|
+
const tree = baseSha === "EMPTY" ? (await readGit(root, ["hash-object", "-t", "tree", "--stdin"], signal, 256, void 0, Buffer.alloc(0))).toString().trim() : baseSha;
|
|
2873
|
+
const inventory = () => readGit(root, [
|
|
2874
|
+
"diff-index",
|
|
2875
|
+
"--raw",
|
|
2876
|
+
"-z",
|
|
2877
|
+
"--no-abbrev",
|
|
2878
|
+
"--no-ext-diff",
|
|
2879
|
+
"--no-textconv",
|
|
2880
|
+
"--ignore-submodules=none",
|
|
2881
|
+
"--find-renames=50%",
|
|
2882
|
+
"-l1000",
|
|
2883
|
+
"--end-of-options",
|
|
2884
|
+
tree,
|
|
2885
|
+
"--"
|
|
2886
|
+
], signal, 8 * 1024 * 1024, indexFile);
|
|
2887
|
+
const pinned = await inventory();
|
|
2888
|
+
const changes = parseTreeChanges(pinned);
|
|
2889
|
+
const headSha = "WORKTREE:" + createHash2("sha256").update(baseSha).update(pinned).digest("hex");
|
|
2890
|
+
const stamps = /* @__PURE__ */ new Map();
|
|
2891
|
+
const stamp = (stat3) => `${stat3.size}:${stat3.mtimeMs}:${stat3.ino}`;
|
|
2892
|
+
return {
|
|
2893
|
+
baseSha,
|
|
2894
|
+
headSha,
|
|
2895
|
+
mergeBaseSha: baseSha,
|
|
2896
|
+
changes,
|
|
2897
|
+
readWorktree: async (relative2) => {
|
|
2898
|
+
const filename = path4.resolve(root, relative2);
|
|
2899
|
+
if (outside(path4.relative(root, filename))) throw new ConfigError("A changed path escapes the repository.");
|
|
2900
|
+
let stat3;
|
|
2901
|
+
try {
|
|
2902
|
+
stat3 = await fs5.lstat(filename);
|
|
2903
|
+
} catch (error) {
|
|
2904
|
+
if (error.code === "ENOENT") throw new ConfigError("A changed file disappeared during the review; rerun it.");
|
|
2905
|
+
throw error;
|
|
2906
|
+
}
|
|
2907
|
+
if (!stat3.isFile()) throw new ConfigError("Changed source is a symlink or special file; comparison is incomplete.");
|
|
2908
|
+
if (stat3.size > MAX_WORKTREE_FILE) throw new ConfigError(`Source file exceeds the ${MAX_WORKTREE_FILE} byte limit.`);
|
|
2909
|
+
if (outside(path4.relative(root, await fs5.realpath(filename)))) throw new ConfigError("A changed file resolves outside the repository.");
|
|
2910
|
+
const content = readRegularFile(filename, MAX_WORKTREE_FILE);
|
|
2911
|
+
stamps.set(filename, stamp(stat3));
|
|
2912
|
+
return content;
|
|
2913
|
+
},
|
|
2914
|
+
verify: async () => {
|
|
2915
|
+
assertNotAborted(signal);
|
|
2916
|
+
if (await resolveHead(cwd, signal) !== baseSha || !(await inventory()).equals(pinned)) {
|
|
2917
|
+
throw new ConfigError("HEAD or the working tree changed during review; rerun it. No complete result was produced.");
|
|
2918
|
+
}
|
|
2919
|
+
for (const [filename, before] of stamps) {
|
|
2920
|
+
const now = await fs5.lstat(filename).catch(() => null);
|
|
2921
|
+
if (!now || stamp(now) !== before) throw new ConfigError("A changed file was modified during review; rerun it. No complete result was produced.");
|
|
2922
|
+
}
|
|
2923
|
+
},
|
|
2924
|
+
dispose
|
|
2925
|
+
};
|
|
2926
|
+
} catch (error) {
|
|
2927
|
+
await dispose();
|
|
2928
|
+
throw error;
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2727
2931
|
|
|
2728
2932
|
// src/check.ts
|
|
2729
2933
|
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
@@ -2742,21 +2946,21 @@ function insideRoot(root, filename) {
|
|
|
2742
2946
|
async function readSource(root, file, signal) {
|
|
2743
2947
|
const filename = path5.resolve(root, file);
|
|
2744
2948
|
if (!insideRoot(root, filename)) throw new Error("File escapes the repository.");
|
|
2745
|
-
let
|
|
2949
|
+
let stat3;
|
|
2746
2950
|
try {
|
|
2747
|
-
|
|
2951
|
+
stat3 = await fs6.lstat(filename);
|
|
2748
2952
|
} catch (error) {
|
|
2749
2953
|
if (error.code === "ENOENT") return null;
|
|
2750
2954
|
throw error;
|
|
2751
2955
|
}
|
|
2752
|
-
if (!
|
|
2956
|
+
if (!stat3.isFile() || stat3.isSymbolicLink()) throw new Error("Symlinks and special files are not scanned.");
|
|
2753
2957
|
const real = await fs6.realpath(filename);
|
|
2754
2958
|
if (!insideRoot(root, real)) throw new Error("File resolves outside the repository.");
|
|
2755
|
-
if (
|
|
2959
|
+
if (stat3.size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
|
|
2756
2960
|
const handle = await fs6.open(filename, constants2.O_RDONLY | (constants2.O_NOFOLLOW || 0) | (constants2.O_NONBLOCK || 0));
|
|
2757
2961
|
try {
|
|
2758
2962
|
const opened = await handle.stat();
|
|
2759
|
-
if (opened.ino !==
|
|
2963
|
+
if (opened.ino !== stat3.ino || opened.dev !== stat3.dev || !opened.isFile()) throw new Error("File changed while opening.");
|
|
2760
2964
|
const chunks = [];
|
|
2761
2965
|
let size = 0;
|
|
2762
2966
|
while (true) {
|
|
@@ -3291,28 +3495,38 @@ function compareFindings(before, after, newPath) {
|
|
|
3291
3495
|
return { introduced, removed: [...old.values()].flatMap((bucket) => bucket.findings.slice(bucket.matched)), unchanged };
|
|
3292
3496
|
}
|
|
3293
3497
|
function reviewChanges(options) {
|
|
3294
|
-
return compareSourceChanges({ ...options,
|
|
3498
|
+
return compareSourceChanges({ ...options, source: "commits" });
|
|
3295
3499
|
}
|
|
3296
3500
|
function reviewStagedChanges(options = {}) {
|
|
3297
|
-
return compareSourceChanges({ ...options,
|
|
3501
|
+
return compareSourceChanges({ ...options, source: "index" });
|
|
3502
|
+
}
|
|
3503
|
+
function reviewWorktreeChanges(options = {}) {
|
|
3504
|
+
return compareSourceChanges({ ...options, source: "worktree" });
|
|
3298
3505
|
}
|
|
3299
3506
|
async function compareSourceChanges(options) {
|
|
3300
3507
|
assertNotAborted(options.signal);
|
|
3301
3508
|
const cwd = path6.resolve(options.cwd || process.cwd());
|
|
3302
|
-
const
|
|
3303
|
-
|
|
3509
|
+
const snapshot = options.source === "index" ? await stagedSnapshot(cwd, options.signal) : options.source === "worktree" ? await worktreeSnapshot(cwd, options.signal) : void 0;
|
|
3510
|
+
try {
|
|
3511
|
+
return await compareSnapshot(options, cwd, snapshot);
|
|
3512
|
+
} finally {
|
|
3513
|
+
await snapshot?.dispose?.();
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
async function compareSnapshot(options, cwd, snapshot) {
|
|
3517
|
+
const refs = options.source === "commits" ? await reviewRefs(cwd, options.base, options.head ?? "HEAD", options.signal) : { baseSha: snapshot.baseSha, headSha: snapshot.headSha, mergeBaseSha: snapshot.mergeBaseSha };
|
|
3304
3518
|
const policy = refs.baseSha === "EMPTY" ? { config: configFromText("{}", "default first-commit policy"), filename: null } : await reviewPolicy(cwd, refs.baseSha, options.signal);
|
|
3305
3519
|
const format = options.format ?? "terminal";
|
|
3306
3520
|
if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported review format.");
|
|
3307
3521
|
const failOn = options.failOn ?? policy.config.failOn ?? ["test-skip", "trivial-assert", "test-focus", "assertion-weakened", "guard-weakened"];
|
|
3308
3522
|
for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown review rule '${rule}'.`);
|
|
3309
|
-
const changes =
|
|
3523
|
+
const changes = snapshot?.changes ?? await changedTreeFiles(cwd, refs.mergeBaseSha, refs.headSha, options.signal);
|
|
3310
3524
|
const policyChanges = changes.filter((change) => CONFIG_FILENAMES.includes(change.oldPath) || CONFIG_FILENAMES.includes(change.newPath)).map((change) => change.newPath);
|
|
3311
3525
|
const ignores = [...GENERATED_PATHS2, ...policy.config.ignorePaths || []];
|
|
3312
3526
|
const result = {
|
|
3313
|
-
schemaVersion: "1.
|
|
3527
|
+
schemaVersion: "1.2.0",
|
|
3314
3528
|
mode: "review",
|
|
3315
|
-
source:
|
|
3529
|
+
source: options.source,
|
|
3316
3530
|
...refs,
|
|
3317
3531
|
policySha: refs.baseSha,
|
|
3318
3532
|
policyFile: policy.filename,
|
|
@@ -3340,15 +3554,26 @@ async function compareSourceChanges(options) {
|
|
|
3340
3554
|
const guardFindings = [];
|
|
3341
3555
|
const ciCounts = [];
|
|
3342
3556
|
let productionChanged = false;
|
|
3343
|
-
const
|
|
3344
|
-
if (mode === "000000") return null;
|
|
3345
|
-
if (!["100644", "100755"].includes(mode)) throw new ConfigError("Changed source is a symlink, submodule or unsupported Git mode; comparison is incomplete.");
|
|
3346
|
-
const content = await readBlob(cwd, oid, options.signal);
|
|
3557
|
+
const decode = (content) => {
|
|
3347
3558
|
totalBytes += content.length;
|
|
3348
3559
|
if (totalBytes > MAX_SCAN_BYTES2) throw new ConfigError("Review exceeds the 50 MiB source comparison limit.");
|
|
3349
3560
|
if (content.includes(0)) throw new ConfigError("Binary content in a source comparison.");
|
|
3350
3561
|
return new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
3351
3562
|
};
|
|
3563
|
+
const regularMode = (mode) => {
|
|
3564
|
+
if (!["100644", "100755"].includes(mode)) throw new ConfigError("Changed source is a symlink, submodule or unsupported Git mode; comparison is incomplete.");
|
|
3565
|
+
};
|
|
3566
|
+
const readText = async (mode, oid) => {
|
|
3567
|
+
if (mode === "000000") return null;
|
|
3568
|
+
regularMode(mode);
|
|
3569
|
+
return decode(await readBlob(cwd, oid, options.signal));
|
|
3570
|
+
};
|
|
3571
|
+
const readNew = async (change) => {
|
|
3572
|
+
if (options.source !== "worktree") return readText(change.newMode, change.newOid);
|
|
3573
|
+
if (change.newMode === "000000") return null;
|
|
3574
|
+
regularMode(change.newMode);
|
|
3575
|
+
return decode(await snapshot.readWorktree(change.newPath));
|
|
3576
|
+
};
|
|
3352
3577
|
const existingCounts = /* @__PURE__ */ new Map();
|
|
3353
3578
|
for (const change of changes) {
|
|
3354
3579
|
assertNotAborted(options.signal);
|
|
@@ -3362,7 +3587,7 @@ async function compareSourceChanges(options) {
|
|
|
3362
3587
|
}
|
|
3363
3588
|
try {
|
|
3364
3589
|
const oldText = await readText(change.oldMode, change.oldOid);
|
|
3365
|
-
const newText = await
|
|
3590
|
+
const newText = await readNew(change);
|
|
3366
3591
|
guardFindings.push(...analyzeGuardFile(change.newPath, oldText, newText, refs.headSha));
|
|
3367
3592
|
const ci = ciTestSteps(change.newPath, oldText, newText);
|
|
3368
3593
|
if (ci) ciCounts.push(ci);
|
|
@@ -3393,7 +3618,7 @@ async function compareSourceChanges(options) {
|
|
|
3393
3618
|
throw new ConfigError("Source moved to an unsupported extension; cannot claim its findings were resolved.");
|
|
3394
3619
|
}
|
|
3395
3620
|
const oldText = await readText(change.oldMode, change.oldOid);
|
|
3396
|
-
const newText = await
|
|
3621
|
+
const newText = await readNew(change);
|
|
3397
3622
|
const before = scan(oldText, change.oldPath, refs.mergeBaseSha);
|
|
3398
3623
|
const after = scan(newText, change.newPath, refs.headSha);
|
|
3399
3624
|
if (isTestFile(change.oldPath) || isTestFile(change.newPath)) tamperingFiles.push(analyzeTestFile(change.oldPath, change.newPath, oldText, newText, refs.headSha));
|
|
@@ -3420,7 +3645,7 @@ async function compareSourceChanges(options) {
|
|
|
3420
3645
|
}
|
|
3421
3646
|
}
|
|
3422
3647
|
assertNotAborted(options.signal);
|
|
3423
|
-
await
|
|
3648
|
+
await snapshot?.verify();
|
|
3424
3649
|
result.complete = result.skippedFiles.length === 0;
|
|
3425
3650
|
const rules = policy.config.rules ?? {};
|
|
3426
3651
|
result.infractions.push(...[...resolveTampering(tamperingFiles, productionChanged), ...guardFindings, ...resolveCiTestSteps(ciCounts, refs.headSha)].filter((finding2) => rules[finding2.type] !== "off"));
|
|
@@ -3449,10 +3674,10 @@ var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
|
3449
3674
|
function clipEvidence(text, limit = 200) {
|
|
3450
3675
|
if (text.length <= limit) return text;
|
|
3451
3676
|
const arrow = text.indexOf(" \u2192 ");
|
|
3452
|
-
const
|
|
3453
|
-
if (arrow < 0) return
|
|
3677
|
+
const clip2 = (part, size) => part.length > size ? `${part.slice(0, size - 1)}\u2026` : part;
|
|
3678
|
+
if (arrow < 0) return clip2(text, limit);
|
|
3454
3679
|
const side = Math.floor((limit - 3) / 2);
|
|
3455
|
-
return `${
|
|
3680
|
+
return `${clip2(text.slice(0, arrow), side)} \u2192 ${clip2(text.slice(arrow + 3), side)}`;
|
|
3456
3681
|
}
|
|
3457
3682
|
async function replayHistory(options = {}) {
|
|
3458
3683
|
assertNotAborted(options.signal);
|
|
@@ -3558,6 +3783,409 @@ function formatReplayTerminal(result, allRules = false) {
|
|
|
3558
3783
|
return lines.join("\n");
|
|
3559
3784
|
}
|
|
3560
3785
|
|
|
3786
|
+
// src/hook.ts
|
|
3787
|
+
import path8 from "path";
|
|
3788
|
+
import * as fs7 from "fs/promises";
|
|
3789
|
+
var HOOK_AGENTS = ["claude", "codex", "cursor"];
|
|
3790
|
+
var DEFAULT_TIMEOUT_MS = 25e3;
|
|
3791
|
+
var PAUSE_FILE = "agent-roast-hook-paused";
|
|
3792
|
+
var LISTED = 6;
|
|
3793
|
+
var LISTED_WARNINGS = 3;
|
|
3794
|
+
function detectAgent(input) {
|
|
3795
|
+
if ("workspace_roots" in input || "cursor_version" in input || "conversation_id" in input) return "cursor";
|
|
3796
|
+
if ("turn_id" in input) return "codex";
|
|
3797
|
+
return "claude";
|
|
3798
|
+
}
|
|
3799
|
+
function inputCwd(agent, input, env) {
|
|
3800
|
+
const roots = input.workspace_roots;
|
|
3801
|
+
if (agent === "cursor" && Array.isArray(roots) && typeof roots[0] === "string") return roots[0];
|
|
3802
|
+
if (typeof input.cwd === "string" && input.cwd) return input.cwd;
|
|
3803
|
+
return env.CLAUDE_PROJECT_DIR || env.CURSOR_PROJECT_DIR || process.cwd();
|
|
3804
|
+
}
|
|
3805
|
+
function clip(text, limit) {
|
|
3806
|
+
const flat = terminalText(text).replace(/\s+/g, " ").trim();
|
|
3807
|
+
return flat.length > limit ? `${flat.slice(0, limit - 1)}\u2026` : flat;
|
|
3808
|
+
}
|
|
3809
|
+
function describe(finding2, index) {
|
|
3810
|
+
const where = finding2.file ? `${terminalText(finding2.file)}${finding2.lineNumber ? `:${finding2.lineNumber}` : ""}` : "unknown location";
|
|
3811
|
+
const evidence = clip(finding2.lineSnippet || finding2.reason, 220);
|
|
3812
|
+
return [`${index}. ${where} [${finding2.type}]`, ` ${evidence}`];
|
|
3813
|
+
}
|
|
3814
|
+
function formatAgentFeedback(result) {
|
|
3815
|
+
const ranked = mostImportantFirst(result.infractions, result.failedRules);
|
|
3816
|
+
const blocking = ranked.filter((finding2) => result.failedRules.includes(finding2.type));
|
|
3817
|
+
const notes = ranked.filter((finding2) => !result.failedRules.includes(finding2.type) && isCheckRule(finding2.type));
|
|
3818
|
+
const lines = ["agent-roast: the uncommitted changes make the checks easier to pass instead of making the code correct.", ""];
|
|
3819
|
+
blocking.slice(0, LISTED).forEach((finding2, index) => lines.push(...describe(finding2, index + 1)));
|
|
3820
|
+
if (blocking.length > LISTED) lines.push(` \u2026 and ${blocking.length - LISTED} more.`);
|
|
3821
|
+
if (notes.length) {
|
|
3822
|
+
lines.push("", "Also worth a second look (these do not block):");
|
|
3823
|
+
notes.slice(0, LISTED_WARNINGS).forEach((finding2) => lines.push(`- ${describe(finding2, 0)[0].slice(3)}: ${clip(finding2.lineSnippet || finding2.reason, 120)}`));
|
|
3824
|
+
if (notes.length > LISTED_WARNINGS) lines.push(`- \u2026 and ${notes.length - LISTED_WARNINGS} more.`);
|
|
3825
|
+
}
|
|
3826
|
+
lines.push(
|
|
3827
|
+
"",
|
|
3828
|
+
"Make the original checks pass by fixing the code. Do not skip, delete, loosen or silence tests, assertions, coverage gates, type checks or CI steps to get a green run.",
|
|
3829
|
+
"If a change to a check is really intended, stop and ask the user to approve it explicitly rather than keeping it quietly."
|
|
3830
|
+
);
|
|
3831
|
+
return lines.join("\n");
|
|
3832
|
+
}
|
|
3833
|
+
function formatUserWarning(result) {
|
|
3834
|
+
const blocking = result.infractions.filter((finding2) => result.failedRules.includes(finding2.type));
|
|
3835
|
+
const first = blocking[0];
|
|
3836
|
+
return `agent-roast: the agent finished with ${blocking.length} change${blocking.length === 1 ? "" : "s"} that weaken${blocking.length === 1 ? "s" : ""} checks (${result.failedRules.join(", ")})${first ? `, for example ${terminalText(first.file ?? "unknown")}:${first.lineNumber ?? "?"}` : ""}. Review them before you commit: npx agent-roast review --worktree`;
|
|
3837
|
+
}
|
|
3838
|
+
async function gitDirectory(cwd, signal) {
|
|
3839
|
+
return fs7.realpath(gitOutputPath((await readGit(cwd, ["rev-parse", "--absolute-git-dir"], signal, 8192)).toString()));
|
|
3840
|
+
}
|
|
3841
|
+
async function hookPausedUntil(cwd, now = /* @__PURE__ */ new Date()) {
|
|
3842
|
+
try {
|
|
3843
|
+
const file = path8.join(await gitDirectory(cwd), PAUSE_FILE);
|
|
3844
|
+
const until = new Date((await fs7.readFile(file, "utf8")).trim());
|
|
3845
|
+
if (!Number.isNaN(until.getTime()) && until > now) return until;
|
|
3846
|
+
await fs7.rm(file, { force: true });
|
|
3847
|
+
} catch {
|
|
3848
|
+
}
|
|
3849
|
+
return null;
|
|
3850
|
+
}
|
|
3851
|
+
async function pauseHook(cwd, until) {
|
|
3852
|
+
const file = path8.join(await gitDirectory(cwd), PAUSE_FILE);
|
|
3853
|
+
await fs7.writeFile(file, until.toISOString() + "\n");
|
|
3854
|
+
return file;
|
|
3855
|
+
}
|
|
3856
|
+
async function resumeHook(cwd) {
|
|
3857
|
+
const file = path8.join(await gitDirectory(cwd), PAUSE_FILE);
|
|
3858
|
+
const existed = await fs7.stat(file).then(() => true, () => false);
|
|
3859
|
+
await fs7.rm(file, { force: true });
|
|
3860
|
+
return existed;
|
|
3861
|
+
}
|
|
3862
|
+
function parseDuration(text) {
|
|
3863
|
+
const match = /^(\d+)\s*([mhd])$/i.exec(text.trim());
|
|
3864
|
+
if (!match || Number(match[1]) < 1) throw new ConfigError("Use a duration such as 30m, 2h or 1d.");
|
|
3865
|
+
return Number(match[1]) * { m: 6e4, h: 36e5, d: 864e5 }[match[2].toLowerCase()];
|
|
3866
|
+
}
|
|
3867
|
+
var outcome = (action, stdout = "", stderr = "", result) => ({ action, stdout, stderr, exitCode: 0, result });
|
|
3868
|
+
async function runHook(rawInput, options = {}) {
|
|
3869
|
+
const env = options.env ?? process.env;
|
|
3870
|
+
let input = {};
|
|
3871
|
+
try {
|
|
3872
|
+
const parsed = rawInput.trim() ? JSON.parse(rawInput) : {};
|
|
3873
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) input = parsed;
|
|
3874
|
+
} catch {
|
|
3875
|
+
return outcome("skip", "", "agent-roast hook: input was not JSON; nothing was checked.\n");
|
|
3876
|
+
}
|
|
3877
|
+
if (["off", "0", "false"].includes((env.AGENT_ROAST_HOOK ?? "").toLowerCase())) return outcome("skip");
|
|
3878
|
+
const agent = options.agent ?? detectAgent(input);
|
|
3879
|
+
const event = String(input.hook_event_name ?? "Stop");
|
|
3880
|
+
if (event.toLowerCase() !== "stop") return outcome("skip");
|
|
3881
|
+
if (agent === "cursor" && typeof input.status === "string" && input.status !== "completed") return outcome("skip");
|
|
3882
|
+
const cwd = options.cwd ?? inputCwd(agent, input, env);
|
|
3883
|
+
if (await hookPausedUntil(cwd, options.now)) return outcome("skip");
|
|
3884
|
+
const controller = new AbortController();
|
|
3885
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
3886
|
+
let result;
|
|
3887
|
+
try {
|
|
3888
|
+
result = await reviewWorktreeChanges({ cwd, signal: controller.signal });
|
|
3889
|
+
} catch (error) {
|
|
3890
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
3891
|
+
return outcome("skip", "", `agent-roast hook: could not check the working tree (${clip(reason, 200)}); the agent was allowed to finish.
|
|
3892
|
+
`);
|
|
3893
|
+
} finally {
|
|
3894
|
+
clearTimeout(timer);
|
|
3895
|
+
}
|
|
3896
|
+
if (!result.failedRules.length) return outcome("pass", agent === "cursor" ? "{}\n" : "", "", result);
|
|
3897
|
+
const alreadyAsked = input.stop_hook_active === true || typeof input.loop_count === "number" && input.loop_count > 0;
|
|
3898
|
+
if (alreadyAsked) {
|
|
3899
|
+
return outcome("warn", agent === "cursor" ? "{}\n" : JSON.stringify({ systemMessage: formatUserWarning(result) }) + "\n", "", result);
|
|
3900
|
+
}
|
|
3901
|
+
const message = formatAgentFeedback(result);
|
|
3902
|
+
return outcome("block", JSON.stringify(agent === "cursor" ? { followup_message: message } : { decision: "block", reason: message }) + "\n", "", result);
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
// src/hook-install.ts
|
|
3906
|
+
import * as fs8 from "fs";
|
|
3907
|
+
import os from "os";
|
|
3908
|
+
import path9 from "path";
|
|
3909
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
3910
|
+
import { createRequire as createRequire2 } from "module";
|
|
3911
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
3912
|
+
var AGENT_LABELS = { claude: "Claude Code", codex: "Codex", cursor: "Cursor" };
|
|
3913
|
+
var TIMEOUT_SECONDS = 60;
|
|
3914
|
+
function packageVersion() {
|
|
3915
|
+
const require3 = createRequire2(import.meta.url);
|
|
3916
|
+
return String(require3("../package.json").version);
|
|
3917
|
+
}
|
|
3918
|
+
function hookCommand(agent, command = `npx --yes agent-roast@${packageVersion()}`) {
|
|
3919
|
+
return `${command.trim()} hook run --agent ${agent}`;
|
|
3920
|
+
}
|
|
3921
|
+
var isOurs = (command) => typeof command === "string" && /\bagent-roast\b.*\bhook\s+run\b/.test(command);
|
|
3922
|
+
function repositoryRoot(cwd) {
|
|
3923
|
+
const absolute = fs8.realpathSync(path9.resolve(cwd));
|
|
3924
|
+
const run = spawnSync3("git", [...HERMETIC_GIT_FLAGS, "rev-parse", "--show-toplevel"], {
|
|
3925
|
+
cwd: absolute,
|
|
3926
|
+
env: { ...getHermeticGitEnv(), LC_ALL: "C" },
|
|
3927
|
+
encoding: "utf8",
|
|
3928
|
+
timeout: 3e4,
|
|
3929
|
+
maxBuffer: 8192,
|
|
3930
|
+
windowsHide: true
|
|
3931
|
+
});
|
|
3932
|
+
return run.status === 0 ? fs8.realpathSync(gitOutputPath(run.stdout)) : absolute;
|
|
3933
|
+
}
|
|
3934
|
+
function hookConfigPath(agent, scope, root, home, env = process.env) {
|
|
3935
|
+
if (scope === "local" && agent !== "claude") throw new ConfigError(`${AGENT_LABELS[agent]} has no hook file that stays out of the repository; use the project file or --global.`);
|
|
3936
|
+
const claude = path9.join(scope === "global" ? home : root, ".claude");
|
|
3937
|
+
if (agent === "claude") return path9.join(claude, scope === "local" ? "settings.local.json" : "settings.json");
|
|
3938
|
+
if (agent === "cursor") return path9.join(scope === "global" ? home : root, ".cursor", "hooks.json");
|
|
3939
|
+
return path9.join(scope === "global" ? env.CODEX_HOME || path9.join(home, ".codex") : path9.join(root, ".codex"), "hooks.json");
|
|
3940
|
+
}
|
|
3941
|
+
function readJsonFile(file) {
|
|
3942
|
+
let raw;
|
|
3943
|
+
let mode;
|
|
3944
|
+
try {
|
|
3945
|
+
mode = fs8.statSync(file).mode & 511;
|
|
3946
|
+
raw = fs8.readFileSync(file, "utf8").replace(/^/, "");
|
|
3947
|
+
} catch (error) {
|
|
3948
|
+
if (error.code === "ENOENT") return { data: {}, indent: 2, mode: void 0, existed: false };
|
|
3949
|
+
throw error;
|
|
3950
|
+
}
|
|
3951
|
+
if (!raw.trim()) return { data: {}, indent: 2, mode, existed: true };
|
|
3952
|
+
let data;
|
|
3953
|
+
try {
|
|
3954
|
+
data = JSON.parse(raw);
|
|
3955
|
+
} catch {
|
|
3956
|
+
throw new ConfigError(`${file} is not valid JSON, so it was left alone. Fix it, or add the hook by hand (see docs/integrations.md).`);
|
|
3957
|
+
}
|
|
3958
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) throw new ConfigError(`${file} must contain a JSON object, so it was left alone.`);
|
|
3959
|
+
const indent = /^([ \t]+)"/m.exec(raw)?.[1] ?? 2;
|
|
3960
|
+
return { data, indent, mode, existed: true };
|
|
3961
|
+
}
|
|
3962
|
+
function writeJsonFile(link, json) {
|
|
3963
|
+
const file = json.existed ? fs8.realpathSync(link) : link;
|
|
3964
|
+
fs8.mkdirSync(path9.dirname(file), { recursive: true });
|
|
3965
|
+
const temporary = path9.join(path9.dirname(file), `.agent-roast-${randomUUID4()}.tmp`);
|
|
3966
|
+
try {
|
|
3967
|
+
fs8.writeFileSync(temporary, JSON.stringify(json.data, null, json.indent) + "\n", { mode: json.mode ?? 420 });
|
|
3968
|
+
fs8.renameSync(temporary, file);
|
|
3969
|
+
} finally {
|
|
3970
|
+
fs8.rmSync(temporary, { force: true });
|
|
3971
|
+
}
|
|
3972
|
+
}
|
|
3973
|
+
var asArray = (value) => Array.isArray(value) ? value : [];
|
|
3974
|
+
function addStopHook(agent, data, command) {
|
|
3975
|
+
const before = JSON.stringify(data);
|
|
3976
|
+
data.hooks = data.hooks && typeof data.hooks === "object" && !Array.isArray(data.hooks) ? data.hooks : {};
|
|
3977
|
+
if (agent === "cursor") {
|
|
3978
|
+
data.version ??= 1;
|
|
3979
|
+
const handlers = asArray(data.hooks.stop);
|
|
3980
|
+
const ours = handlers.find((handler) => isOurs(handler?.command));
|
|
3981
|
+
if (ours) ours.command = command;
|
|
3982
|
+
else handlers.push({ command, timeout: TIMEOUT_SECONDS, loop_limit: 2 });
|
|
3983
|
+
data.hooks.stop = handlers;
|
|
3984
|
+
} else {
|
|
3985
|
+
const groups = asArray(data.hooks.Stop);
|
|
3986
|
+
const ours = groups.flatMap((group) => asArray(group?.hooks)).find((handler) => isOurs(handler?.command));
|
|
3987
|
+
if (ours) ours.command = command;
|
|
3988
|
+
else groups.push({ hooks: [{ type: "command", command, timeout: TIMEOUT_SECONDS }] });
|
|
3989
|
+
data.hooks.Stop = groups;
|
|
3990
|
+
}
|
|
3991
|
+
return JSON.stringify(data) !== before;
|
|
3992
|
+
}
|
|
3993
|
+
function removeStopHook(agent, data) {
|
|
3994
|
+
const before = JSON.stringify(data);
|
|
3995
|
+
const hooks = data.hooks;
|
|
3996
|
+
if (!hooks || typeof hooks !== "object") return false;
|
|
3997
|
+
if (agent === "cursor") {
|
|
3998
|
+
const kept = asArray(hooks.stop).filter((handler) => !isOurs(handler?.command));
|
|
3999
|
+
if (kept.length) hooks.stop = kept;
|
|
4000
|
+
else delete hooks.stop;
|
|
4001
|
+
} else {
|
|
4002
|
+
const groups = asArray(hooks.Stop).map((group) => ({ ...group, hooks: asArray(group?.hooks).filter((handler) => !isOurs(handler?.command)) }));
|
|
4003
|
+
const kept = groups.filter((group) => group.hooks.length);
|
|
4004
|
+
if (kept.length) hooks.Stop = kept;
|
|
4005
|
+
else delete hooks.Stop;
|
|
4006
|
+
}
|
|
4007
|
+
if (!Object.keys(hooks).length) delete data.hooks;
|
|
4008
|
+
return JSON.stringify(data) !== before;
|
|
4009
|
+
}
|
|
4010
|
+
function context(options) {
|
|
4011
|
+
return { root: repositoryRoot(options.cwd ?? "."), scope: options.scope ?? "project", home: options.home ?? os.homedir(), env: options.env ?? process.env };
|
|
4012
|
+
}
|
|
4013
|
+
function installHooks(options) {
|
|
4014
|
+
const { root, scope, home, env } = context(options);
|
|
4015
|
+
return options.agents.map((agent) => {
|
|
4016
|
+
const file = hookConfigPath(agent, scope, root, home, env);
|
|
4017
|
+
const command = hookCommand(agent, options.command);
|
|
4018
|
+
const json = readJsonFile(file);
|
|
4019
|
+
const changed = addStopHook(agent, json.data, command);
|
|
4020
|
+
if (changed && !options.dryRun) writeJsonFile(file, json);
|
|
4021
|
+
return { agent, scope, file, command, action: !json.existed ? "created" : changed ? "updated" : "unchanged" };
|
|
4022
|
+
});
|
|
4023
|
+
}
|
|
4024
|
+
function uninstallHooks(options) {
|
|
4025
|
+
const { root, scope, home, env } = context(options);
|
|
4026
|
+
return options.agents.map((agent) => {
|
|
4027
|
+
const file = hookConfigPath(agent, scope, root, home, env);
|
|
4028
|
+
const json = readJsonFile(file);
|
|
4029
|
+
const removed = json.existed && removeStopHook(agent, json.data);
|
|
4030
|
+
if (removed && !options.dryRun) writeJsonFile(file, json);
|
|
4031
|
+
return { agent, scope, file, action: removed ? "removed" : "absent" };
|
|
4032
|
+
});
|
|
4033
|
+
}
|
|
4034
|
+
function hookStatus(options) {
|
|
4035
|
+
const { root, home, env } = context({ ...options, scope: "project" });
|
|
4036
|
+
const rows = [];
|
|
4037
|
+
for (const agent of ["claude", "codex", "cursor"]) {
|
|
4038
|
+
for (const scope of ["project", "local", "global"]) {
|
|
4039
|
+
if (scope === "local" && agent !== "claude") continue;
|
|
4040
|
+
const file = hookConfigPath(agent, scope, root, home, env);
|
|
4041
|
+
let command;
|
|
4042
|
+
try {
|
|
4043
|
+
const data = readJsonFile(file).data;
|
|
4044
|
+
const handlers = agent === "cursor" ? asArray(data.hooks?.stop) : asArray(data.hooks?.Stop).flatMap((group) => asArray(group?.hooks));
|
|
4045
|
+
command = handlers.map((handler) => handler?.command).find(isOurs);
|
|
4046
|
+
} catch {
|
|
4047
|
+
}
|
|
4048
|
+
rows.push({ agent, scope, file, command, installed: Boolean(command), action: command ? "unchanged" : "absent" });
|
|
4049
|
+
}
|
|
4050
|
+
}
|
|
4051
|
+
return rows;
|
|
4052
|
+
}
|
|
4053
|
+
var INSTRUCTIONS_START = "<!-- agent-roast:start -->";
|
|
4054
|
+
var INSTRUCTIONS_END = "<!-- agent-roast:end -->";
|
|
4055
|
+
var INSTRUCTIONS_BLOCK = `${INSTRUCTIONS_START}
|
|
4056
|
+
## Keep the checks honest
|
|
4057
|
+
|
|
4058
|
+
When a test, type check, linter or CI step fails, fix the code. Do not make the build green by skipping or deleting tests,
|
|
4059
|
+
weakening assertions, lowering coverage thresholds, switching off \`strict\`, or letting a CI step fail. If you think a check
|
|
4060
|
+
is wrong, say so and ask before changing it. Before you finish, run \`npx agent-roast review --worktree\`: it lists uncommitted
|
|
4061
|
+
changes that weaken the checks.
|
|
4062
|
+
${INSTRUCTIONS_END}
|
|
4063
|
+
`;
|
|
4064
|
+
function installInstructions(options = {}) {
|
|
4065
|
+
const root = repositoryRoot(options.cwd ?? ".");
|
|
4066
|
+
const existing = ["AGENTS.md", "CLAUDE.md"].filter((name) => fs8.existsSync(path9.join(root, name)));
|
|
4067
|
+
return (existing.length ? existing : ["AGENTS.md"]).map((name) => {
|
|
4068
|
+
const file = path9.join(root, name);
|
|
4069
|
+
const exists = fs8.existsSync(file);
|
|
4070
|
+
const text = exists ? fs8.readFileSync(file, "utf8") : "";
|
|
4071
|
+
const start = text.indexOf(INSTRUCTIONS_START);
|
|
4072
|
+
const end = text.indexOf(INSTRUCTIONS_END);
|
|
4073
|
+
let next;
|
|
4074
|
+
if (start >= 0 && end > start) next = text.slice(0, start) + INSTRUCTIONS_BLOCK.trimEnd() + text.slice(end + INSTRUCTIONS_END.length);
|
|
4075
|
+
else next = text + (text && !text.endsWith("\n\n") ? text.endsWith("\n") ? "\n" : "\n\n" : "") + INSTRUCTIONS_BLOCK;
|
|
4076
|
+
if (next === text) return { file, action: "unchanged" };
|
|
4077
|
+
if (!options.dryRun) fs8.writeFileSync(file, next);
|
|
4078
|
+
return { file, action: exists ? "updated" : "created" };
|
|
4079
|
+
});
|
|
4080
|
+
}
|
|
4081
|
+
|
|
4082
|
+
// src/demo.ts
|
|
4083
|
+
import * as fs9 from "fs";
|
|
4084
|
+
import os2 from "os";
|
|
4085
|
+
import path10 from "path";
|
|
4086
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
4087
|
+
import pc2 from "picocolors";
|
|
4088
|
+
var CART = `export function total(prices: number[], discount = 0): number {
|
|
4089
|
+
const sum = prices.reduce((a, b) => a + b, 0);
|
|
4090
|
+
return sum - sum * discount;
|
|
4091
|
+
}
|
|
4092
|
+
`;
|
|
4093
|
+
var CART_TEST = (weakened) => `import { total } from "./cart";
|
|
4094
|
+
|
|
4095
|
+
describe("cart total", () => {
|
|
4096
|
+
it("sums the prices", () => {
|
|
4097
|
+
expect(total([10, 20, 12])).${weakened ? "toBeDefined()" : "toBe(42)"};
|
|
4098
|
+
});
|
|
4099
|
+
|
|
4100
|
+
${weakened ? "it.skip" : "it"}("applies a discount", () => {
|
|
4101
|
+
expect(total([50, 50], 0.1)).toBe(90);
|
|
4102
|
+
});
|
|
4103
|
+
|
|
4104
|
+
it("is zero for an empty cart", () => {
|
|
4105
|
+
${weakened ? "expect(true).toBe(true)" : "expect(total([])).toBe(0)"};
|
|
4106
|
+
});
|
|
4107
|
+
});
|
|
4108
|
+
`;
|
|
4109
|
+
var VITEST_CONFIG = (lines, branches) => `import { defineConfig } from "vitest/config";
|
|
4110
|
+
|
|
4111
|
+
export default defineConfig({
|
|
4112
|
+
test: { coverage: { thresholds: { lines: ${lines}, branches: ${branches} } } },
|
|
4113
|
+
});
|
|
4114
|
+
`;
|
|
4115
|
+
var CI = (lenient) => `name: CI
|
|
4116
|
+
on: [push, pull_request]
|
|
4117
|
+
jobs:
|
|
4118
|
+
test:
|
|
4119
|
+
runs-on: ubuntu-latest
|
|
4120
|
+
steps:
|
|
4121
|
+
- uses: actions/checkout@v4
|
|
4122
|
+
- run: npm ci
|
|
4123
|
+
- name: Run tests
|
|
4124
|
+
${lenient ? " continue-on-error: true\n" : ""} run: npm test
|
|
4125
|
+
`;
|
|
4126
|
+
async function runDemo(options = {}) {
|
|
4127
|
+
const dir = fs9.mkdtempSync(path10.join(os2.tmpdir(), "agent-roast-demo-"));
|
|
4128
|
+
try {
|
|
4129
|
+
const git2 = (...args) => {
|
|
4130
|
+
const run = spawnSync4(
|
|
4131
|
+
"git",
|
|
4132
|
+
[
|
|
4133
|
+
...HERMETIC_GIT_FLAGS,
|
|
4134
|
+
"-c",
|
|
4135
|
+
"user.name=agent-roast demo",
|
|
4136
|
+
"-c",
|
|
4137
|
+
"user.email=demo@example.invalid",
|
|
4138
|
+
"-c",
|
|
4139
|
+
"commit.gpgsign=false",
|
|
4140
|
+
"-c",
|
|
4141
|
+
`core.hooksPath=${path10.join(dir, ".no-hooks")}`,
|
|
4142
|
+
...args
|
|
4143
|
+
],
|
|
4144
|
+
{ cwd: dir, env: getHermeticGitEnv(), encoding: "utf8", timeout: 3e4, windowsHide: true }
|
|
4145
|
+
);
|
|
4146
|
+
if (run.error || run.status !== 0) throw new ConfigError(`The demo needs Git to create a temporary repository: ${run.error?.message ?? run.stderr.trim()}`);
|
|
4147
|
+
};
|
|
4148
|
+
const write = (file, text) => {
|
|
4149
|
+
const destination = path10.join(dir, file);
|
|
4150
|
+
fs9.mkdirSync(path10.dirname(destination), { recursive: true });
|
|
4151
|
+
fs9.writeFileSync(destination, text);
|
|
4152
|
+
};
|
|
4153
|
+
git2("init", "-q", "-b", "main");
|
|
4154
|
+
write("src/cart.ts", CART);
|
|
4155
|
+
write("src/cart.test.ts", CART_TEST(false));
|
|
4156
|
+
write("vitest.config.ts", VITEST_CONFIG(90, 85));
|
|
4157
|
+
write(".github/workflows/ci.yml", CI(false));
|
|
4158
|
+
git2("add", "-A");
|
|
4159
|
+
git2("commit", "-q", "-m", "Add cart total");
|
|
4160
|
+
write("src/cart.test.ts", CART_TEST(true));
|
|
4161
|
+
write("vitest.config.ts", VITEST_CONFIG(50, 40));
|
|
4162
|
+
write(".github/workflows/ci.yml", CI(true));
|
|
4163
|
+
const result = await reviewWorktreeChanges({ cwd: dir, signal: options.signal });
|
|
4164
|
+
const output = [
|
|
4165
|
+
"",
|
|
4166
|
+
pc2.bold("agent-roast demo") + pc2.dim(" \u2014 a scripted example in a temporary repository; nothing of yours is read or changed"),
|
|
4167
|
+
"",
|
|
4168
|
+
'A build was red. The "fix" below turns it green without touching the code under test:',
|
|
4169
|
+
"",
|
|
4170
|
+
` ${pc2.cyan("src/cart.test.ts")} toBe(42) \u2192 toBeDefined() \xB7 it.skip(\u2026) \xB7 expect(true).toBe(true)`,
|
|
4171
|
+
` ${pc2.cyan("vitest.config.ts")} coverage thresholds lowered (lines 90 \u2192 50, branches 85 \u2192 40)`,
|
|
4172
|
+
` ${pc2.cyan(".github/workflows/ci.yml")} continue-on-error: true on the test step`,
|
|
4173
|
+
"",
|
|
4174
|
+
"What agent-roast reports for that working tree:",
|
|
4175
|
+
formatReviewTerminal(result).replace(/\s+$/, ""),
|
|
4176
|
+
"",
|
|
4177
|
+
pc2.bold("Use it on your own work"),
|
|
4178
|
+
` ${pc2.cyan("npx agent-roast")} review what you have not committed yet`,
|
|
4179
|
+
` ${pc2.cyan("npx agent-roast replay")} replay your last 50 commits, one at a time`,
|
|
4180
|
+
` ${pc2.cyan("npx agent-roast hook install")} send your coding agent back to fix the code when it weakens a check`,
|
|
4181
|
+
""
|
|
4182
|
+
].join("\n");
|
|
4183
|
+
return { exitCode: result.failedRules.length ? 0 : 1, output, result };
|
|
4184
|
+
} finally {
|
|
4185
|
+
fs9.rmSync(dir, { recursive: true, force: true });
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
4188
|
+
|
|
3561
4189
|
// src/index.ts
|
|
3562
4190
|
var InvalidDetectorError = class extends Error {
|
|
3563
4191
|
constructor(message) {
|
|
@@ -3566,9 +4194,9 @@ var InvalidDetectorError = class extends Error {
|
|
|
3566
4194
|
}
|
|
3567
4195
|
};
|
|
3568
4196
|
async function roastRepository(options = {}) {
|
|
3569
|
-
const cwd =
|
|
4197
|
+
const cwd = path11.resolve(options.cwd || process.cwd());
|
|
3570
4198
|
const since = options.since || "90 days ago";
|
|
3571
|
-
const repoName =
|
|
4199
|
+
const repoName = path11.basename(cwd);
|
|
3572
4200
|
const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths, rules: options.rules, panicLoopWindowMinutes: options.panicLoopWindowMinutes });
|
|
3573
4201
|
const gitStream = streamGitLog(cwd, since);
|
|
3574
4202
|
await parseGitLogStream(gitStream, (commit, stats) => {
|
|
@@ -3583,7 +4211,7 @@ async function roastRepository(options = {}) {
|
|
|
3583
4211
|
return { summary, output };
|
|
3584
4212
|
}
|
|
3585
4213
|
async function auditPullRequest(options) {
|
|
3586
|
-
const cwd =
|
|
4214
|
+
const cwd = path11.resolve(options.cwd || process.cwd());
|
|
3587
4215
|
const format = options.format || "terminal";
|
|
3588
4216
|
const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
|
|
3589
4217
|
for (const det of failOn) {
|
|
@@ -3729,8 +4357,29 @@ export {
|
|
|
3729
4357
|
compareFindings,
|
|
3730
4358
|
reviewChanges,
|
|
3731
4359
|
reviewStagedChanges,
|
|
4360
|
+
reviewWorktreeChanges,
|
|
3732
4361
|
replayHistory,
|
|
3733
4362
|
formatReplayTerminal,
|
|
4363
|
+
HOOK_AGENTS,
|
|
4364
|
+
formatAgentFeedback,
|
|
4365
|
+
hookPausedUntil,
|
|
4366
|
+
pauseHook,
|
|
4367
|
+
resumeHook,
|
|
4368
|
+
parseDuration,
|
|
4369
|
+
runHook,
|
|
4370
|
+
AGENT_LABELS,
|
|
4371
|
+
packageVersion,
|
|
4372
|
+
hookCommand,
|
|
4373
|
+
repositoryRoot,
|
|
4374
|
+
hookConfigPath,
|
|
4375
|
+
installHooks,
|
|
4376
|
+
uninstallHooks,
|
|
4377
|
+
hookStatus,
|
|
4378
|
+
INSTRUCTIONS_START,
|
|
4379
|
+
INSTRUCTIONS_END,
|
|
4380
|
+
INSTRUCTIONS_BLOCK,
|
|
4381
|
+
installInstructions,
|
|
4382
|
+
runDemo,
|
|
3734
4383
|
InvalidDetectorError,
|
|
3735
4384
|
roastRepository,
|
|
3736
4385
|
auditPullRequest
|