agent-roast 0.10.1 → 0.12.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 +94 -464
- package/dist/{chunk-FFKEBW6D.js → chunk-AP5Y4CH6.js} +901 -64
- package/dist/cli.js +289 -18
- package/dist/index.d.ts +163 -2
- package/dist/index.js +51 -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) {
|
|
@@ -1161,11 +1161,149 @@ function matchIgnorePath(filePath, ignorePatterns) {
|
|
|
1161
1161
|
// src/metrics.ts
|
|
1162
1162
|
var PYTHON_TEST_SKIP_PATTERNS = [
|
|
1163
1163
|
{ regex: /@pytest\s*\.\s*mark\s*\.\s*skip\b/, name: "skipped pytest (@pytest.mark.skip)" },
|
|
1164
|
-
{ regex: /(?<![\w.])pytest\s*\.\s*skip\s*\(/, name: "imperative pytest skip (pytest.skip)" },
|
|
1165
|
-
{ regex: /@unittest\s*\.\s*skip\s*\(/, name: "skipped unittest (@unittest.skip)" },
|
|
1166
|
-
{ regex: /\bself\s*\.\s*skipTest\s*\(/, name: "imperative unittest skip (self.skipTest)" }
|
|
1164
|
+
{ regex: /(?<![\w.])pytest\s*\.\s*skip\s*\(/, name: "imperative pytest skip (pytest.skip)", imperative: true },
|
|
1165
|
+
{ regex: /@unittest\s*\.\s*skip\s*\(/, name: "skipped unittest (@unittest.skip)", imperative: false },
|
|
1166
|
+
{ regex: /\bself\s*\.\s*skipTest\s*\(/, name: "imperative unittest skip (self.skipTest)", imperative: true }
|
|
1167
|
+
];
|
|
1168
|
+
function isGuardedSkip(lines) {
|
|
1169
|
+
const indentOf = (text) => text.length - text.trimStart().length;
|
|
1170
|
+
const mentionsCi = (raw) => /\bCI\b|\bGITHUB_ACTIONS\b/.test(raw);
|
|
1171
|
+
const own = lines[lines.length - 1];
|
|
1172
|
+
if (/^\s*(?:if|elif|else)\b.*:\s*\S/.test(own.code)) return !mentionsCi(own.raw);
|
|
1173
|
+
let limit = indentOf(own.code);
|
|
1174
|
+
for (let i = lines.length - 2; i >= 0 && lines.length - 1 - i <= 200; i--) {
|
|
1175
|
+
if (lines[i].number !== lines[i + 1].number - 1) return false;
|
|
1176
|
+
const { code, raw } = lines[i];
|
|
1177
|
+
if (!code.trim() || indentOf(code) >= limit) continue;
|
|
1178
|
+
const keyword = /^\s*(async\s+def|def|class|if|elif|else|try|except|finally|with|for|while)\b/.exec(code)?.[1];
|
|
1179
|
+
if (keyword === "def" || keyword === "async def") return !/^\s*(?:async\s+)?def\s+test/.test(code);
|
|
1180
|
+
if (keyword === "class") return false;
|
|
1181
|
+
if (keyword === "if" || keyword === "elif" || keyword === "else" || keyword === "try" || keyword === "except" || keyword === "finally") return !mentionsCi(raw);
|
|
1182
|
+
limit = indentOf(code) + (keyword ? 0 : 1);
|
|
1183
|
+
}
|
|
1184
|
+
return false;
|
|
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
|
+
] }
|
|
1167
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
|
+
}
|
|
1168
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;
|
|
1279
|
+
function isConditionalSkip(source, raw, open2) {
|
|
1280
|
+
if (source[open2] !== "(") return false;
|
|
1281
|
+
const args = [];
|
|
1282
|
+
let depth = 0;
|
|
1283
|
+
let start = open2 + 1;
|
|
1284
|
+
for (let i = start; i < source.length && i - open2 <= 2e3; i++) {
|
|
1285
|
+
const char = source[i];
|
|
1286
|
+
if (char === "\0") return false;
|
|
1287
|
+
if ("([{".includes(char)) depth++;
|
|
1288
|
+
else if (")]}".includes(char)) {
|
|
1289
|
+
if (depth) {
|
|
1290
|
+
depth--;
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
if (char !== ")") return false;
|
|
1294
|
+
args.push(raw.slice(start, i).trim());
|
|
1295
|
+
while (args.length && !args.at(-1)) args.pop();
|
|
1296
|
+
const isString = (arg) => /^(['"`])[\s\S]*\1$/.test(arg);
|
|
1297
|
+
if (!args.length || isString(args[0]) || args[0] === "true") return false;
|
|
1298
|
+
if (args.length > 1 && !isString(args.at(-1))) return false;
|
|
1299
|
+
return !/\bCI\b|\bisCI\b|GITHUB_ACTIONS/.test(args[0]);
|
|
1300
|
+
} else if (char === "," && !depth) {
|
|
1301
|
+
args.push(raw.slice(start, i).trim());
|
|
1302
|
+
start = i + 1;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
return false;
|
|
1306
|
+
}
|
|
1169
1307
|
var JS_COMMENT_DIRECTIVE_PATTERNS = [
|
|
1170
1308
|
{ regex: /^\s*\/\/\s*@ts-ignore\b/, name: "compiler ignore (@ts-ignore)" },
|
|
1171
1309
|
{ regex: /^\s*\/\/\s*@ts-expect-error\b/, name: "compiler suppression (@ts-expect-error)" },
|
|
@@ -1222,7 +1360,8 @@ var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
|
1222
1360
|
".rb",
|
|
1223
1361
|
".cs",
|
|
1224
1362
|
".cpp",
|
|
1225
|
-
".c"
|
|
1363
|
+
".c",
|
|
1364
|
+
".swift"
|
|
1226
1365
|
]);
|
|
1227
1366
|
var IGNORED_PATH_REGEX = /(?:^|\/)(?:fixtures|__fixtures__|testdata|mocks|test_data|__snapshots__|sample[s]?)\//i;
|
|
1228
1367
|
var TEST_PATH_REGEX = /(?:^|\/)test_[^/]+\.py$|(?:^|\/)(?:tests?|__tests__|specs?)\/|[._-](?:test|spec)\.[a-z0-9]+$/i;
|
|
@@ -1258,6 +1397,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1258
1397
|
const directives = javascript ? JS_COMMENT_DIRECTIVE_PATTERNS : python ? PYTHON_COMMENT_DIRECTIVE_PATTERNS : [];
|
|
1259
1398
|
const stubs = javascript ? JS_STUB_PATTERNS : python ? PYTHON_STUB_PATTERNS : rust ? RUST_STUB_PATTERNS : [];
|
|
1260
1399
|
const firstFinding = infractions.length;
|
|
1400
|
+
const languageRules = LANGUAGE_LINE_RULES.find((entry) => entry.files.test(file.path))?.rules;
|
|
1261
1401
|
const masker = new SourceMasker(file.path);
|
|
1262
1402
|
const maskedLines = [];
|
|
1263
1403
|
for (const added of file.addedLines) {
|
|
@@ -1271,6 +1411,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1271
1411
|
if (python && !isComment) {
|
|
1272
1412
|
for (const rule of PYTHON_TEST_SKIP_PATTERNS) {
|
|
1273
1413
|
if (rule.regex.test(cleanedLine)) {
|
|
1414
|
+
if (rule.imperative && isGuardedSkip(maskedLines)) break;
|
|
1274
1415
|
infractions.push({
|
|
1275
1416
|
type: "test-skip",
|
|
1276
1417
|
file: file.path,
|
|
@@ -1284,6 +1425,14 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1284
1425
|
}
|
|
1285
1426
|
}
|
|
1286
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
|
+
}
|
|
1287
1436
|
let foundEscape = false;
|
|
1288
1437
|
for (const rule of directives) {
|
|
1289
1438
|
if (rule.regex.test(masked.comments)) {
|
|
@@ -1376,6 +1525,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1376
1525
|
if (match[2] && !isTestFile(file.path)) continue;
|
|
1377
1526
|
if (/\bfunction$/.test(source.slice(Math.max(0, previous - 8), previous + 1))) continue;
|
|
1378
1527
|
if (source[match.index + match[0].length] === "" && !/\.\s*each\s*$/.test(match[3])) continue;
|
|
1528
|
+
if (match[1] === "skip" && !match[3] && isConditionalSkip(source, raw, match.index + match[0].length)) continue;
|
|
1379
1529
|
const focus = match[1] === "only" || match[2] === "fit" || match[2] === "fdescribe";
|
|
1380
1530
|
infractions.push({
|
|
1381
1531
|
type: focus ? "test-focus" : "test-skip",
|
|
@@ -1887,6 +2037,9 @@ var RULE_PRIORITY = {
|
|
|
1887
2037
|
"expectation-rewritten": 1,
|
|
1888
2038
|
"test-removed": 1
|
|
1889
2039
|
};
|
|
2040
|
+
function isCheckRule(type) {
|
|
2041
|
+
return RULE_PRIORITY[type] !== void 0;
|
|
2042
|
+
}
|
|
1890
2043
|
function findingRank(type, failedRules) {
|
|
1891
2044
|
return failedRules.includes(type) ? -1 : RULE_PRIORITY[type] ?? 2;
|
|
1892
2045
|
}
|
|
@@ -2095,12 +2248,15 @@ function formatCheckMarkdown(result) {
|
|
|
2095
2248
|
for (const skipped of result.skippedFiles) lines.push("", `Not scanned: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
|
|
2096
2249
|
return lines.join("\n");
|
|
2097
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);
|
|
2098
2254
|
function formatReviewTerminal(result) {
|
|
2099
2255
|
const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
|
|
2100
2256
|
const lines = [
|
|
2101
2257
|
"",
|
|
2102
|
-
pc.bold(`agent-roast \u2014 ${result
|
|
2103
|
-
` ${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)}`,
|
|
2104
2260
|
` ${result.filesInspected} changed files compared \xB7 ${result.linesInspected} source lines inspected across both versions`,
|
|
2105
2261
|
` ${result.infractions.length} new \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected`
|
|
2106
2262
|
];
|
|
@@ -2108,25 +2264,26 @@ function formatReviewTerminal(result) {
|
|
|
2108
2264
|
if (result.policyChanges.length) lines.push(" Candidate policy changes were ignored; adopt them separately after review.");
|
|
2109
2265
|
if (result.excludedFiles || result.unsupportedFiles) lines.push(` ${result.excludedFiles} excluded \xB7 ${result.unsupportedFiles} unsupported/non-source changes`);
|
|
2110
2266
|
for (const finding2 of mostImportantFirst(result.infractions, result.failedRules).slice(0, 20)) {
|
|
2267
|
+
const failing = result.failedRules.includes(finding2.type);
|
|
2111
2268
|
lines.push(
|
|
2112
2269
|
"",
|
|
2113
|
-
` ${
|
|
2270
|
+
(failing ? pc.red : pc.yellow)(` ${failing ? "FAIL" : "WARN"} [${finding2.type}] ${terminalText(finding2.file || "unknown")}:${finding2.lineNumber}`),
|
|
2114
2271
|
` ${terminalText(finding2.lineSnippet || finding2.reason)}`,
|
|
2115
|
-
` Next: ${terminalText(finding2.suggestion || "Review this occurrence.")}`
|
|
2272
|
+
pc.dim(` Next: ${terminalText(finding2.suggestion || "Review this occurrence.")}`)
|
|
2116
2273
|
);
|
|
2117
2274
|
}
|
|
2118
2275
|
if (result.infractions.length > 20) lines.push(` ${result.infractions.length - 20} more new findings; use --format json for all results.`);
|
|
2119
2276
|
for (const skipped of result.skippedFiles) lines.push(` NOT COMPARED ${terminalText(skipped.file)}: ${terminalText(skipped.reason)}`);
|
|
2120
|
-
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(", ")}.`), "");
|
|
2121
2278
|
if (!result.filesInspected) lines.push(" No supported changed source files compared.", "");
|
|
2122
2279
|
return lines.join("\n");
|
|
2123
2280
|
}
|
|
2124
2281
|
function formatReviewMarkdown(result) {
|
|
2125
2282
|
const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
|
|
2126
2283
|
const lines = [
|
|
2127
|
-
`## agent-roast ${result
|
|
2284
|
+
`## agent-roast ${reviewTitle(result)}: ${status}`,
|
|
2128
2285
|
"",
|
|
2129
|
-
`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)}\`.`,
|
|
2130
2287
|
"",
|
|
2131
2288
|
`**${result.infractions.length} new** \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected.`,
|
|
2132
2289
|
""
|
|
@@ -2206,9 +2363,9 @@ function createNewFile(root, relative2, content, mode = 420) {
|
|
|
2206
2363
|
let directory = root;
|
|
2207
2364
|
for (const segment of path3.dirname(inside).split(path3.sep).filter((part) => part !== ".")) {
|
|
2208
2365
|
directory = path3.join(directory, segment);
|
|
2209
|
-
let
|
|
2366
|
+
let stat3;
|
|
2210
2367
|
try {
|
|
2211
|
-
|
|
2368
|
+
stat3 = fs4.lstatSync(directory);
|
|
2212
2369
|
} catch (error) {
|
|
2213
2370
|
if (error.code !== "ENOENT") throw error;
|
|
2214
2371
|
try {
|
|
@@ -2216,9 +2373,9 @@ function createNewFile(root, relative2, content, mode = 420) {
|
|
|
2216
2373
|
} catch (mkdirError) {
|
|
2217
2374
|
if (mkdirError.code !== "EEXIST") throw mkdirError;
|
|
2218
2375
|
}
|
|
2219
|
-
|
|
2376
|
+
stat3 = fs4.lstatSync(directory);
|
|
2220
2377
|
}
|
|
2221
|
-
if (!
|
|
2378
|
+
if (!stat3.isDirectory() || stat3.isSymbolicLink()) throw new ConfigError("Initialization refuses symlinked or non-directory parent paths.");
|
|
2222
2379
|
}
|
|
2223
2380
|
const temporary = path3.join(directory, `.agent-roast-init-${randomUUID3()}.tmp`);
|
|
2224
2381
|
let fd;
|
|
@@ -2471,14 +2628,15 @@ import { constants as constants2 } from "fs";
|
|
|
2471
2628
|
import path5 from "path";
|
|
2472
2629
|
|
|
2473
2630
|
// src/snapshot.ts
|
|
2474
|
-
import { createHash as createHash2 } from "crypto";
|
|
2631
|
+
import { createHash as createHash2, randomBytes } from "crypto";
|
|
2475
2632
|
import * as fs5 from "fs/promises";
|
|
2633
|
+
import { constants as fsConstants } from "fs";
|
|
2476
2634
|
import path4 from "path";
|
|
2477
2635
|
import { spawn as spawn2 } from "child_process";
|
|
2478
2636
|
function assertNotAborted(signal) {
|
|
2479
2637
|
if (signal?.aborted) throw new ConfigError("Review cancelled; no complete result was produced.");
|
|
2480
2638
|
}
|
|
2481
|
-
async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024, indexFile) {
|
|
2639
|
+
async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024, indexFile, input) {
|
|
2482
2640
|
assertNotAborted(signal);
|
|
2483
2641
|
const env = getHermeticGitEnv({ GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0" });
|
|
2484
2642
|
if (indexFile) env.GIT_INDEX_FILE = indexFile;
|
|
@@ -2486,9 +2644,12 @@ async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024, indexFile) {
|
|
|
2486
2644
|
const child = spawn2("git", [...HERMETIC_GIT_FLAGS, ...args], {
|
|
2487
2645
|
cwd,
|
|
2488
2646
|
env,
|
|
2489
|
-
stdio: ["
|
|
2647
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2490
2648
|
windowsHide: true
|
|
2491
2649
|
});
|
|
2650
|
+
child.stdin.on("error", () => {
|
|
2651
|
+
});
|
|
2652
|
+
child.stdin.end(input);
|
|
2492
2653
|
const chunks = [];
|
|
2493
2654
|
let bytes = 0;
|
|
2494
2655
|
let stderr = "";
|
|
@@ -2530,6 +2691,20 @@ async function requireLocalObjects2(cwd, signal) {
|
|
|
2530
2691
|
if (!(error instanceof GitError && error.code === 1)) throw error;
|
|
2531
2692
|
}
|
|
2532
2693
|
}
|
|
2694
|
+
async function unresolvedRefHint(cwd, ref, signal) {
|
|
2695
|
+
try {
|
|
2696
|
+
const count = async (...args) => Number((await readGit(cwd, ["rev-list", "--count", ...args, "HEAD"], signal, 64)).toString().trim());
|
|
2697
|
+
if ((await readGit(cwd, ["rev-parse", "--is-shallow-repository"], signal, 64)).toString().trim() === "true") {
|
|
2698
|
+
return `This clone is shallow (commits available: ${await count()}), so the ref may lie outside it. Run 'git fetch --unshallow' (in GitHub Actions: actions/checkout with fetch-depth: 0) or check the name.`;
|
|
2699
|
+
}
|
|
2700
|
+
const back = /^HEAD~(\d+)$/.exec(ref);
|
|
2701
|
+
const depth = back ? await count("--first-parent") : 0;
|
|
2702
|
+
if (back && Number(back[1]) >= depth) return `HEAD has only ${depth} commit${depth === 1 ? "" : "s"} on its main line${depth > 1 ? `; try HEAD~${depth - 1}` : ""}.`;
|
|
2703
|
+
} catch {
|
|
2704
|
+
assertNotAborted(signal);
|
|
2705
|
+
}
|
|
2706
|
+
return "Check that it names a commit, branch or tag of this repository.";
|
|
2707
|
+
}
|
|
2533
2708
|
async function reviewRefs(cwd, base, head, signal) {
|
|
2534
2709
|
const resolve2 = async (ref) => {
|
|
2535
2710
|
if (!ref.trim()) throw new ConfigError("Review refs must not be empty.");
|
|
@@ -2537,7 +2712,7 @@ async function reviewRefs(cwd, base, head, signal) {
|
|
|
2537
2712
|
return (await readGit(cwd, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], signal, 1024)).toString().trim();
|
|
2538
2713
|
} catch (error) {
|
|
2539
2714
|
assertNotAborted(signal);
|
|
2540
|
-
throw new ConfigError(`Cannot resolve review ref '${ref}'.
|
|
2715
|
+
throw new ConfigError(`Cannot resolve review ref '${ref}'. ${await unresolvedRefHint(cwd, ref, signal)} ${error instanceof Error ? error.message : String(error)}`);
|
|
2541
2716
|
}
|
|
2542
2717
|
};
|
|
2543
2718
|
await requireLocalObjects2(cwd, signal);
|
|
@@ -2594,35 +2769,36 @@ async function reviewPolicy(cwd, baseSha, signal) {
|
|
|
2594
2769
|
}
|
|
2595
2770
|
return { config: configFromText("{}", "default review policy"), filename: null };
|
|
2596
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
|
+
}
|
|
2597
2787
|
async function stagedSnapshot(cwd, signal) {
|
|
2598
2788
|
await requireLocalObjects2(cwd, signal);
|
|
2599
2789
|
let indexFile;
|
|
2600
2790
|
if (process.env.GIT_INDEX_FILE) {
|
|
2601
|
-
const
|
|
2791
|
+
const gitDirectory2 = await fs5.realpath(gitOutputPath((await readGit(cwd, ["rev-parse", "--absolute-git-dir"], signal, 8192)).toString()));
|
|
2602
2792
|
const requested = path4.resolve(cwd, process.env.GIT_INDEX_FILE);
|
|
2603
|
-
const
|
|
2793
|
+
const stat3 = await fs5.lstat(requested);
|
|
2604
2794
|
const resolved = await fs5.realpath(requested);
|
|
2605
|
-
const relative2 = path4.relative(
|
|
2606
|
-
if (!
|
|
2795
|
+
const relative2 = path4.relative(gitDirectory2, resolved);
|
|
2796
|
+
if (!stat3.isFile() || stat3.isSymbolicLink() || !relative2 || relative2 === ".." || relative2.startsWith(".." + path4.sep) || path4.isAbsolute(relative2)) {
|
|
2607
2797
|
throw new ConfigError("GIT_INDEX_FILE must be a regular candidate index inside this repository's Git directory; refusing to review another index.");
|
|
2608
2798
|
}
|
|
2609
2799
|
indexFile = resolved;
|
|
2610
2800
|
}
|
|
2611
|
-
const head =
|
|
2612
|
-
try {
|
|
2613
|
-
return (await readGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], signal, 1024)).toString().trim();
|
|
2614
|
-
} catch (error) {
|
|
2615
|
-
if (!(error instanceof GitError && error.code === 1)) throw error;
|
|
2616
|
-
const ref = (await readGit(cwd, ["symbolic-ref", "--quiet", "HEAD"], signal, 1024)).toString().trim();
|
|
2617
|
-
try {
|
|
2618
|
-
await readGit(cwd, ["show-ref", "--verify", "--quiet", ref], signal, 1024);
|
|
2619
|
-
} catch (missing) {
|
|
2620
|
-
if (missing instanceof GitError && missing.code === 1) return "EMPTY";
|
|
2621
|
-
throw missing;
|
|
2622
|
-
}
|
|
2623
|
-
throw new ConfigError("HEAD exists but cannot be resolved to a commit.");
|
|
2624
|
-
}
|
|
2625
|
-
};
|
|
2801
|
+
const head = () => resolveHead(cwd, signal);
|
|
2626
2802
|
const baseSha = await head();
|
|
2627
2803
|
const inventory = () => readGit(cwd, [
|
|
2628
2804
|
"diff",
|
|
@@ -2659,6 +2835,99 @@ async function stagedSnapshot(cwd, signal) {
|
|
|
2659
2835
|
}
|
|
2660
2836
|
};
|
|
2661
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
|
+
}
|
|
2662
2931
|
|
|
2663
2932
|
// src/check.ts
|
|
2664
2933
|
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
@@ -2677,21 +2946,21 @@ function insideRoot(root, filename) {
|
|
|
2677
2946
|
async function readSource(root, file, signal) {
|
|
2678
2947
|
const filename = path5.resolve(root, file);
|
|
2679
2948
|
if (!insideRoot(root, filename)) throw new Error("File escapes the repository.");
|
|
2680
|
-
let
|
|
2949
|
+
let stat3;
|
|
2681
2950
|
try {
|
|
2682
|
-
|
|
2951
|
+
stat3 = await fs6.lstat(filename);
|
|
2683
2952
|
} catch (error) {
|
|
2684
2953
|
if (error.code === "ENOENT") return null;
|
|
2685
2954
|
throw error;
|
|
2686
2955
|
}
|
|
2687
|
-
if (!
|
|
2956
|
+
if (!stat3.isFile() || stat3.isSymbolicLink()) throw new Error("Symlinks and special files are not scanned.");
|
|
2688
2957
|
const real = await fs6.realpath(filename);
|
|
2689
2958
|
if (!insideRoot(root, real)) throw new Error("File resolves outside the repository.");
|
|
2690
|
-
if (
|
|
2959
|
+
if (stat3.size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
|
|
2691
2960
|
const handle = await fs6.open(filename, constants2.O_RDONLY | (constants2.O_NOFOLLOW || 0) | (constants2.O_NONBLOCK || 0));
|
|
2692
2961
|
try {
|
|
2693
2962
|
const opened = await handle.stat();
|
|
2694
|
-
if (opened.ino !==
|
|
2963
|
+
if (opened.ino !== stat3.ino || opened.dev !== stat3.dev || !opened.isFile()) throw new Error("File changed while opening.");
|
|
2695
2964
|
const chunks = [];
|
|
2696
2965
|
let size = 0;
|
|
2697
2966
|
while (true) {
|
|
@@ -3226,28 +3495,38 @@ function compareFindings(before, after, newPath) {
|
|
|
3226
3495
|
return { introduced, removed: [...old.values()].flatMap((bucket) => bucket.findings.slice(bucket.matched)), unchanged };
|
|
3227
3496
|
}
|
|
3228
3497
|
function reviewChanges(options) {
|
|
3229
|
-
return compareSourceChanges({ ...options,
|
|
3498
|
+
return compareSourceChanges({ ...options, source: "commits" });
|
|
3230
3499
|
}
|
|
3231
3500
|
function reviewStagedChanges(options = {}) {
|
|
3232
|
-
return compareSourceChanges({ ...options,
|
|
3501
|
+
return compareSourceChanges({ ...options, source: "index" });
|
|
3502
|
+
}
|
|
3503
|
+
function reviewWorktreeChanges(options = {}) {
|
|
3504
|
+
return compareSourceChanges({ ...options, source: "worktree" });
|
|
3233
3505
|
}
|
|
3234
3506
|
async function compareSourceChanges(options) {
|
|
3235
3507
|
assertNotAborted(options.signal);
|
|
3236
3508
|
const cwd = path6.resolve(options.cwd || process.cwd());
|
|
3237
|
-
const
|
|
3238
|
-
|
|
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 };
|
|
3239
3518
|
const policy = refs.baseSha === "EMPTY" ? { config: configFromText("{}", "default first-commit policy"), filename: null } : await reviewPolicy(cwd, refs.baseSha, options.signal);
|
|
3240
3519
|
const format = options.format ?? "terminal";
|
|
3241
3520
|
if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported review format.");
|
|
3242
3521
|
const failOn = options.failOn ?? policy.config.failOn ?? ["test-skip", "trivial-assert", "test-focus", "assertion-weakened", "guard-weakened"];
|
|
3243
3522
|
for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown review rule '${rule}'.`);
|
|
3244
|
-
const changes =
|
|
3523
|
+
const changes = snapshot?.changes ?? await changedTreeFiles(cwd, refs.mergeBaseSha, refs.headSha, options.signal);
|
|
3245
3524
|
const policyChanges = changes.filter((change) => CONFIG_FILENAMES.includes(change.oldPath) || CONFIG_FILENAMES.includes(change.newPath)).map((change) => change.newPath);
|
|
3246
3525
|
const ignores = [...GENERATED_PATHS2, ...policy.config.ignorePaths || []];
|
|
3247
3526
|
const result = {
|
|
3248
|
-
schemaVersion: "1.
|
|
3527
|
+
schemaVersion: "1.2.0",
|
|
3249
3528
|
mode: "review",
|
|
3250
|
-
source:
|
|
3529
|
+
source: options.source,
|
|
3251
3530
|
...refs,
|
|
3252
3531
|
policySha: refs.baseSha,
|
|
3253
3532
|
policyFile: policy.filename,
|
|
@@ -3275,15 +3554,26 @@ async function compareSourceChanges(options) {
|
|
|
3275
3554
|
const guardFindings = [];
|
|
3276
3555
|
const ciCounts = [];
|
|
3277
3556
|
let productionChanged = false;
|
|
3278
|
-
const
|
|
3279
|
-
if (mode === "000000") return null;
|
|
3280
|
-
if (!["100644", "100755"].includes(mode)) throw new ConfigError("Changed source is a symlink, submodule or unsupported Git mode; comparison is incomplete.");
|
|
3281
|
-
const content = await readBlob(cwd, oid, options.signal);
|
|
3557
|
+
const decode = (content) => {
|
|
3282
3558
|
totalBytes += content.length;
|
|
3283
3559
|
if (totalBytes > MAX_SCAN_BYTES2) throw new ConfigError("Review exceeds the 50 MiB source comparison limit.");
|
|
3284
3560
|
if (content.includes(0)) throw new ConfigError("Binary content in a source comparison.");
|
|
3285
3561
|
return new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
3286
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
|
+
};
|
|
3287
3577
|
const existingCounts = /* @__PURE__ */ new Map();
|
|
3288
3578
|
for (const change of changes) {
|
|
3289
3579
|
assertNotAborted(options.signal);
|
|
@@ -3297,7 +3587,7 @@ async function compareSourceChanges(options) {
|
|
|
3297
3587
|
}
|
|
3298
3588
|
try {
|
|
3299
3589
|
const oldText = await readText(change.oldMode, change.oldOid);
|
|
3300
|
-
const newText = await
|
|
3590
|
+
const newText = await readNew(change);
|
|
3301
3591
|
guardFindings.push(...analyzeGuardFile(change.newPath, oldText, newText, refs.headSha));
|
|
3302
3592
|
const ci = ciTestSteps(change.newPath, oldText, newText);
|
|
3303
3593
|
if (ci) ciCounts.push(ci);
|
|
@@ -3328,7 +3618,7 @@ async function compareSourceChanges(options) {
|
|
|
3328
3618
|
throw new ConfigError("Source moved to an unsupported extension; cannot claim its findings were resolved.");
|
|
3329
3619
|
}
|
|
3330
3620
|
const oldText = await readText(change.oldMode, change.oldOid);
|
|
3331
|
-
const newText = await
|
|
3621
|
+
const newText = await readNew(change);
|
|
3332
3622
|
const before = scan(oldText, change.oldPath, refs.mergeBaseSha);
|
|
3333
3623
|
const after = scan(newText, change.newPath, refs.headSha);
|
|
3334
3624
|
if (isTestFile(change.oldPath) || isTestFile(change.newPath)) tamperingFiles.push(analyzeTestFile(change.oldPath, change.newPath, oldText, newText, refs.headSha));
|
|
@@ -3355,7 +3645,7 @@ async function compareSourceChanges(options) {
|
|
|
3355
3645
|
}
|
|
3356
3646
|
}
|
|
3357
3647
|
assertNotAborted(options.signal);
|
|
3358
|
-
await
|
|
3648
|
+
await snapshot?.verify();
|
|
3359
3649
|
result.complete = result.skippedFiles.length === 0;
|
|
3360
3650
|
const rules = policy.config.rules ?? {};
|
|
3361
3651
|
result.infractions.push(...[...resolveTampering(tamperingFiles, productionChanged), ...guardFindings, ...resolveCiTestSteps(ciCounts, refs.headSha)].filter((finding2) => rules[finding2.type] !== "off"));
|
|
@@ -3374,6 +3664,528 @@ async function compareSourceChanges(options) {
|
|
|
3374
3664
|
return result;
|
|
3375
3665
|
}
|
|
3376
3666
|
|
|
3667
|
+
// src/replay.ts
|
|
3668
|
+
import path7 from "path";
|
|
3669
|
+
var MAX_COMMITS = 1e3;
|
|
3670
|
+
var CONCURRENCY = 4;
|
|
3671
|
+
var LISTED_COMMITS = 15;
|
|
3672
|
+
var LISTED_PER_RULE = 2;
|
|
3673
|
+
var plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
3674
|
+
function clipEvidence(text, limit = 200) {
|
|
3675
|
+
if (text.length <= limit) return text;
|
|
3676
|
+
const arrow = text.indexOf(" \u2192 ");
|
|
3677
|
+
const clip2 = (part, size) => part.length > size ? `${part.slice(0, size - 1)}\u2026` : part;
|
|
3678
|
+
if (arrow < 0) return clip2(text, limit);
|
|
3679
|
+
const side = Math.floor((limit - 3) / 2);
|
|
3680
|
+
return `${clip2(text.slice(0, arrow), side)} \u2192 ${clip2(text.slice(arrow + 3), side)}`;
|
|
3681
|
+
}
|
|
3682
|
+
async function replayHistory(options = {}) {
|
|
3683
|
+
assertNotAborted(options.signal);
|
|
3684
|
+
const cwd = path7.resolve(options.cwd || process.cwd());
|
|
3685
|
+
const last = options.last ?? 50;
|
|
3686
|
+
if (!Number.isInteger(last) || last < 1 || last > MAX_COMMITS) throw new ConfigError(`Replay needs a whole number of commits between 1 and ${MAX_COMMITS}.`);
|
|
3687
|
+
const format = options.format ?? "terminal";
|
|
3688
|
+
if (format !== "terminal" && format !== "json") throw new ConfigError("Replay supports the terminal and json formats.");
|
|
3689
|
+
let log;
|
|
3690
|
+
try {
|
|
3691
|
+
log = (await readGit(cwd, ["log", "--first-parent", "-n", String(last), "--format=%H%x1f%P%x1f%cI%x1f%s"], options.signal)).toString("utf8");
|
|
3692
|
+
} catch (error) {
|
|
3693
|
+
assertNotAborted(options.signal);
|
|
3694
|
+
throw new ConfigError(`Cannot read the commit history. ${error instanceof Error ? error.message : String(error)}`);
|
|
3695
|
+
}
|
|
3696
|
+
const entries = log.split("\n").filter(Boolean).map((line) => {
|
|
3697
|
+
const [sha, parents, date, ...subject] = line.split("");
|
|
3698
|
+
return { sha, base: parents.split(" ")[0], date, subject: subject.join(" ") };
|
|
3699
|
+
});
|
|
3700
|
+
if (!entries.length) throw new ConfigError("The repository has no commits to replay.");
|
|
3701
|
+
const reviewable = entries.filter((entry) => entry.base);
|
|
3702
|
+
const commits = new Array(reviewable.length);
|
|
3703
|
+
let next = 0;
|
|
3704
|
+
let done = 0;
|
|
3705
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, reviewable.length) }, async () => {
|
|
3706
|
+
while (next < reviewable.length) {
|
|
3707
|
+
const index = next++;
|
|
3708
|
+
commits[index] = await replayCommit(cwd, reviewable[index], options.signal);
|
|
3709
|
+
options.onProgress?.(++done, reviewable.length);
|
|
3710
|
+
}
|
|
3711
|
+
}));
|
|
3712
|
+
if (commits.length && commits.every((commit) => commit.status === "failed")) throw new ConfigError(`No commit could be reviewed. ${commits[0].error}`);
|
|
3713
|
+
const summary = { commits: commits.length, blocked: 0, warned: 0, noted: 0, clean: 0, untouched: 0, incomplete: 0, failed: 0, findingsByRule: {} };
|
|
3714
|
+
for (const commit of commits) {
|
|
3715
|
+
summary[commit.status]++;
|
|
3716
|
+
for (const finding2 of commit.findings) summary.findingsByRule[finding2.type] = (summary.findingsByRule[finding2.type] ?? 0) + 1;
|
|
3717
|
+
}
|
|
3718
|
+
const shallow = (await readGit(cwd, ["rev-parse", "--is-shallow-repository"], options.signal, 64).catch(() => Buffer.from(""))).toString().trim() === "true";
|
|
3719
|
+
const result = { schemaVersion: "1.0.0", mode: "replay", head: entries[0].sha, requested: last, commitsWithoutParent: entries.length - reviewable.length, shallow, summary, commits, exitCode: 0, output: "" };
|
|
3720
|
+
result.output = format === "json" ? JSON.stringify({ ...result, output: void 0 }, null, 2) : formatReplayTerminal(result, options.allRules);
|
|
3721
|
+
return result;
|
|
3722
|
+
}
|
|
3723
|
+
async function replayCommit(cwd, entry, signal) {
|
|
3724
|
+
const identity = { sha: entry.sha, date: entry.date, subject: entry.subject };
|
|
3725
|
+
try {
|
|
3726
|
+
const review = await reviewChanges({ cwd, base: entry.base, head: entry.sha, format: "json", signal });
|
|
3727
|
+
const status = review.failedRules.length ? "blocked" : !review.complete ? "incomplete" : review.infractions.some((finding2) => isCheckRule(finding2.type)) ? "warned" : review.infractions.length ? "noted" : review.filesInspected ? "clean" : "untouched";
|
|
3728
|
+
return { ...identity, status, failedRules: review.failedRules, findings: mostImportantFirst(review.infractions, review.failedRules), filesInspected: review.filesInspected, skipped: review.skippedFiles };
|
|
3729
|
+
} catch (error) {
|
|
3730
|
+
assertNotAborted(signal);
|
|
3731
|
+
return { ...identity, status: "failed", failedRules: [], findings: [], filesInspected: 0, skipped: [], error: error instanceof Error ? error.message : String(error) };
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
function formatReplayTerminal(result, allRules = false) {
|
|
3735
|
+
const { summary } = result;
|
|
3736
|
+
const lines = [
|
|
3737
|
+
"",
|
|
3738
|
+
`agent-roast \u2014 replay of the last ${plural(summary.commits, "commit")} (up to ${result.head.slice(0, 7)})`,
|
|
3739
|
+
" Each commit is reviewed on its own against its parent, as a pull-request check would have seen it."
|
|
3740
|
+
];
|
|
3741
|
+
const shallowHint = result.shallow ? [" This clone is shallow, so only the commits it holds were replayed. Run 'git fetch --unshallow' to replay more."] : [];
|
|
3742
|
+
if (!summary.commits) return [...lines, " There is no earlier commit to compare with, so nothing was reviewed.", ...shallowHint, ""].join("\n");
|
|
3743
|
+
lines.push(...shallowHint);
|
|
3744
|
+
lines.push(
|
|
3745
|
+
` ${plural(summary.commits - summary.untouched - summary.failed, "commit")} changed source, tests or CI config \xB7 ${summary.untouched} did not`,
|
|
3746
|
+
` Would have blocked: ${plural(summary.blocked, "commit")} \xB7 warnings on ${summary.warned} \xB7 line patterns only on ${summary.noted} \xB7 nothing to report on ${summary.clean}`
|
|
3747
|
+
);
|
|
3748
|
+
if (summary.incomplete + summary.failed) lines.push(` Not reviewed in full: ${plural(summary.incomplete + summary.failed, "commit")}`);
|
|
3749
|
+
const listed = result.commits.filter((commit) => commit.status === "blocked" || commit.status === "warned" || allRules && commit.status === "noted");
|
|
3750
|
+
const ordered = [...listed.filter((commit) => commit.status === "blocked"), ...listed.filter((commit) => commit.status !== "blocked")];
|
|
3751
|
+
for (const commit of ordered.slice(0, LISTED_COMMITS)) {
|
|
3752
|
+
lines.push("", ` ${commit.status === "blocked" ? "BLOCK" : "WARN "} ${commit.sha.slice(0, 7)} ${commit.date.slice(0, 10)} ${terminalText(commit.subject).slice(0, 80)}`);
|
|
3753
|
+
const shown = commit.findings.filter((finding2) => allRules || isCheckRule(finding2.type));
|
|
3754
|
+
for (const rule of new Set(shown.map((finding2) => finding2.type))) {
|
|
3755
|
+
const group = shown.filter((finding2) => finding2.type === rule);
|
|
3756
|
+
for (const finding2 of group.slice(0, LISTED_PER_RULE)) {
|
|
3757
|
+
lines.push(
|
|
3758
|
+
` ${commit.failedRules.includes(rule) ? "FAIL" : "WARN"} [${rule}] ${terminalText(finding2.file || "unknown")}:${finding2.lineNumber}`,
|
|
3759
|
+
` ${clipEvidence(terminalText(finding2.lineSnippet || finding2.reason))}`
|
|
3760
|
+
);
|
|
3761
|
+
}
|
|
3762
|
+
if (group.length > LISTED_PER_RULE) lines.push(` \u2026 and ${group.length - LISTED_PER_RULE} more [${rule}] in this commit`);
|
|
3763
|
+
}
|
|
3764
|
+
}
|
|
3765
|
+
if (ordered.length > LISTED_COMMITS) lines.push("", ` \u2026 ${plural(ordered.length - LISTED_COMMITS, "more commit")} with findings; use --json for all of them.`);
|
|
3766
|
+
if (!ordered.length) lines.push("", " Nothing to report on these commits.");
|
|
3767
|
+
if (!allRules) {
|
|
3768
|
+
const hidden = result.commits.flatMap((commit) => commit.findings.filter((finding2) => !isCheckRule(finding2.type)));
|
|
3769
|
+
if (hidden.length) {
|
|
3770
|
+
const byRule = Object.entries(hidden.reduce((counts, finding2) => ({ ...counts, [finding2.type]: (counts[finding2.type] ?? 0) + 1 }), {})).sort((a, b) => b[1] - a[1]);
|
|
3771
|
+
lines.push("", ` Line patterns not listed: ${plural(hidden.length, "finding")} (${byRule.slice(0, 3).map(([rule, count]) => `${rule} ${count}`).join(", ")}). Add --all-rules to list them.`);
|
|
3772
|
+
}
|
|
3773
|
+
}
|
|
3774
|
+
for (const commit of result.commits.filter((commit2) => commit2.status === "incomplete" || commit2.status === "failed").slice(0, 5)) {
|
|
3775
|
+
lines.push(` Not reviewed in full: ${commit.sha.slice(0, 7)} ${terminalText(commit.subject).slice(0, 60)} \u2014 ${terminalText(commit.error ?? `${plural(commit.skipped.length, "changed file")} could not be compared`)}`);
|
|
3776
|
+
}
|
|
3777
|
+
lines.push(
|
|
3778
|
+
"",
|
|
3779
|
+
" Findings are review candidates, not verdicts: `agent-roast explain <rule>` says what a rule means.",
|
|
3780
|
+
" To run this on every pull request: `agent-roast init --ci`.",
|
|
3781
|
+
""
|
|
3782
|
+
);
|
|
3783
|
+
return lines.join("\n");
|
|
3784
|
+
}
|
|
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
|
+
|
|
3377
4189
|
// src/index.ts
|
|
3378
4190
|
var InvalidDetectorError = class extends Error {
|
|
3379
4191
|
constructor(message) {
|
|
@@ -3382,9 +4194,9 @@ var InvalidDetectorError = class extends Error {
|
|
|
3382
4194
|
}
|
|
3383
4195
|
};
|
|
3384
4196
|
async function roastRepository(options = {}) {
|
|
3385
|
-
const cwd =
|
|
4197
|
+
const cwd = path11.resolve(options.cwd || process.cwd());
|
|
3386
4198
|
const since = options.since || "90 days ago";
|
|
3387
|
-
const repoName =
|
|
4199
|
+
const repoName = path11.basename(cwd);
|
|
3388
4200
|
const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths, rules: options.rules, panicLoopWindowMinutes: options.panicLoopWindowMinutes });
|
|
3389
4201
|
const gitStream = streamGitLog(cwd, since);
|
|
3390
4202
|
await parseGitLogStream(gitStream, (commit, stats) => {
|
|
@@ -3399,7 +4211,7 @@ async function roastRepository(options = {}) {
|
|
|
3399
4211
|
return { summary, output };
|
|
3400
4212
|
}
|
|
3401
4213
|
async function auditPullRequest(options) {
|
|
3402
|
-
const cwd =
|
|
4214
|
+
const cwd = path11.resolve(options.cwd || process.cwd());
|
|
3403
4215
|
const format = options.format || "terminal";
|
|
3404
4216
|
const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
|
|
3405
4217
|
for (const det of failOn) {
|
|
@@ -3522,6 +4334,8 @@ export {
|
|
|
3522
4334
|
generateShareUrl,
|
|
3523
4335
|
formatTerminalCard,
|
|
3524
4336
|
formatPrTerminalCard,
|
|
4337
|
+
isCheckRule,
|
|
4338
|
+
mostImportantFirst,
|
|
3525
4339
|
formatGithubAnnotations,
|
|
3526
4340
|
formatGithubStepSummary,
|
|
3527
4341
|
formatPrMarkdownComment,
|
|
@@ -3543,6 +4357,29 @@ export {
|
|
|
3543
4357
|
compareFindings,
|
|
3544
4358
|
reviewChanges,
|
|
3545
4359
|
reviewStagedChanges,
|
|
4360
|
+
reviewWorktreeChanges,
|
|
4361
|
+
replayHistory,
|
|
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,
|
|
3546
4383
|
InvalidDetectorError,
|
|
3547
4384
|
roastRepository,
|
|
3548
4385
|
auditPullRequest
|