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