agent-roast 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -7
- package/dist/{chunk-YGHNTWHX.js → chunk-U7OCVA3D.js} +1061 -648
- package/dist/cli.js +56 -31
- package/dist/index.d.ts +47 -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,83 @@ 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;
|
|
579
|
+
tokenPrefix = "";
|
|
580
|
+
parentheses = [];
|
|
553
581
|
python;
|
|
554
582
|
javascript;
|
|
583
|
+
rust;
|
|
555
584
|
hashComments;
|
|
556
585
|
backticks;
|
|
586
|
+
textBlocks;
|
|
587
|
+
csharp;
|
|
588
|
+
cpp;
|
|
557
589
|
constructor(filename) {
|
|
558
|
-
|
|
559
|
-
this.
|
|
560
|
-
this.
|
|
561
|
-
this.
|
|
590
|
+
const name = filename.toLowerCase();
|
|
591
|
+
this.python = name.endsWith(".py");
|
|
592
|
+
this.rust = name.endsWith(".rs");
|
|
593
|
+
this.hashComments = this.python || /\.(?:rb|php)$/.test(name);
|
|
594
|
+
this.backticks = /\.(?:go|[cm]?[jt]sx?|vue|svelte)$/.test(name);
|
|
595
|
+
this.javascript = /\.(?:[cm]?[jt]sx?|vue|svelte)$/.test(name);
|
|
596
|
+
this.textBlocks = /\.(?:java|kt|cs)$/.test(name);
|
|
597
|
+
this.csharp = name.endsWith(".cs");
|
|
598
|
+
this.cpp = /\.(?:cpp|c)$/.test(name);
|
|
562
599
|
}
|
|
563
600
|
mask(line, number) {
|
|
564
601
|
if (number !== this.previousLine + 1) {
|
|
565
|
-
this.
|
|
602
|
+
this.blockDepth = 0;
|
|
566
603
|
this.quote = "";
|
|
604
|
+
this.rawEnd = "";
|
|
567
605
|
this.triple = false;
|
|
606
|
+
this.verbatim = false;
|
|
607
|
+
this.tokenPrefix = "";
|
|
608
|
+
this.parentheses.length = 0;
|
|
568
609
|
}
|
|
569
610
|
this.previousLine = number;
|
|
570
611
|
const code = Array(line.length).fill(" ");
|
|
571
612
|
const comments = Array(line.length).fill(" ");
|
|
572
613
|
let i = 0;
|
|
573
|
-
let prefix =
|
|
614
|
+
let prefix = this.tokenPrefix;
|
|
615
|
+
const appendPrefix = (text) => {
|
|
616
|
+
for (const char of text) {
|
|
617
|
+
if (/\s/.test(char)) {
|
|
618
|
+
if (!prefix.endsWith(" ")) prefix = (prefix + " ").slice(-64);
|
|
619
|
+
} else prefix = (prefix + char).slice(-64);
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
let continued = false;
|
|
623
|
+
const templateStarts = [];
|
|
624
|
+
const blockStarts = [];
|
|
574
625
|
while (i < line.length) {
|
|
575
|
-
if (this.
|
|
576
|
-
if (line.startsWith("
|
|
577
|
-
this.
|
|
626
|
+
if (this.blockDepth) {
|
|
627
|
+
if (this.rust && line.startsWith("/*", i)) {
|
|
628
|
+
this.blockDepth++;
|
|
629
|
+
i += 2;
|
|
630
|
+
} else if (line.startsWith("*/", i)) {
|
|
631
|
+
this.blockDepth--;
|
|
578
632
|
i += 2;
|
|
579
633
|
} else i++;
|
|
580
634
|
continue;
|
|
581
635
|
}
|
|
636
|
+
if (this.rawEnd) {
|
|
637
|
+
if (line.startsWith(this.rawEnd, i)) {
|
|
638
|
+
i += this.rawEnd.length;
|
|
639
|
+
this.rawEnd = "";
|
|
640
|
+
} else i++;
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
582
643
|
if (this.quote) {
|
|
583
|
-
if (
|
|
644
|
+
if (this.verbatim && line.startsWith('""', i)) {
|
|
645
|
+
i += 2;
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
if (!this.verbatim && !(this.quote === "`" && !this.javascript) && line[i] === "\\") {
|
|
649
|
+
if (i === line.length - 1) continued = true;
|
|
584
650
|
i += 2;
|
|
585
651
|
continue;
|
|
586
652
|
}
|
|
@@ -589,6 +655,7 @@ var SourceMasker = class {
|
|
|
589
655
|
i += delimiter.length;
|
|
590
656
|
this.quote = "";
|
|
591
657
|
this.triple = false;
|
|
658
|
+
this.verbatim = false;
|
|
592
659
|
} else i++;
|
|
593
660
|
continue;
|
|
594
661
|
}
|
|
@@ -597,19 +664,53 @@ var SourceMasker = class {
|
|
|
597
664
|
break;
|
|
598
665
|
}
|
|
599
666
|
if (!this.python && line.startsWith("/*", i)) {
|
|
600
|
-
|
|
667
|
+
blockStarts.push(i);
|
|
668
|
+
this.blockDepth = 1;
|
|
669
|
+
i += 2;
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (this.rust && (i === 0 || !/[\w]/.test(line[i - 1]))) {
|
|
673
|
+
const raw = /^(?:br|cr|r)(#{0,255})"/.exec(line.slice(i));
|
|
674
|
+
if (raw) {
|
|
675
|
+
this.rawEnd = '"' + raw[1];
|
|
676
|
+
i += raw[0].length;
|
|
677
|
+
appendPrefix(" literal ");
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (this.cpp && (i === 0 || !/[\w]/.test(line[i - 1]))) {
|
|
682
|
+
const raw = /^(?:u8|u|U|L)?R"([^\s()\\]{0,16})\(/.exec(line.slice(i));
|
|
683
|
+
if (raw) {
|
|
684
|
+
this.rawEnd = ")" + raw[1] + '"';
|
|
685
|
+
i += raw[0].length;
|
|
686
|
+
appendPrefix(" literal ");
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (this.csharp && line.startsWith('@"', i)) {
|
|
691
|
+
this.quote = '"';
|
|
692
|
+
this.verbatim = true;
|
|
601
693
|
i += 2;
|
|
694
|
+
appendPrefix(" literal ");
|
|
602
695
|
continue;
|
|
603
696
|
}
|
|
604
697
|
const char = line[i];
|
|
698
|
+
const lifetime = this.rust && char === "'" && /^'[A-Za-z_][A-Za-z0-9_]*(?![A-Za-z0-9_])/.exec(line.slice(i));
|
|
699
|
+
if (lifetime && line[i + lifetime[0].length] !== "'") {
|
|
700
|
+
for (let j = 0; j < lifetime[0].length; j++) code[i + j] = line[i + j];
|
|
701
|
+
appendPrefix(lifetime[0]);
|
|
702
|
+
i += lifetime[0].length;
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
605
705
|
if (char === '"' || char === "'" || this.backticks && char === "`") {
|
|
606
706
|
this.quote = char;
|
|
607
|
-
|
|
707
|
+
if (this.javascript && char === "`") templateStarts.push(i);
|
|
708
|
+
this.triple = (this.python || this.textBlocks && char === '"') && line.startsWith(char.repeat(3), i);
|
|
608
709
|
i += this.triple ? 3 : 1;
|
|
609
|
-
|
|
710
|
+
appendPrefix(" literal ");
|
|
610
711
|
continue;
|
|
611
712
|
}
|
|
612
|
-
if (this.javascript && char === "/" && (/(
|
|
713
|
+
if (this.javascript && char === "/" && (/(?:=>|[=(:,[!&|?{;]|\breturn|\byield|\bthrow)\s*$/.test(prefix) || !prefix.trim())) {
|
|
613
714
|
let j = i + 1;
|
|
614
715
|
let inClass = false;
|
|
615
716
|
for (; j < line.length; j++) {
|
|
@@ -623,18 +724,27 @@ var SourceMasker = class {
|
|
|
623
724
|
}
|
|
624
725
|
if (j < line.length) {
|
|
625
726
|
j++;
|
|
626
|
-
while (/[a-z]/i.test(line[j]
|
|
727
|
+
while (j < line.length && /[a-z]/i.test(line[j])) j++;
|
|
627
728
|
i = j;
|
|
628
|
-
|
|
729
|
+
appendPrefix(" literal ");
|
|
629
730
|
continue;
|
|
630
731
|
}
|
|
631
732
|
}
|
|
632
733
|
code[i] = char;
|
|
633
|
-
|
|
734
|
+
if (this.javascript && char === "(") {
|
|
735
|
+
const control = /(?<![\w$])(?:if|while|for|with|switch|catch)\s*$/.exec(prefix);
|
|
736
|
+
this.parentheses.push(Boolean(control && !/\.\s*$/.test(prefix.slice(0, control.index))));
|
|
737
|
+
} else if (this.javascript && char === ")" && this.parentheses.pop()) {
|
|
738
|
+
prefix = "; ";
|
|
739
|
+
i++;
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
appendPrefix(char);
|
|
634
743
|
i++;
|
|
635
744
|
}
|
|
636
|
-
if (this.quote !== "`" && !this.triple) this.quote = "";
|
|
637
|
-
|
|
745
|
+
if (this.quote !== "`" && !this.triple && !this.verbatim && !continued) this.quote = "";
|
|
746
|
+
this.tokenPrefix = this.javascript ? (prefix + " ").slice(-64) : "";
|
|
747
|
+
return { code: code.join(""), comments: comments.join(""), templateStarts, blockStarts };
|
|
638
748
|
}
|
|
639
749
|
};
|
|
640
750
|
|
|
@@ -685,8 +795,8 @@ var RULE_GUIDES = {
|
|
|
685
795
|
};
|
|
686
796
|
|
|
687
797
|
// src/config.ts
|
|
688
|
-
import * as
|
|
689
|
-
import * as
|
|
798
|
+
import * as fs3 from "fs";
|
|
799
|
+
import * as path2 from "path";
|
|
690
800
|
|
|
691
801
|
// src/types.ts
|
|
692
802
|
var VALID_DETECTORS = [
|
|
@@ -699,7 +809,146 @@ var VALID_DETECTORS = [
|
|
|
699
809
|
"test-focus"
|
|
700
810
|
];
|
|
701
811
|
|
|
812
|
+
// src/file-read.ts
|
|
813
|
+
import * as fs from "fs";
|
|
814
|
+
function readRegularFile(filename, limit) {
|
|
815
|
+
const stat = fs.lstatSync(filename);
|
|
816
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > limit) throw new Error(`Expected a regular file no larger than ${limit} bytes.`);
|
|
817
|
+
const fd = fs.openSync(filename, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0));
|
|
818
|
+
try {
|
|
819
|
+
const opened = fs.fstatSync(fd);
|
|
820
|
+
if (!opened.isFile() || opened.ino !== stat.ino || opened.dev !== stat.dev) throw new Error("File changed while opening.");
|
|
821
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
822
|
+
let size = 0;
|
|
823
|
+
while (size < buffer.length) {
|
|
824
|
+
const read = fs.readSync(fd, buffer, size, buffer.length - size, null);
|
|
825
|
+
if (!read) break;
|
|
826
|
+
size += read;
|
|
827
|
+
}
|
|
828
|
+
const after = fs.fstatSync(fd);
|
|
829
|
+
if (size > limit || size !== after.size || opened.size !== after.size || opened.mtimeMs !== after.mtimeMs || opened.ctimeMs !== after.ctimeMs) {
|
|
830
|
+
throw new Error("File changed or exceeded the read limit.");
|
|
831
|
+
}
|
|
832
|
+
return buffer.subarray(0, size);
|
|
833
|
+
} finally {
|
|
834
|
+
fs.closeSync(fd);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// src/baseline.ts
|
|
839
|
+
import { createHash, randomUUID as randomUUID2 } from "crypto";
|
|
840
|
+
import * as fs2 from "fs";
|
|
841
|
+
import path from "path";
|
|
842
|
+
function findingFingerprint(finding) {
|
|
843
|
+
return createHash("sha256").update(JSON.stringify([finding.type, finding.file, finding.lineSnippet?.trim()])).digest("hex");
|
|
844
|
+
}
|
|
845
|
+
function createBaseline(findings) {
|
|
846
|
+
const counts = /* @__PURE__ */ new Map();
|
|
847
|
+
for (const finding of findings) {
|
|
848
|
+
const fingerprint = findingFingerprint(finding);
|
|
849
|
+
counts.set(fingerprint, (counts.get(fingerprint) || 0) + 1);
|
|
850
|
+
}
|
|
851
|
+
return {
|
|
852
|
+
schemaVersion: "1.0.0",
|
|
853
|
+
fingerprintVersion: 1,
|
|
854
|
+
entries: [...counts].sort(([a], [b]) => a.localeCompare(b)).map(([fingerprint, count]) => ({ fingerprint, count }))
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
function loadBaseline(filename) {
|
|
858
|
+
try {
|
|
859
|
+
const content = new TextDecoder("utf-8", { fatal: true }).decode(readRegularFile(filename, 5 * 1024 * 1024));
|
|
860
|
+
const parsed = JSON.parse(content);
|
|
861
|
+
if (!parsed || parsed.schemaVersion !== "1.0.0" || parsed.fingerprintVersion !== 1 || !Array.isArray(parsed.entries) || parsed.entries.length > 1e5) {
|
|
862
|
+
throw new Error("Unsupported baseline schema.");
|
|
863
|
+
}
|
|
864
|
+
const seen = /* @__PURE__ */ new Set();
|
|
865
|
+
for (const entry of parsed.entries) {
|
|
866
|
+
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)) {
|
|
867
|
+
throw new Error("Invalid or duplicate baseline entry.");
|
|
868
|
+
}
|
|
869
|
+
seen.add(entry.fingerprint);
|
|
870
|
+
}
|
|
871
|
+
return parsed;
|
|
872
|
+
} catch (error) {
|
|
873
|
+
throw new ConfigError(`Cannot load baseline '${filename}': ${error instanceof Error ? error.message : String(error)}`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
function applyBaseline(findings, baseline) {
|
|
877
|
+
const remaining = new Map(baseline.entries.map((entry) => [entry.fingerprint, entry.count]));
|
|
878
|
+
const fresh = [];
|
|
879
|
+
let baselineCount = 0;
|
|
880
|
+
for (const finding of findings) {
|
|
881
|
+
const fingerprint = findingFingerprint(finding);
|
|
882
|
+
const count = remaining.get(fingerprint) || 0;
|
|
883
|
+
if (count > 0) {
|
|
884
|
+
remaining.set(fingerprint, count - 1);
|
|
885
|
+
baselineCount++;
|
|
886
|
+
} else {
|
|
887
|
+
fresh.push(finding);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return { findings: fresh, baselineCount, staleBaselineCount: [...remaining.values()].reduce((a, b) => a + b, 0) };
|
|
891
|
+
}
|
|
892
|
+
function writeBaseline(filename, findings) {
|
|
893
|
+
const destination = path.resolve(filename);
|
|
894
|
+
const temporary = path.join(path.dirname(destination), `.agent-roast-${randomUUID2()}.tmp`);
|
|
895
|
+
let fd;
|
|
896
|
+
try {
|
|
897
|
+
const baseline = createBaseline(findings);
|
|
898
|
+
const serialized = JSON.stringify(baseline, null, 2) + "\n";
|
|
899
|
+
if (Buffer.byteLength(serialized, "utf8") > 5 * 1024 * 1024 || baseline.entries.length > 1e5 || baseline.entries.some((entry) => entry.count > 1e6)) {
|
|
900
|
+
throw new Error("Baseline exceeds the supported read limits; narrow the adopted scope.");
|
|
901
|
+
}
|
|
902
|
+
fd = fs2.openSync(temporary, "wx", 384);
|
|
903
|
+
fs2.writeFileSync(fd, serialized);
|
|
904
|
+
fs2.fsyncSync(fd);
|
|
905
|
+
fs2.closeSync(fd);
|
|
906
|
+
fd = void 0;
|
|
907
|
+
fs2.linkSync(temporary, destination);
|
|
908
|
+
} catch (error) {
|
|
909
|
+
throw new ConfigError(`Cannot write baseline '${filename}' (existing files are preserved): ${error instanceof Error ? error.message : String(error)}`);
|
|
910
|
+
} finally {
|
|
911
|
+
if (fd !== void 0) fs2.closeSync(fd);
|
|
912
|
+
fs2.rmSync(temporary, { force: true });
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// src/exceptions.ts
|
|
917
|
+
function validateExceptions(value) {
|
|
918
|
+
if (value === void 0) return;
|
|
919
|
+
if (!Array.isArray(value) || value.length > 1e3) throw new ConfigError("exceptions must be an array with at most 1000 entries.");
|
|
920
|
+
const seen = /* @__PURE__ */ new Set();
|
|
921
|
+
for (const entry of value) {
|
|
922
|
+
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) {
|
|
923
|
+
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).");
|
|
924
|
+
}
|
|
925
|
+
seen.add(entry.fingerprint);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
function applyExceptions(findings, exceptions = [], now = /* @__PURE__ */ new Date(), priorOccurrences = /* @__PURE__ */ new Map()) {
|
|
929
|
+
validateExceptions(exceptions);
|
|
930
|
+
if (!Number.isFinite(now.getTime())) throw new ConfigError("Invalid exception evaluation time.");
|
|
931
|
+
const today = now.toISOString().slice(0, 10);
|
|
932
|
+
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)) }]));
|
|
933
|
+
const occurrences = new Map(priorOccurrences);
|
|
934
|
+
const actionable = [];
|
|
935
|
+
const suppressedFindings = [];
|
|
936
|
+
for (const finding of findings) {
|
|
937
|
+
const fingerprint = findingFingerprint(finding);
|
|
938
|
+
const occurrence = (occurrences.get(fingerprint) ?? 0) + 1;
|
|
939
|
+
occurrences.set(fingerprint, occurrence);
|
|
940
|
+
const enriched = { ...finding, fingerprint, occurrence };
|
|
941
|
+
const allowance = active.get(fingerprint);
|
|
942
|
+
if (allowance && allowance.remaining > 0) {
|
|
943
|
+
allowance.remaining--;
|
|
944
|
+
suppressedFindings.push({ ...enriched, suppression: { reason: allowance.reason, expires: allowance.expires } });
|
|
945
|
+
} else actionable.push(enriched);
|
|
946
|
+
}
|
|
947
|
+
return { findings: actionable, suppressedFindings, expiredExceptionCount: exceptions.filter((entry) => entry.expires < today).length };
|
|
948
|
+
}
|
|
949
|
+
|
|
702
950
|
// src/config.ts
|
|
951
|
+
var MAX_CONFIG_BYTES = 64 * 1024;
|
|
703
952
|
var DEFAULT_CONFIG = {
|
|
704
953
|
failOn: ["test-skip", "trivial-assert", "test-focus"],
|
|
705
954
|
ignorePaths: [
|
|
@@ -738,12 +987,16 @@ function validateConfig(value) {
|
|
|
738
987
|
throw new ConfigError("Configuration must be a JSON object.");
|
|
739
988
|
}
|
|
740
989
|
const config = value;
|
|
990
|
+
const supported = /* @__PURE__ */ new Set(["$schema", "failOn", "ignorePaths", "scoreThreshold", "rules", "panicLoopWindowMinutes", "exceptions"]);
|
|
991
|
+
for (const key of Object.keys(config)) if (!supported.has(key)) throw new ConfigError(`Unknown configuration setting '${key}'.`);
|
|
992
|
+
if (config.$schema !== void 0 && typeof config.$schema !== "string") throw new ConfigError("$schema must be a string.");
|
|
993
|
+
validateExceptions(config.exceptions);
|
|
741
994
|
const knownRules = new Set(VALID_DETECTORS);
|
|
742
995
|
if (config.failOn !== void 0 && (!Array.isArray(config.failOn) || config.failOn.some((rule) => typeof rule !== "string" || !knownRules.has(rule)))) {
|
|
743
996
|
throw new ConfigError(`failOn must contain known rules: ${VALID_DETECTORS.join(", ")}`);
|
|
744
997
|
}
|
|
745
|
-
if (config.ignorePaths !== void 0 && (!Array.isArray(config.ignorePaths) || config.ignorePaths.some((pattern) => typeof pattern !== "string"))) {
|
|
746
|
-
throw new ConfigError("ignorePaths must
|
|
998
|
+
if (config.ignorePaths !== void 0 && (!Array.isArray(config.ignorePaths) || config.ignorePaths.length > 1e3 || config.ignorePaths.some((pattern) => typeof pattern !== "string" || pattern.length > 1024))) {
|
|
999
|
+
throw new ConfigError("ignorePaths must contain at most 1000 strings, each no longer than 1024 characters.");
|
|
747
1000
|
}
|
|
748
1001
|
if (config.scoreThreshold !== void 0 && (typeof config.scoreThreshold !== "number" || !Number.isFinite(config.scoreThreshold) || config.scoreThreshold < 0 || config.scoreThreshold > 100)) {
|
|
749
1002
|
throw new ConfigError("scoreThreshold must be a number between 0 and 100.");
|
|
@@ -756,7 +1009,7 @@ function validateConfig(value) {
|
|
|
756
1009
|
throw new ConfigError("rules must be an object.");
|
|
757
1010
|
}
|
|
758
1011
|
for (const [rule, level] of Object.entries(config.rules)) {
|
|
759
|
-
if (!knownRules.has(rule) || !["error", "warn", "off"].includes(
|
|
1012
|
+
if (!knownRules.has(rule) || typeof level !== "string" || !["error", "warn", "off"].includes(level)) {
|
|
760
1013
|
throw new ConfigError(`Invalid rule '${rule}' or level '${level}'. Use known rules with error, warn, or off.`);
|
|
761
1014
|
}
|
|
762
1015
|
}
|
|
@@ -764,6 +1017,7 @@ function validateConfig(value) {
|
|
|
764
1017
|
}
|
|
765
1018
|
function configFromText(text, source) {
|
|
766
1019
|
try {
|
|
1020
|
+
if (Buffer.byteLength(text, "utf8") > MAX_CONFIG_BYTES) throw new ConfigError("Configuration exceeds the 64 KiB limit.");
|
|
767
1021
|
const parsed = JSON.parse(text);
|
|
768
1022
|
validateConfig(parsed);
|
|
769
1023
|
const rules = { ...DEFAULT_CONFIG.rules, ...parsed.rules };
|
|
@@ -781,10 +1035,16 @@ function configFromText(text, source) {
|
|
|
781
1035
|
}
|
|
782
1036
|
function loadConfig(cwd = process.cwd()) {
|
|
783
1037
|
for (const filename of CONFIG_FILENAMES) {
|
|
784
|
-
const filePath =
|
|
785
|
-
|
|
1038
|
+
const filePath = path2.join(cwd, filename);
|
|
1039
|
+
try {
|
|
1040
|
+
fs3.lstatSync(filePath);
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
if (error.code === "ENOENT") continue;
|
|
1043
|
+
throw new ConfigError(`Cannot inspect ${filename}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1044
|
+
}
|
|
1045
|
+
{
|
|
786
1046
|
try {
|
|
787
|
-
return configFromText(
|
|
1047
|
+
return configFromText(new TextDecoder("utf-8", { fatal: true }).decode(readRegularFile(filePath, MAX_CONFIG_BYTES)), filename);
|
|
788
1048
|
} catch (error) {
|
|
789
1049
|
if (error instanceof ConfigError) throw error;
|
|
790
1050
|
throw new ConfigError(`Cannot read ${filename}: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -793,93 +1053,114 @@ function loadConfig(cwd = process.cwd()) {
|
|
|
793
1053
|
}
|
|
794
1054
|
return configFromText("{}", "default configuration");
|
|
795
1055
|
}
|
|
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] === "*") {
|
|
1056
|
+
function matchesGlob(filename, pattern) {
|
|
1057
|
+
const tokens = [];
|
|
1058
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
1059
|
+
if (pattern[i] === "*" && pattern[i + 1] === "*") {
|
|
1060
|
+
i++;
|
|
1061
|
+
if (pattern[i + 1] === "/") {
|
|
1062
|
+
tokens.push({ kind: "directoryStart" }, { kind: "directoryBody" });
|
|
808
1063
|
i++;
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
1064
|
+
} else tokens.push({ kind: "globstar" });
|
|
1065
|
+
} else if (pattern[i] === "*") tokens.push({ kind: "star" });
|
|
1066
|
+
else if (pattern[i] === "?") tokens.push({ kind: "question" });
|
|
1067
|
+
else tokens.push({ kind: "literal", value: pattern[i].toLowerCase() });
|
|
1068
|
+
}
|
|
1069
|
+
const closure = (states2) => {
|
|
1070
|
+
const queue = [...states2];
|
|
1071
|
+
for (let i = 0; i < queue.length; i++) {
|
|
1072
|
+
const state = queue[i];
|
|
1073
|
+
const kind = tokens[state]?.kind;
|
|
1074
|
+
const add = (next) => {
|
|
1075
|
+
if (!states2.has(next)) {
|
|
1076
|
+
states2.add(next);
|
|
1077
|
+
queue.push(next);
|
|
814
1078
|
}
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
regexPattern += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
1079
|
+
};
|
|
1080
|
+
if (kind === "star" || kind === "globstar") add(state + 1);
|
|
1081
|
+
else if (kind === "directoryStart") {
|
|
1082
|
+
add(state + 1);
|
|
1083
|
+
add(state + 2);
|
|
821
1084
|
}
|
|
822
1085
|
}
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
if (
|
|
836
|
-
|
|
1086
|
+
return states2;
|
|
1087
|
+
};
|
|
1088
|
+
let states = /* @__PURE__ */ new Set();
|
|
1089
|
+
for (let offset = 0; offset <= filename.length; offset++) {
|
|
1090
|
+
if (!offset || filename[offset - 1] === "/") states.add(0);
|
|
1091
|
+
closure(states);
|
|
1092
|
+
if (states.has(tokens.length) && (offset === filename.length || filename[offset] === "/")) return true;
|
|
1093
|
+
if (offset === filename.length) break;
|
|
1094
|
+
const char = filename[offset];
|
|
1095
|
+
const next = /* @__PURE__ */ new Set();
|
|
1096
|
+
for (const state of states) {
|
|
1097
|
+
const token = tokens[state];
|
|
1098
|
+
if (!token) continue;
|
|
1099
|
+
if (token.kind === "literal") {
|
|
1100
|
+
if (token.value === char.toLowerCase()) next.add(state + 1);
|
|
1101
|
+
} else if (token.kind === "question") {
|
|
1102
|
+
if (char !== "/") next.add(state + 1);
|
|
1103
|
+
} else if (token.kind === "star") {
|
|
1104
|
+
if (char !== "/") next.add(state);
|
|
1105
|
+
} else if (token.kind === "globstar") next.add(state);
|
|
1106
|
+
else if (token.kind === "directoryBody") {
|
|
1107
|
+
next.add(state);
|
|
1108
|
+
if (char === "/") next.add(state + 1);
|
|
837
1109
|
}
|
|
838
1110
|
}
|
|
1111
|
+
states = next;
|
|
839
1112
|
}
|
|
840
1113
|
return false;
|
|
841
1114
|
}
|
|
1115
|
+
function matchIgnorePath(filePath, ignorePatterns) {
|
|
1116
|
+
if (!ignorePatterns?.length) return false;
|
|
1117
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
1118
|
+
return ignorePatterns.some((pattern) => Boolean(pattern) && matchesGlob(normalized, pattern.replace(/\\/g, "/")));
|
|
1119
|
+
}
|
|
842
1120
|
|
|
843
1121
|
// src/metrics.ts
|
|
844
|
-
var
|
|
845
|
-
{ regex:
|
|
846
|
-
{ regex:
|
|
847
|
-
{ regex: /@
|
|
848
|
-
{ regex: /\
|
|
849
|
-
{ regex: /\btest\.todo\(/, name: "todo placeholder test" }
|
|
1122
|
+
var PYTHON_TEST_SKIP_PATTERNS = [
|
|
1123
|
+
{ regex: /@pytest\s*\.\s*mark\s*\.\s*skip\b/, name: "skipped pytest (@pytest.mark.skip)" },
|
|
1124
|
+
{ regex: /(?<![\w.])pytest\s*\.\s*skip\s*\(/, name: "imperative pytest skip (pytest.skip)" },
|
|
1125
|
+
{ regex: /@unittest\s*\.\s*skip\s*\(/, name: "skipped unittest (@unittest.skip)" },
|
|
1126
|
+
{ regex: /\bself\s*\.\s*skipTest\s*\(/, name: "imperative unittest skip (self.skipTest)" }
|
|
850
1127
|
];
|
|
851
|
-
var
|
|
852
|
-
var
|
|
1128
|
+
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;
|
|
1129
|
+
var JS_COMMENT_DIRECTIVE_PATTERNS = [
|
|
853
1130
|
{ regex: /^\s*\/\/\s*@ts-ignore\b/, name: "compiler ignore (@ts-ignore)" },
|
|
854
1131
|
{ regex: /^\s*\/\/\s*@ts-expect-error\b/, name: "compiler suppression (@ts-expect-error)" },
|
|
855
1132
|
{ regex: /^\s*\/\/\s*@ts-nocheck\b/, name: "compiler check disable (@ts-nocheck)" },
|
|
856
|
-
{ regex:
|
|
857
|
-
|
|
858
|
-
|
|
1133
|
+
{ regex: /^\s*\/\/\s*eslint-disable(?:-next-line|-line)?\b/, name: "linter suppression (eslint-disable)" }
|
|
1134
|
+
];
|
|
1135
|
+
var PYTHON_COMMENT_DIRECTIVE_PATTERNS = [
|
|
1136
|
+
{ regex: /#\s*type:\s*ignore\b/, name: "python type ignore" },
|
|
1137
|
+
{ regex: /#\s*noqa\b/, name: "python linter suppression (# noqa)" }
|
|
859
1138
|
];
|
|
860
1139
|
var CODE_TYPE_ESCAPE_PATTERNS = [
|
|
861
1140
|
{ regex: /\bas\s+any\b/, name: "type bypass (as any)" },
|
|
862
1141
|
{ regex: /\bas\s+unknown\s+as\b/, name: "double assertion bypass (as unknown as)" },
|
|
863
1142
|
{ regex: /\bas\s+never\b/, name: "type escape (as never)" }
|
|
864
1143
|
];
|
|
865
|
-
var
|
|
866
|
-
{ regex: /
|
|
867
|
-
{ regex: /
|
|
1144
|
+
var JS_TRIVIAL_ASSERT_PATTERNS = [
|
|
1145
|
+
{ 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))" },
|
|
1146
|
+
{ 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))" },
|
|
1147
|
+
{ 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
1148
|
];
|
|
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))" },
|
|
1149
|
+
var PYTHON_TRIVIAL_ASSERT_PATTERNS = [
|
|
872
1150
|
{ regex: /\bassert\s+(?:True|1\s*==\s*1)\s*(?=$|#|,)/, name: "trivial python assert (assert True / 1 == 1)" },
|
|
873
|
-
{ regex:
|
|
874
|
-
{ regex:
|
|
875
|
-
|
|
1151
|
+
{ regex: /\bself\s*\.\s*assertTrue\s*\(\s*(?:True|1)\s*\)/, name: "trivial assert (self.assertTrue(True))" },
|
|
1152
|
+
{ regex: /\bself\s*\.\s*assertEqual\s*\(\s*(?:1\s*,\s*1|True\s*,\s*True)\s*\)/, name: "trivial assert (self.assertEqual(1, 1))" }
|
|
1153
|
+
];
|
|
1154
|
+
var JS_STUB_PATTERNS = [
|
|
1155
|
+
{ regex: /\bthrow\s+new\s+Error\s*\(\s*["'`](?:TODO(?=\s*(?:["'`:]|implement\b))|Not implemented\b|to be implemented\b|stub\b|implement me\b)/i, name: "unimplemented stub (throw new Error('Not implemented'))" }
|
|
876
1156
|
];
|
|
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'))" },
|
|
1157
|
+
var PYTHON_STUB_PATTERNS = [
|
|
879
1158
|
{ regex: /\braise\s+NotImplementedError\b/, name: "unimplemented stub (raise NotImplementedError)" },
|
|
880
|
-
{ regex: /\braise\s+NotImplemented\b/, name: "unimplemented stub (raise NotImplemented)" }
|
|
881
|
-
|
|
882
|
-
|
|
1159
|
+
{ regex: /\braise\s+NotImplemented\b/, name: "unimplemented stub (raise NotImplemented)" }
|
|
1160
|
+
];
|
|
1161
|
+
var RUST_STUB_PATTERNS = [
|
|
1162
|
+
{ regex: /\btodo!\s*\(/, name: "unimplemented rust stub (todo!())" },
|
|
1163
|
+
{ regex: /\bunimplemented!\s*\(/, name: "unimplemented rust stub (unimplemented!())" }
|
|
883
1164
|
];
|
|
884
1165
|
var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
885
1166
|
".ts",
|
|
@@ -929,16 +1210,26 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
929
1210
|
hasSupportedChanges = true;
|
|
930
1211
|
filesInspected++;
|
|
931
1212
|
linesAdded += file.addedLines.length;
|
|
1213
|
+
const javascript = /\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file.path);
|
|
1214
|
+
const typescript = /\.(?:[cm]?tsx?|vue|svelte)$/i.test(file.path);
|
|
1215
|
+
const python = /\.py$/i.test(file.path);
|
|
1216
|
+
const rust = /\.rs$/i.test(file.path);
|
|
1217
|
+
const braceCatches = javascript || /\.(?:java|kt|php|cs|cpp)$/i.test(file.path);
|
|
1218
|
+
const directives = javascript ? JS_COMMENT_DIRECTIVE_PATTERNS : python ? PYTHON_COMMENT_DIRECTIVE_PATTERNS : [];
|
|
1219
|
+
const stubs = javascript ? JS_STUB_PATTERNS : python ? PYTHON_STUB_PATTERNS : rust ? RUST_STUB_PATTERNS : [];
|
|
1220
|
+
const firstFinding = infractions.length;
|
|
932
1221
|
const masker = new SourceMasker(file.path);
|
|
933
1222
|
const maskedLines = [];
|
|
934
1223
|
for (const added of file.addedLines) {
|
|
935
1224
|
const rawLine = added.line;
|
|
936
1225
|
const masked = masker.mask(rawLine, added.lineNumber);
|
|
937
1226
|
const cleanedLine = masked.code;
|
|
938
|
-
|
|
1227
|
+
const testCode = cleanedLine.split("");
|
|
1228
|
+
for (const start of masked.templateStarts) testCode[start] = "";
|
|
1229
|
+
maskedLines.push({ code: cleanedLine, testCode: testCode.join(""), raw: rawLine, number: added.lineNumber });
|
|
939
1230
|
const isComment = !cleanedLine.trim();
|
|
940
|
-
if (!isComment) {
|
|
941
|
-
for (const rule of
|
|
1231
|
+
if (python && !isComment) {
|
|
1232
|
+
for (const rule of PYTHON_TEST_SKIP_PATTERNS) {
|
|
942
1233
|
if (rule.regex.test(cleanedLine)) {
|
|
943
1234
|
infractions.push({
|
|
944
1235
|
type: "test-skip",
|
|
@@ -954,7 +1245,7 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
954
1245
|
}
|
|
955
1246
|
}
|
|
956
1247
|
let foundEscape = false;
|
|
957
|
-
for (const rule of
|
|
1248
|
+
for (const rule of directives) {
|
|
958
1249
|
if (rule.regex.test(masked.comments)) {
|
|
959
1250
|
infractions.push({
|
|
960
1251
|
type: "type-escape",
|
|
@@ -969,40 +1260,12 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
969
1260
|
break;
|
|
970
1261
|
}
|
|
971
1262
|
}
|
|
972
|
-
if (!foundEscape &&
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
infractions.push({
|
|
976
|
-
type: "type-escape",
|
|
977
|
-
file: file.path,
|
|
978
|
-
lineNumber: added.lineNumber,
|
|
979
|
-
lineSnippet: rawLine.trim(),
|
|
980
|
-
commitHash,
|
|
981
|
-
commitDate,
|
|
982
|
-
reason: rule.name
|
|
983
|
-
});
|
|
984
|
-
break;
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
if (!isComment) {
|
|
989
|
-
for (const rule of SWALLOWED_ERROR_PATTERNS) {
|
|
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
|
-
}
|
|
1263
|
+
if (javascript && !foundEscape && masked.blockStarts.some((start) => /^\/\*\s*eslint-disable(?:-next-line|-line)?\b/.test(rawLine.slice(start)))) {
|
|
1264
|
+
infractions.push({ type: "type-escape", file: file.path, lineNumber: added.lineNumber, lineSnippet: rawLine.trim(), commitHash, commitDate, reason: "linter suppression (eslint-disable)" });
|
|
1265
|
+
foundEscape = true;
|
|
1003
1266
|
}
|
|
1004
|
-
if (!isComment && isTestFile(file.path)) {
|
|
1005
|
-
for (const rule of
|
|
1267
|
+
if (python && !isComment && isTestFile(file.path)) {
|
|
1268
|
+
for (const rule of PYTHON_TRIVIAL_ASSERT_PATTERNS) {
|
|
1006
1269
|
if (rule.regex.test(cleanedLine)) {
|
|
1007
1270
|
infractions.push({
|
|
1008
1271
|
type: "trivial-assert",
|
|
@@ -1017,64 +1280,103 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1017
1280
|
}
|
|
1018
1281
|
}
|
|
1019
1282
|
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1283
|
+
}
|
|
1284
|
+
let source = "";
|
|
1285
|
+
let code = "";
|
|
1286
|
+
let raw = "";
|
|
1287
|
+
const offsets = [];
|
|
1288
|
+
for (let i = 0; i < maskedLines.length; i++) {
|
|
1289
|
+
const separator = i ? maskedLines[i].number === maskedLines[i - 1].number + 1 ? "\n" : "\0" : "";
|
|
1290
|
+
source += separator;
|
|
1291
|
+
code += separator;
|
|
1292
|
+
raw += separator;
|
|
1293
|
+
offsets.push(source.length);
|
|
1294
|
+
source += maskedLines[i].testCode;
|
|
1295
|
+
code += maskedLines[i].code;
|
|
1296
|
+
raw += maskedLines[i].raw;
|
|
1297
|
+
}
|
|
1298
|
+
const evidence = (index, length) => {
|
|
1299
|
+
let low = 0;
|
|
1300
|
+
let high = offsets.length;
|
|
1301
|
+
while (low + 1 < high) {
|
|
1302
|
+
const middle = low + high >>> 1;
|
|
1303
|
+
if (offsets[middle] <= index) low = middle;
|
|
1304
|
+
else high = middle;
|
|
1305
|
+
}
|
|
1306
|
+
let end = low;
|
|
1307
|
+
while (end + 1 < offsets.length && offsets[end + 1] < index + length) end++;
|
|
1308
|
+
return { lineNumber: maskedLines[low].number, lineSnippet: maskedLines.slice(low, end + 1).map((line) => line.raw.trim()).join(" ") };
|
|
1309
|
+
};
|
|
1310
|
+
const reportedStubs = /* @__PURE__ */ new Set();
|
|
1311
|
+
for (const rule of stubs) {
|
|
1312
|
+
for (const match of raw.matchAll(new RegExp(rule.regex.source, rule.regex.flags + "g"))) {
|
|
1313
|
+
if (code[match.index] !== raw[match.index]) continue;
|
|
1314
|
+
const location = evidence(match.index, match[0].length);
|
|
1315
|
+
if (reportedStubs.has(location.lineNumber)) continue;
|
|
1316
|
+
reportedStubs.add(location.lineNumber);
|
|
1317
|
+
infractions.push({ type: "stub-abandonment", file: file.path, ...location, commitHash, commitDate, reason: rule.name });
|
|
1036
1318
|
}
|
|
1037
1319
|
}
|
|
1038
|
-
if (
|
|
1039
|
-
|
|
1040
|
-
const
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1320
|
+
if (typescript) {
|
|
1321
|
+
const reported = new Set(infractions.slice(firstFinding).filter((f) => f.type === "type-escape").map((f) => f.lineNumber));
|
|
1322
|
+
for (const rule of CODE_TYPE_ESCAPE_PATTERNS) {
|
|
1323
|
+
for (const match of code.matchAll(new RegExp(rule.regex.source, "g"))) {
|
|
1324
|
+
const location = evidence(match.index, match[0].length);
|
|
1325
|
+
if (reported.has(location.lineNumber)) continue;
|
|
1326
|
+
reported.add(location.lineNumber);
|
|
1327
|
+
infractions.push({ type: "type-escape", file: file.path, ...location, commitHash, commitDate, reason: rule.name });
|
|
1328
|
+
}
|
|
1045
1329
|
}
|
|
1046
|
-
|
|
1047
|
-
|
|
1330
|
+
}
|
|
1331
|
+
if (javascript) {
|
|
1332
|
+
for (const match of source.matchAll(JS_TEST_API)) {
|
|
1048
1333
|
let previous = match.index - 1;
|
|
1049
1334
|
while (previous >= 0 && /\s/.test(source[previous])) previous--;
|
|
1050
1335
|
if (source[previous] === ".") continue;
|
|
1051
|
-
if (
|
|
1336
|
+
if (match[2] && !isTestFile(file.path)) continue;
|
|
1052
1337
|
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++;
|
|
1338
|
+
if (source[match.index + match[0].length] === "" && !/\.\s*each\s*$/.test(match[3])) continue;
|
|
1339
|
+
const focus = match[1] === "only" || match[2] === "fit" || match[2] === "fdescribe";
|
|
1056
1340
|
infractions.push({
|
|
1057
|
-
type: "test-focus",
|
|
1341
|
+
type: focus ? "test-focus" : "test-skip",
|
|
1058
1342
|
file: file.path,
|
|
1059
|
-
|
|
1060
|
-
lineSnippet: maskedLines.slice(row, end + 1).map((line) => line.raw.trim()).join(" "),
|
|
1343
|
+
...evidence(match.index, match[0].length),
|
|
1061
1344
|
commitHash,
|
|
1062
1345
|
commitDate,
|
|
1063
|
-
reason: "focused test or suite excludes other tests from the run"
|
|
1346
|
+
reason: focus ? "focused test or suite excludes other tests from the run" : `skipped, disabled or placeholder test registration (${match[0].replace(/\s/g, "")})`
|
|
1064
1347
|
});
|
|
1065
1348
|
}
|
|
1349
|
+
if (isTestFile(file.path)) {
|
|
1350
|
+
for (const rule of JS_TRIVIAL_ASSERT_PATTERNS) {
|
|
1351
|
+
for (const match of source.matchAll(new RegExp(rule.regex.source, "g"))) {
|
|
1352
|
+
infractions.push({ type: "trivial-assert", file: file.path, ...evidence(match.index, match[0].length), commitHash, commitDate, reason: rule.name });
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1066
1356
|
}
|
|
1067
|
-
|
|
1068
|
-
const
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1357
|
+
if (braceCatches) {
|
|
1358
|
+
for (const match of code.matchAll(/(?<![\w$.])catch\s*(?:\(\s*[^()\s\0][^()\0]*\))?\s*\{\s*\}/g)) {
|
|
1359
|
+
infractions.push({
|
|
1360
|
+
type: "swallowed-error",
|
|
1361
|
+
file: file.path,
|
|
1362
|
+
...evidence(match.index, match[0].length),
|
|
1363
|
+
commitHash,
|
|
1364
|
+
commitDate,
|
|
1365
|
+
reason: match[0].includes("\n") ? "empty multi-line catch block" : "empty catch block (catch {})"
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
for (let index = 0; index < maskedLines.length; index++) {
|
|
1370
|
+
const start = maskedLines[index];
|
|
1371
|
+
const inlineExcept = python && /^\s*except(?:\*?\s+[^:]+)?:\s*pass\s*;?\s*$/.test(start.code);
|
|
1372
|
+
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)" });
|
|
1373
|
+
const isExcept = python && /^\s*except(?:\*?\s+[^:]+)?:\s*$/.test(start.code);
|
|
1374
|
+
if (!isExcept) continue;
|
|
1072
1375
|
let next = index + 1;
|
|
1073
1376
|
while (next < maskedLines.length && maskedLines[next].number === maskedLines[next - 1].number + 1 && !maskedLines[next].code.trim()) next++;
|
|
1074
1377
|
const end = maskedLines[next];
|
|
1075
1378
|
if (!end || end.number !== maskedLines[next - 1].number + 1) continue;
|
|
1076
|
-
|
|
1077
|
-
let closesExcept = isExcept && /^\s*pass\s*$/.test(end.code);
|
|
1379
|
+
let closesExcept = isExcept && /^\s*pass\s*;?\s*$/.test(end.code);
|
|
1078
1380
|
if (closesExcept) {
|
|
1079
1381
|
const indent = start.raw.search(/\S/);
|
|
1080
1382
|
if (end.raw.search(/\S/) <= indent) closesExcept = false;
|
|
@@ -1082,16 +1384,18 @@ function auditFileDiffs(files, commitHash = "HEAD", commitDate = (/* @__PURE__ *
|
|
|
1082
1384
|
while (after < maskedLines.length && maskedLines[after].number === maskedLines[after - 1].number + 1 && !maskedLines[after].code.trim()) after++;
|
|
1083
1385
|
if (after < maskedLines.length && maskedLines[after].number === maskedLines[after - 1].number + 1 && maskedLines[after].raw.search(/\S/) > indent) closesExcept = false;
|
|
1084
1386
|
}
|
|
1085
|
-
if (
|
|
1387
|
+
if (closesExcept) infractions.push({
|
|
1086
1388
|
type: "swallowed-error",
|
|
1087
1389
|
file: file.path,
|
|
1088
1390
|
lineNumber: start.number,
|
|
1089
1391
|
lineSnippet: maskedLines.slice(index, next + 1).map((line) => line.raw.trim()).join(" "),
|
|
1090
1392
|
commitHash,
|
|
1091
1393
|
commitDate,
|
|
1092
|
-
reason:
|
|
1394
|
+
reason: "silent multi-line python error swallow"
|
|
1093
1395
|
});
|
|
1094
1396
|
}
|
|
1397
|
+
const ordered = infractions.splice(firstFinding).sort((left, right) => (left.lineNumber || 0) - (right.lineNumber || 0));
|
|
1398
|
+
for (const finding of ordered) infractions.push(finding);
|
|
1095
1399
|
}
|
|
1096
1400
|
return { infractions: infractions.filter((inf) => options.rules?.[inf.type] !== "off").map((inf) => ({ ...inf, suggestion: RULE_GUIDES[inf.type].suggestion })), hasSupportedChanges, filesInspected, linesAdded };
|
|
1097
1401
|
}
|
|
@@ -1115,6 +1419,7 @@ var MetricsCollector = class {
|
|
|
1115
1419
|
stubAbandonments = [];
|
|
1116
1420
|
skippedLongLines = 0;
|
|
1117
1421
|
skippedUnknownFiles = 0;
|
|
1422
|
+
skippedBinaryFiles = 0;
|
|
1118
1423
|
activePanicChain = null;
|
|
1119
1424
|
includeAll;
|
|
1120
1425
|
ignorePaths;
|
|
@@ -1159,6 +1464,7 @@ var MetricsCollector = class {
|
|
|
1159
1464
|
this.flushPanicChain();
|
|
1160
1465
|
return;
|
|
1161
1466
|
}
|
|
1467
|
+
this.skippedBinaryFiles += commit.files.filter((file) => file.binary && isCodeFile(file.path, this.ignorePaths)).length;
|
|
1162
1468
|
const subject = commit.message.split("\n")[0];
|
|
1163
1469
|
const commitDateMs = Date.parse(commit.date);
|
|
1164
1470
|
const isFixCommit = PANIC_COMMIT_REGEX.test(subject);
|
|
@@ -1231,7 +1537,8 @@ var MetricsCollector = class {
|
|
|
1231
1537
|
getSummary() {
|
|
1232
1538
|
this.flushPanicChain();
|
|
1233
1539
|
const linesSample = this.includeAll ? this.totalLinesAdded : this.aiLinesAdded;
|
|
1234
|
-
const
|
|
1540
|
+
const complete = !this.skippedLongLines && !this.skippedUnknownFiles && !this.skippedBinaryFiles;
|
|
1541
|
+
const isSufficientData = complete && linesSample >= MIN_SAMPLE_LOC;
|
|
1235
1542
|
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
1543
|
let panicInfractionPoints = 0;
|
|
1237
1544
|
for (const loop of this.panicLoops) {
|
|
@@ -1294,7 +1601,12 @@ var MetricsCollector = class {
|
|
|
1294
1601
|
}
|
|
1295
1602
|
}
|
|
1296
1603
|
}
|
|
1604
|
+
if (!complete) {
|
|
1605
|
+
archetype = "Incomplete audit";
|
|
1606
|
+
archetypeDescription = "Source was skipped; findings are partial and no complete score can be issued.";
|
|
1607
|
+
}
|
|
1297
1608
|
return {
|
|
1609
|
+
complete,
|
|
1298
1610
|
schemaVersion: "1.0.0",
|
|
1299
1611
|
totalLinesAdded: this.totalLinesAdded,
|
|
1300
1612
|
aiLinesAdded: this.aiLinesAdded,
|
|
@@ -1313,14 +1625,22 @@ var MetricsCollector = class {
|
|
|
1313
1625
|
archetype,
|
|
1314
1626
|
archetypeDescription,
|
|
1315
1627
|
skippedLongLines: this.skippedLongLines,
|
|
1316
|
-
skippedUnknownFiles: this.skippedUnknownFiles
|
|
1628
|
+
skippedUnknownFiles: this.skippedUnknownFiles,
|
|
1629
|
+
skippedBinaryFiles: this.skippedBinaryFiles
|
|
1317
1630
|
};
|
|
1318
1631
|
}
|
|
1319
1632
|
};
|
|
1320
1633
|
|
|
1321
1634
|
// src/renderer.ts
|
|
1322
1635
|
import pc from "picocolors";
|
|
1636
|
+
function historyIncomplete(summary) {
|
|
1637
|
+
return Boolean(summary.skippedLongLines || summary.skippedUnknownFiles || summary.skippedBinaryFiles);
|
|
1638
|
+
}
|
|
1323
1639
|
function generateShareUrl(summary) {
|
|
1640
|
+
if (historyIncomplete(summary)) {
|
|
1641
|
+
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";
|
|
1642
|
+
return `https://twitter.com/intent/tweet?text=${encodeURIComponent(text2)}`;
|
|
1643
|
+
}
|
|
1324
1644
|
const scoreDisplay = summary.score !== null ? `${summary.score}/100` : "N/A";
|
|
1325
1645
|
const text = `My AI coding agent scored ${scoreDisplay} on agent-roast.
|
|
1326
1646
|
|
|
@@ -1335,27 +1655,32 @@ Audit your repo: npx agent-roast`;
|
|
|
1335
1655
|
}
|
|
1336
1656
|
function formatTerminalCard(summary, options) {
|
|
1337
1657
|
const lines = [];
|
|
1658
|
+
const incomplete = historyIncomplete(summary);
|
|
1338
1659
|
lines.push("");
|
|
1339
1660
|
lines.push(
|
|
1340
|
-
`${pc.bold(pc.cyan("agent-roast"))} ${pc.dim("\u2014 git discipline audit")} ${pc.white(options.repoName)}`
|
|
1661
|
+
`${pc.bold(pc.cyan("agent-roast"))} ${pc.dim("\u2014 git discipline audit")} ${pc.white(terminalText(options.repoName))}`
|
|
1341
1662
|
);
|
|
1342
1663
|
lines.push(
|
|
1343
1664
|
` ${pc.dim("commits:")} ${pc.yellow(summary.aiCommits)} AI / ${summary.totalCommits} total ${pc.dim("lines added:")} ${pc.green(summary.aiLinesAdded)} AI / ${summary.totalLinesAdded} total`
|
|
1344
1665
|
);
|
|
1345
1666
|
lines.push("");
|
|
1346
|
-
if (
|
|
1667
|
+
if (incomplete) {
|
|
1668
|
+
lines.push(pc.yellow(" INCOMPLETE: long lines, unparsed paths or binary source were skipped; no complete score issued."));
|
|
1669
|
+
lines.push(pc.dim(` Skipped: ${summary.skippedLongLines || 0} long lines \xB7 ${summary.skippedUnknownFiles || 0} unparsed files \xB7 ${summary.skippedBinaryFiles || 0} binary source files.`));
|
|
1670
|
+
lines.push("");
|
|
1671
|
+
} else if (!summary.isSufficientData || summary.score === null) {
|
|
1347
1672
|
lines.push(pc.yellow(` insufficient data (< 200 analyzed lines added)`));
|
|
1348
1673
|
lines.push(pc.dim(" Findings below remain actionable; a score needs a larger sample."));
|
|
1349
1674
|
lines.push("");
|
|
1350
1675
|
}
|
|
1351
|
-
if (summary.isSufficientData && summary.score !== null) {
|
|
1676
|
+
if (!incomplete && summary.isSufficientData && summary.score !== null) {
|
|
1352
1677
|
let scoreColor = pc.green;
|
|
1353
1678
|
if (summary.score < 60) scoreColor = pc.red;
|
|
1354
1679
|
else if (summary.score < 80) scoreColor = pc.yellow;
|
|
1355
1680
|
lines.push(
|
|
1356
|
-
` ${pc.bold("SCORE:")} ${scoreColor(pc.bold(`${summary.score} / 100`))} ${pc.dim("\xB7")} ${pc.magenta(pc.bold(summary.archetype))}`
|
|
1681
|
+
` ${pc.bold("SCORE:")} ${scoreColor(pc.bold(`${summary.score} / 100`))} ${pc.dim("\xB7")} ${pc.magenta(pc.bold(terminalText(summary.archetype)))}`
|
|
1357
1682
|
);
|
|
1358
|
-
lines.push(` ${pc.dim(summary.archetypeDescription)}`);
|
|
1683
|
+
lines.push(` ${pc.dim(terminalText(summary.archetypeDescription))}`);
|
|
1359
1684
|
lines.push("");
|
|
1360
1685
|
}
|
|
1361
1686
|
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 +1691,7 @@ function formatTerminalCard(summary, options) {
|
|
|
1366
1691
|
` ${pc.magenta("\u25CF")} ${pc.bold("panic-loop")} (${summary.panicLoops.length}) ${pc.dim("rapid consecutive fixes touching overlapping files")}`
|
|
1367
1692
|
);
|
|
1368
1693
|
const sample = summary.panicLoops[0];
|
|
1369
|
-
lines.push(` ${pc.dim(sample.reason)}`);
|
|
1694
|
+
lines.push(` ${pc.dim(terminalText(sample.reason))}`);
|
|
1370
1695
|
}
|
|
1371
1696
|
if (summary.trivialAsserts && summary.trivialAsserts.length > 0) {
|
|
1372
1697
|
lines.push(
|
|
@@ -1374,7 +1699,7 @@ function formatTerminalCard(summary, options) {
|
|
|
1374
1699
|
);
|
|
1375
1700
|
const sample = summary.trivialAsserts[0];
|
|
1376
1701
|
if (sample.file) {
|
|
1377
|
-
lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
|
|
1702
|
+
lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
|
|
1378
1703
|
}
|
|
1379
1704
|
}
|
|
1380
1705
|
if (summary.focusedTests?.length) {
|
|
@@ -1388,7 +1713,7 @@ function formatTerminalCard(summary, options) {
|
|
|
1388
1713
|
);
|
|
1389
1714
|
const sample = summary.testSkips[0];
|
|
1390
1715
|
if (sample.file) {
|
|
1391
|
-
lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
|
|
1716
|
+
lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
|
|
1392
1717
|
}
|
|
1393
1718
|
}
|
|
1394
1719
|
if (summary.swallowedErrors && summary.swallowedErrors.length > 0) {
|
|
@@ -1397,7 +1722,7 @@ function formatTerminalCard(summary, options) {
|
|
|
1397
1722
|
);
|
|
1398
1723
|
const sample = summary.swallowedErrors[0];
|
|
1399
1724
|
if (sample.file) {
|
|
1400
|
-
lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
|
|
1725
|
+
lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
|
|
1401
1726
|
}
|
|
1402
1727
|
}
|
|
1403
1728
|
if (summary.stubAbandonments && summary.stubAbandonments.length > 0) {
|
|
@@ -1406,7 +1731,7 @@ function formatTerminalCard(summary, options) {
|
|
|
1406
1731
|
);
|
|
1407
1732
|
const sample = summary.stubAbandonments[0];
|
|
1408
1733
|
if (sample.file) {
|
|
1409
|
-
lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
|
|
1734
|
+
lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
|
|
1410
1735
|
}
|
|
1411
1736
|
}
|
|
1412
1737
|
if (summary.typeEscapes && summary.typeEscapes.length > 0) {
|
|
@@ -1415,15 +1740,15 @@ function formatTerminalCard(summary, options) {
|
|
|
1415
1740
|
);
|
|
1416
1741
|
const sample = summary.typeEscapes[0];
|
|
1417
1742
|
if (sample.file) {
|
|
1418
|
-
lines.push(` ${pc.dim(`${sample.file}: ${sample.lineSnippet || ""}`)}`);
|
|
1743
|
+
lines.push(` ${pc.dim(`${terminalText(sample.file)}: ${terminalText(sample.lineSnippet || "")}`)}`);
|
|
1419
1744
|
}
|
|
1420
1745
|
}
|
|
1421
1746
|
if (totalInfractions === 0) {
|
|
1422
|
-
lines.push(` ${pc.green("\u25CF")} ${pc.dim("zero infractions detected across all inspected lines.")}`);
|
|
1747
|
+
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
1748
|
}
|
|
1424
1749
|
lines.push("");
|
|
1425
1750
|
if (options.verbose) {
|
|
1426
|
-
if (
|
|
1751
|
+
if (incomplete) {
|
|
1427
1752
|
lines.push(pc.bold(" PARSER AUDIT STATS:"));
|
|
1428
1753
|
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
1754
|
if (summary.skippedLongLines) {
|
|
@@ -1432,6 +1757,9 @@ function formatTerminalCard(summary, options) {
|
|
|
1432
1757
|
if (summary.skippedUnknownFiles) {
|
|
1433
1758
|
lines.push(` ${pc.dim("skipped files with unparseable path:")} ${pc.yellow(summary.skippedUnknownFiles)}`);
|
|
1434
1759
|
}
|
|
1760
|
+
if (summary.skippedBinaryFiles) {
|
|
1761
|
+
lines.push(` ${pc.dim("skipped binary source files:")} ${pc.yellow(summary.skippedBinaryFiles)}`);
|
|
1762
|
+
}
|
|
1435
1763
|
lines.push("");
|
|
1436
1764
|
}
|
|
1437
1765
|
const allInfractions = [
|
|
@@ -1449,16 +1777,16 @@ function formatTerminalCard(summary, options) {
|
|
|
1449
1777
|
for (const inf of allInfractions.slice(0, 10)) {
|
|
1450
1778
|
const fileLoc = inf.file ? `${inf.file}${inf.lineNumber ? `:${inf.lineNumber}` : ""}` : "commit history";
|
|
1451
1779
|
lines.push(
|
|
1452
|
-
` \u25CF ${pc.cyan(inf.commitHash.slice(0, 7))} [${inf.type}] ${pc.white(fileLoc)}`
|
|
1780
|
+
` \u25CF ${pc.cyan(terminalText(inf.commitHash.slice(0, 7)))} [${inf.type}] ${pc.white(terminalText(fileLoc))}`
|
|
1453
1781
|
);
|
|
1454
1782
|
if (inf.lineSnippet) {
|
|
1455
|
-
lines.push(` ${pc.dim(`\u2514\u2500 ${inf.lineSnippet}`)}`);
|
|
1783
|
+
lines.push(` ${pc.dim(`\u2514\u2500 ${terminalText(inf.lineSnippet)}`)}`);
|
|
1456
1784
|
}
|
|
1457
1785
|
}
|
|
1458
1786
|
lines.push("");
|
|
1459
1787
|
}
|
|
1460
1788
|
}
|
|
1461
|
-
lines.push(
|
|
1789
|
+
if (!incomplete) lines.push(
|
|
1462
1790
|
` ${pc.dim("Share score:")} ${pc.cyan(generateShareUrl(summary))}`
|
|
1463
1791
|
);
|
|
1464
1792
|
lines.push("");
|
|
@@ -1471,6 +1799,7 @@ function formatPrTerminalCard(result) {
|
|
|
1471
1799
|
`${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
1800
|
);
|
|
1473
1801
|
if (!result.hasSupportedChanges) {
|
|
1802
|
+
if (result.complete === false) lines.push(" INCOMPLETE: some long lines, unparsed paths or binary source could not be inspected; exit 2.");
|
|
1474
1803
|
lines.push(` ${pc.dim("no supported source changes detected in the audited diff.")}`);
|
|
1475
1804
|
lines.push("");
|
|
1476
1805
|
return lines.join("\n");
|
|
@@ -1479,7 +1808,7 @@ function formatPrTerminalCard(result) {
|
|
|
1479
1808
|
` ${pc.dim("files inspected:")} ${result.filesInspected} ${pc.dim("lines added:")} ${result.linesAdded}`
|
|
1480
1809
|
);
|
|
1481
1810
|
lines.push("");
|
|
1482
|
-
if (result.infractions.length === 0) {
|
|
1811
|
+
if (result.infractions.length === 0 && result.complete !== false) {
|
|
1483
1812
|
lines.push(` ${pc.green("PASS")} ${pc.dim("zero infractions detected across all audited changes.")}`);
|
|
1484
1813
|
} else {
|
|
1485
1814
|
lines.push(pc.bold(` INFRACTIONS DETECTED (${result.infractions.length})`));
|
|
@@ -1488,15 +1817,17 @@ function formatPrTerminalCard(result) {
|
|
|
1488
1817
|
const isFailedRule = result.failedRules.includes(inf.type);
|
|
1489
1818
|
const badge = isFailedRule ? pc.red("FAIL") : pc.yellow("WARN");
|
|
1490
1819
|
const loc = `${inf.file || "unknown"}:${inf.lineNumber || 1}`;
|
|
1491
|
-
lines.push(` ${badge} [${inf.type}] ${pc.white(loc)} \u2014 ${pc.dim(inf.reason)}`);
|
|
1820
|
+
lines.push(` ${badge} [${inf.type}] ${pc.white(terminalText(loc))} \u2014 ${pc.dim(terminalText(inf.reason))}`);
|
|
1492
1821
|
if (inf.lineSnippet) {
|
|
1493
|
-
lines.push(` ${pc.dim(`\u2514\u2500 ${inf.lineSnippet}`)}`);
|
|
1822
|
+
lines.push(` ${pc.dim(`\u2514\u2500 ${terminalText(inf.lineSnippet)}`)}`);
|
|
1494
1823
|
}
|
|
1495
|
-
if (inf.suggestion) lines.push(` ${pc.dim(`Next: ${inf.suggestion}`)}`);
|
|
1824
|
+
if (inf.suggestion) lines.push(` ${pc.dim(`Next: ${terminalText(inf.suggestion)}`)}`);
|
|
1496
1825
|
}
|
|
1497
1826
|
}
|
|
1498
1827
|
lines.push("");
|
|
1499
|
-
if (result.
|
|
1828
|
+
if (result.complete === false) {
|
|
1829
|
+
lines.push(" INCOMPLETE: some long lines, unparsed paths or binary source could not be inspected; exit 2.");
|
|
1830
|
+
} else if (result.failedRules.length > 0) {
|
|
1500
1831
|
lines.push(
|
|
1501
1832
|
` ${pc.red(pc.bold("BLOCKING FAILURE:"))} ${result.failedRules.length} rule(s) violated in --fail-on: ${result.failedRules.join(", ")}`
|
|
1502
1833
|
);
|
|
@@ -1564,10 +1895,11 @@ function formatGithubStepSummary(result) {
|
|
|
1564
1895
|
lines.push(result.mode === "staged" ? "### agent-roast Staged Changes Audit" : "### agent-roast Pull Request Audit");
|
|
1565
1896
|
lines.push("");
|
|
1566
1897
|
if (!result.hasSupportedChanges) {
|
|
1898
|
+
if (result.complete === false) lines.push("INCOMPLETE: some long lines, unparsed paths or binary source could not be inspected; exit 2.");
|
|
1567
1899
|
lines.push("No supported source file changes detected in the audited diff.");
|
|
1568
1900
|
return lines.join("\n");
|
|
1569
1901
|
}
|
|
1570
|
-
const statusBadge = result.passed ? "Passed" : `Failed (${result.failedRules.join(", ")})`;
|
|
1902
|
+
const statusBadge = result.complete === false ? "INCOMPLETE" : result.passed ? "Passed" : `Failed (${result.failedRules.join(", ")})`;
|
|
1571
1903
|
lines.push(`| Status | Base | Head | Files | Lines Added | Infractions |`);
|
|
1572
1904
|
lines.push(`| --- | --- | --- | --- | --- | --- |`);
|
|
1573
1905
|
lines.push(
|
|
@@ -1581,31 +1913,32 @@ function formatGithubStepSummary(result) {
|
|
|
1581
1913
|
lines.push("| --- | --- | --- | --- |");
|
|
1582
1914
|
const sorted = sortInfractionsDeterministic(result.infractions);
|
|
1583
1915
|
for (const inf of sorted) {
|
|
1584
|
-
const loc = inf.file ?
|
|
1585
|
-
const snippet = inf.lineSnippet ?
|
|
1586
|
-
lines.push(`|
|
|
1916
|
+
const loc = inf.file ? markdownCell(`${inf.file}:${inf.lineNumber || 1}`) : "history";
|
|
1917
|
+
const snippet = inf.lineSnippet ? markdownCell(inf.lineSnippet) : "-";
|
|
1918
|
+
lines.push(`| ${inf.type} | ${loc} | ${markdownCell(inf.reason)} | ${snippet} |`);
|
|
1587
1919
|
}
|
|
1588
1920
|
lines.push("");
|
|
1589
1921
|
} else {
|
|
1590
|
-
lines.push("Clean pull request: zero shortcuts or bypassed checks detected.");
|
|
1922
|
+
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
1923
|
lines.push("");
|
|
1592
1924
|
}
|
|
1593
1925
|
return lines.join("\n");
|
|
1594
1926
|
}
|
|
1595
1927
|
function formatPrMarkdownComment(result) {
|
|
1596
1928
|
const lines = [];
|
|
1597
|
-
const titleEmoji = result.passed ? "\u2705" : "\u274C";
|
|
1598
|
-
const titleStatus = result.passed ? "PASSED" : "BLOCKED";
|
|
1929
|
+
const titleEmoji = result.complete === false ? "\u26A0\uFE0F" : result.passed ? "\u2705" : "\u274C";
|
|
1930
|
+
const titleStatus = result.complete === false ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
|
|
1599
1931
|
lines.push(`## \u{1F6E1}\uFE0F agent-roast audit: ${titleStatus} ${titleEmoji}`);
|
|
1600
1932
|
lines.push("");
|
|
1601
1933
|
if (!result.hasSupportedChanges) {
|
|
1934
|
+
if (result.complete === false) lines.push("INCOMPLETE: some long lines, unparsed paths or binary source could not be inspected; exit 2.");
|
|
1602
1935
|
lines.push("No supported source file changes detected in the audited diff.");
|
|
1603
1936
|
lines.push("");
|
|
1604
1937
|
lines.push("---");
|
|
1605
1938
|
lines.push("*Audited 100% offline with [agent-roast](https://github.com/Nari-Ab/agent-roast)*");
|
|
1606
1939
|
return lines.join("\n");
|
|
1607
1940
|
}
|
|
1608
|
-
const statusText = result.passed ? "\u2705 Passed" : `\u274C Blocked on ${result.failedRules.join(", ")}`;
|
|
1941
|
+
const statusText = result.complete === false ? "\u26A0\uFE0F INCOMPLETE" : result.passed ? "\u2705 Passed" : `\u274C Blocked on ${result.failedRules.join(", ")}`;
|
|
1609
1942
|
lines.push("| Status | Defect Density | Files Inspected | Lines Added | Infractions |");
|
|
1610
1943
|
lines.push("| :--- | :--- | :--- | :--- | :--- |");
|
|
1611
1944
|
lines.push(
|
|
@@ -1620,15 +1953,15 @@ function formatPrMarkdownComment(result) {
|
|
|
1620
1953
|
lines.push("| :--- | :--- | :--- | :--- |");
|
|
1621
1954
|
const sorted = sortInfractionsDeterministic(result.infractions);
|
|
1622
1955
|
for (const inf of sorted) {
|
|
1623
|
-
const loc = inf.file ?
|
|
1624
|
-
const snippet = inf.lineSnippet ?
|
|
1625
|
-
lines.push(`|
|
|
1956
|
+
const loc = inf.file ? markdownCell(`${inf.file}:${inf.lineNumber || 1}`) : "history";
|
|
1957
|
+
const snippet = inf.lineSnippet ? markdownCell(inf.lineSnippet) : "-";
|
|
1958
|
+
lines.push(`| ${inf.type} | ${loc} | ${markdownCell(inf.reason)} | ${snippet} |`);
|
|
1626
1959
|
}
|
|
1627
1960
|
lines.push("");
|
|
1628
1961
|
lines.push("</details>");
|
|
1629
1962
|
lines.push("");
|
|
1630
1963
|
} else {
|
|
1631
|
-
lines.push("\u2728 **Clean pull request**: Zero AI shortcuts, bypassed tests, or compiler type escapes detected.");
|
|
1964
|
+
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
1965
|
lines.push("");
|
|
1633
1966
|
}
|
|
1634
1967
|
lines.push("---");
|
|
@@ -1636,7 +1969,38 @@ function formatPrMarkdownComment(result) {
|
|
|
1636
1969
|
return lines.join("\n");
|
|
1637
1970
|
}
|
|
1638
1971
|
function terminalText(value) {
|
|
1639
|
-
return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
1972
|
+
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")}`);
|
|
1973
|
+
}
|
|
1974
|
+
function exceptionTerminal(result) {
|
|
1975
|
+
const lines = [];
|
|
1976
|
+
const accepted = result.suppressedFindings || [];
|
|
1977
|
+
if (accepted.length) {
|
|
1978
|
+
lines.push(` ${accepted.length} findings accepted by explicit exceptions (not fixed)`);
|
|
1979
|
+
for (const finding of accepted.slice(0, 20)) lines.push(
|
|
1980
|
+
` ACCEPTED [${finding.type}] ${terminalText(finding.file || "unknown")}:${finding.lineNumber} \u2014 ${terminalText(finding.suppression.reason)} (expires ${terminalText(finding.suppression.expires)})`
|
|
1981
|
+
);
|
|
1982
|
+
if (accepted.length > 20) lines.push(` ${accepted.length - 20} more accepted findings; use --format json.`);
|
|
1983
|
+
}
|
|
1984
|
+
if (result.expiredExceptionCount) lines.push(` ${result.expiredExceptionCount} expired exceptions were not applied; review their findings.`);
|
|
1985
|
+
return lines;
|
|
1986
|
+
}
|
|
1987
|
+
function exceptionMarkdown(result) {
|
|
1988
|
+
const lines = [];
|
|
1989
|
+
const accepted = result.suppressedFindings || [];
|
|
1990
|
+
if (accepted.length) {
|
|
1991
|
+
lines.push(
|
|
1992
|
+
"",
|
|
1993
|
+
`### Accepted exceptions (${accepted.length})`,
|
|
1994
|
+
"",
|
|
1995
|
+
"These findings remain in the source and were accepted by explicit policy; they were not fixed.",
|
|
1996
|
+
"",
|
|
1997
|
+
"| Rule | Location | Reason | Expires |",
|
|
1998
|
+
"| --- | --- | --- | --- |"
|
|
1999
|
+
);
|
|
2000
|
+
for (const finding of accepted) lines.push(`| ${finding.type} | ${markdownCell(`${finding.file}:${finding.lineNumber}`)} | ${markdownCell(finding.suppression.reason)} | ${markdownCell(finding.suppression.expires)} |`);
|
|
2001
|
+
}
|
|
2002
|
+
if (result.expiredExceptionCount) lines.push("", `${result.expiredExceptionCount} expired exceptions were not applied; review their findings.`);
|
|
2003
|
+
return lines;
|
|
1640
2004
|
}
|
|
1641
2005
|
function formatCheckTerminal(result) {
|
|
1642
2006
|
const lines = [
|
|
@@ -1645,10 +2009,11 @@ function formatCheckTerminal(result) {
|
|
|
1645
2009
|
` ${result.filesInspected} files \xB7 ${result.linesInspected} lines inspected`,
|
|
1646
2010
|
` ${result.infractions.length} findings to review \xB7 ${result.baselineCount} accepted baseline findings`
|
|
1647
2011
|
];
|
|
2012
|
+
lines.push(...exceptionTerminal(result));
|
|
1648
2013
|
if (result.staleBaselineCount) lines.push(` ${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
|
|
1649
2014
|
for (const inf of result.infractions.slice(0, 20)) {
|
|
1650
2015
|
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}`);
|
|
2016
|
+
if (inf.suggestion) lines.push(` Next: ${terminalText(inf.suggestion)}`);
|
|
1652
2017
|
}
|
|
1653
2018
|
if (result.infractions.length > 20) lines.push(`
|
|
1654
2019
|
${result.infractions.length - 20} more findings. Use --format json for the complete report.`);
|
|
@@ -1659,7 +2024,7 @@ function formatCheckTerminal(result) {
|
|
|
1659
2024
|
return lines.join("\n");
|
|
1660
2025
|
}
|
|
1661
2026
|
function markdownCell(value) {
|
|
1662
|
-
return value.replace(/[\\[\]
|
|
2027
|
+
return terminalText(value).replace(/[\\[\]*_!]/g, "\\$&").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\|/g, "\\|").replace(/`/g, "`").replace(/@/g, "@");
|
|
1663
2028
|
}
|
|
1664
2029
|
function formatCheckMarkdown(result) {
|
|
1665
2030
|
const status = !result.complete ? "INCOMPLETE" : result.passed ? "PASSED" : "BLOCKED";
|
|
@@ -1672,6 +2037,7 @@ function formatCheckMarkdown(result) {
|
|
|
1672
2037
|
"| --- | --- | --- | --- |"
|
|
1673
2038
|
];
|
|
1674
2039
|
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.")} |`);
|
|
2040
|
+
lines.push(...exceptionMarkdown(result));
|
|
1675
2041
|
if (result.staleBaselineCount) lines.push("", `${result.staleBaselineCount} baseline findings no longer present (or now disabled/ignored).`);
|
|
1676
2042
|
for (const skipped of result.skippedFiles) lines.push("", `Not scanned: ${markdownCell(skipped.file)} \u2014 ${markdownCell(skipped.reason)}`);
|
|
1677
2043
|
return lines.join("\n");
|
|
@@ -1685,6 +2051,7 @@ function formatReviewTerminal(result) {
|
|
|
1685
2051
|
` ${result.filesInspected} changed files compared \xB7 ${result.linesInspected} source lines inspected across both versions`,
|
|
1686
2052
|
` ${result.infractions.length} new \xB7 ${result.unchangedCount} existing \xB7 ${result.removedFindings.length} no longer detected`
|
|
1687
2053
|
];
|
|
2054
|
+
lines.push(...exceptionTerminal(result));
|
|
1688
2055
|
if (result.policyChanges.length) lines.push(" Candidate policy changes were ignored; adopt them separately after review.");
|
|
1689
2056
|
if (result.excludedFiles || result.unsupportedFiles) lines.push(` ${result.excludedFiles} excluded \xB7 ${result.unsupportedFiles} unsupported/non-source changes`);
|
|
1690
2057
|
for (const finding of result.infractions.slice(0, 20)) {
|
|
@@ -1692,7 +2059,7 @@ function formatReviewTerminal(result) {
|
|
|
1692
2059
|
"",
|
|
1693
2060
|
` ${result.failedRules.includes(finding.type) ? "FAIL" : "WARN"} [${finding.type}] ${terminalText(finding.file || "unknown")}:${finding.lineNumber}`,
|
|
1694
2061
|
` ${terminalText(finding.lineSnippet || finding.reason)}`,
|
|
1695
|
-
` Next: ${finding.suggestion || "Review this occurrence."}`
|
|
2062
|
+
` Next: ${terminalText(finding.suggestion || "Review this occurrence.")}`
|
|
1696
2063
|
);
|
|
1697
2064
|
}
|
|
1698
2065
|
if (result.infractions.length > 20) lines.push(` ${result.infractions.length - 20} more new findings; use --format json for all results.`);
|
|
@@ -1714,6 +2081,7 @@ function formatReviewMarkdown(result) {
|
|
|
1714
2081
|
if (result.policyChanges.length) lines.push("Candidate policy changes were ignored. This review uses the supplied base commit's policy.", "");
|
|
1715
2082
|
lines.push("| New finding | Head location | Evidence | Next step |", "| --- | --- | --- | --- |");
|
|
1716
2083
|
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.")} |`);
|
|
2084
|
+
lines.push(...exceptionMarkdown(result));
|
|
1717
2085
|
if (result.removedFindings.length) {
|
|
1718
2086
|
lines.push(
|
|
1719
2087
|
"",
|
|
@@ -1732,8 +2100,10 @@ function formatReviewMarkdown(result) {
|
|
|
1732
2100
|
}
|
|
1733
2101
|
|
|
1734
2102
|
// src/init.ts
|
|
1735
|
-
import * as
|
|
1736
|
-
import * as
|
|
2103
|
+
import * as fs4 from "fs";
|
|
2104
|
+
import * as path3 from "path";
|
|
2105
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
2106
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1737
2107
|
var DEFAULT_CONFIG_TEMPLATE = {
|
|
1738
2108
|
ignorePaths: [
|
|
1739
2109
|
"**/*.min.js",
|
|
@@ -1776,58 +2146,125 @@ var PRE_COMMIT_HOOK_TEMPLATE = `#!/bin/sh
|
|
|
1776
2146
|
# Run agent-roast pre-commit verification
|
|
1777
2147
|
npx --no-install agent-roast --staged
|
|
1778
2148
|
`;
|
|
2149
|
+
function createNewFile(root, relative2, content, mode = 420) {
|
|
2150
|
+
const destination = path3.resolve(root, relative2);
|
|
2151
|
+
const inside = path3.relative(root, destination);
|
|
2152
|
+
if (!inside || inside === ".." || inside.startsWith(".." + path3.sep) || path3.isAbsolute(inside)) throw new ConfigError("Initialization target escapes its allowed directory.");
|
|
2153
|
+
let directory = root;
|
|
2154
|
+
for (const segment of path3.dirname(inside).split(path3.sep).filter((part) => part !== ".")) {
|
|
2155
|
+
directory = path3.join(directory, segment);
|
|
2156
|
+
let stat;
|
|
2157
|
+
try {
|
|
2158
|
+
stat = fs4.lstatSync(directory);
|
|
2159
|
+
} catch (error) {
|
|
2160
|
+
if (error.code !== "ENOENT") throw error;
|
|
2161
|
+
try {
|
|
2162
|
+
fs4.mkdirSync(directory);
|
|
2163
|
+
} catch (mkdirError) {
|
|
2164
|
+
if (mkdirError.code !== "EEXIST") throw mkdirError;
|
|
2165
|
+
}
|
|
2166
|
+
stat = fs4.lstatSync(directory);
|
|
2167
|
+
}
|
|
2168
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new ConfigError("Initialization refuses symlinked or non-directory parent paths.");
|
|
2169
|
+
}
|
|
2170
|
+
const temporary = path3.join(directory, `.agent-roast-init-${randomUUID3()}.tmp`);
|
|
2171
|
+
let fd;
|
|
2172
|
+
try {
|
|
2173
|
+
fd = fs4.openSync(temporary, "wx", mode);
|
|
2174
|
+
fs4.writeFileSync(fd, content);
|
|
2175
|
+
fs4.fsyncSync(fd);
|
|
2176
|
+
fs4.closeSync(fd);
|
|
2177
|
+
fd = void 0;
|
|
2178
|
+
try {
|
|
2179
|
+
fs4.linkSync(temporary, destination);
|
|
2180
|
+
} catch (error) {
|
|
2181
|
+
if (error.code === "EEXIST") return false;
|
|
2182
|
+
throw error;
|
|
2183
|
+
}
|
|
2184
|
+
return true;
|
|
2185
|
+
} finally {
|
|
2186
|
+
if (fd !== void 0) fs4.closeSync(fd);
|
|
2187
|
+
fs4.rmSync(temporary, { force: true });
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
var hasEntry = (filename) => {
|
|
2191
|
+
try {
|
|
2192
|
+
fs4.lstatSync(filename);
|
|
2193
|
+
return true;
|
|
2194
|
+
} catch (error) {
|
|
2195
|
+
if (error.code === "ENOENT") return false;
|
|
2196
|
+
throw error;
|
|
2197
|
+
}
|
|
2198
|
+
};
|
|
1779
2199
|
async function initProject(options = {}) {
|
|
1780
|
-
|
|
2200
|
+
let cwd = fs4.realpathSync(path3.resolve(options.cwd || process.cwd()));
|
|
2201
|
+
const root = spawnSync2("git", [...HERMETIC_GIT_FLAGS, "rev-parse", "--show-toplevel"], {
|
|
2202
|
+
cwd,
|
|
2203
|
+
env: { ...getHermeticGitEnv(), LC_ALL: "C" },
|
|
2204
|
+
encoding: "utf8",
|
|
2205
|
+
timeout: 3e4,
|
|
2206
|
+
maxBuffer: 8192,
|
|
2207
|
+
windowsHide: true
|
|
2208
|
+
});
|
|
2209
|
+
if (root.error) throw new ConfigError("Cannot resolve the repository root for initialization.");
|
|
2210
|
+
if (root.status === 0) cwd = fs4.realpathSync(gitOutputPath(root.stdout));
|
|
2211
|
+
else if (root.status !== 128 || !root.stderr.includes("not a git repository")) {
|
|
2212
|
+
throw new ConfigError("Cannot resolve a working repository root for initialization.");
|
|
2213
|
+
}
|
|
1781
2214
|
const created = [];
|
|
1782
2215
|
const skipped = [];
|
|
1783
|
-
const
|
|
1784
|
-
|
|
1785
|
-
|
|
2216
|
+
const publish = (root2, relative2, label, content, mode) => {
|
|
2217
|
+
(createNewFile(root2, relative2, content, mode) ? created : skipped).push(label);
|
|
2218
|
+
};
|
|
2219
|
+
if ([".agentroastrc.json", "agent-roast.config.json", ".agentroastrc"].some((name) => hasEntry(path3.join(cwd, name)))) {
|
|
1786
2220
|
skipped.push(".agentroastrc.json");
|
|
1787
|
-
} else
|
|
1788
|
-
|
|
1789
|
-
configPath,
|
|
1790
|
-
JSON.stringify(DEFAULT_CONFIG_TEMPLATE, null, 2) + "\n",
|
|
1791
|
-
"utf-8"
|
|
1792
|
-
);
|
|
1793
|
-
created.push(".agentroastrc.json");
|
|
1794
|
-
}
|
|
1795
|
-
if (options.ci) {
|
|
1796
|
-
const workflowDir = path2.join(cwd, ".github", "workflows");
|
|
1797
|
-
const workflowPath = path2.join(workflowDir, "agent-roast.yml");
|
|
1798
|
-
if (fs2.existsSync(workflowPath)) {
|
|
1799
|
-
skipped.push(".github/workflows/agent-roast.yml");
|
|
1800
|
-
} else {
|
|
1801
|
-
fs2.mkdirSync(workflowDir, { recursive: true });
|
|
1802
|
-
fs2.writeFileSync(workflowPath, GITHUB_WORKFLOW_TEMPLATE, "utf-8");
|
|
1803
|
-
created.push(".github/workflows/agent-roast.yml");
|
|
1804
|
-
}
|
|
1805
|
-
}
|
|
2221
|
+
} else publish(cwd, ".agentroastrc.json", ".agentroastrc.json", JSON.stringify(DEFAULT_CONFIG_TEMPLATE, null, 2) + "\n");
|
|
2222
|
+
if (options.ci) publish(cwd, ".github/workflows/agent-roast.yml", ".github/workflows/agent-roast.yml", GITHUB_WORKFLOW_TEMPLATE);
|
|
1806
2223
|
if (options.hook) {
|
|
1807
|
-
const
|
|
1808
|
-
|
|
1809
|
-
|
|
2224
|
+
const run = (...args) => spawnSync2("git", [...HERMETIC_GIT_FLAGS, ...args], {
|
|
2225
|
+
cwd,
|
|
2226
|
+
env: getHermeticGitEnv(),
|
|
2227
|
+
encoding: "utf8",
|
|
2228
|
+
timeout: 3e4,
|
|
2229
|
+
maxBuffer: 8192,
|
|
2230
|
+
windowsHide: true
|
|
2231
|
+
});
|
|
2232
|
+
const common = run("rev-parse", "--git-common-dir");
|
|
2233
|
+
if (common.error || common.status !== 0) {
|
|
2234
|
+
const plainGit = path3.join(cwd, ".git");
|
|
2235
|
+
if (hasEntry(plainGit) && fs4.lstatSync(plainGit).isDirectory() && !fs4.lstatSync(plainGit).isSymbolicLink()) {
|
|
2236
|
+
publish(plainGit, "hooks/pre-commit", ".git/hooks/pre-commit", PRE_COMMIT_HOOK_TEMPLATE, 493);
|
|
2237
|
+
} else skipped.push(".git/hooks/pre-commit (no .git directory found)");
|
|
1810
2238
|
} else {
|
|
1811
|
-
const
|
|
1812
|
-
const
|
|
1813
|
-
if (
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
mode: 493
|
|
1820
|
-
});
|
|
1821
|
-
created.push(".git/hooks/pre-commit");
|
|
1822
|
-
}
|
|
2239
|
+
const commonDir = fs4.realpathSync(path3.resolve(cwd, gitOutputPath(common.stdout)));
|
|
2240
|
+
const hooks = run("rev-parse", "--git-path", "hooks");
|
|
2241
|
+
if (hooks.error || hooks.status !== 0) throw new ConfigError("Cannot resolve this repository's hooks directory.");
|
|
2242
|
+
const hookDir = path3.resolve(cwd, gitOutputPath(hooks.stdout));
|
|
2243
|
+
const relative2 = path3.relative(commonDir, path3.join(hookDir, "pre-commit"));
|
|
2244
|
+
if (!relative2 || relative2 === ".." || relative2.startsWith(".." + path3.sep) || path3.isAbsolute(relative2)) {
|
|
2245
|
+
skipped.push(".git/hooks/pre-commit (custom hooks path outside repository metadata; install manually)");
|
|
2246
|
+
} else publish(commonDir, relative2, ".git/hooks/pre-commit", PRE_COMMIT_HOOK_TEMPLATE, 493);
|
|
1823
2247
|
}
|
|
1824
2248
|
}
|
|
1825
|
-
return { created, skipped };
|
|
2249
|
+
return { directory: cwd, created, skipped };
|
|
1826
2250
|
}
|
|
1827
2251
|
|
|
1828
2252
|
// src/badge.ts
|
|
2253
|
+
function displayText(value) {
|
|
2254
|
+
return value.replace(
|
|
2255
|
+
/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/g,
|
|
2256
|
+
(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`
|
|
2257
|
+
);
|
|
2258
|
+
}
|
|
2259
|
+
function xmlText(value) {
|
|
2260
|
+
return displayText(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2261
|
+
}
|
|
2262
|
+
function badgeScore(score) {
|
|
2263
|
+
return score !== null && Number.isFinite(score) && score >= 0 && score <= 100 ? score : null;
|
|
2264
|
+
}
|
|
1829
2265
|
function getScoreColor(score) {
|
|
1830
|
-
|
|
2266
|
+
score = badgeScore(score);
|
|
2267
|
+
if (score === null) {
|
|
1831
2268
|
return { name: "lightgrey", hex: "#9f9f9f" };
|
|
1832
2269
|
}
|
|
1833
2270
|
if (score >= 90) return { name: "brightgreen", hex: "#4c1" };
|
|
@@ -1837,14 +2274,16 @@ function getScoreColor(score) {
|
|
|
1837
2274
|
return { name: "red", hex: "#e05d44" };
|
|
1838
2275
|
}
|
|
1839
2276
|
function generateSvgBadge(score, label = "AI Discipline") {
|
|
1840
|
-
|
|
2277
|
+
score = badgeScore(score);
|
|
2278
|
+
const value = score !== null ? `${score}/100` : "N/A";
|
|
1841
2279
|
const color = getScoreColor(score);
|
|
2280
|
+
const safeLabel = xmlText(label);
|
|
1842
2281
|
const labelWidth = Math.round(label.length * 6.8 + 14);
|
|
1843
2282
|
const valueWidth = Math.round(value.length * 7.2 + 14);
|
|
1844
2283
|
const totalWidth = labelWidth + valueWidth;
|
|
1845
2284
|
const labelX = Math.round(labelWidth / 2);
|
|
1846
2285
|
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="${
|
|
2286
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20" role="img" aria-label="${safeLabel}: ${value}">
|
|
1848
2287
|
<linearGradient id="s" x2="0" y2="100%">
|
|
1849
2288
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
1850
2289
|
<stop offset="1" stop-opacity=".1"/>
|
|
@@ -1858,22 +2297,31 @@ function generateSvgBadge(score, label = "AI Discipline") {
|
|
|
1858
2297
|
<rect width="${totalWidth}" height="20" fill="url(#s)"/>
|
|
1859
2298
|
</g>
|
|
1860
2299
|
<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}">${
|
|
2300
|
+
<text aria-hidden="true" x="${labelX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(labelWidth - 14) * 10}">${safeLabel}</text>
|
|
2301
|
+
<text x="${labelX * 10}" y="140" transform="scale(.1)" fill="#fff" textLength="${(labelWidth - 14) * 10}">${safeLabel}</text>
|
|
1863
2302
|
<text aria-hidden="true" x="${valueX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${(valueWidth - 14) * 10}">${value}</text>
|
|
1864
2303
|
<text x="${valueX * 10}" y="140" transform="scale(.1)" fill="#fff" textLength="${(valueWidth - 14) * 10}">${value}</text>
|
|
1865
2304
|
</g>
|
|
1866
2305
|
</svg>`;
|
|
1867
2306
|
}
|
|
1868
2307
|
function generateShieldsMarkdown(score, label = "AI Discipline", repoUrl = "https://github.com/Nari-Ab/agent-roast") {
|
|
1869
|
-
|
|
2308
|
+
score = badgeScore(score);
|
|
2309
|
+
const value = score !== null ? `${score}%2F100` : "N%2FA";
|
|
1870
2310
|
const color = getScoreColor(score);
|
|
1871
|
-
const
|
|
2311
|
+
const shownLabel = displayText(label);
|
|
2312
|
+
const encodedLabel = encodeURIComponent(shownLabel.replace(/-/g, "--").replace(/_/g, "__"));
|
|
2313
|
+
const alt = shownLabel.replace(/[\\[\]*_!`]/g, "\\$&").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
2314
|
+
const destination = new URL(repoUrl);
|
|
2315
|
+
if (!["https:", "http:"].includes(destination.protocol) || destination.username || destination.password) {
|
|
2316
|
+
throw new Error("Badge destination must be an HTTP(S) URL without credentials.");
|
|
2317
|
+
}
|
|
2318
|
+
const safeDestination = destination.href.replace(/[()\\]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
1872
2319
|
const badgeUrl = `https://img.shields.io/badge/${encodedLabel}-${value}-${color.name}?style=flat-square`;
|
|
1873
|
-
return `[](${safeDestination})`;
|
|
1874
2321
|
}
|
|
1875
2322
|
function generateShieldsJson(score, label = "AI Discipline") {
|
|
1876
|
-
|
|
2323
|
+
score = badgeScore(score);
|
|
2324
|
+
const value = score !== null ? `${score}/100` : "N/A";
|
|
1877
2325
|
const color = getScoreColor(score);
|
|
1878
2326
|
return {
|
|
1879
2327
|
schemaVersion: 1,
|
|
@@ -1885,111 +2333,22 @@ function generateShieldsJson(score, label = "AI Discipline") {
|
|
|
1885
2333
|
|
|
1886
2334
|
// src/sarif.ts
|
|
1887
2335
|
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
2336
|
var require2 = createRequire(import.meta.url);
|
|
1983
2337
|
var version = require2("../package.json").version;
|
|
1984
2338
|
function formatSarif(report) {
|
|
1985
2339
|
const occurrences = /* @__PURE__ */ new Map();
|
|
1986
|
-
const
|
|
2340
|
+
const allFindings = [
|
|
2341
|
+
...report.infractions.map((finding) => ({ finding, suppression: void 0 })),
|
|
2342
|
+
...(report.suppressedFindings ?? []).map((finding) => ({ finding, suppression: finding.suppression }))
|
|
2343
|
+
];
|
|
2344
|
+
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));
|
|
2345
|
+
const results = allFindings.map(({ finding, suppression }) => {
|
|
1987
2346
|
const filename = finding.file;
|
|
1988
2347
|
if (!filename || filename.startsWith("/") || filename.split("/").includes("..") || /^[a-z]:/i.test(filename)) {
|
|
1989
2348
|
throw new ConfigError("SARIF needs repository-relative finding paths.");
|
|
1990
2349
|
}
|
|
1991
2350
|
const fingerprint = findingFingerprint(finding);
|
|
1992
|
-
const occurrence = (occurrences.get(fingerprint) || 0) + 1;
|
|
2351
|
+
const occurrence = finding.occurrence ?? (occurrences.get(fingerprint) || 0) + 1;
|
|
1993
2352
|
occurrences.set(fingerprint, occurrence);
|
|
1994
2353
|
return {
|
|
1995
2354
|
ruleId: finding.type,
|
|
@@ -2001,6 +2360,11 @@ function formatSarif(report) {
|
|
|
2001
2360
|
region: { startLine: Math.max(1, finding.lineNumber || 1) }
|
|
2002
2361
|
} }],
|
|
2003
2362
|
partialFingerprints: { "agentRoastFinding/v1": `${fingerprint}:${occurrence}` },
|
|
2363
|
+
...suppression ? { suppressions: [{
|
|
2364
|
+
kind: "external",
|
|
2365
|
+
status: "accepted",
|
|
2366
|
+
justification: `${suppression.reason} (expires ${suppression.expires}, UTC)`
|
|
2367
|
+
}] } : {},
|
|
2004
2368
|
...report.mode === "review" ? { baselineState: "new" } : {}
|
|
2005
2369
|
};
|
|
2006
2370
|
});
|
|
@@ -2040,156 +2404,31 @@ Example: ${RULE_GUIDES[rule].example}` },
|
|
|
2040
2404
|
mergeBaseSha: report.mergeBaseSha,
|
|
2041
2405
|
policySha: report.policySha,
|
|
2042
2406
|
unchangedFindings: report.unchangedCount,
|
|
2043
|
-
removedFindings: report.removedFindings.length
|
|
2044
|
-
|
|
2407
|
+
removedFindings: report.removedFindings.length,
|
|
2408
|
+
acceptedExceptions: report.suppressedFindings?.length ?? 0,
|
|
2409
|
+
expiredExceptions: report.expiredExceptionCount ?? 0
|
|
2410
|
+
} : { scope: "current source", acceptedBaselineFindings: report.baselineCount, acceptedExceptions: report.suppressedFindings?.length ?? 0, expiredExceptions: report.expiredExceptionCount ?? 0 }
|
|
2045
2411
|
}]
|
|
2046
2412
|
}, null, 2);
|
|
2047
2413
|
}
|
|
2048
2414
|
|
|
2049
2415
|
// src/check.ts
|
|
2050
|
-
import
|
|
2051
|
-
import * as fs4 from "fs/promises";
|
|
2416
|
+
import * as fs6 from "fs/promises";
|
|
2052
2417
|
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
2418
|
import path5 from "path";
|
|
2182
2419
|
|
|
2183
2420
|
// src/snapshot.ts
|
|
2184
2421
|
import { createHash as createHash2 } from "crypto";
|
|
2422
|
+
import * as fs5 from "fs/promises";
|
|
2423
|
+
import path4 from "path";
|
|
2185
2424
|
import { spawn as spawn2 } from "child_process";
|
|
2186
2425
|
function assertNotAborted(signal) {
|
|
2187
2426
|
if (signal?.aborted) throw new ConfigError("Review cancelled; no complete result was produced.");
|
|
2188
2427
|
}
|
|
2189
|
-
async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024) {
|
|
2428
|
+
async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024, indexFile) {
|
|
2190
2429
|
assertNotAborted(signal);
|
|
2191
2430
|
const env = getHermeticGitEnv({ GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_TERMINAL_PROMPT: "0" });
|
|
2192
|
-
|
|
2431
|
+
if (indexFile) env.GIT_INDEX_FILE = indexFile;
|
|
2193
2432
|
return new Promise((resolve2, reject) => {
|
|
2194
2433
|
const child = spawn2("git", [...HERMETIC_GIT_FLAGS, ...args], {
|
|
2195
2434
|
cwd,
|
|
@@ -2228,7 +2467,7 @@ async function readGit(cwd, args, signal, limit = 8 * 1024 * 1024) {
|
|
|
2228
2467
|
if (signal?.aborted) abort();
|
|
2229
2468
|
});
|
|
2230
2469
|
}
|
|
2231
|
-
async function
|
|
2470
|
+
async function requireLocalObjects2(cwd, signal) {
|
|
2232
2471
|
try {
|
|
2233
2472
|
const promises = (await readGit(cwd, ["config", "--type=bool", "--get-regexp", "^remote\\..*\\.promisor$"], signal, 64 * 1024)).toString();
|
|
2234
2473
|
if (promises.split("\n").some((line) => /\s+(?:true|yes|on|1)\s*$/i.test(line))) {
|
|
@@ -2248,7 +2487,7 @@ async function reviewRefs(cwd, base, head, signal) {
|
|
|
2248
2487
|
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
2488
|
}
|
|
2250
2489
|
};
|
|
2251
|
-
await
|
|
2490
|
+
await requireLocalObjects2(cwd, signal);
|
|
2252
2491
|
const baseSha = await resolve2(base);
|
|
2253
2492
|
const headSha = await resolve2(head);
|
|
2254
2493
|
let ancestors;
|
|
@@ -2303,7 +2542,19 @@ async function reviewPolicy(cwd, baseSha, signal) {
|
|
|
2303
2542
|
return { config: configFromText("{}", "default review policy"), filename: null };
|
|
2304
2543
|
}
|
|
2305
2544
|
async function stagedSnapshot(cwd, signal) {
|
|
2306
|
-
await
|
|
2545
|
+
await requireLocalObjects2(cwd, signal);
|
|
2546
|
+
let indexFile;
|
|
2547
|
+
if (process.env.GIT_INDEX_FILE) {
|
|
2548
|
+
const gitDirectory = await fs5.realpath(gitOutputPath((await readGit(cwd, ["rev-parse", "--absolute-git-dir"], signal, 8192)).toString()));
|
|
2549
|
+
const requested = path4.resolve(cwd, process.env.GIT_INDEX_FILE);
|
|
2550
|
+
const stat = await fs5.lstat(requested);
|
|
2551
|
+
const resolved = await fs5.realpath(requested);
|
|
2552
|
+
const relative2 = path4.relative(gitDirectory, resolved);
|
|
2553
|
+
if (!stat.isFile() || stat.isSymbolicLink() || !relative2 || relative2 === ".." || relative2.startsWith(".." + path4.sep) || path4.isAbsolute(relative2)) {
|
|
2554
|
+
throw new ConfigError("GIT_INDEX_FILE must be a regular candidate index inside this repository's Git directory; refusing to review another index.");
|
|
2555
|
+
}
|
|
2556
|
+
indexFile = resolved;
|
|
2557
|
+
}
|
|
2307
2558
|
const head = async () => {
|
|
2308
2559
|
try {
|
|
2309
2560
|
return (await readGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], signal, 1024)).toString().trim();
|
|
@@ -2336,8 +2587,8 @@ async function stagedSnapshot(cwd, signal) {
|
|
|
2336
2587
|
"--end-of-options",
|
|
2337
2588
|
...baseSha === "EMPTY" ? [] : [baseSha],
|
|
2338
2589
|
"--"
|
|
2339
|
-
], signal);
|
|
2340
|
-
const conflicts = await readGit(cwd, ["ls-files", "--unmerged", "-z", "--full-name"], signal);
|
|
2590
|
+
], signal, 8 * 1024 * 1024, indexFile);
|
|
2591
|
+
const conflicts = await readGit(cwd, ["ls-files", "--unmerged", "-z", "--full-name"], signal, 8 * 1024 * 1024, indexFile);
|
|
2341
2592
|
if (conflicts.length) throw new ConfigError("Resolve unmerged index entries before reviewing staged changes.");
|
|
2342
2593
|
const pinned = await inventory();
|
|
2343
2594
|
const changes = parseTreeChanges(pinned);
|
|
@@ -2356,7 +2607,137 @@ async function stagedSnapshot(cwd, signal) {
|
|
|
2356
2607
|
};
|
|
2357
2608
|
}
|
|
2358
2609
|
|
|
2610
|
+
// src/check.ts
|
|
2611
|
+
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
2612
|
+
var MAX_SCAN_BYTES = 50 * 1024 * 1024;
|
|
2613
|
+
var GENERATED_PATHS = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
|
|
2614
|
+
async function git(cwd, args, signal) {
|
|
2615
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(await readGit(cwd, args, signal, 16 * 1024 * 1024));
|
|
2616
|
+
}
|
|
2617
|
+
function checkAborted(signal) {
|
|
2618
|
+
if (signal?.aborted) throw new ConfigError("Project check cancelled; no complete result was produced.");
|
|
2619
|
+
}
|
|
2620
|
+
function insideRoot(root, filename) {
|
|
2621
|
+
const relative2 = path5.relative(root, filename);
|
|
2622
|
+
return relative2.length > 0 && relative2 !== ".." && !relative2.startsWith(".." + path5.sep) && !path5.isAbsolute(relative2);
|
|
2623
|
+
}
|
|
2624
|
+
async function readSource(root, file, signal) {
|
|
2625
|
+
const filename = path5.resolve(root, file);
|
|
2626
|
+
if (!insideRoot(root, filename)) throw new Error("File escapes the repository.");
|
|
2627
|
+
let stat;
|
|
2628
|
+
try {
|
|
2629
|
+
stat = await fs6.lstat(filename);
|
|
2630
|
+
} catch (error) {
|
|
2631
|
+
if (error.code === "ENOENT") return null;
|
|
2632
|
+
throw error;
|
|
2633
|
+
}
|
|
2634
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Symlinks and special files are not scanned.");
|
|
2635
|
+
const real = await fs6.realpath(filename);
|
|
2636
|
+
if (!insideRoot(root, real)) throw new Error("File resolves outside the repository.");
|
|
2637
|
+
if (stat.size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
|
|
2638
|
+
const handle = await fs6.open(filename, constants2.O_RDONLY | (constants2.O_NOFOLLOW || 0) | (constants2.O_NONBLOCK || 0));
|
|
2639
|
+
try {
|
|
2640
|
+
const opened = await handle.stat();
|
|
2641
|
+
if (opened.ino !== stat.ino || opened.dev !== stat.dev || !opened.isFile()) throw new Error("File changed while opening.");
|
|
2642
|
+
const chunks = [];
|
|
2643
|
+
let size = 0;
|
|
2644
|
+
while (true) {
|
|
2645
|
+
checkAborted(signal);
|
|
2646
|
+
const chunk = Buffer.alloc(Math.min(64 * 1024, MAX_FILE_BYTES + 1 - size));
|
|
2647
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null);
|
|
2648
|
+
if (!bytesRead) break;
|
|
2649
|
+
size += bytesRead;
|
|
2650
|
+
if (size > MAX_FILE_BYTES) throw new Error("File exceeds the 2 MiB scan limit.");
|
|
2651
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
2652
|
+
}
|
|
2653
|
+
const after = await handle.stat();
|
|
2654
|
+
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.");
|
|
2655
|
+
return Buffer.concat(chunks);
|
|
2656
|
+
} finally {
|
|
2657
|
+
await handle.close();
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
async function checkProject(options = {}) {
|
|
2661
|
+
checkAborted(options.signal);
|
|
2662
|
+
const cwd = path5.resolve(options.cwd || process.cwd());
|
|
2663
|
+
const root = await fs6.realpath(gitOutputPath(await git(cwd, ["rev-parse", "--show-toplevel"], options.signal)));
|
|
2664
|
+
const config = loadConfig(root);
|
|
2665
|
+
const failOn = options.failOn ?? config.failOn ?? ["test-skip", "trivial-assert", "test-focus"];
|
|
2666
|
+
for (const rule of failOn) if (!VALID_DETECTORS.includes(rule)) throw new ConfigError(`Unknown rule '${rule}'.`);
|
|
2667
|
+
const format = options.format || "terminal";
|
|
2668
|
+
if (!["terminal", "json", "github", "markdown", "sarif"].includes(format)) throw new ConfigError("Unsupported check output format.");
|
|
2669
|
+
const baseline = options.baseline ? loadBaseline(path5.resolve(cwd, options.baseline)) : void 0;
|
|
2670
|
+
const filenames = [...new Set((await git(root, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], options.signal)).split("\0").filter(Boolean))].sort();
|
|
2671
|
+
const infractions = [];
|
|
2672
|
+
const skippedFiles = [];
|
|
2673
|
+
let filesInspected = 0;
|
|
2674
|
+
let linesInspected = 0;
|
|
2675
|
+
let totalBytes = 0;
|
|
2676
|
+
for (const file of filenames) {
|
|
2677
|
+
checkAborted(options.signal);
|
|
2678
|
+
if (!isCodeFile(file, [...GENERATED_PATHS, ...config.ignorePaths || []])) continue;
|
|
2679
|
+
try {
|
|
2680
|
+
const content = await readSource(root, file, options.signal);
|
|
2681
|
+
if (content === null) continue;
|
|
2682
|
+
totalBytes += content.length;
|
|
2683
|
+
if (totalBytes > MAX_SCAN_BYTES) throw new Error("Project exceeds the 50 MiB scan limit.");
|
|
2684
|
+
if (content.includes(0)) throw new Error("Binary content in a source file.");
|
|
2685
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(content);
|
|
2686
|
+
const lines = text.split(/\r?\n/);
|
|
2687
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
2688
|
+
const audit = auditFileDiffs([{ path: file, addedLines: lines.map((line, index) => ({ line, lineNumber: index + 1 })) }], "WORKTREE", "", { rules: config.rules });
|
|
2689
|
+
infractions.push(...audit.infractions);
|
|
2690
|
+
filesInspected++;
|
|
2691
|
+
linesInspected += lines.length;
|
|
2692
|
+
} catch (error) {
|
|
2693
|
+
checkAborted(options.signal);
|
|
2694
|
+
skippedFiles.push({ file, reason: error instanceof Error ? error.message : String(error) });
|
|
2695
|
+
if (totalBytes > MAX_SCAN_BYTES) break;
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
checkAborted(options.signal);
|
|
2699
|
+
const applied = baseline ? applyBaseline(infractions, baseline) : { findings: infractions, baselineCount: 0, staleBaselineCount: 0 };
|
|
2700
|
+
const notInBaseline = new Set(applied.findings);
|
|
2701
|
+
const acceptedCounts = /* @__PURE__ */ new Map();
|
|
2702
|
+
for (const finding of infractions) {
|
|
2703
|
+
if (notInBaseline.has(finding)) continue;
|
|
2704
|
+
const fingerprint = findingFingerprint(finding);
|
|
2705
|
+
acceptedCounts.set(fingerprint, (acceptedCounts.get(fingerprint) ?? 0) + 1);
|
|
2706
|
+
}
|
|
2707
|
+
const exceptions = applyExceptions(applied.findings, options.ignoreExceptions ? [] : config.exceptions, /* @__PURE__ */ new Date(), acceptedCounts);
|
|
2708
|
+
const findings = exceptions.findings;
|
|
2709
|
+
const failedRules = [...new Set(findings.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
|
|
2710
|
+
const complete = skippedFiles.length === 0;
|
|
2711
|
+
const passed = complete && failedRules.length === 0;
|
|
2712
|
+
const result = {
|
|
2713
|
+
schemaVersion: "1.0.0",
|
|
2714
|
+
mode: "check",
|
|
2715
|
+
filesInspected,
|
|
2716
|
+
linesInspected,
|
|
2717
|
+
totalFindings: infractions.length,
|
|
2718
|
+
infractions: findings,
|
|
2719
|
+
baselineCount: applied.baselineCount,
|
|
2720
|
+
staleBaselineCount: applied.staleBaselineCount,
|
|
2721
|
+
suppressedFindings: exceptions.suppressedFindings,
|
|
2722
|
+
expiredExceptionCount: exceptions.expiredExceptionCount,
|
|
2723
|
+
skippedFiles,
|
|
2724
|
+
complete,
|
|
2725
|
+
failedRules,
|
|
2726
|
+
passed,
|
|
2727
|
+
exitCode: complete ? passed ? 0 : 1 : 2,
|
|
2728
|
+
output: ""
|
|
2729
|
+
};
|
|
2730
|
+
if (format === "json") result.output = JSON.stringify(result, null, 2);
|
|
2731
|
+
else if (format === "sarif") result.output = formatSarif(result);
|
|
2732
|
+
else if (format === "markdown") result.output = formatCheckMarkdown(result);
|
|
2733
|
+
else if (format === "github") {
|
|
2734
|
+
result.output = formatGithubAnnotations(result) + (complete ? "" : "\n::error::Project scan incomplete. See the JSON or terminal report for skipped files.");
|
|
2735
|
+
} else result.output = formatCheckTerminal(result);
|
|
2736
|
+
return result;
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2359
2739
|
// src/review.ts
|
|
2740
|
+
import path6 from "path";
|
|
2360
2741
|
var GENERATED_PATHS2 = ["**/node_modules/**", "**/dist/**", "**/build/**", "**/vendor/**", "**/*.min.js", "**/*.map"];
|
|
2361
2742
|
var MAX_SCAN_BYTES2 = 50 * 1024 * 1024;
|
|
2362
2743
|
function compareFindings(before, after, newPath) {
|
|
@@ -2386,7 +2767,7 @@ function reviewStagedChanges(options = {}) {
|
|
|
2386
2767
|
}
|
|
2387
2768
|
async function compareSourceChanges(options) {
|
|
2388
2769
|
assertNotAborted(options.signal);
|
|
2389
|
-
const cwd =
|
|
2770
|
+
const cwd = path6.resolve(options.cwd || process.cwd());
|
|
2390
2771
|
const staged = options.staged ? await stagedSnapshot(cwd, options.signal) : void 0;
|
|
2391
2772
|
const refs = options.staged ? { baseSha: staged.baseSha, headSha: staged.headSha, mergeBaseSha: staged.mergeBaseSha } : await reviewRefs(cwd, options.base, options.head ?? "HEAD", options.signal);
|
|
2392
2773
|
const policy = refs.baseSha === "EMPTY" ? { config: configFromText("{}", "default first-commit policy"), filename: null } : await reviewPolicy(cwd, refs.baseSha, options.signal);
|
|
@@ -2424,6 +2805,7 @@ async function compareSourceChanges(options) {
|
|
|
2424
2805
|
output: ""
|
|
2425
2806
|
};
|
|
2426
2807
|
let totalBytes = 0;
|
|
2808
|
+
const existingCounts = /* @__PURE__ */ new Map();
|
|
2427
2809
|
for (const change of changes) {
|
|
2428
2810
|
assertNotAborted(options.signal);
|
|
2429
2811
|
const oldSupported = change.oldMode !== "000000" && isCodeFile(change.oldPath);
|
|
@@ -2459,6 +2841,12 @@ async function compareSourceChanges(options) {
|
|
|
2459
2841
|
const before = await scan(change.oldMode, change.oldOid, change.oldPath, refs.mergeBaseSha);
|
|
2460
2842
|
const after = await scan(change.newMode, change.newOid, change.newPath, refs.headSha);
|
|
2461
2843
|
const compared = compareFindings(before, after, change.newPath);
|
|
2844
|
+
const introduced = new Set(compared.introduced);
|
|
2845
|
+
for (const finding of after) {
|
|
2846
|
+
if (introduced.has(finding)) continue;
|
|
2847
|
+
const fingerprint = findingFingerprint(finding);
|
|
2848
|
+
existingCounts.set(fingerprint, (existingCounts.get(fingerprint) ?? 0) + 1);
|
|
2849
|
+
}
|
|
2462
2850
|
result.infractions.push(...compared.introduced);
|
|
2463
2851
|
result.removedFindings.push(...compared.removed);
|
|
2464
2852
|
result.unchangedCount += compared.unchanged;
|
|
@@ -2475,6 +2863,10 @@ async function compareSourceChanges(options) {
|
|
|
2475
2863
|
assertNotAborted(options.signal);
|
|
2476
2864
|
await staged?.verify();
|
|
2477
2865
|
result.complete = result.skippedFiles.length === 0;
|
|
2866
|
+
const exceptions = applyExceptions(result.infractions, options.ignoreExceptions ? [] : policy.config.exceptions, /* @__PURE__ */ new Date(), existingCounts);
|
|
2867
|
+
result.infractions = exceptions.findings;
|
|
2868
|
+
result.suppressedFindings = exceptions.suppressedFindings;
|
|
2869
|
+
result.expiredExceptionCount = exceptions.expiredExceptionCount;
|
|
2478
2870
|
result.failedRules = [...new Set(result.infractions.filter((finding) => failOn.includes(finding.type)).map((finding) => finding.type))];
|
|
2479
2871
|
result.passed = result.complete && result.failedRules.length === 0;
|
|
2480
2872
|
result.exitCode = !result.complete ? 2 : result.passed ? 0 : 1;
|
|
@@ -2494,9 +2886,9 @@ var InvalidDetectorError = class extends Error {
|
|
|
2494
2886
|
}
|
|
2495
2887
|
};
|
|
2496
2888
|
async function roastRepository(options = {}) {
|
|
2497
|
-
const cwd =
|
|
2889
|
+
const cwd = path7.resolve(options.cwd || process.cwd());
|
|
2498
2890
|
const since = options.since || "90 days ago";
|
|
2499
|
-
const repoName =
|
|
2891
|
+
const repoName = path7.basename(cwd);
|
|
2500
2892
|
const collector = new MetricsCollector({ all: options.all, ignorePaths: options.ignorePaths, rules: options.rules, panicLoopWindowMinutes: options.panicLoopWindowMinutes });
|
|
2501
2893
|
const gitStream = streamGitLog(cwd, since);
|
|
2502
2894
|
await parseGitLogStream(gitStream, (commit, stats) => {
|
|
@@ -2511,7 +2903,7 @@ async function roastRepository(options = {}) {
|
|
|
2511
2903
|
return { summary, output };
|
|
2512
2904
|
}
|
|
2513
2905
|
async function auditPullRequest(options) {
|
|
2514
|
-
const cwd =
|
|
2906
|
+
const cwd = path7.resolve(options.cwd || process.cwd());
|
|
2515
2907
|
const format = options.format || "terminal";
|
|
2516
2908
|
const failOn = (options.failOn || ["test-skip"]).map((s) => s.trim()).filter(Boolean);
|
|
2517
2909
|
for (const det of failOn) {
|
|
@@ -2530,15 +2922,26 @@ async function auditPullRequest(options) {
|
|
|
2530
2922
|
const baseSha = options.staged ? "INDEX" : resolveCommitSha(cwd, options.base, "Base ref");
|
|
2531
2923
|
const headSha = options.staged ? "INDEX" : resolveCommitSha(cwd, options.head || "HEAD", "Head ref");
|
|
2532
2924
|
const mergeBaseSha = options.staged ? "INDEX" : findMergeBase(cwd, baseSha, headSha);
|
|
2925
|
+
if (!["terminal", "json", "github", "markdown"].includes(format)) throw new InvalidDetectorError("Unsupported legacy audit output format.");
|
|
2533
2926
|
const diffStream = streamGitDiff(cwd, mergeBaseSha, headSha, options.staged);
|
|
2534
|
-
|
|
2927
|
+
const chunks = [];
|
|
2928
|
+
let bytes = 0;
|
|
2535
2929
|
await new Promise((resolve2, reject) => {
|
|
2536
2930
|
diffStream.on("data", (chunk) => {
|
|
2537
|
-
|
|
2931
|
+
bytes += chunk.length;
|
|
2932
|
+
if (bytes > 50 * 1024 * 1024) {
|
|
2933
|
+
diffStream.destroy(new GitResolutionError("Legacy diff exceeds the 50 MiB limit; use a smaller context review."));
|
|
2934
|
+
} else chunks.push(chunk);
|
|
2538
2935
|
});
|
|
2539
|
-
diffStream.on("end",
|
|
2936
|
+
diffStream.on("end", resolve2);
|
|
2540
2937
|
diffStream.on("error", reject);
|
|
2541
2938
|
});
|
|
2939
|
+
let rawDiff;
|
|
2940
|
+
try {
|
|
2941
|
+
rawDiff = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
|
|
2942
|
+
} catch {
|
|
2943
|
+
throw new GitResolutionError("Legacy diff contains invalid UTF-8; no complete result was produced.");
|
|
2944
|
+
}
|
|
2542
2945
|
const { files, skippedLongLines, skippedUnknownFiles } = parseDiff(rawDiff);
|
|
2543
2946
|
const audit = auditFileDiffs(files, headSha, (/* @__PURE__ */ new Date()).toISOString(), { ignorePaths: options.ignorePaths, rules: options.rules });
|
|
2544
2947
|
const infractions = audit.infractions;
|
|
@@ -2546,8 +2949,10 @@ async function auditPullRequest(options) {
|
|
|
2546
2949
|
const failedRules = [...new Set(
|
|
2547
2950
|
infractions.filter((inf) => failOn.includes(inf.type)).map((inf) => inf.type)
|
|
2548
2951
|
)];
|
|
2549
|
-
const
|
|
2550
|
-
const
|
|
2952
|
+
const skippedBinaryFiles = files.filter((file) => file.binary && isCodeFile(file.path, options.ignorePaths)).length;
|
|
2953
|
+
const complete = skippedLongLines === 0 && skippedUnknownFiles === 0 && skippedBinaryFiles === 0;
|
|
2954
|
+
const passed = complete && failedRules.length === 0;
|
|
2955
|
+
const exitCode = !complete ? 2 : passed ? 0 : 1;
|
|
2551
2956
|
const result = {
|
|
2552
2957
|
schemaVersion: "1.0.0",
|
|
2553
2958
|
mode: options.staged ? "staged" : "pr",
|
|
@@ -2561,15 +2966,17 @@ async function auditPullRequest(options) {
|
|
|
2561
2966
|
infractions,
|
|
2562
2967
|
failedRules,
|
|
2563
2968
|
passed,
|
|
2969
|
+
complete,
|
|
2564
2970
|
skippedLongLines,
|
|
2565
2971
|
skippedUnknownFiles,
|
|
2972
|
+
skippedBinaryFiles,
|
|
2566
2973
|
output: "",
|
|
2567
2974
|
exitCode
|
|
2568
2975
|
};
|
|
2569
2976
|
if (format === "json") {
|
|
2570
2977
|
result.output = JSON.stringify(result, null, 2);
|
|
2571
2978
|
} else if (format === "github") {
|
|
2572
|
-
result.output = formatGithubAnnotations(result);
|
|
2979
|
+
result.output = formatGithubAnnotations(result) + (complete ? "" : "\n::error::Legacy audit incomplete: long lines, binary-marked or unparsed source were skipped. Use context review.");
|
|
2573
2980
|
} else if (format === "markdown") {
|
|
2574
2981
|
result.output = formatPrMarkdownComment(result);
|
|
2575
2982
|
} else {
|
|
@@ -2579,12 +2986,28 @@ async function auditPullRequest(options) {
|
|
|
2579
2986
|
}
|
|
2580
2987
|
|
|
2581
2988
|
export {
|
|
2989
|
+
VALID_DETECTORS,
|
|
2990
|
+
findingFingerprint,
|
|
2991
|
+
createBaseline,
|
|
2992
|
+
loadBaseline,
|
|
2993
|
+
applyBaseline,
|
|
2994
|
+
writeBaseline,
|
|
2995
|
+
validateExceptions,
|
|
2996
|
+
applyExceptions,
|
|
2997
|
+
MAX_CONFIG_BYTES,
|
|
2998
|
+
DEFAULT_CONFIG,
|
|
2999
|
+
CONFIG_FILENAMES,
|
|
3000
|
+
ConfigError,
|
|
3001
|
+
configFromText,
|
|
3002
|
+
loadConfig,
|
|
3003
|
+
matchIgnorePath,
|
|
2582
3004
|
detectAiAttribution,
|
|
2583
3005
|
RECORD_SEPARATOR,
|
|
2584
3006
|
FIELD_SEPARATOR,
|
|
2585
3007
|
GitError,
|
|
2586
3008
|
HERMETIC_GIT_FLAGS,
|
|
2587
3009
|
getHermeticGitEnv,
|
|
3010
|
+
gitOutputPath,
|
|
2588
3011
|
parseGitLogStream,
|
|
2589
3012
|
unquoteGitPath,
|
|
2590
3013
|
parseDiff,
|
|
@@ -2593,14 +3016,8 @@ export {
|
|
|
2593
3016
|
resolveCommitSha,
|
|
2594
3017
|
findMergeBase,
|
|
2595
3018
|
GitResolutionError,
|
|
3019
|
+
readGit,
|
|
2596
3020
|
RULE_GUIDES,
|
|
2597
|
-
VALID_DETECTORS,
|
|
2598
|
-
DEFAULT_CONFIG,
|
|
2599
|
-
CONFIG_FILENAMES,
|
|
2600
|
-
ConfigError,
|
|
2601
|
-
configFromText,
|
|
2602
|
-
loadConfig,
|
|
2603
|
-
matchIgnorePath,
|
|
2604
3021
|
isCodeFile,
|
|
2605
3022
|
isTestFile,
|
|
2606
3023
|
auditFileDiffs,
|
|
@@ -2612,6 +3029,7 @@ export {
|
|
|
2612
3029
|
formatGithubAnnotations,
|
|
2613
3030
|
formatGithubStepSummary,
|
|
2614
3031
|
formatPrMarkdownComment,
|
|
3032
|
+
terminalText,
|
|
2615
3033
|
formatCheckTerminal,
|
|
2616
3034
|
formatCheckMarkdown,
|
|
2617
3035
|
formatReviewTerminal,
|
|
@@ -2624,11 +3042,6 @@ export {
|
|
|
2624
3042
|
generateSvgBadge,
|
|
2625
3043
|
generateShieldsMarkdown,
|
|
2626
3044
|
generateShieldsJson,
|
|
2627
|
-
findingFingerprint,
|
|
2628
|
-
createBaseline,
|
|
2629
|
-
loadBaseline,
|
|
2630
|
-
applyBaseline,
|
|
2631
|
-
writeBaseline,
|
|
2632
3045
|
formatSarif,
|
|
2633
3046
|
checkProject,
|
|
2634
3047
|
compareFindings,
|