agent-roast 0.7.0 → 0.8.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 +87 -7
- package/dist/{chunk-YGHNTWHX.js → chunk-C423TSZF.js} +994 -607
- package/dist/cli.js +56 -31
- package/dist/index.d.ts +45 -4
- package/dist/index.js +11 -1
- package/package.json +8 -3
- package/schema.json +138 -0
|
@@ -1,75 +1,53 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import
|
|
4
|
+
import path7 from "path";
|
|
5
5
|
|
|
6
6
|
// src/git.ts
|
|
7
7
|
import { spawn, spawnSync } from "child_process";
|
|
8
|
+
import { randomUUID } from "crypto";
|
|
8
9
|
import { PassThrough } from "stream";
|
|
9
|
-
import { StringDecoder } from "string_decoder";
|
|
10
10
|
|
|
11
11
|
// src/detector.ts
|
|
12
|
-
var
|
|
13
|
-
{
|
|
14
|
-
{
|
|
15
|
-
{
|
|
16
|
-
{
|
|
17
|
-
{
|
|
18
|
-
{
|
|
19
|
-
{
|
|
20
|
-
{
|
|
21
|
-
{
|
|
22
|
-
{
|
|
23
|
-
{
|
|
24
|
-
];
|
|
25
|
-
var MESSAGE_AI_PATTERNS = [
|
|
26
|
-
{ regex: /(?:Generated|Assisted|Created|Written) by Cursor/i, name: "message:cursor" },
|
|
27
|
-
{ regex: /(?:Generated|Assisted|Created|Written) by Claude/i, name: "message:claude" },
|
|
28
|
-
{ regex: /(?:Generated|Assisted|Created|Written) by Aider/i, name: "message:aider" },
|
|
29
|
-
{ regex: /(?:Generated|Assisted|Created|Written) by Copilot/i, name: "message:copilot" },
|
|
30
|
-
{ regex: /(?:Generated|Assisted|Created|Written) by Devin/i, name: "message:devin" },
|
|
31
|
-
{ regex: /(?:Generated|Assisted|Created|Written) by (?:Antigravity|AGY)/i, name: "message:antigravity" },
|
|
32
|
-
{ regex: /(?:Generated|Assisted|Created|Written) by Codex/i, name: "message:codex" },
|
|
33
|
-
{ regex: /(?:Generated|Assisted|Created|Written) by Gemini/i, name: "message:gemini" }
|
|
34
|
-
];
|
|
35
|
-
var AUTHOR_AI_PATTERNS = [
|
|
36
|
-
{ regex: /(?:claude|anthropic)/i, name: "author:claude" },
|
|
37
|
-
{ regex: /(?:cursor|anysphere)/i, name: "author:cursor" },
|
|
38
|
-
{ regex: /(?:copilot)/i, name: "author:copilot" },
|
|
39
|
-
{ regex: /(?:aider)/i, name: "author:aider" },
|
|
40
|
-
{ regex: /(?:devin)/i, name: "author:devin" },
|
|
41
|
-
{ regex: /(?:antigravity|deepmind)/i, name: "author:antigravity" },
|
|
42
|
-
{ regex: /(?:codex)/i, name: "author:codex" },
|
|
43
|
-
{ regex: /(?:gemini)/i, name: "author:gemini" },
|
|
44
|
-
{ regex: /(?:\[bot\]|bot@|automation)/i, name: "author:bot" }
|
|
12
|
+
var AGENT_IDENTITIES = [
|
|
13
|
+
{ name: "claude", label: /^(?:Claude(?: Code| Agent| AI)?|Anthropic Agent)(?:\s*\((?:Anthropic|v?[\d.]+)\))?$/i, email: /^(?:noreply|claude)@anthropic\.com$/i },
|
|
14
|
+
{ name: "cursor", label: /^(?:Cursor(?: Agent| AI)?|Anysphere Agent)$/i, email: /^(?:cursor|noreply)@(?:cursor\.com|cursor\.sh|anysphere\.co)$/i },
|
|
15
|
+
{ name: "copilot", label: /^(?:GitHub )?Copilot(?: Agent| AI|\[bot\])?$/i, email: /^(?:\d+\+)?copilot(?:\[bot\])?@users\.noreply\.github\.com$/i },
|
|
16
|
+
{ name: "aider", label: /^Aider(?:\s*\(v?[\d.]+\))?$/i, email: /^aider@(?:local|aider\.chat)$/i },
|
|
17
|
+
{ name: "devin", label: /^Devin(?: AI| Agent|\[bot\])?$/i, email: /^devin@(?:cognition\.ai|users\.noreply\.github\.com)$/i },
|
|
18
|
+
{ name: "windsurf", label: /^(?:Windsurf|Codeium)(?: Agent| AI)?$/i, email: /^(?:windsurf|codeium)@(?:windsurf\.com|codeium\.com)$/i },
|
|
19
|
+
{ name: "gpt", label: /^(?:ChatGPT|OpenAI Agent|GPT Agent)$/i, email: /^(?:chatgpt|gpt)@openai\.com$/i },
|
|
20
|
+
{ name: "antigravity", label: /^(?:Antigravity(?: Agent| AI)?|DeepMind Agent)$/i, email: /^antigravity@(?:google\.com|deepmind\.com)$/i },
|
|
21
|
+
{ name: "codex", label: /^Codex(?: Agent| AI)?$/i, email: /^codex@openai\.com$/i },
|
|
22
|
+
{ name: "gemini", label: /^Gemini(?: CLI| Agent| AI)?$/i, email: /^gemini@google\.com$/i },
|
|
23
|
+
{ name: "openclaw", label: /^OpenClaw(?: Agent| AI)?$/i, email: /^openclaw@(?:local|openclaw\.ai)$/i }
|
|
45
24
|
];
|
|
25
|
+
function identitySignals(name, email) {
|
|
26
|
+
return AGENT_IDENTITIES.filter((agent) => agent.label.test(name.trim()) || agent.email.test(email.trim())).map((agent) => agent.name);
|
|
27
|
+
}
|
|
46
28
|
function detectAiAttribution(authorName, authorEmail, message) {
|
|
47
|
-
const signatures =
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
return {
|
|
65
|
-
isAiAttributed: signatures.length > 0,
|
|
66
|
-
signatures
|
|
67
|
-
};
|
|
29
|
+
const signatures = /* @__PURE__ */ new Set();
|
|
30
|
+
const lines = message.trimEnd().split(/\r?\n/);
|
|
31
|
+
let trailerStart = lines.length;
|
|
32
|
+
while (trailerStart > 0 && /^[A-Za-z][A-Za-z-]*:\s*.+$/.test(lines[trailerStart - 1])) trailerStart--;
|
|
33
|
+
for (const line of lines.slice(trailerStart)) {
|
|
34
|
+
const trailer = /^Co-Authored-By:\s*(.+?)\s*<([^<>]+)>\s*$/i.exec(line);
|
|
35
|
+
if (!trailer) continue;
|
|
36
|
+
for (const name of identitySignals(trailer[1], trailer[2])) signatures.add(`trailer:${name}`);
|
|
37
|
+
}
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
const attribution = /^\s*(?:Generated|Assisted|Created|Written) by (.+?)\.?\s*$/i.exec(line);
|
|
40
|
+
if (!attribution) continue;
|
|
41
|
+
for (const name of identitySignals(attribution[1], "")) signatures.add(`message:${name}`);
|
|
42
|
+
}
|
|
43
|
+
for (const name of identitySignals(authorName, authorEmail)) signatures.add(`author:${name}`);
|
|
44
|
+
return { isAiAttributed: signatures.size > 0, signatures: [...signatures] };
|
|
68
45
|
}
|
|
69
46
|
|
|
70
47
|
// src/git.ts
|
|
71
48
|
var RECORD_SEPARATOR = "__ROAST_REC__";
|
|
72
49
|
var FIELD_SEPARATOR = "__ROAST_FIELD__";
|
|
50
|
+
var LOG_FRAMING = /* @__PURE__ */ Symbol("agent-roast Git log framing");
|
|
73
51
|
var GitError = class extends Error {
|
|
74
52
|
constructor(stderr, code) {
|
|
75
53
|
super(`git exited with code ${code}: ${stderr.trim() || "unknown error"}`);
|
|
@@ -92,33 +70,46 @@ var HERMETIC_GIT_FLAGS = [
|
|
|
92
70
|
"--no-pager"
|
|
93
71
|
];
|
|
94
72
|
function getHermeticGitEnv(extraEnv) {
|
|
73
|
+
const inherited = { ...process.env };
|
|
74
|
+
for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_NAMESPACE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_DIFF_OPTS"]) delete inherited[key];
|
|
95
75
|
return {
|
|
96
|
-
...
|
|
76
|
+
...inherited,
|
|
97
77
|
...extraEnv,
|
|
98
78
|
GIT_PAGER: "cat",
|
|
99
79
|
GIT_CONFIG_NOSYSTEM: "1",
|
|
100
|
-
GIT_CONFIG_GLOBAL: extraEnv?.GIT_CONFIG_GLOBAL || process.env.GIT_CONFIG_GLOBAL || "/dev/null"
|
|
80
|
+
GIT_CONFIG_GLOBAL: extraEnv?.GIT_CONFIG_GLOBAL || process.env.GIT_CONFIG_GLOBAL || "/dev/null",
|
|
81
|
+
GIT_NO_REPLACE_OBJECTS: "1",
|
|
82
|
+
GIT_NO_LAZY_FETCH: "1",
|
|
83
|
+
GIT_TERMINAL_PROMPT: "0"
|
|
101
84
|
};
|
|
102
85
|
}
|
|
86
|
+
function gitOutputPath(value) {
|
|
87
|
+
return value.endsWith("\n") ? value.slice(0, -1) : value;
|
|
88
|
+
}
|
|
103
89
|
var MAX_RECORD_BUFFER_SIZE = 50 * 1024 * 1024;
|
|
104
90
|
async function parseGitLogStream(stream, onCommit) {
|
|
105
91
|
let buffer = "";
|
|
92
|
+
let bufferedBytes = 0;
|
|
106
93
|
let totalSkippedLongLines = 0;
|
|
107
94
|
let totalSkippedUnknownFiles = 0;
|
|
108
95
|
let scanFrom = 0;
|
|
109
|
-
|
|
110
|
-
let
|
|
96
|
+
const framing = stream[LOG_FRAMING];
|
|
97
|
+
let activeRecSep = framing?.record ?? null;
|
|
98
|
+
let activeFieldSep = framing?.field ?? null;
|
|
111
99
|
const processBlock = (block) => {
|
|
112
100
|
if (!block.trim()) return;
|
|
113
101
|
const fieldSep = activeFieldSep || (block.includes(FIELD_SEPARATOR) ? FIELD_SEPARATOR : "");
|
|
114
102
|
const parts = block.split(fieldSep);
|
|
115
|
-
if (parts.length
|
|
103
|
+
if (parts.length !== 6) throw new GitError("Malformed or ambiguous Git log record; no complete history result was produced.", 2);
|
|
116
104
|
const hash = parts[0].trim();
|
|
117
105
|
const date = parts[1].trim();
|
|
118
106
|
const authorName = parts[2].trim();
|
|
119
107
|
const authorEmail = parts[3].trim();
|
|
120
108
|
const message = parts[4].trim();
|
|
121
109
|
const rawDiff = parts.slice(5).join(fieldSep);
|
|
110
|
+
if (framing && (!/^[a-f0-9]{40,64}$/.test(hash) || !Number.isFinite(Date.parse(date)))) {
|
|
111
|
+
throw new GitError("Invalid Git log header; no complete history result was produced.", 2);
|
|
112
|
+
}
|
|
122
113
|
const attribution = detectAiAttribution(authorName, authorEmail, message);
|
|
123
114
|
const { files, totalAdded, skippedLongLines, skippedUnknownFiles } = parseDiff(rawDiff);
|
|
124
115
|
totalSkippedLongLines += skippedLongLines;
|
|
@@ -152,12 +143,21 @@ async function parseGitLogStream(stream, onCommit) {
|
|
|
152
143
|
reject(err);
|
|
153
144
|
}
|
|
154
145
|
};
|
|
155
|
-
const decoder = new
|
|
146
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
156
147
|
stream.on("data", (chunk) => {
|
|
157
|
-
|
|
148
|
+
let chunkStr;
|
|
149
|
+
try {
|
|
150
|
+
chunkStr = typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true });
|
|
151
|
+
} catch {
|
|
152
|
+
const error = new GitError("Git history contains invalid UTF-8; no complete result was produced.", 2);
|
|
153
|
+
stream.destroy(error);
|
|
154
|
+
safeReject(error);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
158
157
|
buffer += chunkStr;
|
|
159
|
-
|
|
160
|
-
|
|
158
|
+
bufferedBytes += Buffer.byteLength(chunkStr, "utf8");
|
|
159
|
+
if (bufferedBytes > MAX_RECORD_BUFFER_SIZE) {
|
|
160
|
+
const oomErr = new GitError(`Git log record exceeded maximum buffer limit (${MAX_RECORD_BUFFER_SIZE} bytes).`, 2);
|
|
161
161
|
stream.destroy(oomErr);
|
|
162
162
|
safeReject(oomErr);
|
|
163
163
|
return;
|
|
@@ -185,6 +185,7 @@ async function parseGitLogStream(stream, onCommit) {
|
|
|
185
185
|
while ((idx = buffer.indexOf(recSep, scanFrom)) !== -1) {
|
|
186
186
|
const completeBlock = buffer.slice(0, idx);
|
|
187
187
|
buffer = buffer.slice(idx + recSep.length);
|
|
188
|
+
bufferedBytes -= Buffer.byteLength(completeBlock, "utf8") + Buffer.byteLength(recSep, "utf8");
|
|
188
189
|
scanFrom = 0;
|
|
189
190
|
try {
|
|
190
191
|
processBlock(completeBlock);
|
|
@@ -195,17 +196,17 @@ async function parseGitLogStream(stream, onCommit) {
|
|
|
195
196
|
return;
|
|
196
197
|
}
|
|
197
198
|
}
|
|
198
|
-
scanFrom = buffer.length;
|
|
199
|
+
scanFrom = Math.max(0, buffer.length - recSep.length + 1);
|
|
199
200
|
});
|
|
200
201
|
stream.on("end", () => {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
try {
|
|
203
|
+
buffer += decoder.decode();
|
|
204
|
+
if (buffer.trim()) {
|
|
204
205
|
processBlock(buffer);
|
|
205
|
-
} catch (err) {
|
|
206
|
-
safeReject(err);
|
|
207
|
-
return;
|
|
208
206
|
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
safeReject(err);
|
|
209
|
+
return;
|
|
209
210
|
}
|
|
210
211
|
safeResolve({ totalSkippedLongLines, totalSkippedUnknownFiles });
|
|
211
212
|
});
|
|
@@ -302,6 +303,7 @@ function parseDiff(rawDiff) {
|
|
|
302
303
|
continue;
|
|
303
304
|
}
|
|
304
305
|
if (char === "+") {
|
|
306
|
+
if (newLeft <= 0) throw new GitError("Malformed diff hunk adds more lines than declared.", 2);
|
|
305
307
|
if (currentFile && !currentFile.deleted && currentFile.path !== "unknown") {
|
|
306
308
|
const content = line.slice(1);
|
|
307
309
|
if (content.length <= MAX_LINE_LENGTH) {
|
|
@@ -319,23 +321,25 @@ function parseDiff(rawDiff) {
|
|
|
319
321
|
continue;
|
|
320
322
|
}
|
|
321
323
|
if (char === "-") {
|
|
324
|
+
if (oldLeft <= 0) throw new GitError("Malformed diff hunk removes more lines than declared.", 2);
|
|
322
325
|
oldLeft--;
|
|
323
326
|
continue;
|
|
324
327
|
}
|
|
325
328
|
if (char === " ") {
|
|
329
|
+
if (oldLeft <= 0 || newLeft <= 0) throw new GitError("Malformed diff hunk has invalid context counts.", 2);
|
|
326
330
|
oldLeft--;
|
|
327
331
|
newLeft--;
|
|
328
332
|
lineNo++;
|
|
329
333
|
continue;
|
|
330
334
|
}
|
|
331
|
-
|
|
332
|
-
newLeft = 0;
|
|
335
|
+
throw new GitError("Truncated or malformed diff hunk; no complete audit result was produced.", 2);
|
|
333
336
|
}
|
|
334
337
|
const hunkMatch = HUNK_REGEX.exec(line);
|
|
335
338
|
if (hunkMatch) {
|
|
336
339
|
oldLeft = hunkMatch[1] === void 0 ? 1 : parseInt(hunkMatch[1], 10);
|
|
337
340
|
lineNo = parseInt(hunkMatch[2], 10);
|
|
338
341
|
newLeft = hunkMatch[3] === void 0 ? 1 : parseInt(hunkMatch[3], 10);
|
|
342
|
+
if (![oldLeft, newLeft, lineNo].every(Number.isSafeInteger)) throw new GitError("Invalid diff hunk line counts.", 2);
|
|
339
343
|
continue;
|
|
340
344
|
}
|
|
341
345
|
if (line.startsWith("diff --git ")) {
|
|
@@ -353,6 +357,10 @@ function parseDiff(rawDiff) {
|
|
|
353
357
|
files.push(currentFile);
|
|
354
358
|
continue;
|
|
355
359
|
}
|
|
360
|
+
if (currentFile && (line.startsWith("Binary files ") || line === "GIT binary patch")) {
|
|
361
|
+
currentFile.binary = true;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
356
364
|
if (line.startsWith("+++ /dev/null")) {
|
|
357
365
|
if (currentFile) {
|
|
358
366
|
currentFile.deleted = true;
|
|
@@ -371,6 +379,7 @@ function parseDiff(rawDiff) {
|
|
|
371
379
|
continue;
|
|
372
380
|
}
|
|
373
381
|
}
|
|
382
|
+
if (oldLeft > 0 || newLeft > 0) throw new GitError("Truncated diff hunk; no complete audit result was produced.", 2);
|
|
374
383
|
let skippedUnknownFiles = 0;
|
|
375
384
|
for (const file of files) {
|
|
376
385
|
if (file.path === "unknown" && !file.deleted) {
|
|
@@ -381,17 +390,69 @@ function parseDiff(rawDiff) {
|
|
|
381
390
|
}
|
|
382
391
|
var COMMON_EXCLUDE_SPECS = [
|
|
383
392
|
"--",
|
|
384
|
-
"
|
|
385
|
-
":(exclude,glob)**/*.lock",
|
|
386
|
-
":(exclude,glob)**/*-lock.json",
|
|
387
|
-
":(exclude,glob)**/*.lockb",
|
|
388
|
-
":(exclude,glob)**/dist/**",
|
|
389
|
-
":(exclude,glob)**/build/**",
|
|
390
|
-
":(exclude,glob)**/node_modules/**",
|
|
391
|
-
":(exclude,glob)**/*.min.js",
|
|
392
|
-
":(exclude,glob)**/*.map"
|
|
393
|
+
":(top,glob)**",
|
|
394
|
+
":(top,exclude,glob)**/*.lock",
|
|
395
|
+
":(top,exclude,glob)**/*-lock.json",
|
|
396
|
+
":(top,exclude,glob)**/*.lockb",
|
|
397
|
+
":(top,exclude,glob)**/dist/**",
|
|
398
|
+
":(top,exclude,glob)**/build/**",
|
|
399
|
+
":(top,exclude,glob)**/node_modules/**",
|
|
400
|
+
":(top,exclude,glob)**/*.min.js",
|
|
401
|
+
":(top,exclude,glob)**/*.map"
|
|
393
402
|
];
|
|
403
|
+
function requireLocalObjects(cwd) {
|
|
404
|
+
const result = spawnSync("git", [...HERMETIC_GIT_FLAGS, "config", "--type=bool", "--get-regexp", "^remote\\..*\\.promisor$"], {
|
|
405
|
+
cwd,
|
|
406
|
+
env: getHermeticGitEnv(),
|
|
407
|
+
encoding: "utf8",
|
|
408
|
+
timeout: 3e4,
|
|
409
|
+
maxBuffer: 64 * 1024,
|
|
410
|
+
windowsHide: true
|
|
411
|
+
});
|
|
412
|
+
if (result.error) throw new GitError(result.error.message, result.status);
|
|
413
|
+
if (result.status !== 0 && result.status !== 1) throw new GitError(result.stderr, result.status);
|
|
414
|
+
if (result.stdout.split("\n").some((line) => /\s+(?:true|yes|on|1)\s*$/i.test(line))) {
|
|
415
|
+
throw new GitError("Offline audits require a non-partial clone with local objects; promisor remotes are unsupported.", 2);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function streamGit(cwd, gitArgs) {
|
|
419
|
+
const output = new PassThrough();
|
|
420
|
+
try {
|
|
421
|
+
requireLocalObjects(cwd);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
queueMicrotask(() => output.destroy(error instanceof Error ? error : new Error(String(error))));
|
|
424
|
+
return output;
|
|
425
|
+
}
|
|
426
|
+
const child = spawn("git", gitArgs, {
|
|
427
|
+
cwd,
|
|
428
|
+
env: getHermeticGitEnv(),
|
|
429
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
430
|
+
windowsHide: true
|
|
431
|
+
});
|
|
432
|
+
let stderr = "";
|
|
433
|
+
const timer = setTimeout(() => output.destroy(new GitError("Git audit timed out after 30 seconds; no complete result was produced.", 2)), 3e4);
|
|
434
|
+
child.stderr.on("data", (chunk) => {
|
|
435
|
+
if (stderr.length < 64 * 1024) stderr += chunk.toString().slice(0, 64 * 1024 - stderr.length);
|
|
436
|
+
});
|
|
437
|
+
child.stdout.pipe(output, { end: false });
|
|
438
|
+
output.on("close", () => {
|
|
439
|
+
clearTimeout(timer);
|
|
440
|
+
if (child.exitCode === null) child.kill();
|
|
441
|
+
});
|
|
442
|
+
child.stdout.on("error", (error) => output.destroy(error));
|
|
443
|
+
child.on("error", (error) => output.destroy(error));
|
|
444
|
+
child.on("close", (code) => {
|
|
445
|
+
clearTimeout(timer);
|
|
446
|
+
if (output.destroyed) return;
|
|
447
|
+
if (code !== 0) output.destroy(new GitError(stderr, code));
|
|
448
|
+
else output.end();
|
|
449
|
+
});
|
|
450
|
+
return output;
|
|
451
|
+
}
|
|
394
452
|
function streamGitLog(cwd = process.cwd(), since = "90 days ago") {
|
|
453
|
+
const nonce = randomUUID();
|
|
454
|
+
const record = `__ROAST_REC_${nonce}__`;
|
|
455
|
+
const field = `__ROAST_FIELD_${nonce}__`;
|
|
395
456
|
const gitArgs = [
|
|
396
457
|
...HERMETIC_GIT_FLAGS,
|
|
397
458
|
"log",
|
|
@@ -402,37 +463,16 @@ function streamGitLog(cwd = process.cwd(), since = "90 days ago") {
|
|
|
402
463
|
"--no-show-signature",
|
|
403
464
|
"--no-ext-diff",
|
|
404
465
|
"--no-textconv",
|
|
466
|
+
"--no-relative",
|
|
405
467
|
"--encoding=UTF-8",
|
|
406
468
|
"--src-prefix=a/",
|
|
407
469
|
"--dst-prefix=b/",
|
|
408
470
|
`--since=${since}`,
|
|
409
|
-
`--format=${
|
|
471
|
+
`--format=${record}%H${field}%aI${field}%an${field}%ae${field}%B${field}`,
|
|
410
472
|
...COMMON_EXCLUDE_SPECS
|
|
411
473
|
];
|
|
412
|
-
const output =
|
|
413
|
-
|
|
414
|
-
cwd,
|
|
415
|
-
env: getHermeticGitEnv(),
|
|
416
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
417
|
-
windowsHide: true
|
|
418
|
-
});
|
|
419
|
-
child.stdout.on("data", (chunk) => {
|
|
420
|
-
output.write(chunk);
|
|
421
|
-
});
|
|
422
|
-
let stderrBuffer = "";
|
|
423
|
-
child.stderr.on("data", (chunk) => {
|
|
424
|
-
stderrBuffer += chunk.toString();
|
|
425
|
-
});
|
|
426
|
-
child.on("close", (code) => {
|
|
427
|
-
if (code !== 0) {
|
|
428
|
-
output.destroy(new GitError(stderrBuffer, code));
|
|
429
|
-
} else {
|
|
430
|
-
output.end();
|
|
431
|
-
}
|
|
432
|
-
});
|
|
433
|
-
child.on("error", (err) => {
|
|
434
|
-
output.destroy(err);
|
|
435
|
-
});
|
|
474
|
+
const output = streamGit(cwd, gitArgs);
|
|
475
|
+
output[LOG_FRAMING] = { record, field };
|
|
436
476
|
return output;
|
|
437
477
|
}
|
|
438
478
|
function streamGitDiff(cwd, baseSha, headSha, staged = false) {
|
|
@@ -443,6 +483,7 @@ function streamGitDiff(cwd, baseSha, headSha, staged = false) {
|
|
|
443
483
|
"--no-color",
|
|
444
484
|
"--no-ext-diff",
|
|
445
485
|
"--no-textconv",
|
|
486
|
+
"--no-relative",
|
|
446
487
|
"--src-prefix=a/",
|
|
447
488
|
"--dst-prefix=b/",
|
|
448
489
|
...staged ? ["--cached"] : [],
|
|
@@ -450,33 +491,10 @@ function streamGitDiff(cwd, baseSha, headSha, staged = false) {
|
|
|
450
491
|
...staged ? [] : [baseSha, headSha],
|
|
451
492
|
...COMMON_EXCLUDE_SPECS
|
|
452
493
|
];
|
|
453
|
-
|
|
454
|
-
const child = spawn("git", gitArgs, {
|
|
455
|
-
cwd,
|
|
456
|
-
env: getHermeticGitEnv(),
|
|
457
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
458
|
-
windowsHide: true
|
|
459
|
-
});
|
|
460
|
-
child.stdout.on("data", (chunk) => {
|
|
461
|
-
output.write(chunk);
|
|
462
|
-
});
|
|
463
|
-
let stderrBuffer = "";
|
|
464
|
-
child.stderr.on("data", (chunk) => {
|
|
465
|
-
stderrBuffer += chunk.toString();
|
|
466
|
-
});
|
|
467
|
-
child.on("close", (code) => {
|
|
468
|
-
if (code !== 0) {
|
|
469
|
-
output.destroy(new GitError(stderrBuffer, code));
|
|
470
|
-
} else {
|
|
471
|
-
output.end();
|
|
472
|
-
}
|
|
473
|
-
});
|
|
474
|
-
child.on("error", (err) => {
|
|
475
|
-
output.destroy(err);
|
|
476
|
-
});
|
|
477
|
-
return output;
|
|
494
|
+
return streamGit(cwd, gitArgs);
|
|
478
495
|
}
|
|
479
496
|
function resolveCommitSha(cwd, ref, roleName = "ref") {
|
|
497
|
+
requireLocalObjects(cwd);
|
|
480
498
|
const result = spawnSync(
|
|
481
499
|
"git",
|
|
482
500
|
[...HERMETIC_GIT_FLAGS, "rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`],
|
|
@@ -484,6 +502,8 @@ function resolveCommitSha(cwd, ref, roleName = "ref") {
|
|
|
484
502
|
cwd,
|
|
485
503
|
env: getHermeticGitEnv(),
|
|
486
504
|
encoding: "utf8",
|
|
505
|
+
timeout: 3e4,
|
|
506
|
+
maxBuffer: 64 * 1024,
|
|
487
507
|
windowsHide: true
|
|
488
508
|
}
|
|
489
509
|
);
|
|
@@ -508,6 +528,8 @@ function findMergeBase(cwd, baseSha, headSha) {
|
|
|
508
528
|
cwd,
|
|
509
529
|
env: getHermeticGitEnv(),
|
|
510
530
|
encoding: "utf8",
|
|
531
|
+
timeout: 3e4,
|
|
532
|
+
maxBuffer: 64 * 1024,
|
|
511
533
|
windowsHide: true
|
|
512
534
|
}
|
|
513
535
|
);
|
|
@@ -530,6 +552,8 @@ function checkIfShallow(cwd) {
|
|
|
530
552
|
cwd,
|
|
531
553
|
env: getHermeticGitEnv(),
|
|
532
554
|
encoding: "utf8",
|
|
555
|
+
timeout: 3e4,
|
|
556
|
+
maxBuffer: 64 * 1024,
|
|
533
557
|
windowsHide: true
|
|
534
558
|
});
|
|
535
559
|
return res.stdout.trim() === "true";
|
|
@@ -546,41 +570,79 @@ var GitResolutionError = class extends Error {
|
|
|
546
570
|
|
|
547
571
|
// src/source.ts
|
|
548
572
|
var SourceMasker = class {
|
|
549
|
-
|
|
573
|
+
blockDepth = 0;
|
|
550
574
|
quote = "";
|
|
575
|
+
rawEnd = "";
|
|
551
576
|
triple = false;
|
|
577
|
+
verbatim = false;
|
|
552
578
|
previousLine = 0;
|
|
553
579
|
python;
|
|
554
580
|
javascript;
|
|
581
|
+
rust;
|
|
555
582
|
hashComments;
|
|
556
583
|
backticks;
|
|
584
|
+
textBlocks;
|
|
585
|
+
csharp;
|
|
586
|
+
cpp;
|
|
557
587
|
constructor(filename) {
|
|
558
|
-
|
|
559
|
-
this.
|
|
560
|
-
this.
|
|
561
|
-
this.
|
|
588
|
+
const name = filename.toLowerCase();
|
|
589
|
+
this.python = name.endsWith(".py");
|
|
590
|
+
this.rust = name.endsWith(".rs");
|
|
591
|
+
this.hashComments = this.python || /\.(?:rb|php)$/.test(name);
|
|
592
|
+
this.backticks = /\.(?:go|[cm]?[jt]sx?|vue|svelte)$/.test(name);
|
|
593
|
+
this.javascript = /\.(?:[cm]?[jt]sx?|vue|svelte)$/.test(name);
|
|
594
|
+
this.textBlocks = /\.(?:java|kt|cs)$/.test(name);
|
|
595
|
+
this.csharp = name.endsWith(".cs");
|
|
596
|
+
this.cpp = /\.(?:cpp|c)$/.test(name);
|
|
562
597
|
}
|
|
563
598
|
mask(line, number) {
|
|
564
599
|
if (number !== this.previousLine + 1) {
|
|
565
|
-
this.
|
|
600
|
+
this.blockDepth = 0;
|
|
566
601
|
this.quote = "";
|
|
602
|
+
this.rawEnd = "";
|
|
567
603
|
this.triple = false;
|
|
604
|
+
this.verbatim = false;
|
|
568
605
|
}
|
|
569
606
|
this.previousLine = number;
|
|
570
607
|
const code = Array(line.length).fill(" ");
|
|
571
608
|
const comments = Array(line.length).fill(" ");
|
|
572
609
|
let i = 0;
|
|
573
610
|
let prefix = "";
|
|
611
|
+
const appendPrefix = (text) => {
|
|
612
|
+
for (const char of text) {
|
|
613
|
+
if (/\s/.test(char)) {
|
|
614
|
+
if (!prefix.endsWith(" ")) prefix = (prefix + " ").slice(-64);
|
|
615
|
+
} else prefix = (prefix + char).slice(-64);
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
let continued = false;
|
|
619
|
+
const templateStarts = [];
|
|
620
|
+
const blockStarts = [];
|
|
574
621
|
while (i < line.length) {
|
|
575
|
-
if (this.
|
|
576
|
-
if (line.startsWith("
|
|
577
|
-
this.
|
|
622
|
+
if (this.blockDepth) {
|
|
623
|
+
if (this.rust && line.startsWith("/*", i)) {
|
|
624
|
+
this.blockDepth++;
|
|
625
|
+
i += 2;
|
|
626
|
+
} else if (line.startsWith("*/", i)) {
|
|
627
|
+
this.blockDepth--;
|
|
578
628
|
i += 2;
|
|
579
629
|
} else i++;
|
|
580
630
|
continue;
|
|
581
631
|
}
|
|
632
|
+
if (this.rawEnd) {
|
|
633
|
+
if (line.startsWith(this.rawEnd, i)) {
|
|
634
|
+
i += this.rawEnd.length;
|
|
635
|
+
this.rawEnd = "";
|
|
636
|
+
} else i++;
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
582
639
|
if (this.quote) {
|
|
583
|
-
if (
|
|
640
|
+
if (this.verbatim && line.startsWith('""', i)) {
|
|
641
|
+
i += 2;
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
if (!this.verbatim && !(this.quote === "`" && !this.javascript) && line[i] === "\\") {
|
|
645
|
+
if (i === line.length - 1) continued = true;
|
|
584
646
|
i += 2;
|
|
585
647
|
continue;
|
|
586
648
|
}
|
|
@@ -589,6 +651,7 @@ var SourceMasker = class {
|
|
|
589
651
|
i += delimiter.length;
|
|
590
652
|
this.quote = "";
|
|
591
653
|
this.triple = false;
|
|
654
|
+
this.verbatim = false;
|
|
592
655
|
} else i++;
|
|
593
656
|
continue;
|
|
594
657
|
}
|
|
@@ -597,16 +660,50 @@ var SourceMasker = class {
|
|
|
597
660
|
break;
|
|
598
661
|
}
|
|
599
662
|
if (!this.python && line.startsWith("/*", i)) {
|
|
600
|
-
|
|
663
|
+
blockStarts.push(i);
|
|
664
|
+
this.blockDepth = 1;
|
|
665
|
+
i += 2;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (this.rust && (i === 0 || !/[\w]/.test(line[i - 1]))) {
|
|
669
|
+
const raw = /^(?:br|cr|r)(#{0,255})"/.exec(line.slice(i));
|
|
670
|
+
if (raw) {
|
|
671
|
+
this.rawEnd = '"' + raw[1];
|
|
672
|
+
i += raw[0].length;
|
|
673
|
+
appendPrefix(" literal ");
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
if (this.cpp && (i === 0 || !/[\w]/.test(line[i - 1]))) {
|
|
678
|
+
const raw = /^(?:u8|u|U|L)?R"([^\s()\\]{0,16})\(/.exec(line.slice(i));
|
|
679
|
+
if (raw) {
|
|
680
|
+
this.rawEnd = ")" + raw[1] + '"';
|
|
681
|
+
i += raw[0].length;
|
|
682
|
+
appendPrefix(" literal ");
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
if (this.csharp && line.startsWith('@"', i)) {
|
|
687
|
+
this.quote = '"';
|
|
688
|
+
this.verbatim = true;
|
|
601
689
|
i += 2;
|
|
690
|
+
appendPrefix(" literal ");
|
|
602
691
|
continue;
|
|
603
692
|
}
|
|
604
693
|
const char = line[i];
|
|
694
|
+
const lifetime = this.rust && char === "'" && /^'[A-Za-z_][A-Za-z0-9_]*(?![A-Za-z0-9_])/.exec(line.slice(i));
|
|
695
|
+
if (lifetime && line[i + lifetime[0].length] !== "'") {
|
|
696
|
+
for (let j = 0; j < lifetime[0].length; j++) code[i + j] = line[i + j];
|
|
697
|
+
appendPrefix(lifetime[0]);
|
|
698
|
+
i += lifetime[0].length;
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
605
701
|
if (char === '"' || char === "'" || this.backticks && char === "`") {
|
|
606
702
|
this.quote = char;
|
|
607
|
-
|
|
703
|
+
if (this.javascript && char === "`") templateStarts.push(i);
|
|
704
|
+
this.triple = (this.python || this.textBlocks && char === '"') && line.startsWith(char.repeat(3), i);
|
|
608
705
|
i += this.triple ? 3 : 1;
|
|
609
|
-
|
|
706
|
+
appendPrefix(" literal ");
|
|
610
707
|
continue;
|
|
611
708
|
}
|
|
612
709
|
if (this.javascript && char === "/" && (/(?:[=(:,[!&|?{;]|\breturn|\byield)\s*$/.test(prefix) || !prefix.trim())) {
|
|
@@ -623,18 +720,18 @@ var SourceMasker = class {
|
|
|
623
720
|
}
|
|
624
721
|
if (j < line.length) {
|
|
625
722
|
j++;
|
|
626
|
-
while (/[a-z]/i.test(line[j]
|
|
723
|
+
while (j < line.length && /[a-z]/i.test(line[j])) j++;
|
|
627
724
|
i = j;
|
|
628
|
-
|
|
725
|
+
appendPrefix(" literal ");
|
|
629
726
|
continue;
|
|
630
727
|
}
|
|
631
728
|
}
|
|
632
729
|
code[i] = char;
|
|
633
|
-
|
|
730
|
+
appendPrefix(char);
|
|
634
731
|
i++;
|
|
635
732
|
}
|
|
636
|
-
if (this.quote !== "`" && !this.triple) this.quote = "";
|
|
637
|
-
return { code: code.join(""), comments: comments.join("") };
|
|
733
|
+
if (this.quote !== "`" && !this.triple && !this.verbatim && !continued) this.quote = "";
|
|
734
|
+
return { code: code.join(""), comments: comments.join(""), templateStarts, blockStarts };
|
|
638
735
|
}
|
|
639
736
|
};
|
|
640
737
|
|
|
@@ -685,8 +782,8 @@ var RULE_GUIDES = {
|
|
|
685
782
|
};
|
|
686
783
|
|
|
687
784
|
// src/config.ts
|
|
688
|
-
import * as
|
|
689
|
-
import * as
|
|
785
|
+
import * as fs3 from "fs";
|
|
786
|
+
import * as path2 from "path";
|
|
690
787
|
|
|
691
788
|
// src/types.ts
|
|
692
789
|
var VALID_DETECTORS = [
|
|
@@ -699,7 +796,146 @@ var VALID_DETECTORS = [
|
|
|
699
796
|
"test-focus"
|
|
700
797
|
];
|
|
701
798
|
|
|
799
|
+
// src/file-read.ts
|
|
800
|
+
import * as fs from "fs";
|
|
801
|
+
function readRegularFile(filename, limit) {
|
|
802
|
+
const stat = fs.lstatSync(filename);
|
|
803
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > limit) throw new Error(`Expected a regular file no larger than ${limit} bytes.`);
|
|
804
|
+
const fd = fs.openSync(filename, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
|
|
805
|
+
try {
|
|
806
|
+
const opened = fs.fstatSync(fd);
|
|
807
|
+
if (!opened.isFile() || opened.ino !== stat.ino || opened.dev !== stat.dev) throw new Error("File changed while opening.");
|
|
808
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
809
|
+
let size = 0;
|
|
810
|
+
while (size < buffer.length) {
|
|
811
|
+
const read = fs.readSync(fd, buffer, size, buffer.length - size, null);
|
|
812
|
+
if (!read) break;
|
|
813
|
+
size += read;
|
|
814
|
+
}
|
|
815
|
+
const after = fs.fstatSync(fd);
|
|
816
|
+
if (size > limit || size !== after.size || opened.size !== after.size || opened.mtimeMs !== after.mtimeMs || opened.ctimeMs !== after.ctimeMs) {
|
|
817
|
+
throw new Error("File changed or exceeded the read limit.");
|
|
818
|
+
}
|
|
819
|
+
return buffer.subarray(0, size);
|
|
820
|
+
} finally {
|
|
821
|
+
fs.closeSync(fd);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// src/baseline.ts
|
|
826
|
+
import { createHash, randomUUID as randomUUID2 } from "crypto";
|
|
827
|
+
import * as fs2 from "fs";
|
|
828
|
+
import path from "path";
|
|
829
|
+
function findingFingerprint(finding) {
|
|
830
|
+
return createHash("sha256").update(JSON.stringify([finding.type, finding.file, finding.lineSnippet?.trim()])).digest("hex");
|
|
831
|
+
}
|
|
832
|
+
function createBaseline(findings) {
|
|
833
|
+
const counts = /* @__PURE__ */ new Map();
|
|
834
|
+
for (const finding of findings) {
|
|
835
|
+
const fingerprint = findingFingerprint(finding);
|
|
836
|
+
counts.set(fingerprint, (counts.get(fingerprint) || 0) + 1);
|
|
837
|
+
}
|
|
838
|
+
return {
|
|
839
|
+
schemaVersion: "1.0.0",
|
|
840
|
+
fingerprintVersion: 1,
|
|
841
|
+
entries: [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([fingerprint, count]) => ({ fingerprint, count }))
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
function loadBaseline(filename) {
|
|
845
|
+
try {
|
|
846
|
+
const content = new TextDecoder("utf-8", { fatal: true }).decode(readRegularFile(filename, 5 * 1024 * 1024));
|
|
847
|
+
const parsed = JSON.parse(content);
|
|
848
|
+
if (!parsed || parsed.schemaVersion !== "1.0.0" || parsed.fingerprintVersion !== 1 || !Array.isArray(parsed.entries) || parsed.entries.length > 1e5) {
|
|
849
|
+
throw new Error("Unsupported baseline schema.");
|
|
850
|
+
}
|
|
851
|
+
const seen = /* @__PURE__ */ new Set();
|
|
852
|
+
for (const entry of parsed.entries) {
|
|
853
|
+
if (!entry || typeof entry.fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(entry.fingerprint) || !Number.isSafeInteger(entry.count) || entry.count <= 0 || entry.count > 1e6 || seen.has(entry.fingerprint)) {
|
|
854
|
+
throw new Error("Invalid or duplicate baseline entry.");
|
|
855
|
+
}
|
|
856
|
+
seen.add(entry.fingerprint);
|
|
857
|
+
}
|
|
858
|
+
return parsed;
|
|
859
|
+
} catch (error) {
|
|
860
|
+
throw new ConfigError(`Cannot load baseline '${filename}': ${error instanceof Error ? error.message : String(error)}`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
function applyBaseline(findings, baseline) {
|
|
864
|
+
const remaining = new Map(baseline.entries.map((entry) => [entry.fingerprint, entry.count]));
|
|
865
|
+
const fresh = [];
|
|
866
|
+
let baselineCount = 0;
|
|
867
|
+
for (const finding of findings) {
|
|
868
|
+
const fingerprint = findingFingerprint(finding);
|
|
869
|
+
const count = remaining.get(fingerprint) || 0;
|
|
870
|
+
if (count > 0) {
|
|
871
|
+
remaining.set(fingerprint, count - 1);
|
|
872
|
+
baselineCount++;
|
|
873
|
+
} else {
|
|
874
|
+
fresh.push(finding);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return { findings: fresh, baselineCount, staleBaselineCount: [...remaining.values()].reduce((a, b) => a + b, 0) };
|
|
878
|
+
}
|
|
879
|
+
function writeBaseline(filename, findings) {
|
|
880
|
+
const destination = path.resolve(filename);
|
|
881
|
+
const temporary = path.join(path.dirname(destination), `.agent-roast-${randomUUID2()}.tmp`);
|
|
882
|
+
let fd;
|
|
883
|
+
try {
|
|
884
|
+
const baseline = createBaseline(findings);
|
|
885
|
+
const serialized = JSON.stringify(baseline, null, 2) + "\n";
|
|
886
|
+
if (Buffer.byteLength(serialized, "utf8") > 5 * 1024 * 1024 || baseline.entries.length > 1e5 || baseline.entries.some((entry) => entry.count > 1e6)) {
|
|
887
|
+
throw new Error("Baseline exceeds the supported read limits; narrow the adopted scope.");
|
|
888
|
+
}
|
|
889
|
+
fd = fs2.openSync(temporary, "wx", 384);
|
|
890
|
+
fs2.writeFileSync(fd, serialized);
|
|
891
|
+
fs2.fsyncSync(fd);
|
|
892
|
+
fs2.closeSync(fd);
|
|
893
|
+
fd = void 0;
|
|
894
|
+
fs2.linkSync(temporary, destination);
|
|
895
|
+
} catch (error) {
|
|
896
|
+
throw new ConfigError(`Cannot write baseline '${filename}' (existing files are preserved): ${error instanceof Error ? error.message : String(error)}`);
|
|
897
|
+
} finally {
|
|
898
|
+
if (fd !== void 0) fs2.closeSync(fd);
|
|
899
|
+
fs2.rmSync(temporary, { force: true });
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// src/exceptions.ts
|
|
904
|
+
function validateExceptions(value) {
|
|
905
|
+
if (value === void 0) return;
|
|
906
|
+
if (!Array.isArray(value) || value.length > 1e3) throw new ConfigError("exceptions must be an array with at most 1000 entries.");
|
|
907
|
+
const seen = /* @__PURE__ */ new Set();
|
|
908
|
+
for (const entry of value) {
|
|
909
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry) || Object.keys(entry).some((key) => !["fingerprint", "count", "reason", "expires"].includes(key)) || typeof entry.fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(entry.fingerprint) || seen.has(entry.fingerprint) || !Number.isSafeInteger(entry.count) || entry.count < 1 || entry.count > 1e5 || typeof entry.reason !== "string" || !entry.reason.trim() || entry.reason.length > 1e3 || /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u.test(entry.reason) || typeof entry.expires !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(entry.expires) || !Number.isFinite(Date.parse(entry.expires)) || new Date(entry.expires).toISOString().slice(0, 10) !== entry.expires) {
|
|
910
|
+
throw new ConfigError("Each exception needs a unique SHA-256 fingerprint, positive count, non-empty single-line reason and real expires date (YYYY-MM-DD).");
|
|
911
|
+
}
|
|
912
|
+
seen.add(entry.fingerprint);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
function applyExceptions(findings, exceptions = [], now = /* @__PURE__ */ new Date(), priorOccurrences = /* @__PURE__ */ new Map()) {
|
|
916
|
+
validateExceptions(exceptions);
|
|
917
|
+
if (!Number.isFinite(now.getTime())) throw new ConfigError("Invalid exception evaluation time.");
|
|
918
|
+
const today = now.toISOString().slice(0, 10);
|
|
919
|
+
const active = new Map(exceptions.filter((entry) => entry.expires >= today).map((entry) => [entry.fingerprint, { ...entry, remaining: Math.max(0, entry.count - (priorOccurrences.get(entry.fingerprint) ?? 0)) }]));
|
|
920
|
+
const occurrences = new Map(priorOccurrences);
|
|
921
|
+
const actionable = [];
|
|
922
|
+
const suppressedFindings = [];
|
|
923
|
+
for (const finding of findings) {
|
|
924
|
+
const fingerprint = findingFingerprint(finding);
|
|
925
|
+
const occurrence = (occurrences.get(fingerprint) ?? 0) + 1;
|
|
926
|
+
occurrences.set(fingerprint, occurrence);
|
|
927
|
+
const enriched = { ...finding, fingerprint, occurrence };
|
|
928
|
+
const allowance = active.get(fingerprint);
|
|
929
|
+
if (allowance && allowance.remaining > 0) {
|
|
930
|
+
allowance.remaining--;
|
|
931
|
+
suppressedFindings.push({ ...enriched, suppression: { reason: allowance.reason, expires: allowance.expires } });
|
|
932
|
+
} else actionable.push(enriched);
|
|
933
|
+
}
|
|
934
|
+
return { findings: actionable, suppressedFindings, expiredExceptionCount: exceptions.filter((entry) => entry.expires < today).length };
|
|
935
|
+
}
|
|
936
|
+
|
|
702
937
|
// src/config.ts
|
|
938
|
+
var MAX_CONFIG_BYTES = 64 * 1024;
|
|
703
939
|
var DEFAULT_CONFIG = {
|
|
704
940
|
failOn: ["test-skip", "trivial-assert", "test-focus"],
|
|
705
941
|
ignorePaths: [
|
|
@@ -738,12 +974,16 @@ function validateConfig(value) {
|
|
|
738
974
|
throw new ConfigError("Configuration must be a JSON object.");
|
|
739
975
|
}
|
|
740
976
|
const config = value;
|
|
977
|
+
const supported = /* @__PURE__ */ new Set(["$schema", "failOn", "ignorePaths", "scoreThreshold", "rules", "panicLoopWindowMinutes", "exceptions"]);
|
|
978
|
+
for (const key of Object.keys(config)) if (!supported.has(key)) throw new ConfigError(`Unknown configuration setting '${key}'.`);
|
|
979
|
+
if (config.$schema !== void 0 && typeof config.$schema !== "string") throw new ConfigError("$schema must be a string.");
|
|
980
|
+
validateExceptions(config.exceptions);
|
|
741
981
|
const knownRules = new Set(VALID_DETECTORS);
|
|
742
982
|
if (config.failOn !== void 0 && (!Array.isArray(config.failOn) || config.failOn.some((rule) => typeof rule !== "string" || !knownRules.has(rule)))) {
|
|
743
983
|
throw new ConfigError(`failOn must contain known rules: ${VALID_DETECTORS.join(", ")}`);
|
|
744
984
|
}
|
|
745
|
-
if (config.ignorePaths !== void 0 && (!Array.isArray(config.ignorePaths) || config.ignorePaths.some((pattern) => typeof pattern !== "string"))) {
|
|
746
|
-
throw new ConfigError("ignorePaths must
|
|
985
|
+
if (config.ignorePaths !== void 0 && (!Array.isArray(config.ignorePaths) || config.ignorePaths.length > 1e3 || config.ignorePaths.some((pattern) => typeof pattern !== "string" || pattern.length > 1024))) {
|
|
986
|
+
throw new ConfigError("ignorePaths must contain at most 1000 strings, each no longer than 1024 characters.");
|
|
747
987
|
}
|
|
748
988
|
if (config.scoreThreshold !== void 0 && (typeof config.scoreThreshold !== "number" || !Number.isFinite(config.scoreThreshold) || config.scoreThreshold < 0 || config.scoreThreshold > 100)) {
|
|
749
989
|
throw new ConfigError("scoreThreshold must be a number between 0 and 100.");
|
|
@@ -756,7 +996,7 @@ function validateConfig(value) {
|
|
|
756
996
|
throw new ConfigError("rules must be an object.");
|
|
757
997
|
}
|
|
758
998
|
for (const [rule, level] of Object.entries(config.rules)) {
|
|
759
|
-
if (!knownRules.has(rule) || !["error", "warn", "off"].includes(
|
|
999
|
+
if (!knownRules.has(rule) || typeof level !== "string" || !["error", "warn", "off"].includes(level)) {
|
|
760
1000
|
throw new ConfigError(`Invalid rule '${rule}' or level '${level}'. Use known rules with error, warn, or off.`);
|
|
761
1001
|
}
|
|
762
1002
|
}
|
|
@@ -764,6 +1004,7 @@ function validateConfig(value) {
|
|
|
764
1004
|
}
|
|
765
1005
|
function configFromText(text, source) {
|
|
766
1006
|
try {
|
|
1007
|
+
if (Buffer.byteLength(text, "utf8") > MAX_CONFIG_BYTES) throw new ConfigError("Configuration exceeds the 64 KiB limit.");
|
|
767
1008
|
const parsed = JSON.parse(text);
|
|
768
1009
|
validateConfig(parsed);
|
|
769
1010
|
const rules = { ...DEFAULT_CONFIG.rules, ...parsed.rules };
|
|
@@ -781,10 +1022,16 @@ function configFromText(text, source) {
|
|
|
781
1022
|
}
|
|
782
1023
|
function loadConfig(cwd = process.cwd()) {
|
|
783
1024
|
for (const filename of CONFIG_FILENAMES) {
|
|
784
|
-
const filePath =
|
|
785
|
-
|
|
1025
|
+
const filePath = path2.join(cwd, filename);
|
|
1026
|
+
try {
|
|
1027
|
+
fs3.lstatSync(filePath);
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
if (error.code === "ENOENT") continue;
|
|
1030
|
+
throw new ConfigError(`Cannot inspect ${filename}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1031
|
+
}
|
|
1032
|
+
{
|
|
786
1033
|
try {
|
|
787
|
-
return configFromText(
|
|
1034
|
+
return configFromText(new TextDecoder("utf-8", { fatal: true }).decode(readRegularFile(filePath, MAX_CONFIG_BYTES)), filename);
|
|
788
1035
|
} catch (error) {
|
|
789
1036
|
if (error instanceof ConfigError) throw error;
|
|
790
1037
|
throw new ConfigError(`Cannot read ${filename}: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -793,93 +1040,114 @@ function loadConfig(cwd = process.cwd()) {
|
|
|
793
1040
|
}
|
|
794
1041
|
return configFromText("{}", "default configuration");
|
|
795
1042
|
}
|
|
796
|
-
function
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
const cleanPattern = pattern.replace(/\\/g, "/");
|
|
804
|
-
let regexPattern = "";
|
|
805
|
-
for (let i = 0; i < cleanPattern.length; i++) {
|
|
806
|
-
const char = cleanPattern[i];
|
|
807
|
-
if (char === "*" && cleanPattern[i + 1] === "*") {
|
|
1043
|
+
function matchesGlob(filename, pattern) {
|
|
1044
|
+
const tokens = [];
|
|
1045
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
1046
|
+
if (pattern[i] === "*" && pattern[i + 1] === "*") {
|
|
1047
|
+
i++;
|
|
1048
|
+
if (pattern[i + 1] === "/") {
|
|
1049
|
+
tokens.push({ kind: "directoryStart" }, { kind: "directoryBody" });
|
|
808
1050
|
i++;
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
1051
|
+
} else tokens.push({ kind: "globstar" });
|
|
1052
|
+
} else if (pattern[i] === "*") tokens.push({ kind: "star" });
|
|
1053
|
+
else if (pattern[i] === "?") tokens.push({ kind: "question" });
|
|
1054
|
+
else tokens.push({ kind: "literal", value: pattern[i].toLowerCase() });
|
|
1055
|
+
}
|
|
1056
|
+
const closure = (states2) => {
|
|
1057
|
+
const queue = [...states2];
|
|
1058
|
+
for (let i = 0; i < queue.length; i++) {
|
|
1059
|
+
const state = queue[i];
|
|
1060
|
+
const kind = tokens[state]?.kind;
|
|
1061
|
+
const add = (next) => {
|
|
1062
|
+
if (!states2.has(next)) {
|
|
1063
|
+
states2.add(next);
|
|
1064
|
+
queue.push(next);
|
|
814
1065
|
}
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
regexPattern += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
1066
|
+
};
|
|
1067
|
+
if (kind === "star" || kind === "globstar") add(state + 1);
|
|
1068
|
+
else if (kind === "directoryStart") {
|
|
1069
|
+
add(state + 1);
|
|
1070
|
+
add(state + 2);
|
|
821
1071
|
}
|
|
822
1072
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
if (
|
|
836
|
-
|
|
1073
|
+
return states2;
|
|
1074
|
+
};
|
|
1075
|
+
let states = /* @__PURE__ */ new Set();
|
|
1076
|
+
for (let offset = 0; offset <= filename.length; offset++) {
|
|
1077
|
+
if (!offset || filename[offset - 1] === "/") states.add(0);
|
|
1078
|
+
closure(states);
|
|
1079
|
+
if (states.has(tokens.length) && (offset === filename.length || filename[offset] === "/")) return true;
|
|
1080
|
+
if (offset === filename.length) break;
|
|
1081
|
+
const char = filename[offset];
|
|
1082
|
+
const next = /* @__PURE__ */ new Set();
|
|
1083
|
+
for (const state of states) {
|
|
1084
|
+
const token = tokens[state];
|
|
1085
|
+
if (!token) continue;
|
|
1086
|
+
if (token.kind === "literal") {
|
|
1087
|
+
if (token.value === char.toLowerCase()) next.add(state + 1);
|
|
1088
|
+
} else if (token.kind === "question") {
|
|
1089
|
+
if (char !== "/") next.add(state + 1);
|
|
1090
|
+
} else if (token.kind === "star") {
|
|
1091
|
+
if (char !== "/") next.add(state);
|
|
1092
|
+
} else if (token.kind === "globstar") next.add(state);
|
|
1093
|
+
else if (token.kind === "directoryBody") {
|
|
1094
|
+
next.add(state);
|
|
1095
|
+
if (char === "/") next.add(state + 1);
|
|
837
1096
|
}
|
|
838
1097
|
}
|
|
1098
|
+
states = next;
|
|
839
1099
|
}
|
|
840
1100
|
return false;
|
|
841
1101
|
}
|
|
1102
|
+
function matchIgnorePath(filePath, ignorePatterns) {
|
|
1103
|
+
if (!ignorePatterns?.length) return false;
|
|
1104
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
1105
|
+
return ignorePatterns.some((pattern) => Boolean(pattern) && matchesGlob(normalized, pattern.replace(/\\/g, "/")));
|
|
1106
|
+
}
|
|
842
1107
|
|
|
843
1108
|
// src/metrics.ts
|
|
844
|
-
var
|
|
845
|
-
{ regex:
|
|
846
|
-
{ regex:
|
|
847
|
-
{ regex: /@
|
|
848
|
-
{ regex: /\
|
|
849
|
-
{ regex: /\btest\.todo\(/, name: "todo placeholder test" }
|
|
1109
|
+
var PYTHON_TEST_SKIP_PATTERNS = [
|
|
1110
|
+
{ regex: /@pytest\s*\.\s*mark\s*\.\s*skip\b/, name: "skipped pytest (@pytest.mark.skip)" },
|
|
1111
|
+
{ regex: /(?<![\w.])pytest\s*\.\s*skip\s*\(/, name: "imperative pytest skip (pytest.skip)" },
|
|
1112
|
+
{ regex: /@unittest\s*\.\s*skip\s*\(/, name: "skipped unittest (@unittest.skip)" },
|
|
1113
|
+
{ regex: /\bself\s*\.\s*skipTest\s*\(/, name: "imperative unittest skip (self.skipTest)" }
|
|
850
1114
|
];
|
|
851
|
-
var
|
|
852
|
-
var
|
|
1115
|
+
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;
|
|
1116
|
+
var JS_COMMENT_DIRECTIVE_PATTERNS = [
|
|
853
1117
|
{ regex: /^\s*\/\/\s*@ts-ignore\b/, name: "compiler ignore (@ts-ignore)" },
|
|
854
1118
|
{ regex: /^\s*\/\/\s*@ts-expect-error\b/, name: "compiler suppression (@ts-expect-error)" },
|
|
855
1119
|
{ regex: /^\s*\/\/\s*@ts-nocheck\b/, name: "compiler check disable (@ts-nocheck)" },
|
|
856
|
-
{ regex:
|
|
857
|
-
|
|
858
|
-
|
|
1120
|
+
{ regex: /^\s*\/\/\s*eslint-disable(?:-next-line|-line)?\b/, name: "linter suppression (eslint-disable)" }
|
|
1121
|
+
];
|
|
1122
|
+
var PYTHON_COMMENT_DIRECTIVE_PATTERNS = [
|
|
1123
|
+
{ regex: /#\s*type:\s*ignore\b/, name: "python type ignore" },
|
|
1124
|
+
{ regex: /#\s*noqa\b/, name: "python linter suppression (# noqa)" }
|
|
859
1125
|
];
|
|
860
1126
|
var CODE_TYPE_ESCAPE_PATTERNS = [
|
|
861
1127
|
{ regex: /\bas\s+any\b/, name: "type bypass (as any)" },
|
|
862
1128
|
{ regex: /\bas\s+unknown\s+as\b/, name: "double assertion bypass (as unknown as)" },
|
|
863
1129
|
{ regex: /\bas\s+never\b/, name: "type escape (as never)" }
|
|
864
1130
|
];
|
|
865
|
-
var
|
|
866
|
-
{ regex: /
|
|
867
|
-
{ regex: /
|
|
1131
|
+
var JS_TRIVIAL_ASSERT_PATTERNS = [
|
|
1132
|
+
{ regex: /(?<![\w$.])expect\s*\(\s*(true|false|1|0|null|undefined)\s*\)\s*\.\s*toBe\s*\(\s*\1\s*\)/, name: "trivial assertion (expect(val).toBe(val))" },
|
|
1133
|
+
{ regex: /(?<![\w$.])expect\s*\(\s*(true|false|1|0|null|undefined)\s*\)\s*\.\s*toEqual\s*\(\s*\1\s*\)/, name: "trivial assertion (expect(val).toEqual(val))" },
|
|
1134
|
+
{ regex: /(?<![\w$.])assert\s*\.\s*(?:strictEqual|equal)\s*\(\s*(?:true\s*,\s*true|1\s*,\s*1)\s*\)/, name: "trivial assert (assert.equal(1, 1))" }
|
|
868
1135
|
];
|
|
869
|
-
var
|
|
870
|
-
{ regex: /expect\s*\(\s*(true|false|1|0|null|undefined)\s*\)\.toBe\s*\(\s*\1\s*\)/, name: "trivial assertion (expect(val).toBe(val))" },
|
|
871
|
-
{ regex: /expect\s*\(\s*(true|false|1|0|null|undefined)\s*\)\.toEqual\s*\(\s*\1\s*\)/, name: "trivial assertion (expect(val).toEqual(val))" },
|
|
1136
|
+
var PYTHON_TRIVIAL_ASSERT_PATTERNS = [
|
|
872
1137
|
{ regex: /\bassert\s+(?:True|1\s*==\s*1)\s*(?=$|#|,)/, name: "trivial python assert (assert True / 1 == 1)" },
|
|
873
|
-
{ regex:
|
|
874
|
-
{ regex:
|
|
875
|
-
{ regex: /assert\.(?:strictEqual|equal)\s*\(\s*(?:true\s*,\s*true|1\s*,\s*1)\s*\)/, name: "trivial assert (assert.equal(1, 1))" }
|
|
1138
|
+
{ regex: /\bself\s*\.\s*assertTrue\s*\(\s*(?:True|1)\s*\)/, name: "trivial assert (self.assertTrue(True))" },
|
|
1139
|
+
{ regex: /\bself\s*\.\s*assertEqual\s*\(\s*(?:1\s*,\s*1|True\s*,\s*True)\s*\)/, name: "trivial assert (self.assertEqual(1, 1))" }
|
|
876
1140
|
];
|
|
877
|
-
var
|
|
878
|
-
{ regex: /\bthrow\s+new\s+Error\s*\(\s*["'](?:TODO|Not implemented|to be implemented|stub|implement me)/i, name: "unimplemented stub (throw new Error('Not implemented'))" }
|
|
1141
|
+
var JS_STUB_PATTERNS = [
|
|
1142
|
+
{ regex: /\bthrow\s+new\s+Error\s*\(\s*["'](?:TODO\b|Not implemented\b|to be implemented\b|stub\b|implement me\b)/i, name: "unimplemented stub (throw new Error('Not implemented'))" }
|
|
1143
|
+
];
|
|
1144
|
+
var PYTHON_STUB_PATTERNS = [
|
|
879
1145
|
{ regex: /\braise\s+NotImplementedError\b/, name: "unimplemented stub (raise NotImplementedError)" },
|
|
880
|
-
{ regex: /\braise\s+NotImplemented\b/, name: "unimplemented stub (raise NotImplemented)" }
|
|
881
|
-
|
|
882
|
-
|
|
1146
|
+
{ regex: /\braise\s+NotImplemented\b/, name: "unimplemented stub (raise NotImplemented)" }
|
|
1147
|
+
];
|
|
1148
|
+
var RUST_STUB_PATTERNS = [
|
|
1149
|
+
{ regex: /\btodo!\s*\(/, name: "unimplemented rust stub (todo!())" },
|
|
1150
|
+
{ regex: /\bunimplemented!\s*\(/, name: "unimplemented rust stub (unimplemented!())" }
|
|
883
1151
|
];
|
|
884
1152
|
var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
885
1153
|
".ts",
|
|
@@ -929,16 +1197,26 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
929
1197
|
hasSupportedChanges = true;
|
|
930
1198
|
filesInspected++;
|
|
931
1199
|
linesAdded += file.addedLines.length;
|
|
1200
|
+
const javascript = /\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file.path);
|
|
1201
|
+
const typescript = /\.(?:[cm]?tsx?|vue|svelte)$/i.test(file.path);
|
|
1202
|
+
const python = /\.py$/i.test(file.path);
|
|
1203
|
+
const rust = /\.rs$/i.test(file.path);
|
|
1204
|
+
const braceCatches = javascript || /\.(?:java|kt|php|cs|cpp)$/i.test(file.path);
|
|
1205
|
+
const directives = javascript ? JS_COMMENT_DIRECTIVE_PATTERNS : python ? PYTHON_COMMENT_DIRECTIVE_PATTERNS : [];
|
|
1206
|
+
const stubs = javascript ? JS_STUB_PATTERNS : python ? PYTHON_STUB_PATTERNS : rust ? RUST_STUB_PATTERNS : [];
|
|
1207
|
+
const firstFinding = infractions.length;
|
|
932
1208
|
const masker = new SourceMasker(file.path);
|
|
933
1209
|
const maskedLines = [];
|
|
934
1210
|
for (const added of file.addedLines) {
|
|
935
1211
|
const rawLine = added.line;
|
|
936
1212
|
const masked = masker.mask(rawLine, added.lineNumber);
|
|
937
1213
|
const cleanedLine = masked.code;
|
|
938
|
-
|
|
1214
|
+
const testCode = cleanedLine.split("");
|
|
1215
|
+
for (const start of masked.templateStarts) testCode[start] = "";
|
|
1216
|
+
maskedLines.push({ code: cleanedLine, testCode: testCode.join(""), raw: rawLine, number: added.lineNumber });
|
|
939
1217
|
const isComment = !cleanedLine.trim();
|
|
940
|
-
if (!isComment) {
|
|
941
|
-
for (const rule of
|
|
1218
|
+
if (python && !isComment) {
|
|
1219
|
+
for (const rule of PYTHON_TEST_SKIP_PATTERNS) {
|
|
942
1220
|
if (rule.regex.test(cleanedLine)) {
|
|
943
1221
|
infractions.push({
|
|
944
1222
|
type: "test-skip",
|
|
@@ -954,7 +1232,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
954
1232
|
}
|
|
955
1233
|
}
|
|
956
1234
|
let foundEscape = false;
|
|
957
|
-
for (const rule of
|
|
1235
|
+
for (const rule of directives) {
|
|
958
1236
|
if (rule.regex.test(masked.comments)) {
|
|
959
1237
|
infractions.push({
|
|
960
1238
|
type: "type-escape",
|
|
@@ -969,7 +1247,11 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
969
1247
|
break;
|
|
970
1248
|
}
|
|
971
1249
|
}
|
|
972
|
-
if (!foundEscape &&
|
|
1250
|
+
if (javascript && !foundEscape && masked.blockStarts.some((start) => /^\/\*\s*eslint-disable(?:-next-line|-line)?\b/.test(rawLine.slice(start)))) {
|
|
1251
|
+
infractions.push({ type: "type-escape", file: file.path, lineNumber: added.lineNumber, lineSnippet: rawLine.trim(), commitHash, commitDate, reason: "linter suppression (eslint-disable)" });
|
|
1252
|
+
foundEscape = true;
|
|
1253
|
+
}
|
|
1254
|
+
if (typescript && !foundEscape && !isComment) {
|
|
973
1255
|
for (const rule of CODE_TYPE_ESCAPE_PATTERNS) {
|
|
974
1256
|
if (rule.regex.test(cleanedLine)) {
|
|
975
1257
|
infractions.push({
|
|
@@ -985,24 +1267,8 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
985
1267
|
}
|
|
986
1268
|
}
|
|
987
1269
|
}
|
|
988
|
-
if (!isComment) {
|
|
989
|
-
for (const rule of
|
|
990
|
-
if (rule.regex.test(cleanedLine)) {
|
|
991
|
-
infractions.push({
|
|
992
|
-
type: "swallowed-error",
|
|
993
|
-
file: file.path,
|
|
994
|
-
lineNumber: added.lineNumber,
|
|
995
|
-
lineSnippet: rawLine.trim(),
|
|
996
|
-
commitHash,
|
|
997
|
-
commitDate,
|
|
998
|
-
reason: rule.name
|
|
999
|
-
});
|
|
1000
|
-
break;
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
if (!isComment && isTestFile(file.path)) {
|
|
1005
|
-
for (const rule of TRIVIAL_ASSERT_PATTERNS) {
|
|
1270
|
+
if (python && !isComment && isTestFile(file.path)) {
|
|
1271
|
+
for (const rule of PYTHON_TRIVIAL_ASSERT_PATTERNS) {
|
|
1006
1272
|
if (rule.regex.test(cleanedLine)) {
|
|
1007
1273
|
infractions.push({
|
|
1008
1274
|
type: "trivial-assert",
|
|
@@ -1018,9 +1284,10 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1018
1284
|
}
|
|
1019
1285
|
}
|
|
1020
1286
|
if (!isComment) {
|
|
1021
|
-
for (const rule of
|
|
1022
|
-
const
|
|
1023
|
-
|
|
1287
|
+
for (const rule of stubs) {
|
|
1288
|
+
const matches = rawLine.matchAll(new RegExp(rule.regex.source, rule.regex.flags + "g"));
|
|
1289
|
+
const match = [...matches].find((candidate) => cleanedLine[candidate.index] === rawLine[candidate.index]);
|
|
1290
|
+
if (match) {
|
|
1024
1291
|
infractions.push({
|
|
1025
1292
|
type: "stub-abandonment",
|
|
1026
1293
|
file: file.path,
|
|
@@ -1035,46 +1302,68 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1035
1302
|
}
|
|
1036
1303
|
}
|
|
1037
1304
|
}
|
|
1038
|
-
if (
|
|
1305
|
+
if (javascript) {
|
|
1039
1306
|
let source = "";
|
|
1040
1307
|
const offsets = [];
|
|
1041
1308
|
for (let i = 0; i < maskedLines.length; i++) {
|
|
1042
1309
|
if (i) source += maskedLines[i].number === maskedLines[i - 1].number + 1 ? "\n" : "\0";
|
|
1043
1310
|
offsets.push(source.length);
|
|
1044
|
-
source += maskedLines[i].
|
|
1311
|
+
source += maskedLines[i].testCode;
|
|
1045
1312
|
}
|
|
1046
1313
|
let row = 0;
|
|
1047
|
-
|
|
1314
|
+
const evidence = (index, length) => {
|
|
1315
|
+
while (row + 1 < offsets.length && offsets[row + 1] <= index) row++;
|
|
1316
|
+
let end = row;
|
|
1317
|
+
while (end + 1 < offsets.length && offsets[end + 1] < index + length) end++;
|
|
1318
|
+
return { lineNumber: maskedLines[row].number, lineSnippet: maskedLines.slice(row, end + 1).map((line) => line.raw.trim()).join(" ") };
|
|
1319
|
+
};
|
|
1320
|
+
for (const match of source.matchAll(JS_TEST_API)) {
|
|
1048
1321
|
let previous = match.index - 1;
|
|
1049
1322
|
while (previous >= 0 && /\s/.test(source[previous])) previous--;
|
|
1050
1323
|
if (source[previous] === ".") continue;
|
|
1051
|
-
if (
|
|
1324
|
+
if (match[2] && !isTestFile(file.path)) continue;
|
|
1052
1325
|
if (/\bfunction$/.test(source.slice(Math.max(0, previous - 8), previous + 1))) continue;
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
while (end + 1 < offsets.length && offsets[end + 1] < match.index + match[0].length) end++;
|
|
1326
|
+
if (source[match.index + match[0].length] === "" && !/\.\s*each\s*$/.test(match[3])) continue;
|
|
1327
|
+
const focus = match[1] === "only" || match[2] === "fit" || match[2] === "fdescribe";
|
|
1056
1328
|
infractions.push({
|
|
1057
|
-
type: "test-focus",
|
|
1329
|
+
type: focus ? "test-focus" : "test-skip",
|
|
1058
1330
|
file: file.path,
|
|
1059
|
-
|
|
1060
|
-
lineSnippet: maskedLines.slice(row, end + 1).map((line) => line.raw.trim()).join(" "),
|
|
1331
|
+
...evidence(match.index, match[0].length),
|
|
1061
1332
|
commitHash,
|
|
1062
1333
|
commitDate,
|
|
1063
|
-
reason: "focused test or suite excludes other tests from the run"
|
|
1334
|
+
reason: focus ? "focused test or suite excludes other tests from the run" : `skipped, disabled or placeholder test registration (${match[0].replace(/\s/g, "")})`
|
|
1064
1335
|
});
|
|
1065
1336
|
}
|
|
1337
|
+
if (isTestFile(file.path)) {
|
|
1338
|
+
row = 0;
|
|
1339
|
+
for (const rule of JS_TRIVIAL_ASSERT_PATTERNS) {
|
|
1340
|
+
for (const match of source.matchAll(new RegExp(rule.regex.source, "g"))) {
|
|
1341
|
+
infractions.push({ type: "trivial-assert", file: file.path, ...evidence(match.index, match[0].length), commitHash, commitDate, reason: rule.name });
|
|
1342
|
+
}
|
|
1343
|
+
row = 0;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1066
1346
|
}
|
|
1067
1347
|
for (let index = 0; index < maskedLines.length; index++) {
|
|
1068
1348
|
const start = maskedLines[index];
|
|
1069
|
-
const
|
|
1070
|
-
const
|
|
1349
|
+
const catches = braceCatches ? [...start.code.matchAll(/(?<![\w$.])catch\s*(?:\(\s*[^)\s][^)]*\))?\s*\{/g)] : [];
|
|
1350
|
+
for (const match of catches) {
|
|
1351
|
+
if (/^\s*}/.test(start.code.slice(match.index + match[0].length))) {
|
|
1352
|
+
infractions.push({ type: "swallowed-error", file: file.path, lineNumber: start.number, lineSnippet: start.raw.trim(), commitHash, commitDate, reason: "empty catch block (catch {})" });
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
const lastCatch = catches.at(-1);
|
|
1356
|
+
const isCatch = lastCatch && /^\s*$/.test(start.code.slice(lastCatch.index + lastCatch[0].length));
|
|
1357
|
+
const inlineExcept = python && /^\s*except(?:\*?\s+[^:]+)?:\s*pass\s*;?\s*$/.test(start.code);
|
|
1358
|
+
if (inlineExcept) infractions.push({ type: "swallowed-error", file: file.path, lineNumber: start.number, lineSnippet: start.raw.trim(), commitHash, commitDate, reason: "silent python error swallow (except: pass)" });
|
|
1359
|
+
const isExcept = python && /^\s*except(?:\*?\s+[^:]+)?:\s*$/.test(start.code);
|
|
1071
1360
|
if (!isCatch && !isExcept) continue;
|
|
1072
1361
|
let next = index + 1;
|
|
1073
1362
|
while (next < maskedLines.length && maskedLines[next].number === maskedLines[next - 1].number + 1 && !maskedLines[next].code.trim()) next++;
|
|
1074
1363
|
const end = maskedLines[next];
|
|
1075
1364
|
if (!end || end.number !== maskedLines[next - 1].number + 1) continue;
|
|
1076
1365
|
const closesCatch = isCatch && /^\s*}/.test(end.code);
|
|
1077
|
-
let closesExcept = isExcept && /^\s*pass\s*$/.test(end.code);
|
|
1366
|
+
let closesExcept = isExcept && /^\s*pass\s*;?\s*$/.test(end.code);
|
|
1078
1367
|
if (closesExcept) {
|
|
1079
1368
|
const indent = start.raw.search(/\S/);
|
|
1080
1369
|
if (end.raw.search(/\S/) <= indent) closesExcept = false;
|
|
@@ -1092,6 +1381,8 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1092
1381
|
reason: closesCatch ? "empty multi-line catch block" : "silent multi-line python error swallow"
|
|
1093
1382
|
});
|
|
1094
1383
|
}
|
|
1384
|
+
const ordered = infractions.splice(firstFinding).sort((left, right) => (left.lineNumber || 0) - (right.lineNumber || 0));
|
|
1385
|
+
for (const finding of ordered) infractions.push(finding);
|
|
1095
1386
|
}
|
|
1096
1387
|
return { infractions: infractions.filter((inf) => options.rules?.[inf.type] !== "off").map((inf) => ({ ...inf, suggestion: RULE_GUIDES[inf.type].suggestion })), hasSupportedChanges, filesInspected, linesAdded };
|
|
1097
1388
|
}
|
|
@@ -1115,6 +1406,7 @@ var MetricsCollector = class {
|
|
|
1115
1406
|
stubAbandonments = [];
|
|
1116
1407
|
skippedLongLines = 0;
|
|
1117
1408
|
skippedUnknownFiles = 0;
|
|
1409
|
+
skippedBinaryFiles = 0;
|
|
1118
1410
|
activePanicChain = null;
|
|
1119
1411
|
includeAll;
|
|
1120
1412
|
ignorePaths;
|
|
@@ -1159,6 +1451,7 @@ var MetricsCollector = class {
|
|
|
1159
1451
|
this.flushPanicChain();
|
|
1160
1452
|
return;
|
|
1161
1453
|
}
|
|
1454
|
+
this.skippedBinaryFiles += commit.files.filter((file) => file.binary && isCodeFile(file.path, this.ignorePaths)).length;
|
|
1162
1455
|
const subject = commit.message.split("\n")[0];
|
|
1163
1456
|
const commitDateMs = Date.parse(commit.date);
|
|
1164
1457
|
const isFixCommit = PANIC_COMMIT_REGEX.test(subject);
|
|
@@ -1231,7 +1524,8 @@ var MetricsCollector = class {
|
|
|
1231
1524
|
getSummary() {
|
|
1232
1525
|
this.flushPanicChain();
|
|
1233
1526
|
const linesSample = this.includeAll ? this.totalLinesAdded : this.aiLinesAdded;
|
|
1234
|
-
const
|
|
1527
|
+
const complete = !this.skippedLongLines && !this.skippedUnknownFiles && !this.skippedBinaryFiles;
|
|
1528
|
+
const isSufficientData = complete && linesSample >= MIN_SAMPLE_LOC;
|
|
1235
1529
|
const codeInfractionPoints = this.testSkips.length * 15 + this.focusedTests.length * 15 + this.trivialAsserts.length * 15 + this.swallowedErrors.length * 10 + this.stubAbandonments.length * 8 + this.typeEscapes.length * 3;
|
|
1236
1530
|
let panicInfractionPoints = 0;
|
|
1237
1531
|
for (const loop of this.panicLoops) {
|
|
@@ -1294,7 +1588,12 @@ var MetricsCollector = class {
|
|
|
1294
1588
|
}
|
|
1295
1589
|
}
|
|
1296
1590
|
}
|
|
1591
|
+
if (!complete) {
|
|
1592
|
+
archetype = "Incomplete audit";
|
|
1593
|
+
archetypeDescription = "Source was skipped; findings are partial and no complete score can be issued.";
|
|
1594
|
+
}
|
|
1297
1595
|
return {
|
|
1596
|
+
complete,
|
|
1298
1597
|
schemaVersion: "1.0.0",
|
|
1299
1598
|
totalLinesAdded: this.totalLinesAdded,
|
|
1300
1599
|
aiLinesAdded: this.aiLinesAdded,
|
|
@@ -1313,14 +1612,22 @@ var MetricsCollector = class {
|
|
|
1313
1612
|
archetype,
|
|
1314
1613
|
archetypeDescription,
|
|
1315
1614
|
skippedLongLines: this.skippedLongLines,
|
|
1316
|
-
skippedUnknownFiles: this.skippedUnknownFiles
|
|
1615
|
+
skippedUnknownFiles: this.skippedUnknownFiles,
|
|
1616
|
+
skippedBinaryFiles: this.skippedBinaryFiles
|
|
1317
1617
|
};
|
|
1318
1618
|
}
|
|
1319
1619
|
};
|
|
1320
1620
|
|
|
1321
1621
|
// src/renderer.ts
|
|
1322
1622
|
import pc from "picocolors";
|
|
1623
|
+
function historyIncomplete(summary) {
|
|
1624
|
+
return Boolean(summary.skippedLongLines || summary.skippedUnknownFiles || summary.skippedBinaryFiles);
|
|
1625
|
+
}
|
|
1323
1626
|
function generateShareUrl(summary) {
|
|
1627
|
+
if (historyIncomplete(summary)) {
|
|
1628
|
+
const text2 = "My agent-roast history audit was incomplete: some long lines, unparsed paths or binary source were skipped. No complete score was issued.\n\nAudit your repo: npx agent-roast";
|
|
1629
|
+
return `https://twitter.com/intent/tweet?text=${encodeURIComponent(text2)}`;
|
|
1630
|
+
}
|
|
1324
1631
|
const scoreDisplay = summary.score !== null ? `${summary.score}/100` : "N/A";
|
|
1325
1632
|
const text = `My AI coding agent scored ${scoreDisplay} on agent-roast.
|
|
1326
1633
|
|
|
@@ -1335,27 +1642,32 @@ Audit your repo: npx agent-roast`;
|
|
|
1335
1642
|
}
|
|
1336
1643
|
function formatTerminalCard(summary, options) {
|
|
1337
1644
|
const lines = [];
|
|
1645
|
+
const incomplete = historyIncomplete(summary);
|
|
1338
1646
|
lines.push("");
|
|
1339
1647
|
lines.push(
|
|
1340
|
-
`${pc.bold(pc.cyan("agent-roast"))} ${pc.dim("\u2014 git discipline audit")} ${pc.white(options.repoName)}`
|
|
1648
|
+
`${pc.bold(pc.cyan("agent-roast"))} ${pc.dim("\u2014 git discipline audit")} ${pc.white(terminalText(options.repoName))}`
|
|
1341
1649
|
);
|
|
1342
1650
|
lines.push(
|
|
1343
1651
|
` ${pc.dim("commits:")} ${pc.yellow(summary.aiCommits)} AI / ${summary.totalCommits} total ${pc.dim("lines added:")} ${pc.green(summary.aiLinesAdded)} AI / ${summary.totalLinesAdded} total`
|
|
1344
1652
|
);
|
|
1345
1653
|
lines.push("");
|
|
1346
|
-
if (
|
|
1654
|
+
if (incomplete) {
|
|
1655
|
+
lines.push(pc.yellow(" INCOMPLETE: long lines, unparsed paths or binary source were skipped; no complete score issued."));
|
|
1656
|
+
lines.push(pc.dim(` Skipped: ${summary.skippedLongLines || 0} long lines \xB7 ${summary.skippedUnknownFiles || 0} unparsed files \xB7 ${summary.skippedBinaryFiles || 0} binary source files.`));
|
|
1657
|
+
lines.push("");
|
|
1658
|
+
} else if (!summary.isSufficientData || summary.score === null) {
|
|
1347
1659
|
lines.push(pc.yellow(` insufficient data (< 200 analyzed lines added)`));
|
|
1348
1660
|
lines.push(pc.dim(" Findings below remain actionable; a score needs a larger sample."));
|
|
1349
1661
|
lines.push("");
|
|
1350
1662
|
}
|
|
1351
|
-
if (summary.isSufficientData && summary.score !== null) {
|
|
1663
|
+
if (!incomplete && summary.isSufficientData && summary.score !== null) {
|
|
1352
1664
|
let scoreColor = pc.green;
|
|
1353
1665
|
if (summary.score < 60) scoreColor = pc.red;
|
|
1354
1666
|
else if (summary.score < 80) scoreColor = pc.yellow;
|
|
1355
1667
|
lines.push(
|
|
1356
|
-
` ${pc.bold("SCORE:")} ${scoreColor(pc.bold(`${summary.score} / 100`))} ${pc.dim("\xB7")} ${pc.magenta(pc.bold(summary.archetype))}`
|
|
1668
|
+
` ${pc.bold("SCORE:")} ${scoreColor(pc.bold(`${summary.score} / 100`))} ${pc.dim("\xB7")} ${pc.magenta(pc.bold(terminalText(summary.archetype)))}`
|
|
1357
1669
|
);
|
|
1358
|
-
lines.push(` ${pc.dim(summary.archetypeDescription)}`);
|
|
1670
|
+
lines.push(` ${pc.dim(terminalText(summary.archetypeDescription))}`);
|
|
1359
1671
|
lines.push("");
|
|
1360
1672
|
}
|
|
1361
1673
|
const totalInfractions = (summary.testSkips?.length || 0) + (summary.focusedTests?.length || 0) + (summary.typeEscapes?.length || 0) + (summary.swallowedErrors?.length || 0) + (summary.panicLoops?.length || 0) + (summary.trivialAsserts?.length || 0) + (summary.stubAbandonments?.length || 0);
|
|
@@ -1366,7 +1678,7 @@ function formatTerminalCard(summary, options) {
|
|
|
1366
1678
|
` ${pc.magenta("\u25CF")} ${pc.bold("panic-loop")} (${summary.panicLoops.length}) ${pc.dim("rapid consecutive fixes touching overlapping files")}`
|
|
1367
1679
|
);
|
|
1368
1680
|
const sample = summary.panicLoops[0];
|
|
1369
|
-
lines.push(` ${pc.dim(sample.reason)}`);
|
|
1681
|
+
lines.push(` ${pc.dim(terminalText(sample.reason))}`);
|
|
1370
1682
|
}
|
|
1371
1683
|
if (summary.trivialAsserts && summary.trivialAsserts.length > 0) {
|
|
1372
1684
|
lines.push(
|
|
@@ -1374,7 +1686,7 @@ function formatTerminalCard(summary, options) {
|
|
|
1374
1686
|
);
|
|
1375
1687
|
const sample = summary.trivialAsserts[0];
|
|
1376
1688
|
if (sample.file) {
|
|
1377
|
-
lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
|
|
1689
|
+
lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
|
|
1378
1690
|
}
|
|
1379
1691
|
}
|
|
1380
1692
|
if (summary.focusedTests?.length) {
|
|
@@ -1388,7 +1700,7 @@ function formatTerminalCard(summary, options) {
|
|
|
1388
1700
|
);
|
|
1389
1701
|
const sample = summary.testSkips[0];
|
|
1390
1702
|
if (sample.file) {
|
|
1391
|
-
lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
|
|
1703
|
+
lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
|
|
1392
1704
|
}
|
|
1393
1705
|
}
|
|
1394
1706
|
if (summary.swallowedErrors && summary.swallowedErrors.length > 0) {
|
|
@@ -1397,7 +1709,7 @@ function formatTerminalCard(summary, options) {
|
|
|
1397
1709
|
);
|
|
1398
1710
|
const sample = summary.swallowedErrors[0];
|
|
1399
1711
|
if (sample.file) {
|
|
1400
|
-
lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
|
|
1712
|
+
lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
|
|
1401
1713
|
}
|
|
1402
1714
|
}
|
|
1403
1715
|
if (summary.stubAbandonments && summary.stubAbandonments.length > 0) {
|
|
@@ -1406,7 +1718,7 @@ function formatTerminalCard(summary, options) {
|
|
|
1406
1718
|
);
|
|
1407
1719
|
const sample = summary.stubAbandonments[0];
|
|
1408
1720
|
if (sample.file) {
|
|
1409
|
-
lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
|
|
1721
|
+
lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
|
|
1410
1722
|
}
|
|
1411
1723
|
}
|
|
1412
1724
|
if (summary.typeEscapes && summary.typeEscapes.length > 0) {
|
|
@@ -1415,15 +1727,15 @@ function formatTerminalCard(summary, options) {
|
|
|
1415
1727
|
);
|
|
1416
1728
|
const sample = summary.typeEscapes[0];
|
|
1417
1729
|
if (sample.file) {
|
|
1418
|
-
lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
|
|
1730
|
+
lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
|
|
1419
1731
|
}
|
|
1420
1732
|
}
|
|
1421
1733
|
if (totalInfractions === 0) {
|
|
1422
|
-
lines.push(` ${pc.green("\u25CF")} ${pc.dim("zero infractions detected across all inspected lines.")}`);
|
|
1734
|
+
lines.push(incomplete ? " No findings in the inspected portion; skipped source remains unreviewed." : ` ${pc.green("\u25CF")} ${pc.dim("zero infractions detected across all inspected lines.")}`);
|
|
1423
1735
|
}
|
|
1424
1736
|
lines.push("");
|
|
1425
1737
|
if (options.verbose) {
|
|
1426
|
-
if (
|
|
1738
|
+
if (incomplete) {
|
|
1427
1739
|
lines.push(pc.bold(" PARSER AUDIT STATS:"));
|
|
1428
1740
|
lines.push(pc.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
1429
1741
|
if (summary.skippedLongLines) {
|
|
@@ -1432,6 +1744,9 @@ function formatTerminalCard(summary, options) {
|
|
|
1432
1744
|
if (summary.skippedUnknownFiles) {
|
|
1433
1745
|
lines.push(` ${pc.dim("skipped files with unparseable path:")} ${pc.yellow(summary.skippedUnknownFiles)}`);
|
|
1434
1746
|
}
|
|
1747
|
+
if (summary.skippedBinaryFiles) {
|
|
1748
|
+
lines.push(` ${pc.dim("skipped binary source files:")} ${pc.yellow(summary.skippedBinaryFiles)}`);
|
|
1749
|
+
}
|
|
1435
1750
|
lines.push("");
|
|
1436
1751
|
}
|
|
1437
1752
|
const allInfractions = [
|
|
@@ -1449,16 +1764,16 @@ function formatTerminalCard(summary, options) {
|
|
|
1449
1764
|
for (const inf of allInfractions.slice(0, 10)) {
|
|
1450
1765
|
const fileLoc = inf.file ? `${inf.file}${inf.lineNumber ? `:${inf.lineNumber}` : ""}` : "commit history";
|
|
1451
1766
|
lines.push(
|
|
1452
|
-
` \u25CF ${pc.cyan(inf.commitHash.slice(0, 7))} [${inf.type}] ${pc.white(fileLoc)}`
|
|
1767
|
+
` \u25CF ${pc.cyan(terminalText(inf.commitHash.slice(0, 7)))} [${inf.type}] ${pc.white(terminalText(fileLoc))}`
|
|
1453
1768
|
);
|
|
1454
1769
|
if (inf.lineSnippet) {
|
|
1455
|
-
lines.push(` ${pc.dim(`\u2514\u2500 ${inf.lineSnippet}`)}`);
|
|
1770
|
+
lines.push(` ${pc.dim(`\u2514\u2500 ${terminalText(inf.lineSnippet)}`)}`);
|
|
1456
1771
|
}
|
|
1457
1772
|
}
|
|
1458
1773
|
lines.push("");
|
|
1459
1774
|
}
|
|
1460
1775
|
}
|
|
1461
|
-
lines.push(
|
|
1776
|
+
if (!incomplete) lines.push(
|
|
1462
1777
|
` ${pc.dim("Share score:")} ${pc.cyan(generateShareUrl(summary))}`
|
|
1463
1778
|
);
|
|
1464
1779
|
lines.push("");
|
|
@@ -1471,6 +1786,7 @@ function formatPrTerminalCard(result) {
|
|
|
1471
1786
|
`${pc.bold(pc.cyan("agent-roast"))} ${pc.dim(result.mode === "staged" ? "\u2014 staged changes audit" : "\u2014 pull request audit")} ${pc.white(`${result.baseSha.slice(0, 7)}...${result.headSha.slice(0, 7)}`)}`
|
|
1472
1787
|
);
|
|
1473
1788
|
if (!result.hasSupportedChanges) {
|
|
1789
|
+
if (result.complete === false) lines.push(" INCOMPLETE: some long lines, unparsed paths or binary source could not be inspected; exit 2.");
|
|
1474
1790
|
lines.push(` ${pc.dim("no supported source changes detected in the audited diff.")}`);
|
|
1475
1791
|
lines.push("");
|
|
1476
1792
|
return lines.join("\n");
|
|
@@ -1479,7 +1795,7 @@ function formatPrTerminalCard(result) {
|
|
|
1479
1795
|
` ${pc.dim("files inspected:")} ${result.filesInspected} ${pc.dim("lines added:")} ${result.linesAdded}`
|
|
1480
1796
|
);
|
|
1481
1797
|
lines.push("");
|
|
1482
|
-
if (result.infractions.length === 0) {
|
|
1798
|
+
if (result.infractions.length === 0 && result.complete !== false) {
|
|
1483
1799
|
lines.push(` ${pc.green("PASS")} ${pc.dim("zero infractions detected across all audited changes.")}`);
|
|
1484
1800
|
} else {
|
|
1485
1801
|
lines.push(pc.bold(` INFRACTIONS DETECTED (${result.infractions.length})`));
|
|
@@ -1488,15 +1804,17 @@ function formatPrTerminalCard(result) {
|
|
|
1488
1804
|
const isFailedRule = result.failedRules.includes(inf.type);
|
|
1489
1805
|
const badge = isFailedRule ? pc.red("FAIL") : pc.yellow("WARN");
|
|
1490
1806
|
const loc = `${inf.file || "unknown"}:${inf.lineNumber || 1}`;
|
|
1491
|
-
lines.push(` ${badge} [${inf.type}] ${pc.white(loc)} \u2014 ${pc.dim(inf.reason)}`);
|
|
1807
|
+
lines.push(` ${badge} [${inf.type}] ${pc.white(terminalText(loc))} \u2014 ${pc.dim(terminalText(inf.reason))}`);
|
|
1492
1808
|
if (inf.lineSnippet) {
|
|
1493
|
-
lines.push(` ${pc.dim(`\u2514\u2500 ${inf.lineSnippet}`)}`);
|
|
1809
|
+
lines.push(` ${pc.dim(`\u2514\u2500 ${terminalText(inf.lineSnippet)}`)}`);
|
|
1494
1810
|
}
|
|
1495
|
-
if (inf.suggestion) lines.push(` ${pc.dim(`Next: ${inf.suggestion}`)}`);
|
|
1811
|
+
if (inf.suggestion) lines.push(` ${pc.dim(`Next: ${terminalText(inf.suggestion)}`)}`);
|
|
1496
1812
|
}
|
|
1497
1813
|
}
|
|
1498
1814
|
lines.push("");
|
|
1499
|
-
if (result.
|
|
1815
|
+
if (result.complete === false) {
|
|
1816
|
+
lines.push(" INCOMPLETE: some long lines, unparsed paths or binary source could not be inspected; exit 2.");
|
|
1817
|
+
} else if (result.failedRules.length > 0) {
|
|
1500
1818
|
lines.push(
|
|
1501
1819
|
` ${pc.red(pc.bold("BLOCKING FAILURE:"))} ${result.failedRules.length} rule(s) violated in --fail-on: ${result.failedRules.join(", ")}`
|
|
1502
1820
|
);
|
|
@@ -1564,10 +1882,11 @@ function formatGithubStepSummary(result) {
|
|
|
1564
1882
|
lines.push(result.mode === "staged" ? "### agent-roast Staged Changes Audit" : "### agent-roast Pull Request Audit");
|
|
1565
1883
|
lines.push("");
|
|
1566
1884
|
if (!result.hasSupportedChanges) {
|
|
1885
|
+
if (result.complete === false) lines.push("INCOMPLETE: some long lines, unparsed paths or binary source could not be inspected; exit 2.");
|
|
1567
1886
|
lines.push("No supported source file changes detected in the audited diff.");
|
|
1568
1887
|
return lines.join("\n");
|
|
1569
1888
|
}
|
|
1570
|
-
const statusBadge = result.passed ? "Passed" : `Failed (${result.failedRules.join(", ")})`;
|
|
1889
|
+
const statusBadge = result.complete === false ? "INCOMPLETE" : result.passed ? "Passed" : `Failed (${result.failedRules.join(", ")})`;
|
|
1571
1890
|
lines.push(`| Status | Base | Head | Files | Lines Added | Infractions |`);
|
|
1572
1891
|
lines.push(`| --- | --- | --- | --- | --- | --- |`);
|
|
1573
1892
|
lines.push(
|
|
@@ -1581,31 +1900,32 @@ function formatGithubStepSummary(result) {
|
|
|
1581
1900
|
lines.push("| --- | --- | --- | --- |");
|
|
1582
1901
|
const sorted = sortInfractionsDeterministic(result.infractions);
|
|
1583
1902
|
for (const inf of sorted) {
|
|
1584
|
-
const loc = inf.file ?
|
|
1585
|
-
const snippet = inf.lineSnippet ?
|
|
1586
|
-
lines.push(`|
|
|
1903
|
+
const loc = inf.file ? markdownCell(`${inf.file}:${inf.lineNumber || 1}`) : "history";
|
|
1904
|
+
const snippet = inf.lineSnippet ? markdownCell(inf.lineSnippet) : "-";
|
|
1905
|
+
lines.push(`| ${inf.type} | ${loc} | ${markdownCell(inf.reason)} | ${snippet} |`);
|
|
1587
1906
|
}
|
|
1588
1907
|
lines.push("");
|
|
1589
1908
|
} else {
|
|
1590
|
-
lines.push("Clean pull request: zero shortcuts or bypassed checks detected.");
|
|
1909
|
+
lines.push(result.complete === false ? "INCOMPLETE: skipped long lines, unparsed paths or binary source prevent a clean result." : "Clean pull request: zero shortcuts or bypassed checks detected.");
|
|
1591
1910
|
lines.push("");
|
|
1592
1911
|
}
|
|
1593
1912
|
return lines.join("\n");
|
|
1594
1913
|
}
|
|
1595
1914
|
function formatPrMarkdownComment(result) {
|
|
1596
1915
|
const lines = [];
|
|
1597
|
-
const titleEmoji = result.passed ? "\u2705" : "\u274C";
|
|
1598
|
-
const titleStatus = result.passed ? "PASSED" : "BLOCKED";
|
|
1916
|
+
const titleEmoji = result.complete === false ? "\u26A0\uFE0F" : result.passed ? "\u2705" : "\u274C";
|
|
1917
|
+
const titleStatus = result.complete === false ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
|
|
1599
1918
|
lines.push(`## \u{1F6E1}\uFE0F agent-roast audit: ${titleStatus} ${titleEmoji}`);
|
|
1600
1919
|
lines.push("");
|
|
1601
1920
|
if (!result.hasSupportedChanges) {
|
|
1921
|
+
if (result.complete === false) lines.push("INCOMPLETE: some long lines, unparsed paths or binary source could not be inspected; exit 2.");
|
|
1602
1922
|
lines.push("No supported source file changes detected in the audited diff.");
|
|
1603
1923
|
lines.push("");
|
|
1604
1924
|
lines.push("---");
|
|
1605
1925
|
lines.push("*Audited 100% offline with [agent-roast](https://github.com/Nari-Ab/agent-roast)*");
|
|
1606
1926
|
return lines.join("\n");
|
|
1607
1927
|
}
|
|
1608
|
-
const statusText = result.passed ? "\u2705 Passed" : `\u274C Blocked on ${result.failedRules.join(", ")}`;
|
|
1928
|
+
const statusText = result.complete === false ? "\u26A0\uFE0F INCOMPLETE" : result.passed ? "\u2705 Passed" : `\u274C Blocked on ${result.failedRules.join(", ")}`;
|
|
1609
1929
|
lines.push("| Status | Defect Density | Files Inspected | Lines Added | Infractions |");
|
|
1610
1930
|
lines.push("| :--- | :--- | :--- | :--- | :--- |");
|
|
1611
1931
|
lines.push(
|
|
@@ -1620,15 +1940,15 @@ function formatPrMarkdownComment(result) {
|
|
|
1620
1940
|
lines.push("| :--- | :--- | :--- | :--- |");
|
|
1621
1941
|
const sorted = sortInfractionsDeterministic(result.infractions);
|
|
1622
1942
|
for (const inf of sorted) {
|
|
1623
|
-
const loc = inf.file ?
|
|
1624
|
-
const snippet = inf.lineSnippet ?
|
|
1625
|
-
lines.push(`|
|
|
1943
|
+
const loc = inf.file ? markdownCell(`${inf.file}:${inf.lineNumber || 1}`) : "history";
|
|
1944
|
+
const snippet = inf.lineSnippet ? markdownCell(inf.lineSnippet) : "-";
|
|
1945
|
+
lines.push(`| ${inf.type} | ${loc} | ${markdownCell(inf.reason)} | ${snippet} |`);
|
|
1626
1946
|
}
|
|
1627
1947
|
lines.push("");
|
|
1628
1948
|
lines.push("</details>");
|
|
1629
1949
|
lines.push("");
|
|
1630
1950
|
} else {
|
|
1631
|
-
lines.push("\u2728 **Clean pull request**: Zero AI shortcuts, bypassed tests, or compiler type escapes detected.");
|
|
1951
|
+
lines.push(result.complete === false ? "INCOMPLETE: skipped long lines, unparsed paths or binary source prevent a clean result." : "\u2728 **Clean pull request**: Zero AI shortcuts, bypassed tests, or compiler type escapes detected.");
|
|
1632
1952
|
lines.push("");
|
|
1633
1953
|
}
|
|
1634
1954
|
lines.push("---");
|
|
@@ -1636,7 +1956,38 @@ function formatPrMarkdownComment(result) {
|
|
|
1636
1956
|
return lines.join("\n");
|
|
1637
1957
|
}
|
|
1638
1958
|
function terminalText(value) {
|
|
1639
|
-
return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
1959
|
+
return value.replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
1960
|
+
}
|
|
1961
|
+
function exceptionTerminal(result) {
|
|
1962
|
+
const lines = [];
|
|
1963
|
+
const accepted = result.suppressedFindings || [];
|
|
1964
|
+
if (accepted.length) {
|
|
1965
|
+
lines.push(` ${accepted.length} findings accepted by explicit exceptions (not fixed)`);
|
|
1966
|
+
for (const finding of accepted.slice(0, 20)) lines.push(
|
|
1967
|
+
` ACCEPTED [${finding.type}] ${terminalText(finding.file || "unknown")}:${finding.lineNumber} \u2014 ${terminalText(finding.suppression.reason)} (expires ${terminalText(finding.suppression.expires)})`
|
|
1968
|
+
);
|
|
1969
|
+
if (accepted.length > 20) lines.push(` ${accepted.length - 20} more accepted findings; use --format json.`);
|
|
1970
|
+
}
|
|
1971
|
+
if (result.expiredExceptionCount) lines.push(` ${result.expiredExceptionCount} expired exceptions were not applied; review their findings.`);
|
|
1972
|
+
return lines;
|
|
1973
|
+
}
|
|
1974
|
+
function exceptionMarkdown(result) {
|
|
1975
|
+
const lines = [];
|
|
1976
|
+
const accepted = result.suppressedFindings || [];
|
|
1977
|
+
if (accepted.length) {
|
|
1978
|
+
lines.push(
|
|
1979
|
+
"",
|
|
1980
|
+
`### Accepted exceptions (${accepted.length})`,
|
|
1981
|
+
"",
|
|
1982
|
+
"These findings remain in the source and were accepted by explicit policy; they were not fixed.",
|
|
1983
|
+
"",
|
|
1984
|
+
"| Rule | Location | Reason | Expires |",
|
|
1985
|
+
"| --- | --- | --- | --- |"
|
|
1986
|
+
);
|
|
1987
|
+
for (const finding of accepted) lines.push(`| ${finding.type} | ${markdownCell(`${finding.file}:${finding.lineNumber}`)} | ${markdownCell(finding.suppression.reason)} | ${markdownCell(finding.suppression.expires)} |`);
|
|
1988
|
+
}
|
|
1989
|
+
if (result.expiredExceptionCount) lines.push("", `${result.expiredExceptionCount} expired exceptions were not applied; review their findings.`);
|
|
1990
|
+
return lines;
|
|
1640
1991
|
}
|
|
1641
1992
|
function formatCheckTerminal(result) {
|
|
1642
1993
|
const lines = [
|
|
@@ -1645,10 +1996,11 @@ function formatCheckTerminal(result) {
|
|
|
1645
1996
|
` ${result.filesInspected} files \xB7 ${result.linesInspected} lines inspected`,
|
|
1646
1997
|
` ${result.infractions.length} findings to review \xB7 ${result.baselineCount} accepted baseline findings`
|
|
1647
1998
|
];
|
|
1999
|
+
lines.push(...exceptionTerminal(result));
|
|
1648
2000
|
if (result.staleBaselineCount) lines.push(` ${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
|
|
1649
2001
|
for (const inf of result.infractions.slice(0, 20)) {
|
|
1650
2002
|
lines.push("", ` ${result.failedRules.includes(inf.type) ? pc.red("FAIL") : pc.yellow("WARN")} [${inf.type}] ${terminalText(inf.file || "unknown")}:${inf.lineNumber}`, ` ${terminalText(inf.lineSnippet || inf.reason)}`);
|
|
1651
|
-
if (inf.suggestion) lines.push(` Next: ${inf.suggestion}`);
|
|
2003
|
+
if (inf.suggestion) lines.push(` Next: ${terminalText(inf.suggestion)}`);
|
|
1652
2004
|
}
|
|
1653
2005
|
if (result.infractions.length > 20) lines.push(`
|
|
1654
2006
|
${result.infractions.length - 20} more findings. Use --format json for the complete report.`);
|
|
@@ -1659,7 +2011,7 @@ function formatCheckTerminal(result) {
|
|
|
1659
2011
|
return lines.join("\n");
|
|
1660
2012
|
}
|
|
1661
2013
|
function markdownCell(value) {
|
|
1662
|
-
return value.replace(/[\\[\]
|
|
2014
|
+
return terminalText(value).replace(/[\\[\]*_!]/g, "\\$&").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\|/g, "\\|").replace(/`/g, "`").replace(/@/g, "@");
|
|
1663
2015
|
}
|
|
1664
2016
|
function formatCheckMarkdown(result) {
|
|
1665
2017
|
const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
|
|
@@ -1672,6 +2024,7 @@ function formatCheckMarkdown(result) {
|
|
|
1672
2024
|
"| --- | --- | --- | --- |"
|
|
1673
2025
|
];
|
|
1674
2026
|
for (const inf of result.infractions) lines.push(`| ${inf.type} | ${markdownCell(`${inf.file}:${inf.lineNumber}`)} | ${markdownCell(inf.lineSnippet || inf.reason)} | ${markdownCell(inf.suggestion || "Review this occurrence.")} |`);
|
|
2027
|
+
lines.push(...exceptionMarkdown(result));
|
|
1675
2028
|
if (result.staleBaselineCount) lines.push("", `${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
|
|
1676
2029
|
for (const skipped of result.skippedFiles) lines.push("", `Not scanned: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
|
|
1677
2030
|
return lines.join("\n");
|
|
@@ -1685,6 +2038,7 @@ function formatReviewTerminal(result) {
|
|
|
1685
2038
|
` ${result.filesInspected} changed files compared \xB7 ${result.linesInspected} source lines inspected across both versions`,
|
|
1686
2039
|
` ${result.infractions.length} new \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected`
|
|
1687
2040
|
];
|
|
2041
|
+
lines.push(...exceptionTerminal(result));
|
|
1688
2042
|
if (result.policyChanges.length) lines.push(" Candidate policy changes were ignored; adopt them separately after review.");
|
|
1689
2043
|
if (result.excludedFiles || result.unsupportedFiles) lines.push(` ${result.excludedFiles} excluded \xB7 ${result.unsupportedFiles} unsupported/non-source changes`);
|
|
1690
2044
|
for (const finding of result.infractions.slice(0, 20)) {
|
|
@@ -1692,7 +2046,7 @@ function formatReviewTerminal(result) {
|
|
|
1692
2046
|
"",
|
|
1693
2047
|
` ${result.failedRules.includes(finding.type) ? "FAIL" : "WARN"} [${finding.type}] ${terminalText(finding.file || "unknown")}:${finding.lineNumber}`,
|
|
1694
2048
|
` ${terminalText(finding.lineSnippet || finding.reason)}`,
|
|
1695
|
-
` Next: ${finding.suggestion || "Review this occurrence."}`
|
|
2049
|
+
` Next: ${terminalText(finding.suggestion || "Review this occurrence.")}`
|
|
1696
2050
|
);
|
|
1697
2051
|
}
|
|
1698
2052
|
if (result.infractions.length > 20) lines.push(` ${result.infractions.length - 20} more new findings; use --format json for all results.`);
|
|
@@ -1714,6 +2068,7 @@ function formatReviewMarkdown(result) {
|
|
|
1714
2068
|
if (result.policyChanges.length) lines.push("Candidate policy changes were ignored. This review uses the supplied base commit's policy.", "");
|
|
1715
2069
|
lines.push("| New finding | Head location | Evidence | Next step |", "| --- | --- | --- | --- |");
|
|
1716
2070
|
for (const finding of result.infractions) lines.push(`| ${finding.type} | ${markdownCell(`${finding.file}:${finding.lineNumber}`)} | ${markdownCell(finding.lineSnippet || finding.reason)} | ${markdownCell(finding.suggestion || "Review this occurrence.")} |`);
|
|
2071
|
+
lines.push(...exceptionMarkdown(result));
|
|
1717
2072
|
if (result.removedFindings.length) {
|
|
1718
2073
|
lines.push(
|
|
1719
2074
|
"",
|
|
@@ -1732,8 +2087,10 @@ function formatReviewMarkdown(result) {
|
|
|
1732
2087
|
}
|
|
1733
2088
|
|
|
1734
2089
|
// src/init.ts
|
|
1735
|
-
import * as
|
|
1736
|
-
import * as
|
|
2090
|
+
import * as fs4 from "fs";
|
|
2091
|
+
import * as path3 from "path";
|
|
2092
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
2093
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1737
2094
|
var DEFAULT_CONFIG_TEMPLATE = {
|
|
1738
2095
|
ignorePaths: [
|
|
1739
2096
|
"**/*.min.js",
|
|
@@ -1776,58 +2133,112 @@ var PRE_COMMIT_HOOK_TEMPLATE = `#!/bin/sh
|
|
|
1776
2133
|
# Run agent-roast pre-commit verification
|
|
1777
2134
|
npx --no-install agent-roast --staged
|
|
1778
2135
|
`;
|
|
1779
|
-
|
|
1780
|
-
const
|
|
1781
|
-
const
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
const
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
2136
|
+
function createNewFile(root, relative2, content, mode = 420) {
|
|
2137
|
+
const destination = path3.resolve(root, relative2);
|
|
2138
|
+
const inside = path3.relative(root, destination);
|
|
2139
|
+
if (!inside || inside === ".." || inside.startsWith(".." + path3.sep) || path3.isAbsolute(inside)) throw new ConfigError("Initialization target escapes its allowed directory.");
|
|
2140
|
+
let directory = root;
|
|
2141
|
+
for (const segment of path3.dirname(inside).split(path3.sep).filter((part) => part !== ".")) {
|
|
2142
|
+
directory = path3.join(directory, segment);
|
|
2143
|
+
let stat;
|
|
2144
|
+
try {
|
|
2145
|
+
stat = fs4.lstatSync(directory);
|
|
2146
|
+
} catch (error) {
|
|
2147
|
+
if (error.code !== "ENOENT") throw error;
|
|
2148
|
+
try {
|
|
2149
|
+
fs4.mkdirSync(directory);
|
|
2150
|
+
} catch (mkdirError) {
|
|
2151
|
+
if (mkdirError.code !== "EEXIST") throw mkdirError;
|
|
2152
|
+
}
|
|
2153
|
+
stat = fs4.lstatSync(directory);
|
|
2154
|
+
}
|
|
2155
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new ConfigError("Initialization refuses symlinked or non-directory parent paths.");
|
|
1794
2156
|
}
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
2157
|
+
const temporary = path3.join(directory, `.agent-roast-init-${randomUUID3()}.tmp`);
|
|
2158
|
+
let fd;
|
|
2159
|
+
try {
|
|
2160
|
+
fd = fs4.openSync(temporary, "wx", mode);
|
|
2161
|
+
fs4.writeFileSync(fd, content);
|
|
2162
|
+
fs4.fsyncSync(fd);
|
|
2163
|
+
fs4.closeSync(fd);
|
|
2164
|
+
fd = void 0;
|
|
2165
|
+
try {
|
|
2166
|
+
fs4.linkSync(temporary, destination);
|
|
2167
|
+
} catch (error) {
|
|
2168
|
+
if (error.code === "EEXIST") return false;
|
|
2169
|
+
throw error;
|
|
1804
2170
|
}
|
|
2171
|
+
return true;
|
|
2172
|
+
} finally {
|
|
2173
|
+
if (fd !== void 0) fs4.closeSync(fd);
|
|
2174
|
+
fs4.rmSync(temporary, { force: true });
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
var hasEntry = (filename) => {
|
|
2178
|
+
try {
|
|
2179
|
+
fs4.lstatSync(filename);
|
|
2180
|
+
return true;
|
|
2181
|
+
} catch (error) {
|
|
2182
|
+
if (error.code === "ENOENT") return false;
|
|
2183
|
+
throw error;
|
|
1805
2184
|
}
|
|
2185
|
+
};
|
|
2186
|
+
async function initProject(options = {}) {
|
|
2187
|
+
const cwd = fs4.realpathSync(path3.resolve(options.cwd || process.cwd()));
|
|
2188
|
+
const created = [];
|
|
2189
|
+
const skipped = [];
|
|
2190
|
+
const publish = (root, relative2, label, content, mode) => {
|
|
2191
|
+
(createNewFile(root, relative2, content, mode) ? created : skipped).push(label);
|
|
2192
|
+
};
|
|
2193
|
+
if ([".agentroastrc.json", "agent-roast.config.json", ".agentroastrc"].some((name) => hasEntry(path3.join(cwd, name)))) {
|
|
2194
|
+
skipped.push(".agentroastrc.json");
|
|
2195
|
+
} else publish(cwd, ".agentroastrc.json", ".agentroastrc.json", JSON.stringify(DEFAULT_CONFIG_TEMPLATE, null, 2) + "\n");
|
|
2196
|
+
if (options.ci) publish(cwd, ".github/workflows/agent-roast.yml", ".github/workflows/agent-roast.yml", GITHUB_WORKFLOW_TEMPLATE);
|
|
1806
2197
|
if (options.hook) {
|
|
1807
|
-
const
|
|
1808
|
-
|
|
1809
|
-
|
|
2198
|
+
const run = (...args) => spawnSync2("git", [...HERMETIC_GIT_FLAGS, ...args], {
|
|
2199
|
+
cwd,
|
|
2200
|
+
env: getHermeticGitEnv(),
|
|
2201
|
+
encoding: "utf8",
|
|
2202
|
+
timeout: 3e4,
|
|
2203
|
+
maxBuffer: 8192,
|
|
2204
|
+
windowsHide: true
|
|
2205
|
+
});
|
|
2206
|
+
const common = run("rev-parse", "--git-common-dir");
|
|
2207
|
+
if (common.error || common.status !== 0) {
|
|
2208
|
+
const plainGit = path3.join(cwd, ".git");
|
|
2209
|
+
if (hasEntry(plainGit) && fs4.lstatSync(plainGit).isDirectory() && !fs4.lstatSync(plainGit).isSymbolicLink()) {
|
|
2210
|
+
publish(plainGit, "hooks/pre-commit", ".git/hooks/pre-commit", PRE_COMMIT_HOOK_TEMPLATE, 493);
|
|
2211
|
+
} else skipped.push(".git/hooks/pre-commit (no .git directory found)");
|
|
1810
2212
|
} else {
|
|
1811
|
-
const
|
|
1812
|
-
const
|
|
1813
|
-
if (
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
mode: 493
|
|
1820
|
-
});
|
|
1821
|
-
created.push(".git/hooks/pre-commit");
|
|
1822
|
-
}
|
|
2213
|
+
const commonDir = fs4.realpathSync(path3.resolve(cwd, gitOutputPath(common.stdout)));
|
|
2214
|
+
const hooks = run("rev-parse", "--git-path", "hooks");
|
|
2215
|
+
if (hooks.error || hooks.status !== 0) throw new ConfigError("Cannot resolve this repository's hooks directory.");
|
|
2216
|
+
const hookDir = path3.resolve(cwd, gitOutputPath(hooks.stdout));
|
|
2217
|
+
const relative2 = path3.relative(commonDir, path3.join(hookDir, "pre-commit"));
|
|
2218
|
+
if (!relative2 || relative2 === ".." || relative2.startsWith(".." + path3.sep) || path3.isAbsolute(relative2)) {
|
|
2219
|
+
skipped.push(".git/hooks/pre-commit (custom hooks path outside repository metadata; install manually)");
|
|
2220
|
+
} else publish(commonDir, relative2, ".git/hooks/pre-commit", PRE_COMMIT_HOOK_TEMPLATE, 493);
|
|
1823
2221
|
}
|
|
1824
2222
|
}
|
|
1825
2223
|
return { created, skipped };
|
|
1826
2224
|
}
|
|
1827
2225
|
|
|
1828
2226
|
// src/badge.ts
|
|
2227
|
+
function displayText(value) {
|
|
2228
|
+
return value.replace(
|
|
2229
|
+
/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/g,
|
|
2230
|
+
(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`
|
|
2231
|
+
);
|
|
2232
|
+
}
|
|
2233
|
+
function xmlText(value) {
|
|
2234
|
+
return displayText(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2235
|
+
}
|
|
2236
|
+
function badgeScore(score) {
|
|
2237
|
+
return score !== null && Number.isFinite(score) && score >= 0 && score <= 100 ? score : null;
|
|
2238
|
+
}
|
|
1829
2239
|
function getScoreColor(score) {
|
|
1830
|
-
|
|
2240
|
+
score = badgeScore(score);
|
|
2241
|
+
if (score === null) {
|
|
1831
2242
|
return { name: "lightgrey", hex: "#9f9f9f" };
|
|
1832
2243
|
}
|
|
1833
2244
|
if (score >= 90) return { name: "brightgreen", hex: "#4c1" };
|
|
@@ -1837,14 +2248,16 @@ function getScoreColor(score) {
|
|
|
1837
2248
|
return { name: "red", hex: "#e05d44" };
|
|
1838
2249
|
}
|
|
1839
2250
|
function generateSvgBadge(score, label = "AI Discipline") {
|
|
1840
|
-
|
|
2251
|
+
score = badgeScore(score);
|
|
2252
|
+
const value = score !== null ? `${score}/100` : "N/A";
|
|
1841
2253
|
const color = getScoreColor(score);
|
|
2254
|
+
const safeLabel = xmlText(label);
|
|
1842
2255
|
const labelWidth = Math.round(label.length * 6.8 + 14);
|
|
1843
2256
|
const valueWidth = Math.round(value.length * 7.2 + 14);
|
|
1844
2257
|
const totalWidth = labelWidth + valueWidth;
|
|
1845
2258
|
const labelX = Math.round(labelWidth / 2);
|
|
1846
2259
|
const valueX = Math.round(labelWidth + valueWidth / 2);
|
|
1847
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${
|
|
2260
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${safeLabel}: ${value}">
|
|
1848
2261
|
<linearGradient id="s" x2="0" y2="100%">
|
|
1849
2262
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
1850
2263
|
<stop offset="1" stop-opacity=".1"/>
|
|
@@ -1858,22 +2271,31 @@ function generateSvgBadge(score, label = "AI Discipline") {
|
|
|
1858
2271
|
<rect width="${totalWidth}" height="20" fill="url(#s)"/>
|
|
1859
2272
|
</g>
|
|
1860
2273
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
|
|
1861
|
-
<text aria-hidden="true" x="${labelX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(labelWidth - 14) * 10}">${
|
|
1862
|
-
<text x="${labelX * 10}" y="140" transform="scale(.1)" fill="#fff" textLength="${(labelWidth - 14) * 10}">${
|
|
2274
|
+
<text aria-hidden="true" x="${labelX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(labelWidth - 14) * 10}">${safeLabel}</text>
|
|
2275
|
+
<text x="${labelX * 10}" y="140" transform="scale(.1)" fill="#fff" textLength="${(labelWidth - 14) * 10}">${safeLabel}</text>
|
|
1863
2276
|
<text aria-hidden="true" x="${valueX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(valueWidth - 14) * 10}">${value}</text>
|
|
1864
2277
|
<text x="${valueX * 10}" y="140" transform="scale(.1)" fill="#fff" textLength="${(valueWidth - 14) * 10}">${value}</text>
|
|
1865
2278
|
</g>
|
|
1866
2279
|
</svg>`;
|
|
1867
2280
|
}
|
|
1868
2281
|
function generateShieldsMarkdown(score, label = "AI Discipline", repoUrl = "https://github.com/Nari-Ab/agent-roast") {
|
|
1869
|
-
|
|
2282
|
+
score = badgeScore(score);
|
|
2283
|
+
const value = score !== null ? `${score}%2F100` : "N%2FA";
|
|
1870
2284
|
const color = getScoreColor(score);
|
|
1871
|
-
const
|
|
2285
|
+
const shownLabel = displayText(label);
|
|
2286
|
+
const encodedLabel = encodeURIComponent(shownLabel.replace(/-/g, "--").replace(/_/g, "__"));
|
|
2287
|
+
const alt = shownLabel.replace(/[\\[\]*_!`]/g, "\\$&").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2288
|
+
const destination = new URL(repoUrl);
|
|
2289
|
+
if (!["https:", "http:"].includes(destination.protocol) || destination.username || destination.password) {
|
|
2290
|
+
throw new Error("Badge destination must be an HTTP(S) URL without credentials.");
|
|
2291
|
+
}
|
|
2292
|
+
const safeDestination = destination.href.replace(/[()\\]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
1872
2293
|
const badgeUrl = `https://img.shields.io/badge/${encodedLabel}-${value}-${color.name}?style=flat-square`;
|
|
1873
|
-
return `[](${safeDestination})`;
|
|
1874
2295
|
}
|
|
1875
2296
|
function generateShieldsJson(score, label = "AI Discipline") {
|
|
1876
|
-
|
|
2297
|
+
score = badgeScore(score);
|
|
2298
|
+
const value = score !== null ? `${score}/100` : "N/A";
|
|
1877
2299
|
const color = getScoreColor(score);
|
|
1878
2300
|
return {
|
|
1879
2301
|
schemaVersion: 1,
|
|
@@ -1885,111 +2307,22 @@ function generateShieldsJson(score, label = "AI Discipline") {
|
|
|
1885
2307
|
|
|
1886
2308
|
// src/sarif.ts
|
|
1887
2309
|
import { createRequire } from "module";
|
|
1888
|
-
|
|
1889
|
-
// src/baseline.ts
|
|
1890
|
-
import { createHash, randomUUID } from "crypto";
|
|
1891
|
-
import * as fs3 from "fs";
|
|
1892
|
-
import path3 from "path";
|
|
1893
|
-
function findingFingerprint(finding) {
|
|
1894
|
-
return createHash("sha256").update(JSON.stringify([finding.type, finding.file, finding.lineSnippet?.trim()])).digest("hex");
|
|
1895
|
-
}
|
|
1896
|
-
function createBaseline(findings) {
|
|
1897
|
-
const counts = /* @__PURE__ */ new Map();
|
|
1898
|
-
for (const finding of findings) {
|
|
1899
|
-
const fingerprint = findingFingerprint(finding);
|
|
1900
|
-
counts.set(fingerprint, (counts.get(fingerprint) || 0) + 1);
|
|
1901
|
-
}
|
|
1902
|
-
return {
|
|
1903
|
-
schemaVersion: "1.0.0",
|
|
1904
|
-
fingerprintVersion: 1,
|
|
1905
|
-
entries: [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([fingerprint, count]) => ({ fingerprint, count }))
|
|
1906
|
-
};
|
|
1907
|
-
}
|
|
1908
|
-
function loadBaseline(filename) {
|
|
1909
|
-
try {
|
|
1910
|
-
const stat = fs3.lstatSync(filename);
|
|
1911
|
-
if (!stat.isFile() || stat.size > 5 * 1024 * 1024) throw new Error("Expected a regular baseline file under 5 MiB.");
|
|
1912
|
-
const fd = fs3.openSync(filename, fs3.constants.O_RDONLY | (fs3.constants.O_NOFOLLOW || 0));
|
|
1913
|
-
let content;
|
|
1914
|
-
try {
|
|
1915
|
-
const opened = fs3.fstatSync(fd);
|
|
1916
|
-
if (opened.ino !== stat.ino || opened.dev !== stat.dev) throw new Error("Baseline changed while opening.");
|
|
1917
|
-
const buffer = Buffer.alloc(5 * 1024 * 1024 + 1);
|
|
1918
|
-
let size = 0;
|
|
1919
|
-
while (size < buffer.length) {
|
|
1920
|
-
const read = fs3.readSync(fd, buffer, size, buffer.length - size, null);
|
|
1921
|
-
if (!read) break;
|
|
1922
|
-
size += read;
|
|
1923
|
-
}
|
|
1924
|
-
const after = fs3.fstatSync(fd);
|
|
1925
|
-
if (size > 5 * 1024 * 1024 || size !== after.size || opened.mtimeMs !== after.mtimeMs) throw new Error("Baseline changed or exceeded the size limit.");
|
|
1926
|
-
content = buffer.subarray(0, size).toString("utf8");
|
|
1927
|
-
} finally {
|
|
1928
|
-
fs3.closeSync(fd);
|
|
1929
|
-
}
|
|
1930
|
-
const parsed = JSON.parse(content);
|
|
1931
|
-
if (!parsed || parsed.schemaVersion !== "1.0.0" || parsed.fingerprintVersion !== 1 || !Array.isArray(parsed.entries) || parsed.entries.length > 1e5) {
|
|
1932
|
-
throw new Error("Unsupported baseline schema.");
|
|
1933
|
-
}
|
|
1934
|
-
const seen = /* @__PURE__ */ new Set();
|
|
1935
|
-
for (const entry of parsed.entries) {
|
|
1936
|
-
if (!entry || typeof entry.fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(entry.fingerprint) || !Number.isSafeInteger(entry.count) || entry.count <= 0 || entry.count > 1e6 || seen.has(entry.fingerprint)) {
|
|
1937
|
-
throw new Error("Invalid or duplicate baseline entry.");
|
|
1938
|
-
}
|
|
1939
|
-
seen.add(entry.fingerprint);
|
|
1940
|
-
}
|
|
1941
|
-
return parsed;
|
|
1942
|
-
} catch (error) {
|
|
1943
|
-
throw new ConfigError(`Cannot load baseline '${filename}': ${error instanceof Error ? error.message : String(error)}`);
|
|
1944
|
-
}
|
|
1945
|
-
}
|
|
1946
|
-
function applyBaseline(findings, baseline) {
|
|
1947
|
-
const remaining = new Map(baseline.entries.map((entry) => [entry.fingerprint, entry.count]));
|
|
1948
|
-
const fresh = [];
|
|
1949
|
-
let baselineCount = 0;
|
|
1950
|
-
for (const finding of findings) {
|
|
1951
|
-
const fingerprint = findingFingerprint(finding);
|
|
1952
|
-
const count = remaining.get(fingerprint) || 0;
|
|
1953
|
-
if (count > 0) {
|
|
1954
|
-
remaining.set(fingerprint, count - 1);
|
|
1955
|
-
baselineCount++;
|
|
1956
|
-
} else {
|
|
1957
|
-
fresh.push(finding);
|
|
1958
|
-
}
|
|
1959
|
-
}
|
|
1960
|
-
return { findings: fresh, baselineCount, staleBaselineCount: [...remaining.values()].reduce((a, b) => a + b, 0) };
|
|
1961
|
-
}
|
|
1962
|
-
function writeBaseline(filename, findings) {
|
|
1963
|
-
const destination = path3.resolve(filename);
|
|
1964
|
-
const temporary = path3.join(path3.dirname(destination), `.agent-roast-${randomUUID()}.tmp`);
|
|
1965
|
-
let fd;
|
|
1966
|
-
try {
|
|
1967
|
-
fd = fs3.openSync(temporary, "wx", 384);
|
|
1968
|
-
fs3.writeFileSync(fd, JSON.stringify(createBaseline(findings), null, 2) + "\n");
|
|
1969
|
-
fs3.fsyncSync(fd);
|
|
1970
|
-
fs3.closeSync(fd);
|
|
1971
|
-
fd = void 0;
|
|
1972
|
-
fs3.linkSync(temporary, destination);
|
|
1973
|
-
} catch (error) {
|
|
1974
|
-
throw new ConfigError(`Cannot write baseline '${filename}' (existing files are preserved): ${error instanceof Error ? error.message : String(error)}`);
|
|
1975
|
-
} finally {
|
|
1976
|
-
if (fd !== void 0) fs3.closeSync(fd);
|
|
1977
|
-
fs3.rmSync(temporary, { force: true });
|
|
1978
|
-
}
|
|
1979
|
-
}
|
|
1980
|
-
|
|
1981
|
-
// src/sarif.ts
|
|
1982
2310
|
var require2 = createRequire(import.meta.url);
|
|
1983
2311
|
var version = require2("../package.json").version;
|
|
1984
2312
|
function formatSarif(report) {
|
|
1985
2313
|
const occurrences = /* @__PURE__ */ new Map();
|
|
1986
|
-
const
|
|
2314
|
+
const allFindings = [
|
|
2315
|
+
...report.infractions.map((finding) => ({ finding, suppression: void 0 })),
|
|
2316
|
+
...(report.suppressedFindings ?? []).map((finding) => ({ finding, suppression: finding.suppression }))
|
|
2317
|
+
];
|
|
2318
|
+
allFindings.sort((a, b) => (a.finding.file ?? "").localeCompare(b.finding.file ?? "") || (a.finding.lineNumber ?? 1) - (b.finding.lineNumber ?? 1) || a.finding.type.localeCompare(b.finding.type));
|
|
2319
|
+
const results = allFindings.map(({ finding, suppression }) => {
|
|
1987
2320
|
const filename = finding.file;
|
|
1988
2321
|
if (!filename || filename.startsWith("/") || filename.split("/").includes("..") || /^[a-z]:/i.test(filename)) {
|
|
1989
2322
|
throw new ConfigError("SARIF needs repository-relative finding paths.");
|
|
1990
2323
|
}
|
|
1991
2324
|
const fingerprint = findingFingerprint(finding);
|
|
1992
|
-
const occurrence = (occurrences.get(fingerprint) || 0) + 1;
|
|
2325
|
+
const occurrence = finding.occurrence ?? (occurrences.get(fingerprint) || 0) + 1;
|
|
1993
2326
|
occurrences.set(fingerprint, occurrence);
|
|
1994
2327
|
return {
|
|
1995
2328
|
ruleId: finding.type,
|
|
@@ -2001,6 +2334,11 @@ function formatSarif(report) {
|
|
|
2001
2334
|
region: { startLine: Math.max(1, finding.lineNumber || 1) }
|
|
2002
2335
|
} }],
|
|
2003
2336
|
partialFingerprints: { "agentRoastFinding/v1": `${fingerprint}:${occurrence}` },
|
|
2337
|
+
...suppression ? { suppressions: [{
|
|
2338
|
+
kind: "external",
|
|
2339
|
+
status: "accepted",
|
|
2340
|
+
justification: `${suppression.reason} (expires ${suppression.expires}, UTC)`
|
|
2341
|
+
}] } : {},
|
|
2004
2342
|
...report.mode === "review" ? { baselineState: "new" } : {}
|
|
2005
2343
|
};
|
|
2006
2344
|
});
|
|
@@ -2040,156 +2378,31 @@ Example: ${RULE_GUIDES[rule].example}` },
|
|
|
2040
2378
|
mergeBaseSha: report.mergeBaseSha,
|
|
2041
2379
|
policySha: report.policySha,
|
|
2042
2380
|
unchangedFindings: report.unchangedCount,
|
|
2043
|
-
removedFindings: report.removedFindings.length
|
|
2044
|
-
|
|
2381
|
+
removedFindings: report.removedFindings.length,
|
|
2382
|
+
acceptedExceptions: report.suppressedFindings?.length ?? 0,
|
|
2383
|
+
expiredExceptions: report.expiredExceptionCount ?? 0
|
|
2384
|
+
} : { scope: "current source", acceptedBaselineFindings: report.baselineCount, acceptedExceptions: report.suppressedFindings?.length ?? 0, expiredExceptions: report.expiredExceptionCount ?? 0 }
|
|
2045
2385
|
}]
|
|
2046
2386
|
}, null, 2);
|
|
2047
2387
|
}
|
|
2048
2388
|
|
|
2049
2389
|
// src/check.ts
|
|
2050
|
-
import
|
|
2051
|
-
import * as fs4 from "fs/promises";
|
|
2390
|
+
import * as fs6 from "fs/promises";
|
|
2052
2391
|
import { constants as constants2 } from "fs";
|
|
2053
|
-
import path4 from "path";
|
|
2054
|
-
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
2055
|
-
var MAX_SCAN_BYTES = 50 * 1024 * 1024;
|
|
2056
|
-
var GENERATED_PATHS = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
|
|
2057
|
-
function git(cwd, args) {
|
|
2058
|
-
const result = spawnSync2("git", [...HERMETIC_GIT_FLAGS, ...args], {
|
|
2059
|
-
cwd,
|
|
2060
|
-
env: getHermeticGitEnv(),
|
|
2061
|
-
encoding: "utf8",
|
|
2062
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
2063
|
-
windowsHide: true
|
|
2064
|
-
});
|
|
2065
|
-
if (result.error || result.status !== 0) throw new GitError(result.error?.message || result.stderr, result.status);
|
|
2066
|
-
return result.stdout;
|
|
2067
|
-
}
|
|
2068
|
-
function checkAborted(signal) {
|
|
2069
|
-
if (signal?.aborted) throw new ConfigError("Project check cancelled; no complete result was produced.");
|
|
2070
|
-
}
|
|
2071
|
-
function insideRoot(root, filename) {
|
|
2072
|
-
const relative = path4.relative(root, filename);
|
|
2073
|
-
return relative.length > 0 && relative !== ".." && !relative.startsWith(".." + path4.sep) && !path4.isAbsolute(relative);
|
|
2074
|
-
}
|
|
2075
|
-
async function readSource(root, file, signal) {
|
|
2076
|
-
const filename = path4.resolve(root, file);
|
|
2077
|
-
if (!insideRoot(root, filename)) throw new Error("File escapes the repository.");
|
|
2078
|
-
let stat;
|
|
2079
|
-
try {
|
|
2080
|
-
stat = await fs4.lstat(filename);
|
|
2081
|
-
} catch (error) {
|
|
2082
|
-
if (error.code === "ENOENT") return null;
|
|
2083
|
-
throw error;
|
|
2084
|
-
}
|
|
2085
|
-
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Symlinks and special files are not scanned.");
|
|
2086
|
-
const real = await fs4.realpath(filename);
|
|
2087
|
-
if (!insideRoot(root, real)) throw new Error("File resolves outside the repository.");
|
|
2088
|
-
if (stat.size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
|
|
2089
|
-
const handle = await fs4.open(filename, constants2.O_RDONLY | (constants2.O_NOFOLLOW || 0));
|
|
2090
|
-
try {
|
|
2091
|
-
const opened = await handle.stat();
|
|
2092
|
-
if (opened.ino !== stat.ino || opened.dev !== stat.dev || !opened.isFile()) throw new Error("File changed while opening.");
|
|
2093
|
-
const chunks = [];
|
|
2094
|
-
let size = 0;
|
|
2095
|
-
while (true) {
|
|
2096
|
-
checkAborted(signal);
|
|
2097
|
-
const chunk = Buffer.alloc(Math.min(64 * 1024, MAX_FILE_BYTES + 1 - size));
|
|
2098
|
-
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
|
2099
|
-
if (!bytesRead) break;
|
|
2100
|
-
size += bytesRead;
|
|
2101
|
-
if (size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
|
|
2102
|
-
chunks.push(chunk.subarray(0, bytesRead));
|
|
2103
|
-
}
|
|
2104
|
-
const after = await handle.stat();
|
|
2105
|
-
if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || size !== after.size) throw new Error("File changed while reading; rerun the check.");
|
|
2106
|
-
return Buffer.concat(chunks);
|
|
2107
|
-
} finally {
|
|
2108
|
-
await handle.close();
|
|
2109
|
-
}
|
|
2110
|
-
}
|
|
2111
|
-
async function checkProject(options = {}) {
|
|
2112
|
-
checkAborted(options.signal);
|
|
2113
|
-
const cwd = path4.resolve(options.cwd || process.cwd());
|
|
2114
|
-
const root = await fs4.realpath(git(cwd, ["rev-parse", "--show-toplevel"]).trim());
|
|
2115
|
-
const config = loadConfig(root);
|
|
2116
|
-
const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert", "test-focus"];
|
|
2117
|
-
for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown rule '${rule}'.`);
|
|
2118
|
-
const format = options.format || "terminal";
|
|
2119
|
-
if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported check output format.");
|
|
2120
|
-
const baseline = options.baseline ? loadBaseline(path4.resolve(cwd, options.baseline)) : void 0;
|
|
2121
|
-
const filenames = [...new Set(git(root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"]).split("\0").filter(Boolean))].sort();
|
|
2122
|
-
const infractions = [];
|
|
2123
|
-
const skippedFiles = [];
|
|
2124
|
-
let filesInspected = 0;
|
|
2125
|
-
let linesInspected = 0;
|
|
2126
|
-
let totalBytes = 0;
|
|
2127
|
-
for (const file of filenames) {
|
|
2128
|
-
checkAborted(options.signal);
|
|
2129
|
-
if (!isCodeFile(file, [...GENERATED_PATHS, ...config.ignorePaths || []])) continue;
|
|
2130
|
-
try {
|
|
2131
|
-
const content = await readSource(root, file, options.signal);
|
|
2132
|
-
if (content === null) continue;
|
|
2133
|
-
totalBytes += content.length;
|
|
2134
|
-
if (totalBytes > MAX_SCAN_BYTES) throw new Error("Project exceeds the 50 MiB scan limit.");
|
|
2135
|
-
if (content.includes(0)) throw new Error("Binary content in a source file.");
|
|
2136
|
-
const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
2137
|
-
const lines = text.split(/\r?\n/);
|
|
2138
|
-
if (lines[lines.length - 1] === "") lines.pop();
|
|
2139
|
-
const audit = auditFileDiffs([{ path: file, addedLines: lines.map((line, index) => ({ line, lineNumber: index + 1 })) }], "WORKTREE", "", { rules: config.rules });
|
|
2140
|
-
infractions.push(...audit.infractions);
|
|
2141
|
-
filesInspected++;
|
|
2142
|
-
linesInspected += lines.length;
|
|
2143
|
-
} catch (error) {
|
|
2144
|
-
checkAborted(options.signal);
|
|
2145
|
-
skippedFiles.push({ file, reason: error instanceof Error ? error.message : String(error) });
|
|
2146
|
-
if (totalBytes > MAX_SCAN_BYTES) break;
|
|
2147
|
-
}
|
|
2148
|
-
}
|
|
2149
|
-
checkAborted(options.signal);
|
|
2150
|
-
const applied = baseline ? applyBaseline(infractions, baseline) : { findings: infractions, baselineCount: 0, staleBaselineCount: 0 };
|
|
2151
|
-
const findings = applied.findings;
|
|
2152
|
-
const failedRules = [...new Set(findings.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
|
|
2153
|
-
const complete = skippedFiles.length === 0;
|
|
2154
|
-
const passed = complete && failedRules.length === 0;
|
|
2155
|
-
const result = {
|
|
2156
|
-
schemaVersion: "1.0.0",
|
|
2157
|
-
mode: "check",
|
|
2158
|
-
filesInspected,
|
|
2159
|
-
linesInspected,
|
|
2160
|
-
totalFindings: infractions.length,
|
|
2161
|
-
infractions: findings,
|
|
2162
|
-
baselineCount: applied.baselineCount,
|
|
2163
|
-
staleBaselineCount: applied.staleBaselineCount,
|
|
2164
|
-
skippedFiles,
|
|
2165
|
-
complete,
|
|
2166
|
-
failedRules,
|
|
2167
|
-
passed,
|
|
2168
|
-
exitCode: complete ? passed ? 0 : 1 : 2,
|
|
2169
|
-
output: ""
|
|
2170
|
-
};
|
|
2171
|
-
if (format === "json") result.output = JSON.stringify(result, null, 2);
|
|
2172
|
-
else if (format === "sarif") result.output = formatSarif(result);
|
|
2173
|
-
else if (format === "markdown") result.output = formatCheckMarkdown(result);
|
|
2174
|
-
else if (format === "github") {
|
|
2175
|
-
result.output = formatGithubAnnotations(result) + (complete ? "" : "\n::error::Project scan incomplete. See the JSON or terminal report for skipped files.");
|
|
2176
|
-
} else result.output = formatCheckTerminal(result);
|
|
2177
|
-
return result;
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
|
-
// src/review.ts
|
|
2181
2392
|
import path5 from "path";
|
|
2182
2393
|
|
|
2183
2394
|
// src/snapshot.ts
|
|
2184
2395
|
import { createHash as createHash2 } from "crypto";
|
|
2396
|
+
import * as fs5 from "fs/promises";
|
|
2397
|
+
import path4 from "path";
|
|
2185
2398
|
import { spawn as spawn2 } from "child_process";
|
|
2186
2399
|
function assertNotAborted(signal) {
|
|
2187
2400
|
if (signal?.aborted) throw new ConfigError("Review cancelled; no complete result was produced.");
|
|
2188
2401
|
}
|
|
2189
|
-
async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024) {
|
|
2402
|
+
async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024, indexFile) {
|
|
2190
2403
|
assertNotAborted(signal);
|
|
2191
2404
|
const env = getHermeticGitEnv({ GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0" });
|
|
2192
|
-
|
|
2405
|
+
if (indexFile) env.GIT_INDEX_FILE = indexFile;
|
|
2193
2406
|
return new Promise((resolve2, reject) => {
|
|
2194
2407
|
const child = spawn2("git", [...HERMETIC_GIT_FLAGS, ...args], {
|
|
2195
2408
|
cwd,
|
|
@@ -2228,7 +2441,7 @@ async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024) {
|
|
|
2228
2441
|
if (signal?.aborted) abort();
|
|
2229
2442
|
});
|
|
2230
2443
|
}
|
|
2231
|
-
async function
|
|
2444
|
+
async function requireLocalObjects2(cwd, signal) {
|
|
2232
2445
|
try {
|
|
2233
2446
|
const promises = (await readGit(cwd, ["config", "--type=bool", "--get-regexp", "^remote\\..*\\.promisor$"], signal, 64 * 1024)).toString();
|
|
2234
2447
|
if (promises.split("\n").some((line) => /\s+(?:true|yes|on|1)\s*$/i.test(line))) {
|
|
@@ -2248,7 +2461,7 @@ async function reviewRefs(cwd, base, head, signal) {
|
|
|
2248
2461
|
throw new ConfigError(`Cannot resolve review ref '${ref}'. Use valid commits and fetch complete history (actions/checkout fetch-depth: 0). ${error instanceof Error ? error.message : String(error)}`);
|
|
2249
2462
|
}
|
|
2250
2463
|
};
|
|
2251
|
-
await
|
|
2464
|
+
await requireLocalObjects2(cwd, signal);
|
|
2252
2465
|
const baseSha = await resolve2(base);
|
|
2253
2466
|
const headSha = await resolve2(head);
|
|
2254
2467
|
let ancestors;
|
|
@@ -2303,7 +2516,19 @@ async function reviewPolicy(cwd, baseSha, signal) {
|
|
|
2303
2516
|
return { config: configFromText("{}", "default review policy"), filename: null };
|
|
2304
2517
|
}
|
|
2305
2518
|
async function stagedSnapshot(cwd, signal) {
|
|
2306
|
-
await
|
|
2519
|
+
await requireLocalObjects2(cwd, signal);
|
|
2520
|
+
let indexFile;
|
|
2521
|
+
if (process.env.GIT_INDEX_FILE) {
|
|
2522
|
+
const gitDirectory = await fs5.realpath(gitOutputPath((await readGit(cwd, ["rev-parse", "--absolute-git-dir"], signal, 8192)).toString()));
|
|
2523
|
+
const requested = path4.resolve(cwd, process.env.GIT_INDEX_FILE);
|
|
2524
|
+
const stat = await fs5.lstat(requested);
|
|
2525
|
+
const resolved = await fs5.realpath(requested);
|
|
2526
|
+
const relative2 = path4.relative(gitDirectory, resolved);
|
|
2527
|
+
if (!stat.isFile() || stat.isSymbolicLink() || !relative2 || relative2 === ".." || relative2.startsWith(".." + path4.sep) || path4.isAbsolute(relative2)) {
|
|
2528
|
+
throw new ConfigError("GIT_INDEX_FILE must be a regular candidate index inside this repository's Git directory; refusing to review another index.");
|
|
2529
|
+
}
|
|
2530
|
+
indexFile = resolved;
|
|
2531
|
+
}
|
|
2307
2532
|
const head = async () => {
|
|
2308
2533
|
try {
|
|
2309
2534
|
return (await readGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], signal, 1024)).toString().trim();
|
|
@@ -2336,8 +2561,8 @@ async function stagedSnapshot(cwd, signal) {
|
|
|
2336
2561
|
"--end-of-options",
|
|
2337
2562
|
...baseSha === "EMPTY" ? [] : [baseSha],
|
|
2338
2563
|
"--"
|
|
2339
|
-
], signal);
|
|
2340
|
-
const conflicts = await readGit(cwd, ["ls-files", "--unmerged", "-z", "--full-name"], signal);
|
|
2564
|
+
], signal, 8 * 1024 * 1024, indexFile);
|
|
2565
|
+
const conflicts = await readGit(cwd, ["ls-files", "--unmerged", "-z", "--full-name"], signal, 8 * 1024 * 1024, indexFile);
|
|
2341
2566
|
if (conflicts.length) throw new ConfigError("Resolve unmerged index entries before reviewing staged changes.");
|
|
2342
2567
|
const pinned = await inventory();
|
|
2343
2568
|
const changes = parseTreeChanges(pinned);
|
|
@@ -2356,7 +2581,137 @@ async function stagedSnapshot(cwd, signal) {
|
|
|
2356
2581
|
};
|
|
2357
2582
|
}
|
|
2358
2583
|
|
|
2584
|
+
// src/check.ts
|
|
2585
|
+
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
2586
|
+
var MAX_SCAN_BYTES = 50 * 1024 * 1024;
|
|
2587
|
+
var GENERATED_PATHS = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
|
|
2588
|
+
async function git(cwd, args, signal) {
|
|
2589
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(await readGit(cwd, args, signal, 16 * 1024 * 1024));
|
|
2590
|
+
}
|
|
2591
|
+
function checkAborted(signal) {
|
|
2592
|
+
if (signal?.aborted) throw new ConfigError("Project check cancelled; no complete result was produced.");
|
|
2593
|
+
}
|
|
2594
|
+
function insideRoot(root, filename) {
|
|
2595
|
+
const relative2 = path5.relative(root, filename);
|
|
2596
|
+
return relative2.length > 0 && relative2 !== ".." && !relative2.startsWith(".." + path5.sep) && !path5.isAbsolute(relative2);
|
|
2597
|
+
}
|
|
2598
|
+
async function readSource(root, file, signal) {
|
|
2599
|
+
const filename = path5.resolve(root, file);
|
|
2600
|
+
if (!insideRoot(root, filename)) throw new Error("File escapes the repository.");
|
|
2601
|
+
let stat;
|
|
2602
|
+
try {
|
|
2603
|
+
stat = await fs6.lstat(filename);
|
|
2604
|
+
} catch (error) {
|
|
2605
|
+
if (error.code === "ENOENT") return null;
|
|
2606
|
+
throw error;
|
|
2607
|
+
}
|
|
2608
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Symlinks and special files are not scanned.");
|
|
2609
|
+
const real = await fs6.realpath(filename);
|
|
2610
|
+
if (!insideRoot(root, real)) throw new Error("File resolves outside the repository.");
|
|
2611
|
+
if (stat.size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
|
|
2612
|
+
const handle = await fs6.open(filename, constants2.O_RDONLY | (constants2.O_NOFOLLOW || 0) | (constants2.O_NONBLOCK || 0));
|
|
2613
|
+
try {
|
|
2614
|
+
const opened = await handle.stat();
|
|
2615
|
+
if (opened.ino !== stat.ino || opened.dev !== stat.dev || !opened.isFile()) throw new Error("File changed while opening.");
|
|
2616
|
+
const chunks = [];
|
|
2617
|
+
let size = 0;
|
|
2618
|
+
while (true) {
|
|
2619
|
+
checkAborted(signal);
|
|
2620
|
+
const chunk = Buffer.alloc(Math.min(64 * 1024, MAX_FILE_BYTES + 1 - size));
|
|
2621
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
|
2622
|
+
if (!bytesRead) break;
|
|
2623
|
+
size += bytesRead;
|
|
2624
|
+
if (size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
|
|
2625
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
2626
|
+
}
|
|
2627
|
+
const after = await handle.stat();
|
|
2628
|
+
if (after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs || size !== after.size) throw new Error("File changed while reading; rerun the check.");
|
|
2629
|
+
return Buffer.concat(chunks);
|
|
2630
|
+
} finally {
|
|
2631
|
+
await handle.close();
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
async function checkProject(options = {}) {
|
|
2635
|
+
checkAborted(options.signal);
|
|
2636
|
+
const cwd = path5.resolve(options.cwd || process.cwd());
|
|
2637
|
+
const root = await fs6.realpath(gitOutputPath(await git(cwd, ["rev-parse", "--show-toplevel"], options.signal)));
|
|
2638
|
+
const config = loadConfig(root);
|
|
2639
|
+
const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert", "test-focus"];
|
|
2640
|
+
for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown rule '${rule}'.`);
|
|
2641
|
+
const format = options.format || "terminal";
|
|
2642
|
+
if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported check output format.");
|
|
2643
|
+
const baseline = options.baseline ? loadBaseline(path5.resolve(cwd, options.baseline)) : void 0;
|
|
2644
|
+
const filenames = [...new Set((await git(root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], options.signal)).split("\0").filter(Boolean))].sort();
|
|
2645
|
+
const infractions = [];
|
|
2646
|
+
const skippedFiles = [];
|
|
2647
|
+
let filesInspected = 0;
|
|
2648
|
+
let linesInspected = 0;
|
|
2649
|
+
let totalBytes = 0;
|
|
2650
|
+
for (const file of filenames) {
|
|
2651
|
+
checkAborted(options.signal);
|
|
2652
|
+
if (!isCodeFile(file, [...GENERATED_PATHS, ...config.ignorePaths || []])) continue;
|
|
2653
|
+
try {
|
|
2654
|
+
const content = await readSource(root, file, options.signal);
|
|
2655
|
+
if (content === null) continue;
|
|
2656
|
+
totalBytes += content.length;
|
|
2657
|
+
if (totalBytes > MAX_SCAN_BYTES) throw new Error("Project exceeds the 50 MiB scan limit.");
|
|
2658
|
+
if (content.includes(0)) throw new Error("Binary content in a source file.");
|
|
2659
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
2660
|
+
const lines = text.split(/\r?\n/);
|
|
2661
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
2662
|
+
const audit = auditFileDiffs([{ path: file, addedLines: lines.map((line, index) => ({ line, lineNumber: index + 1 })) }], "WORKTREE", "", { rules: config.rules });
|
|
2663
|
+
infractions.push(...audit.infractions);
|
|
2664
|
+
filesInspected++;
|
|
2665
|
+
linesInspected += lines.length;
|
|
2666
|
+
} catch (error) {
|
|
2667
|
+
checkAborted(options.signal);
|
|
2668
|
+
skippedFiles.push({ file, reason: error instanceof Error ? error.message : String(error) });
|
|
2669
|
+
if (totalBytes > MAX_SCAN_BYTES) break;
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
checkAborted(options.signal);
|
|
2673
|
+
const applied = baseline ? applyBaseline(infractions, baseline) : { findings: infractions, baselineCount: 0, staleBaselineCount: 0 };
|
|
2674
|
+
const notInBaseline = new Set(applied.findings);
|
|
2675
|
+
const acceptedCounts = /* @__PURE__ */ new Map();
|
|
2676
|
+
for (const finding of infractions) {
|
|
2677
|
+
if (notInBaseline.has(finding)) continue;
|
|
2678
|
+
const fingerprint = findingFingerprint(finding);
|
|
2679
|
+
acceptedCounts.set(fingerprint, (acceptedCounts.get(fingerprint) ?? 0) + 1);
|
|
2680
|
+
}
|
|
2681
|
+
const exceptions = applyExceptions(applied.findings, options.ignoreExceptions ? [] : config.exceptions, /* @__PURE__ */ new Date(), acceptedCounts);
|
|
2682
|
+
const findings = exceptions.findings;
|
|
2683
|
+
const failedRules = [...new Set(findings.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
|
|
2684
|
+
const complete = skippedFiles.length === 0;
|
|
2685
|
+
const passed = complete && failedRules.length === 0;
|
|
2686
|
+
const result = {
|
|
2687
|
+
schemaVersion: "1.0.0",
|
|
2688
|
+
mode: "check",
|
|
2689
|
+
filesInspected,
|
|
2690
|
+
linesInspected,
|
|
2691
|
+
totalFindings: infractions.length,
|
|
2692
|
+
infractions: findings,
|
|
2693
|
+
baselineCount: applied.baselineCount,
|
|
2694
|
+
staleBaselineCount: applied.staleBaselineCount,
|
|
2695
|
+
suppressedFindings: exceptions.suppressedFindings,
|
|
2696
|
+
expiredExceptionCount: exceptions.expiredExceptionCount,
|
|
2697
|
+
skippedFiles,
|
|
2698
|
+
complete,
|
|
2699
|
+
failedRules,
|
|
2700
|
+
passed,
|
|
2701
|
+
exitCode: complete ? passed ? 0 : 1 : 2,
|
|
2702
|
+
output: ""
|
|
2703
|
+
};
|
|
2704
|
+
if (format === "json") result.output = JSON.stringify(result, null, 2);
|
|
2705
|
+
else if (format === "sarif") result.output = formatSarif(result);
|
|
2706
|
+
else if (format === "markdown") result.output = formatCheckMarkdown(result);
|
|
2707
|
+
else if (format === "github") {
|
|
2708
|
+
result.output = formatGithubAnnotations(result) + (complete ? "" : "\n::error::Project scan incomplete. See the JSON or terminal report for skipped files.");
|
|
2709
|
+
} else result.output = formatCheckTerminal(result);
|
|
2710
|
+
return result;
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2359
2713
|
// src/review.ts
|
|
2714
|
+
import path6 from "path";
|
|
2360
2715
|
var GENERATED_PATHS2 = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
|
|
2361
2716
|
var MAX_SCAN_BYTES2 = 50 * 1024 * 1024;
|
|
2362
2717
|
function compareFindings(before, after, newPath) {
|
|
@@ -2386,7 +2741,7 @@ function reviewStagedChanges(options = {}) {
|
|
|
2386
2741
|
}
|
|
2387
2742
|
async function compareSourceChanges(options) {
|
|
2388
2743
|
assertNotAborted(options.signal);
|
|
2389
|
-
const cwd =
|
|
2744
|
+
const cwd = path6.resolve(options.cwd || process.cwd());
|
|
2390
2745
|
const staged = options.staged ? await stagedSnapshot(cwd, options.signal) : void 0;
|
|
2391
2746
|
const refs = options.staged ? { baseSha: staged.baseSha, headSha: staged.headSha, mergeBaseSha: staged.mergeBaseSha } : await reviewRefs(cwd, options.base, options.head ?? "HEAD", options.signal);
|
|
2392
2747
|
const policy = refs.baseSha === "EMPTY" ? { config: configFromText("{}", "default first-commit policy"), filename: null } : await reviewPolicy(cwd, refs.baseSha, options.signal);
|
|
@@ -2424,6 +2779,7 @@ async function compareSourceChanges(options) {
|
|
|
2424
2779
|
output: ""
|
|
2425
2780
|
};
|
|
2426
2781
|
let totalBytes = 0;
|
|
2782
|
+
const existingCounts = /* @__PURE__ */ new Map();
|
|
2427
2783
|
for (const change of changes) {
|
|
2428
2784
|
assertNotAborted(options.signal);
|
|
2429
2785
|
const oldSupported = change.oldMode !== "000000" && isCodeFile(change.oldPath);
|
|
@@ -2459,6 +2815,12 @@ async function compareSourceChanges(options) {
|
|
|
2459
2815
|
const before = await scan(change.oldMode, change.oldOid, change.oldPath, refs.mergeBaseSha);
|
|
2460
2816
|
const after = await scan(change.newMode, change.newOid, change.newPath, refs.headSha);
|
|
2461
2817
|
const compared = compareFindings(before, after, change.newPath);
|
|
2818
|
+
const introduced = new Set(compared.introduced);
|
|
2819
|
+
for (const finding of after) {
|
|
2820
|
+
if (introduced.has(finding)) continue;
|
|
2821
|
+
const fingerprint = findingFingerprint(finding);
|
|
2822
|
+
existingCounts.set(fingerprint, (existingCounts.get(fingerprint) ?? 0) + 1);
|
|
2823
|
+
}
|
|
2462
2824
|
result.infractions.push(...compared.introduced);
|
|
2463
2825
|
result.removedFindings.push(...compared.removed);
|
|
2464
2826
|
result.unchangedCount += compared.unchanged;
|
|
@@ -2475,6 +2837,10 @@ async function compareSourceChanges(options) {
|
|
|
2475
2837
|
assertNotAborted(options.signal);
|
|
2476
2838
|
await staged?.verify();
|
|
2477
2839
|
result.complete = result.skippedFiles.length === 0;
|
|
2840
|
+
const exceptions = applyExceptions(result.infractions, options.ignoreExceptions ? [] : policy.config.exceptions, /* @__PURE__ */ new Date(), existingCounts);
|
|
2841
|
+
result.infractions = exceptions.findings;
|
|
2842
|
+
result.suppressedFindings = exceptions.suppressedFindings;
|
|
2843
|
+
result.expiredExceptionCount = exceptions.expiredExceptionCount;
|
|
2478
2844
|
result.failedRules = [...new Set(result.infractions.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
|
|
2479
2845
|
result.passed = result.complete && result.failedRules.length === 0;
|
|
2480
2846
|
result.exitCode = !result.complete ? 2 : result.passed ? 0 : 1;
|
|
@@ -2494,9 +2860,9 @@ var InvalidDetectorError = class extends Error {
|
|
|
2494
2860
|
}
|
|
2495
2861
|
};
|
|
2496
2862
|
async function roastRepository(options = {}) {
|
|
2497
|
-
const cwd =
|
|
2863
|
+
const cwd = path7.resolve(options.cwd || process.cwd());
|
|
2498
2864
|
const since = options.since || "90 days ago";
|
|
2499
|
-
const repoName =
|
|
2865
|
+
const repoName = path7.basename(cwd);
|
|
2500
2866
|
const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths, rules: options.rules, panicLoopWindowMinutes: options.panicLoopWindowMinutes });
|
|
2501
2867
|
const gitStream = streamGitLog(cwd, since);
|
|
2502
2868
|
await parseGitLogStream(gitStream, (commit, stats) => {
|
|
@@ -2511,7 +2877,7 @@ async function roastRepository(options = {}) {
|
|
|
2511
2877
|
return { summary, output };
|
|
2512
2878
|
}
|
|
2513
2879
|
async function auditPullRequest(options) {
|
|
2514
|
-
const cwd =
|
|
2880
|
+
const cwd = path7.resolve(options.cwd || process.cwd());
|
|
2515
2881
|
const format = options.format || "terminal";
|
|
2516
2882
|
const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
|
|
2517
2883
|
for (const det of failOn) {
|
|
@@ -2530,15 +2896,26 @@ async function auditPullRequest(options) {
|
|
|
2530
2896
|
const baseSha = options.staged ? "INDEX" : resolveCommitSha(cwd, options.base, "Base ref");
|
|
2531
2897
|
const headSha = options.staged ? "INDEX" : resolveCommitSha(cwd, options.head || "HEAD", "Head ref");
|
|
2532
2898
|
const mergeBaseSha = options.staged ? "INDEX" : findMergeBase(cwd, baseSha, headSha);
|
|
2899
|
+
if (!["terminal", "json", "github", "markdown"].includes(format)) throw new InvalidDetectorError("Unsupported legacy audit output format.");
|
|
2533
2900
|
const diffStream = streamGitDiff(cwd, mergeBaseSha, headSha, options.staged);
|
|
2534
|
-
|
|
2901
|
+
const chunks = [];
|
|
2902
|
+
let bytes = 0;
|
|
2535
2903
|
await new Promise((resolve2, reject) => {
|
|
2536
2904
|
diffStream.on("data", (chunk) => {
|
|
2537
|
-
|
|
2905
|
+
bytes += chunk.length;
|
|
2906
|
+
if (bytes > 50 * 1024 * 1024) {
|
|
2907
|
+
diffStream.destroy(new GitResolutionError("Legacy diff exceeds the 50 MiB limit; use a smaller context review."));
|
|
2908
|
+
} else chunks.push(chunk);
|
|
2538
2909
|
});
|
|
2539
|
-
diffStream.on("end",
|
|
2910
|
+
diffStream.on("end", resolve2);
|
|
2540
2911
|
diffStream.on("error", reject);
|
|
2541
2912
|
});
|
|
2913
|
+
let rawDiff;
|
|
2914
|
+
try {
|
|
2915
|
+
rawDiff = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
|
|
2916
|
+
} catch {
|
|
2917
|
+
throw new GitResolutionError("Legacy diff contains invalid UTF-8; no complete result was produced.");
|
|
2918
|
+
}
|
|
2542
2919
|
const { files, skippedLongLines, skippedUnknownFiles } = parseDiff(rawDiff);
|
|
2543
2920
|
const audit = auditFileDiffs(files, headSha, (/* @__PURE__ */ new Date()).toISOString(), { ignorePaths: options.ignorePaths, rules: options.rules });
|
|
2544
2921
|
const infractions = audit.infractions;
|
|
@@ -2546,8 +2923,10 @@ async function auditPullRequest(options) {
|
|
|
2546
2923
|
const failedRules = [...new Set(
|
|
2547
2924
|
infractions.filter((inf) => failOn.includes(inf.type)).map((inf) => inf.type)
|
|
2548
2925
|
)];
|
|
2549
|
-
const
|
|
2550
|
-
const
|
|
2926
|
+
const skippedBinaryFiles = files.filter((file) => file.binary && isCodeFile(file.path, options.ignorePaths)).length;
|
|
2927
|
+
const complete = skippedLongLines === 0 && skippedUnknownFiles === 0 && skippedBinaryFiles === 0;
|
|
2928
|
+
const passed = complete && failedRules.length === 0;
|
|
2929
|
+
const exitCode = !complete ? 2 : passed ? 0 : 1;
|
|
2551
2930
|
const result = {
|
|
2552
2931
|
schemaVersion: "1.0.0",
|
|
2553
2932
|
mode: options.staged ? "staged" : "pr",
|
|
@@ -2561,15 +2940,17 @@ async function auditPullRequest(options) {
|
|
|
2561
2940
|
infractions,
|
|
2562
2941
|
failedRules,
|
|
2563
2942
|
passed,
|
|
2943
|
+
complete,
|
|
2564
2944
|
skippedLongLines,
|
|
2565
2945
|
skippedUnknownFiles,
|
|
2946
|
+
skippedBinaryFiles,
|
|
2566
2947
|
output: "",
|
|
2567
2948
|
exitCode
|
|
2568
2949
|
};
|
|
2569
2950
|
if (format === "json") {
|
|
2570
2951
|
result.output = JSON.stringify(result, null, 2);
|
|
2571
2952
|
} else if (format === "github") {
|
|
2572
|
-
result.output = formatGithubAnnotations(result);
|
|
2953
|
+
result.output = formatGithubAnnotations(result) + (complete ? "" : "\n::error::Legacy audit incomplete: long lines, binary-marked or unparsed source were skipped. Use context review.");
|
|
2573
2954
|
} else if (format === "markdown") {
|
|
2574
2955
|
result.output = formatPrMarkdownComment(result);
|
|
2575
2956
|
} else {
|
|
@@ -2579,12 +2960,28 @@ async function auditPullRequest(options) {
|
|
|
2579
2960
|
}
|
|
2580
2961
|
|
|
2581
2962
|
export {
|
|
2963
|
+
VALID_DETECTORS,
|
|
2964
|
+
findingFingerprint,
|
|
2965
|
+
createBaseline,
|
|
2966
|
+
loadBaseline,
|
|
2967
|
+
applyBaseline,
|
|
2968
|
+
writeBaseline,
|
|
2969
|
+
validateExceptions,
|
|
2970
|
+
applyExceptions,
|
|
2971
|
+
MAX_CONFIG_BYTES,
|
|
2972
|
+
DEFAULT_CONFIG,
|
|
2973
|
+
CONFIG_FILENAMES,
|
|
2974
|
+
ConfigError,
|
|
2975
|
+
configFromText,
|
|
2976
|
+
loadConfig,
|
|
2977
|
+
matchIgnorePath,
|
|
2582
2978
|
detectAiAttribution,
|
|
2583
2979
|
RECORD_SEPARATOR,
|
|
2584
2980
|
FIELD_SEPARATOR,
|
|
2585
2981
|
GitError,
|
|
2586
2982
|
HERMETIC_GIT_FLAGS,
|
|
2587
2983
|
getHermeticGitEnv,
|
|
2984
|
+
gitOutputPath,
|
|
2588
2985
|
parseGitLogStream,
|
|
2589
2986
|
unquoteGitPath,
|
|
2590
2987
|
parseDiff,
|
|
@@ -2593,14 +2990,8 @@ export {
|
|
|
2593
2990
|
resolveCommitSha,
|
|
2594
2991
|
findMergeBase,
|
|
2595
2992
|
GitResolutionError,
|
|
2993
|
+
readGit,
|
|
2596
2994
|
RULE_GUIDES,
|
|
2597
|
-
VALID_DETECTORS,
|
|
2598
|
-
DEFAULT_CONFIG,
|
|
2599
|
-
CONFIG_FILENAMES,
|
|
2600
|
-
ConfigError,
|
|
2601
|
-
configFromText,
|
|
2602
|
-
loadConfig,
|
|
2603
|
-
matchIgnorePath,
|
|
2604
2995
|
isCodeFile,
|
|
2605
2996
|
isTestFile,
|
|
2606
2997
|
auditFileDiffs,
|
|
@@ -2612,6 +3003,7 @@ export {
|
|
|
2612
3003
|
formatGithubAnnotations,
|
|
2613
3004
|
formatGithubStepSummary,
|
|
2614
3005
|
formatPrMarkdownComment,
|
|
3006
|
+
terminalText,
|
|
2615
3007
|
formatCheckTerminal,
|
|
2616
3008
|
formatCheckMarkdown,
|
|
2617
3009
|
formatReviewTerminal,
|
|
@@ -2624,11 +3016,6 @@ export {
|
|
|
2624
3016
|
generateSvgBadge,
|
|
2625
3017
|
generateShieldsMarkdown,
|
|
2626
3018
|
generateShieldsJson,
|
|
2627
|
-
findingFingerprint,
|
|
2628
|
-
createBaseline,
|
|
2629
|
-
loadBaseline,
|
|
2630
|
-
applyBaseline,
|
|
2631
|
-
writeBaseline,
|
|
2632
3019
|
formatSarif,
|
|
2633
3020
|
checkProject,
|
|
2634
3021
|
compareFindings,
|