roast-my-codebase 1.0.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/LICENSE +21 -0
- package/README.md +468 -0
- package/dist/index.js +4480 -0
- package/package.json +67 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4480 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
3
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
4
|
+
}) : x)(function(x) {
|
|
5
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/cli/index.ts
|
|
10
|
+
import { Command } from "commander";
|
|
11
|
+
import path15 from "path";
|
|
12
|
+
import fs17 from "fs";
|
|
13
|
+
import { fileURLToPath } from "url";
|
|
14
|
+
import ora from "ora";
|
|
15
|
+
import chalk8 from "chalk";
|
|
16
|
+
|
|
17
|
+
// src/scanners/files.ts
|
|
18
|
+
import fg from "fast-glob";
|
|
19
|
+
import path2 from "path";
|
|
20
|
+
|
|
21
|
+
// src/utils/constants.ts
|
|
22
|
+
var SOURCE_EXTENSIONS = [
|
|
23
|
+
".ts",
|
|
24
|
+
".tsx",
|
|
25
|
+
".js",
|
|
26
|
+
".jsx",
|
|
27
|
+
".mjs",
|
|
28
|
+
".cjs",
|
|
29
|
+
".py",
|
|
30
|
+
// Python
|
|
31
|
+
".go",
|
|
32
|
+
// Go
|
|
33
|
+
".rs",
|
|
34
|
+
// Rust
|
|
35
|
+
".java",
|
|
36
|
+
// Java
|
|
37
|
+
".cs"
|
|
38
|
+
// C#
|
|
39
|
+
];
|
|
40
|
+
var IGNORE_PATTERNS = [
|
|
41
|
+
"**/node_modules/**",
|
|
42
|
+
"**/dist/**",
|
|
43
|
+
"**/build/**",
|
|
44
|
+
"**/.next/**",
|
|
45
|
+
"**/coverage/**",
|
|
46
|
+
"**/.git/**",
|
|
47
|
+
"**/.turbo/**",
|
|
48
|
+
"**/.cache/**",
|
|
49
|
+
"**/out/**",
|
|
50
|
+
"**/.output/**"
|
|
51
|
+
];
|
|
52
|
+
var LARGE_FILE_THRESHOLDS = {
|
|
53
|
+
warning: 500,
|
|
54
|
+
large: 1e3,
|
|
55
|
+
extreme: 2e3
|
|
56
|
+
};
|
|
57
|
+
var HEALTH_DEDUCTIONS = {
|
|
58
|
+
unusedDependency: -2,
|
|
59
|
+
todo: -0.25,
|
|
60
|
+
largeFile: -3,
|
|
61
|
+
extremeFile: -5,
|
|
62
|
+
circularDependency: -5,
|
|
63
|
+
criticalIssue: -10,
|
|
64
|
+
excessiveDeps: -5,
|
|
65
|
+
deepNesting: -2,
|
|
66
|
+
utilExplosion: -1,
|
|
67
|
+
complexFunction: -2,
|
|
68
|
+
veryComplexFunction: -4,
|
|
69
|
+
duplicateCode: -3,
|
|
70
|
+
deadExport: -1,
|
|
71
|
+
typeSafetyIssue: -2,
|
|
72
|
+
criticalTypeSafety: -5,
|
|
73
|
+
gitChurn: -3,
|
|
74
|
+
largePRSize: -2,
|
|
75
|
+
secret: -10,
|
|
76
|
+
envInGit: -10,
|
|
77
|
+
evalUsage: -3,
|
|
78
|
+
missingTest: -0.5,
|
|
79
|
+
frameworkViolation: -2
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// src/utils/files.ts
|
|
83
|
+
import fs from "fs";
|
|
84
|
+
import path from "path";
|
|
85
|
+
function readFileLines(filePath) {
|
|
86
|
+
try {
|
|
87
|
+
return fs.readFileSync(filePath, "utf-8").split("\n");
|
|
88
|
+
} catch {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function countLines(filePath) {
|
|
93
|
+
try {
|
|
94
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
95
|
+
return content.split("\n").length;
|
|
96
|
+
} catch {
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function relativePath(rootDir, filePath) {
|
|
101
|
+
return path.relative(rootDir, filePath).replace(/\\/g, "/");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/scanners/files.ts
|
|
105
|
+
var FileScanner = class {
|
|
106
|
+
constructor() {
|
|
107
|
+
this.name = "files";
|
|
108
|
+
}
|
|
109
|
+
async scan(rootDir) {
|
|
110
|
+
const findings = [];
|
|
111
|
+
const extGlob = SOURCE_EXTENSIONS.map((e) => `**/*${e}`);
|
|
112
|
+
const allFiles = await fg(extGlob, {
|
|
113
|
+
cwd: rootDir,
|
|
114
|
+
ignore: IGNORE_PATTERNS,
|
|
115
|
+
absolute: true
|
|
116
|
+
});
|
|
117
|
+
const allProjectFiles = await fg("**/*", {
|
|
118
|
+
cwd: rootDir,
|
|
119
|
+
ignore: IGNORE_PATTERNS,
|
|
120
|
+
onlyFiles: true
|
|
121
|
+
});
|
|
122
|
+
const fileSizes = [];
|
|
123
|
+
let totalLines = 0;
|
|
124
|
+
for (const file of allFiles) {
|
|
125
|
+
const lines = countLines(file);
|
|
126
|
+
totalLines += lines;
|
|
127
|
+
const rel = relativePath(rootDir, file);
|
|
128
|
+
fileSizes.push({ path: rel, lines });
|
|
129
|
+
if (lines >= LARGE_FILE_THRESHOLDS.extreme) {
|
|
130
|
+
findings.push({
|
|
131
|
+
id: `large-file-extreme-${rel}`,
|
|
132
|
+
severity: "critical",
|
|
133
|
+
category: "large-files",
|
|
134
|
+
message: `${rel} is ${lines.toLocaleString()} lines \u2014 this file has its own gravitational field`,
|
|
135
|
+
file: rel,
|
|
136
|
+
detail: `${lines} lines`
|
|
137
|
+
});
|
|
138
|
+
} else if (lines >= LARGE_FILE_THRESHOLDS.large) {
|
|
139
|
+
findings.push({
|
|
140
|
+
id: `large-file-${rel}`,
|
|
141
|
+
severity: "warning",
|
|
142
|
+
category: "large-files",
|
|
143
|
+
message: `${rel} is ${lines.toLocaleString()} lines`,
|
|
144
|
+
file: rel,
|
|
145
|
+
detail: `${lines} lines`
|
|
146
|
+
});
|
|
147
|
+
} else if (lines >= LARGE_FILE_THRESHOLDS.warning) {
|
|
148
|
+
findings.push({
|
|
149
|
+
id: `large-file-warn-${rel}`,
|
|
150
|
+
severity: "info",
|
|
151
|
+
category: "large-files",
|
|
152
|
+
message: `${rel} is ${lines.toLocaleString()} lines`,
|
|
153
|
+
file: rel,
|
|
154
|
+
detail: `${lines} lines`
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
fileSizes.sort((a, b) => b.lines - a.lines);
|
|
159
|
+
const pkgPath = path2.join(rootDir, "package.json");
|
|
160
|
+
let dependencies = 0;
|
|
161
|
+
let devDependencies = 0;
|
|
162
|
+
try {
|
|
163
|
+
const pkg = await import("fs").then(
|
|
164
|
+
(fs18) => JSON.parse(fs18.readFileSync(pkgPath, "utf-8"))
|
|
165
|
+
);
|
|
166
|
+
dependencies = Object.keys(pkg.dependencies || {}).length;
|
|
167
|
+
devDependencies = Object.keys(pkg.devDependencies || {}).length;
|
|
168
|
+
} catch {
|
|
169
|
+
}
|
|
170
|
+
const stats = {
|
|
171
|
+
totalFiles: allProjectFiles.length,
|
|
172
|
+
sourceFiles: allFiles.length,
|
|
173
|
+
totalLines,
|
|
174
|
+
largestFiles: fileSizes.slice(0, 5),
|
|
175
|
+
dependencies,
|
|
176
|
+
devDependencies
|
|
177
|
+
};
|
|
178
|
+
return { findings, stats };
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// src/scanners/todos.ts
|
|
183
|
+
import fg2 from "fast-glob";
|
|
184
|
+
var TodoScanner = class {
|
|
185
|
+
constructor() {
|
|
186
|
+
this.name = "todos";
|
|
187
|
+
}
|
|
188
|
+
async scan(rootDir) {
|
|
189
|
+
const findings = [];
|
|
190
|
+
const extGlob = SOURCE_EXTENSIONS.map((e) => `**/*${e}`);
|
|
191
|
+
const files = await fg2(extGlob, {
|
|
192
|
+
cwd: rootDir,
|
|
193
|
+
ignore: IGNORE_PATTERNS,
|
|
194
|
+
absolute: true
|
|
195
|
+
});
|
|
196
|
+
let totalTodos = 0;
|
|
197
|
+
let totalFixmes = 0;
|
|
198
|
+
let totalHacks = 0;
|
|
199
|
+
for (const file of files) {
|
|
200
|
+
const lines = readFileLines(file);
|
|
201
|
+
const rel = relativePath(rootDir, file);
|
|
202
|
+
for (let i = 0; i < lines.length; i++) {
|
|
203
|
+
const line = lines[i];
|
|
204
|
+
if (/\bTODO\b/.test(line)) totalTodos++;
|
|
205
|
+
if (/\bFIXME\b/.test(line)) totalFixmes++;
|
|
206
|
+
if (/\bHACK\b/.test(line) || /\bXXX\b/.test(line)) totalHacks++;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const total = totalTodos + totalFixmes + totalHacks;
|
|
210
|
+
if (total > 0) {
|
|
211
|
+
findings.push({
|
|
212
|
+
id: "todo-count",
|
|
213
|
+
severity: total > 50 ? "warning" : "info",
|
|
214
|
+
category: "todos",
|
|
215
|
+
message: `Found ${total} comment markers (${totalTodos} TODO, ${totalFixmes} FIXME, ${totalHacks} HACK/XXX)`
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (totalHacks > 5) {
|
|
219
|
+
findings.push({
|
|
220
|
+
id: "hack-count",
|
|
221
|
+
severity: "warning",
|
|
222
|
+
category: "todos",
|
|
223
|
+
message: `${totalHacks} HACK/XXX comments \u2014 someone was in survival mode`
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
findings,
|
|
228
|
+
stats: { totalTodos, totalFixmes, totalHacks, total }
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// src/scanners/dependencies.ts
|
|
234
|
+
import fg3 from "fast-glob";
|
|
235
|
+
import path3 from "path";
|
|
236
|
+
import fs2 from "fs";
|
|
237
|
+
var DependencyScanner = class {
|
|
238
|
+
constructor() {
|
|
239
|
+
this.name = "dependencies";
|
|
240
|
+
}
|
|
241
|
+
async scan(rootDir) {
|
|
242
|
+
const findings = [];
|
|
243
|
+
const pkgPath = path3.join(rootDir, "package.json");
|
|
244
|
+
if (!fs2.existsSync(pkgPath)) {
|
|
245
|
+
return { findings };
|
|
246
|
+
}
|
|
247
|
+
const pkg = JSON.parse(fs2.readFileSync(pkgPath, "utf-8"));
|
|
248
|
+
const deps = Object.keys(pkg.dependencies || {});
|
|
249
|
+
const devDeps = Object.keys(pkg.devDependencies || {});
|
|
250
|
+
const totalDeps = deps.length + devDeps.length;
|
|
251
|
+
if (totalDeps > 150) {
|
|
252
|
+
findings.push({
|
|
253
|
+
id: "excessive-deps",
|
|
254
|
+
severity: "critical",
|
|
255
|
+
category: "dependencies",
|
|
256
|
+
message: `${totalDeps} total dependencies \u2014 this package.json has commitment issues`
|
|
257
|
+
});
|
|
258
|
+
} else if (totalDeps > 80) {
|
|
259
|
+
findings.push({
|
|
260
|
+
id: "many-deps",
|
|
261
|
+
severity: "warning",
|
|
262
|
+
category: "dependencies",
|
|
263
|
+
message: `${totalDeps} total dependencies`
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
const extGlob = SOURCE_EXTENSIONS.map((e) => `**/*${e}`);
|
|
267
|
+
const sourceFiles = await fg3(extGlob, {
|
|
268
|
+
cwd: rootDir,
|
|
269
|
+
ignore: IGNORE_PATTERNS,
|
|
270
|
+
absolute: true
|
|
271
|
+
});
|
|
272
|
+
const allImports = /* @__PURE__ */ new Set();
|
|
273
|
+
for (const file of sourceFiles) {
|
|
274
|
+
try {
|
|
275
|
+
const content = fs2.readFileSync(file, "utf-8");
|
|
276
|
+
const patterns = [
|
|
277
|
+
/from\s+['"]([^./][^'"]*)['"]/g,
|
|
278
|
+
/import\s+['"]([^./][^'"]*)['"]/g,
|
|
279
|
+
/require\s*\(\s*['"]([^./][^'"]*)['"]\s*\)/g
|
|
280
|
+
];
|
|
281
|
+
for (const regex of patterns) {
|
|
282
|
+
for (const match of content.matchAll(regex)) {
|
|
283
|
+
const pkg2 = match[1].startsWith("@") ? match[1].split("/").slice(0, 2).join("/") : match[1].split("/")[0];
|
|
284
|
+
allImports.add(pkg2);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const configFiles = await fg3(
|
|
291
|
+
[
|
|
292
|
+
"*.config.*",
|
|
293
|
+
".babelrc",
|
|
294
|
+
".eslintrc*",
|
|
295
|
+
"tsconfig.json",
|
|
296
|
+
"jest.config.*",
|
|
297
|
+
"vite.config.*",
|
|
298
|
+
"next.config.*",
|
|
299
|
+
"tailwind.config.*",
|
|
300
|
+
"postcss.config.*"
|
|
301
|
+
],
|
|
302
|
+
{ cwd: rootDir, absolute: true }
|
|
303
|
+
);
|
|
304
|
+
for (const cf of configFiles) {
|
|
305
|
+
try {
|
|
306
|
+
const content = fs2.readFileSync(cf, "utf-8");
|
|
307
|
+
for (const dep of deps) {
|
|
308
|
+
if (content.includes(dep)) {
|
|
309
|
+
allImports.add(dep);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
} catch {
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const implicitDeps = /* @__PURE__ */ new Set([
|
|
316
|
+
"typescript",
|
|
317
|
+
"@types/node",
|
|
318
|
+
"@types/react",
|
|
319
|
+
"@types/react-dom",
|
|
320
|
+
"eslint",
|
|
321
|
+
"prettier",
|
|
322
|
+
"husky",
|
|
323
|
+
"lint-staged",
|
|
324
|
+
"nodemon",
|
|
325
|
+
"ts-node",
|
|
326
|
+
"tsx",
|
|
327
|
+
"concurrently",
|
|
328
|
+
"cross-env",
|
|
329
|
+
"dotenv",
|
|
330
|
+
"env-cmd"
|
|
331
|
+
]);
|
|
332
|
+
const unusedDeps = [];
|
|
333
|
+
for (const dep of deps) {
|
|
334
|
+
if (implicitDeps.has(dep)) continue;
|
|
335
|
+
if (dep.startsWith("@types/")) continue;
|
|
336
|
+
if (!allImports.has(dep)) {
|
|
337
|
+
unusedDeps.push(dep);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (unusedDeps.length > 0) {
|
|
341
|
+
for (const dep of unusedDeps.slice(0, 10)) {
|
|
342
|
+
findings.push({
|
|
343
|
+
id: `unused-dep-${dep}`,
|
|
344
|
+
severity: "warning",
|
|
345
|
+
category: "unused-deps",
|
|
346
|
+
message: `"${dep}" appears unused \u2014 paying rent for no reason`,
|
|
347
|
+
detail: dep
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
if (unusedDeps.length > 10) {
|
|
351
|
+
findings.push({
|
|
352
|
+
id: "unused-deps-overflow",
|
|
353
|
+
severity: "warning",
|
|
354
|
+
category: "unused-deps",
|
|
355
|
+
message: `...and ${unusedDeps.length - 10} more potentially unused dependencies`
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
findings,
|
|
361
|
+
stats: {
|
|
362
|
+
deps: deps.length,
|
|
363
|
+
devDeps: devDeps.length,
|
|
364
|
+
total: totalDeps,
|
|
365
|
+
unusedCount: unusedDeps.length,
|
|
366
|
+
unused: unusedDeps
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// src/scanners/circular.ts
|
|
373
|
+
import fg4 from "fast-glob";
|
|
374
|
+
import path4 from "path";
|
|
375
|
+
import fs3 from "fs";
|
|
376
|
+
var CircularDependencyScanner = class {
|
|
377
|
+
constructor() {
|
|
378
|
+
this.name = "circular";
|
|
379
|
+
}
|
|
380
|
+
async scan(rootDir) {
|
|
381
|
+
const findings = [];
|
|
382
|
+
const extGlob = SOURCE_EXTENSIONS.map((e) => `**/*${e}`);
|
|
383
|
+
const files = await fg4(extGlob, {
|
|
384
|
+
cwd: rootDir,
|
|
385
|
+
ignore: IGNORE_PATTERNS,
|
|
386
|
+
absolute: true
|
|
387
|
+
});
|
|
388
|
+
const graph = /* @__PURE__ */ new Map();
|
|
389
|
+
for (const file of files) {
|
|
390
|
+
const rel = relativePath(rootDir, file);
|
|
391
|
+
const imports = this.extractImports(file, rootDir);
|
|
392
|
+
graph.set(rel, imports);
|
|
393
|
+
}
|
|
394
|
+
const cycles = this.findCycles(graph);
|
|
395
|
+
for (const cycle of cycles.slice(0, 5)) {
|
|
396
|
+
const chain = cycle.join(" \u2192 ");
|
|
397
|
+
findings.push({
|
|
398
|
+
id: `circular-${cycle[0]}`,
|
|
399
|
+
severity: "warning",
|
|
400
|
+
category: "circular-deps",
|
|
401
|
+
message: `Circular dependency: ${chain}`,
|
|
402
|
+
file: cycle[0],
|
|
403
|
+
detail: chain
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
if (cycles.length > 5) {
|
|
407
|
+
findings.push({
|
|
408
|
+
id: "circular-overflow",
|
|
409
|
+
severity: "warning",
|
|
410
|
+
category: "circular-deps",
|
|
411
|
+
message: `...and ${cycles.length - 5} more circular dependency chains`
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
findings,
|
|
416
|
+
stats: { cycleCount: cycles.length }
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
extractImports(filePath, rootDir) {
|
|
420
|
+
const imports = /* @__PURE__ */ new Set();
|
|
421
|
+
try {
|
|
422
|
+
const content = fs3.readFileSync(filePath, "utf-8");
|
|
423
|
+
const dir = path4.dirname(filePath);
|
|
424
|
+
const importRegex = /(?:import|export)\s+.*?from\s+['"](\.[^'"]+)['"]/g;
|
|
425
|
+
const requireRegex = /require\s*\(\s*['"](\.[^'"]+)['"]\s*\)/g;
|
|
426
|
+
for (const regex of [importRegex, requireRegex]) {
|
|
427
|
+
let match;
|
|
428
|
+
while ((match = regex.exec(content)) !== null) {
|
|
429
|
+
const resolved = this.resolveImport(match[1], dir, rootDir);
|
|
430
|
+
if (resolved) {
|
|
431
|
+
imports.add(resolved);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
} catch {
|
|
436
|
+
}
|
|
437
|
+
return imports;
|
|
438
|
+
}
|
|
439
|
+
resolveImport(importPath, fromDir, rootDir) {
|
|
440
|
+
const resolved = path4.resolve(fromDir, importPath);
|
|
441
|
+
const rel = relativePath(rootDir, resolved);
|
|
442
|
+
const stripped = rel.replace(/\.(js|mjs|cjs)$/, "");
|
|
443
|
+
const candidates = [rel, stripped];
|
|
444
|
+
for (const base of candidates) {
|
|
445
|
+
for (const ext of ["", ...SOURCE_EXTENSIONS]) {
|
|
446
|
+
const candidate = base + ext;
|
|
447
|
+
if (fs3.existsSync(path4.join(rootDir, candidate))) {
|
|
448
|
+
return candidate;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
for (const ext of SOURCE_EXTENSIONS) {
|
|
452
|
+
const candidateIndex = base + "/index" + ext;
|
|
453
|
+
if (fs3.existsSync(path4.join(rootDir, candidateIndex))) {
|
|
454
|
+
return candidateIndex;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
findCycles(graph) {
|
|
461
|
+
const cycles = [];
|
|
462
|
+
const visited = /* @__PURE__ */ new Set();
|
|
463
|
+
const inStack = /* @__PURE__ */ new Set();
|
|
464
|
+
const stack = [];
|
|
465
|
+
const dfs = (node) => {
|
|
466
|
+
if (cycles.length >= 10) return;
|
|
467
|
+
visited.add(node);
|
|
468
|
+
inStack.add(node);
|
|
469
|
+
stack.push(node);
|
|
470
|
+
const neighbors = graph.get(node) || /* @__PURE__ */ new Set();
|
|
471
|
+
for (const neighbor of neighbors) {
|
|
472
|
+
if (!graph.has(neighbor)) continue;
|
|
473
|
+
if (!visited.has(neighbor)) {
|
|
474
|
+
dfs(neighbor);
|
|
475
|
+
} else if (inStack.has(neighbor)) {
|
|
476
|
+
const cycleStart = stack.indexOf(neighbor);
|
|
477
|
+
const cycle = stack.slice(cycleStart);
|
|
478
|
+
cycle.push(neighbor);
|
|
479
|
+
cycles.push(cycle);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
stack.pop();
|
|
483
|
+
inStack.delete(node);
|
|
484
|
+
};
|
|
485
|
+
for (const node of graph.keys()) {
|
|
486
|
+
if (!visited.has(node)) {
|
|
487
|
+
dfs(node);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return cycles;
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
// src/scanners/structure.ts
|
|
495
|
+
import fg5 from "fast-glob";
|
|
496
|
+
import path5 from "path";
|
|
497
|
+
var UTIL_PATTERNS = [
|
|
498
|
+
"utils",
|
|
499
|
+
"helpers",
|
|
500
|
+
"common",
|
|
501
|
+
"shared",
|
|
502
|
+
"misc",
|
|
503
|
+
"lib",
|
|
504
|
+
"tools"
|
|
505
|
+
];
|
|
506
|
+
var StructureScanner = class {
|
|
507
|
+
constructor() {
|
|
508
|
+
this.name = "structure";
|
|
509
|
+
}
|
|
510
|
+
async scan(rootDir) {
|
|
511
|
+
const findings = [];
|
|
512
|
+
const allFiles = await fg5("**/*", {
|
|
513
|
+
cwd: rootDir,
|
|
514
|
+
ignore: IGNORE_PATTERNS,
|
|
515
|
+
onlyFiles: true
|
|
516
|
+
});
|
|
517
|
+
let maxDepth = 0;
|
|
518
|
+
let deepestPath = "";
|
|
519
|
+
for (const file of allFiles) {
|
|
520
|
+
const depth = file.split("/").length;
|
|
521
|
+
if (depth > maxDepth) {
|
|
522
|
+
maxDepth = depth;
|
|
523
|
+
deepestPath = file;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (maxDepth > 10) {
|
|
527
|
+
findings.push({
|
|
528
|
+
id: "deep-nesting",
|
|
529
|
+
severity: "warning",
|
|
530
|
+
category: "structure",
|
|
531
|
+
message: `Maximum nesting depth is ${maxDepth} levels \u2014 someone really loves subdirectories`,
|
|
532
|
+
file: deepestPath,
|
|
533
|
+
detail: `${maxDepth} levels deep`
|
|
534
|
+
});
|
|
535
|
+
} else if (maxDepth > 7) {
|
|
536
|
+
findings.push({
|
|
537
|
+
id: "moderate-nesting",
|
|
538
|
+
severity: "info",
|
|
539
|
+
category: "structure",
|
|
540
|
+
message: `Nesting depth reaches ${maxDepth} levels`,
|
|
541
|
+
file: deepestPath
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
const folderCounts = /* @__PURE__ */ new Map();
|
|
545
|
+
for (const file of allFiles) {
|
|
546
|
+
const dir = path5.dirname(file);
|
|
547
|
+
folderCounts.set(dir, (folderCounts.get(dir) || 0) + 1);
|
|
548
|
+
}
|
|
549
|
+
for (const [dir, count] of folderCounts) {
|
|
550
|
+
if (count > 50) {
|
|
551
|
+
findings.push({
|
|
552
|
+
id: `large-folder-${dir}`,
|
|
553
|
+
severity: "warning",
|
|
554
|
+
category: "structure",
|
|
555
|
+
message: `${dir}/ contains ${count} files \u2014 this folder needs a filing cabinet`,
|
|
556
|
+
file: dir
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const utilFiles = [];
|
|
561
|
+
for (const file of allFiles) {
|
|
562
|
+
const basename = path5.basename(file, path5.extname(file)).toLowerCase();
|
|
563
|
+
if (UTIL_PATTERNS.some((p) => basename.includes(p))) {
|
|
564
|
+
utilFiles.push(file);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (utilFiles.length > 5) {
|
|
568
|
+
findings.push({
|
|
569
|
+
id: "util-explosion",
|
|
570
|
+
severity: "warning",
|
|
571
|
+
category: "structure",
|
|
572
|
+
message: `${utilFiles.length} utility/helper files detected \u2014 the junk drawer is overflowing`,
|
|
573
|
+
detail: utilFiles.slice(0, 5).join(", ")
|
|
574
|
+
});
|
|
575
|
+
} else if (utilFiles.length > 2) {
|
|
576
|
+
findings.push({
|
|
577
|
+
id: "util-files",
|
|
578
|
+
severity: "info",
|
|
579
|
+
category: "structure",
|
|
580
|
+
message: `${utilFiles.length} utility/helper files`
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
const componentFiles = await fg5(
|
|
584
|
+
["**/*.tsx", "**/*.jsx"],
|
|
585
|
+
{
|
|
586
|
+
cwd: rootDir,
|
|
587
|
+
ignore: IGNORE_PATTERNS
|
|
588
|
+
}
|
|
589
|
+
);
|
|
590
|
+
return {
|
|
591
|
+
findings,
|
|
592
|
+
stats: {
|
|
593
|
+
maxDepth,
|
|
594
|
+
utilFiles: utilFiles.length,
|
|
595
|
+
componentFiles: componentFiles.length,
|
|
596
|
+
totalFolders: folderCounts.size
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// src/scanners/complexity.ts
|
|
603
|
+
import fs4 from "fs";
|
|
604
|
+
import fg6 from "fast-glob";
|
|
605
|
+
var WARNING_THRESHOLD = 15;
|
|
606
|
+
var CRITICAL_THRESHOLD = 25;
|
|
607
|
+
var ComplexityScanner = class {
|
|
608
|
+
constructor() {
|
|
609
|
+
this.name = "complexity";
|
|
610
|
+
}
|
|
611
|
+
async scan(rootDir) {
|
|
612
|
+
const findings = [];
|
|
613
|
+
const extGlob = SOURCE_EXTENSIONS.map((e) => `**/*${e}`);
|
|
614
|
+
const allFiles = await fg6(extGlob, {
|
|
615
|
+
cwd: rootDir,
|
|
616
|
+
ignore: IGNORE_PATTERNS,
|
|
617
|
+
absolute: true
|
|
618
|
+
});
|
|
619
|
+
let totalComplexity = 0;
|
|
620
|
+
let totalFunctions = 0;
|
|
621
|
+
let maxComplexity = 0;
|
|
622
|
+
let complexFunctions = 0;
|
|
623
|
+
for (const file of allFiles) {
|
|
624
|
+
let content;
|
|
625
|
+
try {
|
|
626
|
+
content = fs4.readFileSync(file, "utf-8");
|
|
627
|
+
} catch {
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
const functions = extractFunctions(content);
|
|
631
|
+
const rel = relativePath(rootDir, file);
|
|
632
|
+
for (const fn of functions) {
|
|
633
|
+
const complexity = calculateComplexity(fn.body);
|
|
634
|
+
totalComplexity += complexity;
|
|
635
|
+
totalFunctions++;
|
|
636
|
+
if (complexity > maxComplexity) {
|
|
637
|
+
maxComplexity = complexity;
|
|
638
|
+
}
|
|
639
|
+
if (complexity >= CRITICAL_THRESHOLD) {
|
|
640
|
+
complexFunctions++;
|
|
641
|
+
findings.push({
|
|
642
|
+
id: `complexity-critical-${rel}-${fn.name}`,
|
|
643
|
+
severity: "critical",
|
|
644
|
+
category: "complexity",
|
|
645
|
+
message: `${fn.name} in ${rel} has cyclomatic complexity of ${complexity} \u2014 this function needs its own zip code`,
|
|
646
|
+
file: rel,
|
|
647
|
+
detail: `complexity: ${complexity}`
|
|
648
|
+
});
|
|
649
|
+
} else if (complexity >= WARNING_THRESHOLD) {
|
|
650
|
+
complexFunctions++;
|
|
651
|
+
findings.push({
|
|
652
|
+
id: `complexity-warning-${rel}-${fn.name}`,
|
|
653
|
+
severity: "warning",
|
|
654
|
+
category: "complexity",
|
|
655
|
+
message: `${fn.name} in ${rel} has cyclomatic complexity of ${complexity}`,
|
|
656
|
+
file: rel,
|
|
657
|
+
detail: `complexity: ${complexity}`
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const stats = {
|
|
663
|
+
totalFunctions,
|
|
664
|
+
averageComplexity: totalFunctions > 0 ? Math.round(totalComplexity / totalFunctions * 100) / 100 : 0,
|
|
665
|
+
maxComplexity,
|
|
666
|
+
complexFunctions
|
|
667
|
+
};
|
|
668
|
+
return { findings, stats };
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
function extractFunctions(content) {
|
|
672
|
+
const functions = [];
|
|
673
|
+
const lines = content.split("\n");
|
|
674
|
+
const patterns = [
|
|
675
|
+
// function declarations: function name(, async function name(
|
|
676
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- Bounded by file content, not user input
|
|
677
|
+
/(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/,
|
|
678
|
+
// arrow functions assigned to variables: const name = (, let name = (, var name = (
|
|
679
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- Bounded by file content, not user input
|
|
680
|
+
/(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\(/,
|
|
681
|
+
// arrow functions assigned with arrow after params: const name = async (
|
|
682
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- Bounded by file content, not user input
|
|
683
|
+
/(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[a-zA-Z_]\w*)\s*=>/,
|
|
684
|
+
// class methods: name(, async name(, public name(, private name(, protected name(
|
|
685
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- Bounded by file content, not user input
|
|
686
|
+
/^\s+(?:public|private|protected|static|async|\s)*\s*(\w+)\s*\([^)]*\)\s*(?::\s*\w[^{]*)?\s*\{/
|
|
687
|
+
];
|
|
688
|
+
for (let i = 0; i < lines.length; i++) {
|
|
689
|
+
const line = lines[i];
|
|
690
|
+
let fnName = null;
|
|
691
|
+
for (const pattern of patterns) {
|
|
692
|
+
const match = line.match(pattern);
|
|
693
|
+
if (match && match[1]) {
|
|
694
|
+
if (["if", "else", "for", "while", "switch", "catch", "import", "from", "return", "class", "interface", "type", "constructor"].includes(match[1])) {
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
fnName = match[1];
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
if (!fnName && /^\s+constructor\s*\(/.test(line)) {
|
|
702
|
+
fnName = "constructor";
|
|
703
|
+
}
|
|
704
|
+
if (fnName) {
|
|
705
|
+
const body = extractFunctionBody(lines, i);
|
|
706
|
+
if (body) {
|
|
707
|
+
functions.push({ name: fnName, startLine: i + 1, body });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return functions;
|
|
712
|
+
}
|
|
713
|
+
function extractFunctionBody(lines, startLine) {
|
|
714
|
+
let braceCount = 0;
|
|
715
|
+
let started = false;
|
|
716
|
+
const bodyLines = [];
|
|
717
|
+
for (let i = startLine; i < lines.length; i++) {
|
|
718
|
+
const line = lines[i];
|
|
719
|
+
for (const ch of line) {
|
|
720
|
+
if (ch === "{") {
|
|
721
|
+
braceCount++;
|
|
722
|
+
started = true;
|
|
723
|
+
} else if (ch === "}") {
|
|
724
|
+
braceCount--;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
bodyLines.push(line);
|
|
728
|
+
if (started && braceCount === 0) {
|
|
729
|
+
return bodyLines.join("\n");
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
if (!started && bodyLines.length > 0) {
|
|
733
|
+
return bodyLines.slice(0, 5).join("\n");
|
|
734
|
+
}
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
function calculateComplexity(body) {
|
|
738
|
+
let complexity = 1;
|
|
739
|
+
const cleaned = body.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/"(?:[^"\\]|\\.)*"/g, '""').replace(/'(?:[^'\\]|\\.)*'/g, "''").replace(/`(?:[^`\\]|\\.)*`/g, "``");
|
|
740
|
+
const decisionPatterns = [
|
|
741
|
+
[/\bif\s*\(/g, 1],
|
|
742
|
+
[/\belse\s+if\s*\(/g, 1],
|
|
743
|
+
[/\bcase\s+/g, 1],
|
|
744
|
+
[/\bwhile\s*\(/g, 1],
|
|
745
|
+
[/\bfor\s*\(/g, 1],
|
|
746
|
+
[/&&/g, 1],
|
|
747
|
+
[/\|\|/g, 1],
|
|
748
|
+
[/\?\./g, 1],
|
|
749
|
+
[/\?\?/g, 1],
|
|
750
|
+
[/\bcatch\s*\(/g, 1],
|
|
751
|
+
// Ternary: ? not followed by . or ? (to avoid ?. and ??)
|
|
752
|
+
[/\?(?![.?])/g, 1]
|
|
753
|
+
];
|
|
754
|
+
for (const [pattern, weight] of decisionPatterns) {
|
|
755
|
+
const matches = cleaned.match(pattern);
|
|
756
|
+
if (matches) {
|
|
757
|
+
complexity += matches.length * weight;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
const elseIfMatches = cleaned.match(/\belse\s+if\s*\(/g);
|
|
761
|
+
if (elseIfMatches) {
|
|
762
|
+
complexity -= elseIfMatches.length;
|
|
763
|
+
}
|
|
764
|
+
return complexity;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// src/scanners/duplicates.ts
|
|
768
|
+
import fg7 from "fast-glob";
|
|
769
|
+
import fs5 from "fs";
|
|
770
|
+
var WINDOW_SIZE = 6;
|
|
771
|
+
var DuplicateScanner = class {
|
|
772
|
+
constructor() {
|
|
773
|
+
this.name = "duplicates";
|
|
774
|
+
}
|
|
775
|
+
async scan(rootDir) {
|
|
776
|
+
const findings = [];
|
|
777
|
+
const extGlob = SOURCE_EXTENSIONS.map((e) => `**/*${e}`);
|
|
778
|
+
const files = await fg7(extGlob, {
|
|
779
|
+
cwd: rootDir,
|
|
780
|
+
ignore: IGNORE_PATTERNS,
|
|
781
|
+
absolute: true
|
|
782
|
+
});
|
|
783
|
+
const hashMap = /* @__PURE__ */ new Map();
|
|
784
|
+
for (const file of files) {
|
|
785
|
+
try {
|
|
786
|
+
const content = fs5.readFileSync(file, "utf-8");
|
|
787
|
+
const lines = content.split("\n");
|
|
788
|
+
const rel = relativePath(rootDir, file);
|
|
789
|
+
for (let i = 0; i <= lines.length - WINDOW_SIZE; i++) {
|
|
790
|
+
const window = lines.slice(i, i + WINDOW_SIZE);
|
|
791
|
+
const normalized = this.normalizeWindow(window);
|
|
792
|
+
if (this.shouldSkipWindow(normalized)) continue;
|
|
793
|
+
const hash = this.hashString(normalized.join("\n"));
|
|
794
|
+
if (!hashMap.has(hash)) {
|
|
795
|
+
hashMap.set(hash, []);
|
|
796
|
+
}
|
|
797
|
+
hashMap.get(hash).push({
|
|
798
|
+
file: rel,
|
|
799
|
+
startLine: i + 1,
|
|
800
|
+
endLine: i + WINDOW_SIZE
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
} catch {
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
const duplicates = [];
|
|
807
|
+
for (const [hash, locations] of hashMap) {
|
|
808
|
+
if (locations.length < 2) continue;
|
|
809
|
+
const uniqueLocations = this.filterUniqueLocations(locations);
|
|
810
|
+
if (uniqueLocations.length < 2) continue;
|
|
811
|
+
const blockSize = this.calculateBlockSize(
|
|
812
|
+
uniqueLocations,
|
|
813
|
+
files,
|
|
814
|
+
rootDir
|
|
815
|
+
);
|
|
816
|
+
duplicates.push({ locations: uniqueLocations, blockSize });
|
|
817
|
+
}
|
|
818
|
+
duplicates.sort((a, b) => b.blockSize - a.blockSize);
|
|
819
|
+
const topDuplicates = duplicates.slice(0, 10);
|
|
820
|
+
let totalDuplicateLines = 0;
|
|
821
|
+
for (const dup of topDuplicates) {
|
|
822
|
+
const severity = dup.blockSize >= 15 ? "warning" : dup.blockSize >= 6 ? "info" : "info";
|
|
823
|
+
const locs = dup.locations.slice(0, 2).map((l) => `${l.file}:${l.startLine}`).join(" and ");
|
|
824
|
+
findings.push({
|
|
825
|
+
id: `duplicate-${dup.locations[0].file}-${dup.locations[0].startLine}`,
|
|
826
|
+
severity,
|
|
827
|
+
category: "duplicates",
|
|
828
|
+
message: `${dup.blockSize}-line duplicate found in ${locs}${dup.locations.length > 2 ? ` (and ${dup.locations.length - 2} more)` : ""}`,
|
|
829
|
+
file: dup.locations[0].file,
|
|
830
|
+
detail: `${dup.blockSize} lines duplicated`
|
|
831
|
+
});
|
|
832
|
+
totalDuplicateLines += dup.blockSize * dup.locations.length;
|
|
833
|
+
}
|
|
834
|
+
return {
|
|
835
|
+
findings,
|
|
836
|
+
stats: {
|
|
837
|
+
totalBlocks: topDuplicates.length,
|
|
838
|
+
totalDuplicateLines,
|
|
839
|
+
filesAnalyzed: files.length
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
normalizeWindow(lines) {
|
|
844
|
+
return lines.map((line) => {
|
|
845
|
+
let normalized = line.replace(/\/\/.*$/, "");
|
|
846
|
+
normalized = normalized.replace(/\/\*.*?\*\//g, "");
|
|
847
|
+
normalized = normalized.trim().replace(/\s+/g, " ");
|
|
848
|
+
return normalized;
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
shouldSkipWindow(normalized) {
|
|
852
|
+
const nonBlank = normalized.filter((l) => l.length > 0);
|
|
853
|
+
if (nonBlank.length < 3) return true;
|
|
854
|
+
const imports = normalized.filter(
|
|
855
|
+
(l) => l.startsWith("import ") || l.startsWith("export ")
|
|
856
|
+
);
|
|
857
|
+
if (imports.length > normalized.length * 0.5) return true;
|
|
858
|
+
return false;
|
|
859
|
+
}
|
|
860
|
+
filterUniqueLocations(locations) {
|
|
861
|
+
const unique = [];
|
|
862
|
+
for (const loc of locations) {
|
|
863
|
+
const isDuplicate = unique.some(
|
|
864
|
+
(u) => u.file === loc.file && Math.abs(u.startLine - loc.startLine) < WINDOW_SIZE * 2
|
|
865
|
+
);
|
|
866
|
+
if (!isDuplicate) {
|
|
867
|
+
unique.push(loc);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return unique;
|
|
871
|
+
}
|
|
872
|
+
calculateBlockSize(locations, files, rootDir) {
|
|
873
|
+
return WINDOW_SIZE;
|
|
874
|
+
}
|
|
875
|
+
hashString(str) {
|
|
876
|
+
let hash = 0;
|
|
877
|
+
for (let i = 0; i < str.length; i++) {
|
|
878
|
+
const char = str.charCodeAt(i);
|
|
879
|
+
hash = (hash << 5) - hash + char;
|
|
880
|
+
hash = hash & hash;
|
|
881
|
+
}
|
|
882
|
+
return hash.toString(36);
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
// src/scanners/dead-exports.ts
|
|
887
|
+
import fg8 from "fast-glob";
|
|
888
|
+
import path6 from "path";
|
|
889
|
+
import fs6 from "fs";
|
|
890
|
+
var DeadExportScanner = class {
|
|
891
|
+
constructor() {
|
|
892
|
+
this.name = "dead-exports";
|
|
893
|
+
}
|
|
894
|
+
async scan(rootDir) {
|
|
895
|
+
const findings = [];
|
|
896
|
+
const extGlob = SOURCE_EXTENSIONS.map((e) => `**/*${e}`);
|
|
897
|
+
const files = await fg8(extGlob, {
|
|
898
|
+
cwd: rootDir,
|
|
899
|
+
ignore: IGNORE_PATTERNS,
|
|
900
|
+
absolute: true
|
|
901
|
+
});
|
|
902
|
+
if (files.length === 0) {
|
|
903
|
+
return {
|
|
904
|
+
findings,
|
|
905
|
+
stats: { totalExports: 0, deadExports: 0, percentDead: 0 }
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
const entryPoints = this.getEntryPoints(rootDir);
|
|
909
|
+
const allExports = [];
|
|
910
|
+
const fileContents = /* @__PURE__ */ new Map();
|
|
911
|
+
for (const file of files) {
|
|
912
|
+
const rel = relativePath(rootDir, file);
|
|
913
|
+
if (this.isBarrelFile(rel)) continue;
|
|
914
|
+
try {
|
|
915
|
+
const content = fs6.readFileSync(file, "utf-8");
|
|
916
|
+
fileContents.set(rel, content);
|
|
917
|
+
const exports = this.extractExports(content, rel);
|
|
918
|
+
allExports.push(...exports);
|
|
919
|
+
} catch {
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (allExports.length === 0) {
|
|
923
|
+
return {
|
|
924
|
+
findings,
|
|
925
|
+
stats: { totalExports: 0, deadExports: 0, percentDead: 0 }
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
const usedExports = /* @__PURE__ */ new Set();
|
|
929
|
+
for (const file of files) {
|
|
930
|
+
const rel = relativePath(rootDir, file);
|
|
931
|
+
let content = fileContents.get(rel);
|
|
932
|
+
if (!content) {
|
|
933
|
+
try {
|
|
934
|
+
content = fs6.readFileSync(file, "utf-8");
|
|
935
|
+
} catch {
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
this.trackImports(content, file, rootDir, allExports, usedExports);
|
|
940
|
+
}
|
|
941
|
+
const deadExports = [];
|
|
942
|
+
for (const exp of allExports) {
|
|
943
|
+
const key = `${exp.file}::${exp.name}`;
|
|
944
|
+
if (usedExports.has(key)) continue;
|
|
945
|
+
if (exp.name === "default" && entryPoints.has(exp.file)) continue;
|
|
946
|
+
deadExports.push(exp);
|
|
947
|
+
}
|
|
948
|
+
for (const dead of deadExports.slice(0, 15)) {
|
|
949
|
+
const severity = dead.kind === "type" || dead.kind === "interface" ? "info" : "warning";
|
|
950
|
+
const exportLabel = dead.name === "default" ? "default export" : `"${dead.name}"`;
|
|
951
|
+
findings.push({
|
|
952
|
+
id: `dead-export-${dead.file}-${dead.name}`,
|
|
953
|
+
severity,
|
|
954
|
+
category: "dead-exports",
|
|
955
|
+
message: `Exported ${dead.kind} ${exportLabel} in ${dead.file} is never imported`,
|
|
956
|
+
file: dead.file,
|
|
957
|
+
detail: `${dead.kind} ${dead.name}`
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
if (deadExports.length > 15) {
|
|
961
|
+
findings.push({
|
|
962
|
+
id: "dead-exports-overflow",
|
|
963
|
+
severity: "info",
|
|
964
|
+
category: "dead-exports",
|
|
965
|
+
message: `...and ${deadExports.length - 15} more dead exports`
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
const totalExports = allExports.length;
|
|
969
|
+
const totalDead = deadExports.length;
|
|
970
|
+
const percentDead = totalExports > 0 ? Math.round(totalDead / totalExports * 100) : 0;
|
|
971
|
+
return {
|
|
972
|
+
findings,
|
|
973
|
+
stats: { totalExports, deadExports: totalDead, percentDead }
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
getEntryPoints(rootDir) {
|
|
977
|
+
const entryPoints = /* @__PURE__ */ new Set();
|
|
978
|
+
try {
|
|
979
|
+
const pkgPath = path6.join(rootDir, "package.json");
|
|
980
|
+
const pkg = JSON.parse(fs6.readFileSync(pkgPath, "utf-8"));
|
|
981
|
+
const candidates = [];
|
|
982
|
+
if (pkg.main) candidates.push(pkg.main);
|
|
983
|
+
if (pkg.module) candidates.push(pkg.module);
|
|
984
|
+
if (pkg.bin) {
|
|
985
|
+
if (typeof pkg.bin === "string") {
|
|
986
|
+
candidates.push(pkg.bin);
|
|
987
|
+
} else {
|
|
988
|
+
candidates.push(...Object.values(pkg.bin));
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
for (const entry of candidates) {
|
|
992
|
+
const normalized = entry.replace(/^\.\//, "").replace(/\\/g, "/");
|
|
993
|
+
entryPoints.add(normalized);
|
|
994
|
+
const stripped = normalized.replace(/\.(js|mjs|cjs|ts|tsx)$/, "");
|
|
995
|
+
for (const ext of SOURCE_EXTENSIONS) {
|
|
996
|
+
entryPoints.add(stripped + ext);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
} catch {
|
|
1000
|
+
}
|
|
1001
|
+
return entryPoints;
|
|
1002
|
+
}
|
|
1003
|
+
isBarrelFile(rel) {
|
|
1004
|
+
const basename = path6.basename(rel);
|
|
1005
|
+
return /^index\.(ts|tsx|js|jsx|mjs|cjs)$/.test(basename);
|
|
1006
|
+
}
|
|
1007
|
+
extractExports(content, file) {
|
|
1008
|
+
const exports = [];
|
|
1009
|
+
const funcRegex = /export\s+function\s+(\w+)/g;
|
|
1010
|
+
let match;
|
|
1011
|
+
while ((match = funcRegex.exec(content)) !== null) {
|
|
1012
|
+
exports.push({ name: match[1], kind: "function", file });
|
|
1013
|
+
}
|
|
1014
|
+
const constRegex = /export\s+(?:const|let|var)\s+(\w+)/g;
|
|
1015
|
+
while ((match = constRegex.exec(content)) !== null) {
|
|
1016
|
+
exports.push({ name: match[1], kind: "const", file });
|
|
1017
|
+
}
|
|
1018
|
+
const classRegex = /export\s+class\s+(\w+)/g;
|
|
1019
|
+
while ((match = classRegex.exec(content)) !== null) {
|
|
1020
|
+
exports.push({ name: match[1], kind: "class", file });
|
|
1021
|
+
}
|
|
1022
|
+
const ifaceRegex = /export\s+interface\s+(\w+)/g;
|
|
1023
|
+
while ((match = ifaceRegex.exec(content)) !== null) {
|
|
1024
|
+
exports.push({ name: match[1], kind: "interface", file });
|
|
1025
|
+
}
|
|
1026
|
+
const typeRegex = /export\s+type\s+(\w+)/g;
|
|
1027
|
+
while ((match = typeRegex.exec(content)) !== null) {
|
|
1028
|
+
exports.push({ name: match[1], kind: "type", file });
|
|
1029
|
+
}
|
|
1030
|
+
const namedRegex = /export\s*\{([^}]+)\}/g;
|
|
1031
|
+
while ((match = namedRegex.exec(content)) !== null) {
|
|
1032
|
+
const names = match[1].split(",").map((n) => n.trim().split(/\s+as\s+/)[0].trim());
|
|
1033
|
+
for (const name of names) {
|
|
1034
|
+
if (name) {
|
|
1035
|
+
exports.push({ name, kind: "const", file });
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
const defaultRegex = /export\s+default\s+/g;
|
|
1040
|
+
while ((match = defaultRegex.exec(content)) !== null) {
|
|
1041
|
+
exports.push({ name: "default", kind: "default", file });
|
|
1042
|
+
}
|
|
1043
|
+
return exports;
|
|
1044
|
+
}
|
|
1045
|
+
trackImports(content, filePath, rootDir, allExports, usedExports) {
|
|
1046
|
+
const currentFile = relativePath(rootDir, filePath);
|
|
1047
|
+
const namedImportRegex = /import\s*\{([^}]+)\}\s*from\s*['"](\.[^'"]+)['"]/g;
|
|
1048
|
+
let match;
|
|
1049
|
+
while ((match = namedImportRegex.exec(content)) !== null) {
|
|
1050
|
+
const names = match[1].split(",").map((n) => {
|
|
1051
|
+
const parts = n.trim().split(/\s+as\s+/);
|
|
1052
|
+
return parts[0].trim();
|
|
1053
|
+
});
|
|
1054
|
+
const resolved = this.resolveImport(match[2], filePath, rootDir);
|
|
1055
|
+
if (resolved && resolved !== currentFile) {
|
|
1056
|
+
for (const name of names) {
|
|
1057
|
+
if (name) usedExports.add(`${resolved}::${name}`);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
const defaultImportRegex = /import\s+(\w+)\s+from\s*['"](\.[^'"]+)['"]/g;
|
|
1062
|
+
while ((match = defaultImportRegex.exec(content)) !== null) {
|
|
1063
|
+
const resolved = this.resolveImport(match[2], filePath, rootDir);
|
|
1064
|
+
if (resolved && resolved !== currentFile) {
|
|
1065
|
+
usedExports.add(`${resolved}::default`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
const namespaceImportRegex = /import\s*\*\s*as\s+\w+\s+from\s*['"](\.[^'"]+)['"]/g;
|
|
1069
|
+
while ((match = namespaceImportRegex.exec(content)) !== null) {
|
|
1070
|
+
const resolved = this.resolveImport(match[1], filePath, rootDir);
|
|
1071
|
+
if (resolved && resolved !== currentFile) {
|
|
1072
|
+
for (const exp of allExports) {
|
|
1073
|
+
if (exp.file === resolved) {
|
|
1074
|
+
usedExports.add(`${resolved}::${exp.name}`);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
const reExportRegex = /export\s*\*\s*from\s*['"](\.[^'"]+)['"]/g;
|
|
1080
|
+
while ((match = reExportRegex.exec(content)) !== null) {
|
|
1081
|
+
const resolved = this.resolveImport(match[1], filePath, rootDir);
|
|
1082
|
+
if (resolved && resolved !== currentFile) {
|
|
1083
|
+
for (const exp of allExports) {
|
|
1084
|
+
if (exp.file === resolved) {
|
|
1085
|
+
usedExports.add(`${resolved}::${exp.name}`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
const namedReExportRegex = /export\s*\{([^}]+)\}\s*from\s*['"](\.[^'"]+)['"]/g;
|
|
1091
|
+
while ((match = namedReExportRegex.exec(content)) !== null) {
|
|
1092
|
+
const names = match[1].split(",").map((n) => {
|
|
1093
|
+
const parts = n.trim().split(/\s+as\s+/);
|
|
1094
|
+
return parts[0].trim();
|
|
1095
|
+
});
|
|
1096
|
+
const resolved = this.resolveImport(match[2], filePath, rootDir);
|
|
1097
|
+
if (resolved && resolved !== currentFile) {
|
|
1098
|
+
for (const name of names) {
|
|
1099
|
+
if (name) usedExports.add(`${resolved}::${name}`);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
resolveImport(importPath, fromFile, rootDir) {
|
|
1105
|
+
const fromDir = path6.dirname(fromFile);
|
|
1106
|
+
const resolved = path6.resolve(fromDir, importPath);
|
|
1107
|
+
const rel = relativePath(rootDir, resolved);
|
|
1108
|
+
const stripped = rel.replace(/\.(js|mjs|cjs)$/, "");
|
|
1109
|
+
const candidates = [rel, stripped];
|
|
1110
|
+
for (const base of candidates) {
|
|
1111
|
+
for (const ext of ["", ...SOURCE_EXTENSIONS]) {
|
|
1112
|
+
const candidate = base + ext;
|
|
1113
|
+
if (fs6.existsSync(path6.join(rootDir, candidate))) {
|
|
1114
|
+
return candidate;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
for (const ext of SOURCE_EXTENSIONS) {
|
|
1118
|
+
const candidateIndex = base + "/index" + ext;
|
|
1119
|
+
if (fs6.existsSync(path6.join(rootDir, candidateIndex))) {
|
|
1120
|
+
return candidateIndex;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
return null;
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
// src/scanners/type-safety.ts
|
|
1129
|
+
import fg9 from "fast-glob";
|
|
1130
|
+
import fs7 from "fs";
|
|
1131
|
+
var TypeSafetyScanner = class {
|
|
1132
|
+
constructor() {
|
|
1133
|
+
this.name = "type-safety";
|
|
1134
|
+
}
|
|
1135
|
+
async scan(rootDir) {
|
|
1136
|
+
const findings = [];
|
|
1137
|
+
const allFiles = await fg9(["**/*.ts", "**/*.tsx"], {
|
|
1138
|
+
cwd: rootDir,
|
|
1139
|
+
ignore: IGNORE_PATTERNS,
|
|
1140
|
+
absolute: true
|
|
1141
|
+
});
|
|
1142
|
+
let totalAny = 0;
|
|
1143
|
+
let totalTsIgnore = 0;
|
|
1144
|
+
let totalTsNocheck = 0;
|
|
1145
|
+
let totalTsExpectError = 0;
|
|
1146
|
+
const fileViolations = [];
|
|
1147
|
+
for (const file of allFiles) {
|
|
1148
|
+
const rel = relativePath(rootDir, file);
|
|
1149
|
+
let content;
|
|
1150
|
+
try {
|
|
1151
|
+
content = fs7.readFileSync(file, "utf-8");
|
|
1152
|
+
} catch {
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
const lines = content.split("\n");
|
|
1156
|
+
let inBlockComment = false;
|
|
1157
|
+
let fileAnyCount = 0;
|
|
1158
|
+
let fileTsIgnore = 0;
|
|
1159
|
+
let fileTsNocheck = 0;
|
|
1160
|
+
let fileTsExpectError = 0;
|
|
1161
|
+
for (const line of lines) {
|
|
1162
|
+
const trimmed = line.trim();
|
|
1163
|
+
if (inBlockComment) {
|
|
1164
|
+
if (trimmed.includes("*/")) {
|
|
1165
|
+
inBlockComment = false;
|
|
1166
|
+
}
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
if (trimmed.startsWith("/*")) {
|
|
1170
|
+
if (!trimmed.includes("*/")) {
|
|
1171
|
+
inBlockComment = true;
|
|
1172
|
+
}
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
const isComment = trimmed.startsWith("//");
|
|
1176
|
+
if (/@ts-ignore/.test(line)) {
|
|
1177
|
+
fileTsIgnore++;
|
|
1178
|
+
}
|
|
1179
|
+
if (/@ts-nocheck/.test(line)) {
|
|
1180
|
+
fileTsNocheck++;
|
|
1181
|
+
}
|
|
1182
|
+
if (/@ts-expect-error/.test(line)) {
|
|
1183
|
+
fileTsExpectError++;
|
|
1184
|
+
}
|
|
1185
|
+
if (isComment) {
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
const colonAny = line.match(/:\s*any\b/g);
|
|
1189
|
+
const asAny = line.match(/\bas\s+any\b/g);
|
|
1190
|
+
if (colonAny) {
|
|
1191
|
+
fileAnyCount += colonAny.length;
|
|
1192
|
+
}
|
|
1193
|
+
if (asAny) {
|
|
1194
|
+
fileAnyCount += asAny.length;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
totalAny += fileAnyCount;
|
|
1198
|
+
totalTsIgnore += fileTsIgnore;
|
|
1199
|
+
totalTsNocheck += fileTsNocheck;
|
|
1200
|
+
totalTsExpectError += fileTsExpectError;
|
|
1201
|
+
const totalFileViolations = fileAnyCount + fileTsIgnore + fileTsNocheck + fileTsExpectError;
|
|
1202
|
+
if (totalFileViolations > 0) {
|
|
1203
|
+
fileViolations.push({ path: rel, violations: totalFileViolations });
|
|
1204
|
+
}
|
|
1205
|
+
if (fileTsNocheck > 0) {
|
|
1206
|
+
findings.push({
|
|
1207
|
+
id: `ts-nocheck-${rel}`,
|
|
1208
|
+
severity: "warning",
|
|
1209
|
+
category: "type-safety",
|
|
1210
|
+
message: `${rel} uses @ts-nocheck \u2014 entire file skips type checking`,
|
|
1211
|
+
file: rel,
|
|
1212
|
+
detail: `${fileTsNocheck} @ts-nocheck directive(s)`
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
if (totalAny > 20) {
|
|
1217
|
+
findings.push({
|
|
1218
|
+
id: "type-safety-any-critical",
|
|
1219
|
+
severity: "critical",
|
|
1220
|
+
category: "type-safety",
|
|
1221
|
+
message: `${totalAny} uses of \`any\` \u2014 the type system is being bypassed wholesale`,
|
|
1222
|
+
detail: `${totalAny} total any usages across the codebase`
|
|
1223
|
+
});
|
|
1224
|
+
} else if (totalAny >= 6) {
|
|
1225
|
+
findings.push({
|
|
1226
|
+
id: "type-safety-any-warning",
|
|
1227
|
+
severity: "warning",
|
|
1228
|
+
category: "type-safety",
|
|
1229
|
+
message: `${totalAny} uses of \`any\` \u2014 significant type safety gaps`,
|
|
1230
|
+
detail: `${totalAny} total any usages across the codebase`
|
|
1231
|
+
});
|
|
1232
|
+
} else if (totalAny >= 1) {
|
|
1233
|
+
findings.push({
|
|
1234
|
+
id: "type-safety-any-info",
|
|
1235
|
+
severity: "info",
|
|
1236
|
+
category: "type-safety",
|
|
1237
|
+
message: `${totalAny} uses of \`any\` \u2014 minor type safety gaps`,
|
|
1238
|
+
detail: `${totalAny} total any usages across the codebase`
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
if (totalTsIgnore > 5) {
|
|
1242
|
+
findings.push({
|
|
1243
|
+
id: "type-safety-ts-ignore",
|
|
1244
|
+
severity: "warning",
|
|
1245
|
+
category: "type-safety",
|
|
1246
|
+
message: `${totalTsIgnore} @ts-ignore comments \u2014 errors are being silenced`,
|
|
1247
|
+
detail: `${totalTsIgnore} total @ts-ignore directives`
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
fileViolations.sort((a, b) => b.violations - a.violations);
|
|
1251
|
+
const worstOffenders = fileViolations.slice(0, 5);
|
|
1252
|
+
const stats = {
|
|
1253
|
+
totalAny,
|
|
1254
|
+
totalTsIgnore,
|
|
1255
|
+
totalTsNocheck,
|
|
1256
|
+
totalTsExpectError,
|
|
1257
|
+
worstOffenders
|
|
1258
|
+
};
|
|
1259
|
+
return { findings, stats };
|
|
1260
|
+
}
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
// src/scanners/test-coverage.ts
|
|
1264
|
+
import fg10 from "fast-glob";
|
|
1265
|
+
import path7 from "path";
|
|
1266
|
+
var TestCoverageScanner = class {
|
|
1267
|
+
constructor() {
|
|
1268
|
+
this.name = "test-coverage";
|
|
1269
|
+
}
|
|
1270
|
+
async scan(rootDir) {
|
|
1271
|
+
const findings = [];
|
|
1272
|
+
const sourceFiles = await fg10(["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], {
|
|
1273
|
+
cwd: rootDir,
|
|
1274
|
+
ignore: [
|
|
1275
|
+
...IGNORE_PATTERNS,
|
|
1276
|
+
"**/*.test.*",
|
|
1277
|
+
"**/*.spec.*",
|
|
1278
|
+
"**/__tests__/**",
|
|
1279
|
+
"**/*.d.ts",
|
|
1280
|
+
"**/index.ts",
|
|
1281
|
+
"**/index.js",
|
|
1282
|
+
"**/*.config.*"
|
|
1283
|
+
],
|
|
1284
|
+
absolute: false
|
|
1285
|
+
});
|
|
1286
|
+
const testFiles = await fg10(
|
|
1287
|
+
[
|
|
1288
|
+
"**/*.test.ts",
|
|
1289
|
+
"**/*.test.tsx",
|
|
1290
|
+
"**/*.test.js",
|
|
1291
|
+
"**/*.test.jsx",
|
|
1292
|
+
"**/*.spec.ts",
|
|
1293
|
+
"**/*.spec.tsx",
|
|
1294
|
+
"**/*.spec.js",
|
|
1295
|
+
"**/*.spec.jsx"
|
|
1296
|
+
],
|
|
1297
|
+
{
|
|
1298
|
+
cwd: rootDir,
|
|
1299
|
+
ignore: IGNORE_PATTERNS,
|
|
1300
|
+
absolute: false
|
|
1301
|
+
}
|
|
1302
|
+
);
|
|
1303
|
+
const testFileSet = new Set(testFiles.map(normalizeTestPath));
|
|
1304
|
+
let missingTestCount = 0;
|
|
1305
|
+
for (const sourceFile of sourceFiles) {
|
|
1306
|
+
const hasTest = hasCorrespondingTest(sourceFile, testFileSet);
|
|
1307
|
+
if (!hasTest) {
|
|
1308
|
+
missingTestCount++;
|
|
1309
|
+
if (missingTestCount <= 10) {
|
|
1310
|
+
findings.push({
|
|
1311
|
+
id: `missing-test-${sourceFile}`,
|
|
1312
|
+
severity: "info",
|
|
1313
|
+
category: "test-coverage",
|
|
1314
|
+
message: `${sourceFile} has no corresponding test file`,
|
|
1315
|
+
file: sourceFile
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
if (missingTestCount > 10) {
|
|
1321
|
+
findings.push({
|
|
1322
|
+
id: "many-missing-tests",
|
|
1323
|
+
severity: "info",
|
|
1324
|
+
category: "test-coverage",
|
|
1325
|
+
message: `...and ${missingTestCount - 10} more files without tests`
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
const coveragePercent = sourceFiles.length > 0 ? ((sourceFiles.length - missingTestCount) / sourceFiles.length * 100).toFixed(1) : "100.0";
|
|
1329
|
+
return {
|
|
1330
|
+
findings,
|
|
1331
|
+
stats: {
|
|
1332
|
+
sourceFiles: sourceFiles.length,
|
|
1333
|
+
missingTests: missingTestCount,
|
|
1334
|
+
coveragePercent
|
|
1335
|
+
}
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
};
|
|
1339
|
+
function hasCorrespondingTest(sourceFile, testFileSet) {
|
|
1340
|
+
const dir = path7.dirname(sourceFile);
|
|
1341
|
+
const base = path7.basename(sourceFile, path7.extname(sourceFile));
|
|
1342
|
+
const ext = path7.extname(sourceFile);
|
|
1343
|
+
const possibleTests = [
|
|
1344
|
+
path7.join(dir, `${base}.test${ext}`),
|
|
1345
|
+
path7.join(dir, `${base}.spec${ext}`),
|
|
1346
|
+
path7.join(dir, "__tests__", `${base}.test${ext}`),
|
|
1347
|
+
path7.join(dir, "__tests__", `${base}.spec${ext}`)
|
|
1348
|
+
];
|
|
1349
|
+
return possibleTests.some((test) => testFileSet.has(normalizeTestPath(test)));
|
|
1350
|
+
}
|
|
1351
|
+
function normalizeTestPath(testPath) {
|
|
1352
|
+
return testPath.replace(/\\/g, "/");
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// src/scanners/git-insights.ts
|
|
1356
|
+
import { spawnSync } from "child_process";
|
|
1357
|
+
var GitInsightsScanner = class {
|
|
1358
|
+
constructor() {
|
|
1359
|
+
this.name = "git-insights";
|
|
1360
|
+
}
|
|
1361
|
+
async scan(rootDir) {
|
|
1362
|
+
const findings = [];
|
|
1363
|
+
const gitCheck = spawnSync("git", ["rev-parse", "--git-dir"], {
|
|
1364
|
+
cwd: rootDir,
|
|
1365
|
+
stdio: "pipe"
|
|
1366
|
+
});
|
|
1367
|
+
if (gitCheck.status !== 0) {
|
|
1368
|
+
return { findings: [], stats: { isGitRepo: false } };
|
|
1369
|
+
}
|
|
1370
|
+
const churnResult = spawnSync(
|
|
1371
|
+
"git",
|
|
1372
|
+
["log", "--name-only", "--format=", "--since=6 months ago"],
|
|
1373
|
+
{ cwd: rootDir, encoding: "utf-8" }
|
|
1374
|
+
);
|
|
1375
|
+
if (churnResult.status === 0 && churnResult.stdout) {
|
|
1376
|
+
const churnMap = this.analyzeChurn(churnResult.stdout);
|
|
1377
|
+
for (const [file, count] of churnMap.entries()) {
|
|
1378
|
+
if (count >= 100) {
|
|
1379
|
+
findings.push({
|
|
1380
|
+
id: `high-churn-${file}`,
|
|
1381
|
+
severity: "critical",
|
|
1382
|
+
category: "git-churn",
|
|
1383
|
+
message: `${file} changed ${count} times in 6 months \u2014 high instability`,
|
|
1384
|
+
file,
|
|
1385
|
+
detail: `${count} changes`
|
|
1386
|
+
});
|
|
1387
|
+
} else if (count >= 50) {
|
|
1388
|
+
findings.push({
|
|
1389
|
+
id: `moderate-churn-${file}`,
|
|
1390
|
+
severity: "warning",
|
|
1391
|
+
category: "git-churn",
|
|
1392
|
+
message: `${file} changed ${count} times in 6 months`,
|
|
1393
|
+
file,
|
|
1394
|
+
detail: `${count} changes`
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
const prSizeStats = this.analyzePRSize(rootDir);
|
|
1400
|
+
if (prSizeStats.avgFilesPerPR > 0) {
|
|
1401
|
+
if (prSizeStats.avgFilesPerPR > 40) {
|
|
1402
|
+
findings.push({
|
|
1403
|
+
id: "large-pr-size",
|
|
1404
|
+
severity: "critical",
|
|
1405
|
+
category: "pr-size",
|
|
1406
|
+
message: `Average PR changes ${Math.round(prSizeStats.avgFilesPerPR)} files \u2014 PRs are too large`,
|
|
1407
|
+
detail: `${Math.round(prSizeStats.avgFilesPerPR)} files per PR`
|
|
1408
|
+
});
|
|
1409
|
+
} else if (prSizeStats.avgFilesPerPR > 20) {
|
|
1410
|
+
findings.push({
|
|
1411
|
+
id: "moderate-pr-size",
|
|
1412
|
+
severity: "warning",
|
|
1413
|
+
category: "pr-size",
|
|
1414
|
+
message: `Average PR changes ${Math.round(prSizeStats.avgFilesPerPR)} files`,
|
|
1415
|
+
detail: `${Math.round(prSizeStats.avgFilesPerPR)} files per PR`
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
const staleBranches = this.findStaleBranches(rootDir);
|
|
1420
|
+
if (staleBranches.length > 0) {
|
|
1421
|
+
findings.push({
|
|
1422
|
+
id: "stale-branches",
|
|
1423
|
+
severity: "info",
|
|
1424
|
+
category: "stale-branches",
|
|
1425
|
+
message: `${staleBranches.length} branches haven't been updated in 90+ days`,
|
|
1426
|
+
detail: staleBranches.slice(0, 5).join(", ")
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
return {
|
|
1430
|
+
findings,
|
|
1431
|
+
stats: {
|
|
1432
|
+
isGitRepo: true,
|
|
1433
|
+
avgPRSize: prSizeStats.avgFilesPerPR,
|
|
1434
|
+
staleBranchCount: staleBranches.length,
|
|
1435
|
+
highChurnFiles: findings.filter((f) => f.category === "git-churn").length
|
|
1436
|
+
}
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
analyzeChurn(gitOutput) {
|
|
1440
|
+
const churnMap = /* @__PURE__ */ new Map();
|
|
1441
|
+
const lines = gitOutput.split("\n").filter((line) => line.trim() !== "");
|
|
1442
|
+
for (const file of lines) {
|
|
1443
|
+
const normalized = file.trim();
|
|
1444
|
+
if (normalized) {
|
|
1445
|
+
churnMap.set(normalized, (churnMap.get(normalized) || 0) + 1);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
return churnMap;
|
|
1449
|
+
}
|
|
1450
|
+
analyzePRSize(rootDir) {
|
|
1451
|
+
const mergeResult = spawnSync(
|
|
1452
|
+
"git",
|
|
1453
|
+
["log", "--format=%H", "--merges", "--since=6 months ago"],
|
|
1454
|
+
{ cwd: rootDir, encoding: "utf-8" }
|
|
1455
|
+
);
|
|
1456
|
+
if (mergeResult.status !== 0 || !mergeResult.stdout) {
|
|
1457
|
+
return { avgFilesPerPR: 0 };
|
|
1458
|
+
}
|
|
1459
|
+
const mergeCommits = mergeResult.stdout.split("\n").filter((line) => line.trim() !== "");
|
|
1460
|
+
if (mergeCommits.length === 0) {
|
|
1461
|
+
return { avgFilesPerPR: 0 };
|
|
1462
|
+
}
|
|
1463
|
+
let totalFiles = 0;
|
|
1464
|
+
let validMerges = 0;
|
|
1465
|
+
for (const commit of mergeCommits) {
|
|
1466
|
+
const diffResult = spawnSync(
|
|
1467
|
+
"git",
|
|
1468
|
+
["diff-tree", "--no-commit-id", "--name-only", "-r", commit],
|
|
1469
|
+
{ cwd: rootDir, encoding: "utf-8" }
|
|
1470
|
+
);
|
|
1471
|
+
if (diffResult.status === 0 && diffResult.stdout) {
|
|
1472
|
+
const files = diffResult.stdout.split("\n").filter((line) => line.trim() !== "");
|
|
1473
|
+
totalFiles += files.length;
|
|
1474
|
+
validMerges++;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
return {
|
|
1478
|
+
avgFilesPerPR: validMerges > 0 ? totalFiles / validMerges : 0
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
findStaleBranches(rootDir) {
|
|
1482
|
+
const branchResult = spawnSync(
|
|
1483
|
+
"git",
|
|
1484
|
+
["branch", "-a", "--format=%(refname:short) %(committerdate:unix)"],
|
|
1485
|
+
{ cwd: rootDir, encoding: "utf-8" }
|
|
1486
|
+
);
|
|
1487
|
+
if (branchResult.status !== 0 || !branchResult.stdout) {
|
|
1488
|
+
return [];
|
|
1489
|
+
}
|
|
1490
|
+
const staleBranches = [];
|
|
1491
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1492
|
+
const ninetyDaysAgo = now - 90 * 24 * 60 * 60;
|
|
1493
|
+
const lines = branchResult.stdout.split("\n");
|
|
1494
|
+
for (const line of lines) {
|
|
1495
|
+
const match = line.match(/^(.+?)\s+(\d+)$/);
|
|
1496
|
+
if (match) {
|
|
1497
|
+
const [, branchName, timestamp] = match;
|
|
1498
|
+
const ts = parseInt(timestamp, 10);
|
|
1499
|
+
if (ts < ninetyDaysAgo) {
|
|
1500
|
+
if (!branchName.includes("HEAD") && !branchName.includes("origin/main") && !branchName.includes("origin/master")) {
|
|
1501
|
+
staleBranches.push(branchName);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
return staleBranches;
|
|
1507
|
+
}
|
|
1508
|
+
};
|
|
1509
|
+
|
|
1510
|
+
// src/scanners/security.ts
|
|
1511
|
+
import fg11 from "fast-glob";
|
|
1512
|
+
import fs8 from "fs";
|
|
1513
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
1514
|
+
var SECRET_PATTERNS = [
|
|
1515
|
+
{
|
|
1516
|
+
name: "AWS Access Key",
|
|
1517
|
+
pattern: /AKIA[0-9A-Z]{16}/g,
|
|
1518
|
+
severity: "critical"
|
|
1519
|
+
},
|
|
1520
|
+
{
|
|
1521
|
+
name: "Private Key",
|
|
1522
|
+
pattern: /-----BEGIN (RSA|DSA|EC|OPENSSH) PRIVATE KEY-----/g,
|
|
1523
|
+
severity: "critical"
|
|
1524
|
+
},
|
|
1525
|
+
{
|
|
1526
|
+
name: "Generic API Key",
|
|
1527
|
+
pattern: /(api[_-]?key|apikey|secret[_-]?key)\s*[=:]\s*['"][A-Za-z0-9_-]{20,1000}['"]/gi,
|
|
1528
|
+
severity: "warning"
|
|
1529
|
+
},
|
|
1530
|
+
{
|
|
1531
|
+
name: "JWT Token",
|
|
1532
|
+
pattern: /eyJ[A-Za-z0-9_-]{1,500}\.eyJ[A-Za-z0-9_-]{1,500}\.[A-Za-z0-9_-]{1,500}/g,
|
|
1533
|
+
severity: "warning"
|
|
1534
|
+
}
|
|
1535
|
+
];
|
|
1536
|
+
var SecurityScanner = class {
|
|
1537
|
+
constructor() {
|
|
1538
|
+
this.name = "security";
|
|
1539
|
+
}
|
|
1540
|
+
async scan(rootDir) {
|
|
1541
|
+
const findings = [];
|
|
1542
|
+
const secretFindings = await this.detectSecrets(rootDir);
|
|
1543
|
+
findings.push(...secretFindings);
|
|
1544
|
+
const envFindings = await this.detectEnvInGit(rootDir);
|
|
1545
|
+
findings.push(...envFindings);
|
|
1546
|
+
const evalFindings = await this.detectEvalUsage(rootDir);
|
|
1547
|
+
findings.push(...evalFindings);
|
|
1548
|
+
return {
|
|
1549
|
+
findings,
|
|
1550
|
+
stats: {
|
|
1551
|
+
secretsFound: secretFindings.length,
|
|
1552
|
+
envFilesInGit: envFindings.length,
|
|
1553
|
+
evalUsage: evalFindings.length
|
|
1554
|
+
}
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
async detectSecrets(rootDir) {
|
|
1558
|
+
const findings = [];
|
|
1559
|
+
const files = await fg11(["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], {
|
|
1560
|
+
cwd: rootDir,
|
|
1561
|
+
ignore: [
|
|
1562
|
+
...IGNORE_PATTERNS,
|
|
1563
|
+
"**/test/**",
|
|
1564
|
+
"**/tests/**",
|
|
1565
|
+
"**/__tests__/**",
|
|
1566
|
+
"**/*.test.*",
|
|
1567
|
+
"**/*.spec.*",
|
|
1568
|
+
"**/examples/**",
|
|
1569
|
+
"**/demo/**"
|
|
1570
|
+
],
|
|
1571
|
+
absolute: true
|
|
1572
|
+
});
|
|
1573
|
+
for (const file of files) {
|
|
1574
|
+
try {
|
|
1575
|
+
const content = fs8.readFileSync(file, "utf-8");
|
|
1576
|
+
const rel = relativePath(rootDir, file);
|
|
1577
|
+
for (const { name, pattern, severity } of SECRET_PATTERNS) {
|
|
1578
|
+
pattern.lastIndex = 0;
|
|
1579
|
+
const matches = content.match(pattern);
|
|
1580
|
+
if (matches) {
|
|
1581
|
+
findings.push({
|
|
1582
|
+
id: `secret-${rel}-${name.replace(/\s+/g, "-")}`,
|
|
1583
|
+
severity,
|
|
1584
|
+
category: "secrets",
|
|
1585
|
+
message: `Potential ${name} found in ${rel}`,
|
|
1586
|
+
file: rel,
|
|
1587
|
+
detail: `${matches.length} match(es)`
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
} catch {
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
return findings;
|
|
1595
|
+
}
|
|
1596
|
+
async detectEnvInGit(rootDir) {
|
|
1597
|
+
const findings = [];
|
|
1598
|
+
const gitCheck = spawnSync2("git", ["rev-parse", "--git-dir"], {
|
|
1599
|
+
cwd: rootDir,
|
|
1600
|
+
stdio: "pipe"
|
|
1601
|
+
});
|
|
1602
|
+
if (gitCheck.status !== 0) {
|
|
1603
|
+
return findings;
|
|
1604
|
+
}
|
|
1605
|
+
const envFiles = spawnSync2("git", ["ls-files"], {
|
|
1606
|
+
cwd: rootDir,
|
|
1607
|
+
encoding: "utf-8",
|
|
1608
|
+
stdio: "pipe"
|
|
1609
|
+
});
|
|
1610
|
+
if (envFiles.status === 0 && envFiles.stdout) {
|
|
1611
|
+
const trackedFiles = envFiles.stdout.split("\n").filter(Boolean);
|
|
1612
|
+
const envInGit = trackedFiles.filter((f) => /\.env(\.|$)/.test(f));
|
|
1613
|
+
for (const envFile of envInGit) {
|
|
1614
|
+
findings.push({
|
|
1615
|
+
id: `env-in-git-${envFile}`,
|
|
1616
|
+
severity: "critical",
|
|
1617
|
+
category: "env-in-git",
|
|
1618
|
+
message: `${envFile} is tracked in git \u2014 secrets may be exposed`,
|
|
1619
|
+
file: envFile
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
return findings;
|
|
1624
|
+
}
|
|
1625
|
+
async detectEvalUsage(rootDir) {
|
|
1626
|
+
const findings = [];
|
|
1627
|
+
const files = await fg11(["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], {
|
|
1628
|
+
cwd: rootDir,
|
|
1629
|
+
ignore: IGNORE_PATTERNS,
|
|
1630
|
+
absolute: true
|
|
1631
|
+
});
|
|
1632
|
+
for (const file of files) {
|
|
1633
|
+
try {
|
|
1634
|
+
const content = fs8.readFileSync(file, "utf-8");
|
|
1635
|
+
const rel = relativePath(rootDir, file);
|
|
1636
|
+
if (/\beval\s*\(/.test(content)) {
|
|
1637
|
+
findings.push({
|
|
1638
|
+
id: `eval-${rel}`,
|
|
1639
|
+
severity: "warning",
|
|
1640
|
+
category: "eval-usage",
|
|
1641
|
+
message: `${rel} uses eval() \u2014 potential code injection risk`,
|
|
1642
|
+
file: rel
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1645
|
+
} catch {
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
return findings;
|
|
1649
|
+
}
|
|
1650
|
+
};
|
|
1651
|
+
|
|
1652
|
+
// src/scanners/framework.ts
|
|
1653
|
+
import fg12 from "fast-glob";
|
|
1654
|
+
import fs9 from "fs";
|
|
1655
|
+
import path8 from "path";
|
|
1656
|
+
var FrameworkScanner = class {
|
|
1657
|
+
constructor() {
|
|
1658
|
+
this.name = "framework";
|
|
1659
|
+
}
|
|
1660
|
+
async scan(rootDir) {
|
|
1661
|
+
const findings = [];
|
|
1662
|
+
const pkgPath = path8.join(rootDir, "package.json");
|
|
1663
|
+
let isNextJS = false;
|
|
1664
|
+
let isReact = false;
|
|
1665
|
+
if (fs9.existsSync(pkgPath)) {
|
|
1666
|
+
try {
|
|
1667
|
+
const pkg = JSON.parse(fs9.readFileSync(pkgPath, "utf-8"));
|
|
1668
|
+
isNextJS = !!(pkg.dependencies?.next || pkg.devDependencies?.next);
|
|
1669
|
+
isReact = !!(pkg.dependencies?.react || pkg.devDependencies?.react);
|
|
1670
|
+
} catch (error) {
|
|
1671
|
+
return { findings: [], stats: { isNextJS: false, isReact: false } };
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
if (!isNextJS && !isReact) {
|
|
1675
|
+
return { findings: [], stats: { isNextJS: false, isReact: false } };
|
|
1676
|
+
}
|
|
1677
|
+
if (isNextJS) {
|
|
1678
|
+
const appPages = await fg12(["app/**/page.{ts,tsx,js,jsx}"], {
|
|
1679
|
+
cwd: rootDir,
|
|
1680
|
+
ignore: IGNORE_PATTERNS,
|
|
1681
|
+
absolute: true
|
|
1682
|
+
});
|
|
1683
|
+
for (const pagePath of appPages) {
|
|
1684
|
+
try {
|
|
1685
|
+
const content = fs9.readFileSync(pagePath, "utf-8");
|
|
1686
|
+
const hasMetadata = /export\s+(const|function)\s+metadata/.test(content) || // eslint-disable-next-line security/detect-unsafe-regex -- Bounded by file content
|
|
1687
|
+
/export\s+(async\s+)?function\s+generateMetadata/.test(content);
|
|
1688
|
+
if (!hasMetadata) {
|
|
1689
|
+
const rel = path8.relative(rootDir, pagePath).replace(/\\/g, "/");
|
|
1690
|
+
findings.push({
|
|
1691
|
+
id: `no-metadata-${rel}`,
|
|
1692
|
+
severity: "warning",
|
|
1693
|
+
category: "nextjs-metadata",
|
|
1694
|
+
message: `${rel} missing metadata export \u2014 SEO impact`,
|
|
1695
|
+
file: rel
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
} catch (error) {
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
const serverComponents = await fg12(["app/**/*.{ts,tsx}"], {
|
|
1703
|
+
cwd: rootDir,
|
|
1704
|
+
ignore: IGNORE_PATTERNS,
|
|
1705
|
+
absolute: true
|
|
1706
|
+
});
|
|
1707
|
+
for (const compPath of serverComponents) {
|
|
1708
|
+
try {
|
|
1709
|
+
const content = fs9.readFileSync(compPath, "utf-8");
|
|
1710
|
+
const isClientComponent = /['"]use client['"]/.test(content);
|
|
1711
|
+
if (!isClientComponent) {
|
|
1712
|
+
const hasClientHooks = /\b(useState|useEffect|useLayoutEffect|useReducer)\s*\(/.test(
|
|
1713
|
+
content
|
|
1714
|
+
);
|
|
1715
|
+
if (hasClientHooks) {
|
|
1716
|
+
const rel = path8.relative(rootDir, compPath).replace(/\\/g, "/");
|
|
1717
|
+
findings.push({
|
|
1718
|
+
id: `server-client-mismatch-${rel}`,
|
|
1719
|
+
severity: "warning",
|
|
1720
|
+
category: "nextjs-client-server",
|
|
1721
|
+
message: `${rel} uses client hooks without 'use client' directive`,
|
|
1722
|
+
file: rel
|
|
1723
|
+
});
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
} catch (error) {
|
|
1727
|
+
continue;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
if (isReact) {
|
|
1732
|
+
const rootComponents = await fg12(
|
|
1733
|
+
[
|
|
1734
|
+
"app/layout.{ts,tsx}",
|
|
1735
|
+
"app/error.{ts,tsx}",
|
|
1736
|
+
"pages/_app.{ts,tsx,js,jsx}",
|
|
1737
|
+
"src/App.{ts,tsx,js,jsx}"
|
|
1738
|
+
],
|
|
1739
|
+
{
|
|
1740
|
+
cwd: rootDir,
|
|
1741
|
+
ignore: IGNORE_PATTERNS,
|
|
1742
|
+
absolute: true
|
|
1743
|
+
}
|
|
1744
|
+
);
|
|
1745
|
+
if (rootComponents.length > 0) {
|
|
1746
|
+
for (const compPath of rootComponents) {
|
|
1747
|
+
try {
|
|
1748
|
+
const content = fs9.readFileSync(compPath, "utf-8");
|
|
1749
|
+
const hasErrorBoundary = /ErrorBoundary|componentDidCatch|static getDerivedStateFromError/.test(
|
|
1750
|
+
content
|
|
1751
|
+
);
|
|
1752
|
+
if (!hasErrorBoundary) {
|
|
1753
|
+
const rel = path8.relative(rootDir, compPath).replace(/\\/g, "/");
|
|
1754
|
+
findings.push({
|
|
1755
|
+
id: `no-error-boundary-${rel}`,
|
|
1756
|
+
severity: "info",
|
|
1757
|
+
category: "react-error-boundary",
|
|
1758
|
+
message: `${rel} could benefit from an error boundary`,
|
|
1759
|
+
file: rel
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
} catch (error) {
|
|
1763
|
+
continue;
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
return {
|
|
1769
|
+
findings,
|
|
1770
|
+
stats: { isNextJS, isReact }
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
};
|
|
1774
|
+
|
|
1775
|
+
// src/scanners/python.ts
|
|
1776
|
+
import fg13 from "fast-glob";
|
|
1777
|
+
import fs10 from "fs";
|
|
1778
|
+
var PythonComplexityScanner = class {
|
|
1779
|
+
constructor() {
|
|
1780
|
+
this.name = "python-complexity";
|
|
1781
|
+
}
|
|
1782
|
+
async scan(rootDir) {
|
|
1783
|
+
const findings = [];
|
|
1784
|
+
const files = await fg13(["**/*.py"], {
|
|
1785
|
+
cwd: rootDir,
|
|
1786
|
+
ignore: IGNORE_PATTERNS,
|
|
1787
|
+
absolute: true
|
|
1788
|
+
});
|
|
1789
|
+
for (const file of files) {
|
|
1790
|
+
try {
|
|
1791
|
+
const content = fs10.readFileSync(file, "utf-8");
|
|
1792
|
+
const rel = relativePath(rootDir, file);
|
|
1793
|
+
const functions = this.extractFunctions(content);
|
|
1794
|
+
for (const func of functions) {
|
|
1795
|
+
const complexity = this.calculateComplexity(func.body);
|
|
1796
|
+
if (complexity >= 20) {
|
|
1797
|
+
findings.push({
|
|
1798
|
+
id: `py-complexity-${rel}-${func.name}`,
|
|
1799
|
+
severity: "critical",
|
|
1800
|
+
category: "complexity",
|
|
1801
|
+
message: `Function ${func.name} has cyclomatic complexity of ${complexity}`,
|
|
1802
|
+
file: rel,
|
|
1803
|
+
detail: `${complexity} decision points`
|
|
1804
|
+
});
|
|
1805
|
+
} else if (complexity >= 10) {
|
|
1806
|
+
findings.push({
|
|
1807
|
+
id: `py-complexity-${rel}-${func.name}`,
|
|
1808
|
+
severity: "warning",
|
|
1809
|
+
category: "complexity",
|
|
1810
|
+
message: `Function ${func.name} has cyclomatic complexity of ${complexity}`,
|
|
1811
|
+
file: rel,
|
|
1812
|
+
detail: `${complexity} decision points`
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
} catch {
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
return { findings };
|
|
1820
|
+
}
|
|
1821
|
+
extractFunctions(content) {
|
|
1822
|
+
const functions = [];
|
|
1823
|
+
const lines = content.split("\n");
|
|
1824
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1825
|
+
const line = lines[i];
|
|
1826
|
+
const funcMatch = line.match(/^\s*def\s+(\w+)\s*\(/);
|
|
1827
|
+
if (funcMatch) {
|
|
1828
|
+
const name = funcMatch[1];
|
|
1829
|
+
const indent = line.match(/^\s*/)?.[0].length || 0;
|
|
1830
|
+
let body = line + "\n";
|
|
1831
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
1832
|
+
const nextLine = lines[j];
|
|
1833
|
+
const nextIndent = nextLine.match(/^\s*/)?.[0].length || 0;
|
|
1834
|
+
if (nextLine.trim() === "" || nextLine.trim().startsWith("#")) {
|
|
1835
|
+
body += nextLine + "\n";
|
|
1836
|
+
continue;
|
|
1837
|
+
}
|
|
1838
|
+
if (nextIndent <= indent) {
|
|
1839
|
+
break;
|
|
1840
|
+
}
|
|
1841
|
+
body += nextLine + "\n";
|
|
1842
|
+
}
|
|
1843
|
+
functions.push({ name, body });
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
return functions;
|
|
1847
|
+
}
|
|
1848
|
+
calculateComplexity(code) {
|
|
1849
|
+
let complexity = 1;
|
|
1850
|
+
const patterns = [
|
|
1851
|
+
/\bif\b/g,
|
|
1852
|
+
/\belif\b/g,
|
|
1853
|
+
/\bfor\b/g,
|
|
1854
|
+
/\bwhile\b/g,
|
|
1855
|
+
/\band\b/g,
|
|
1856
|
+
/\bor\b/g,
|
|
1857
|
+
/\bexcept\b/g,
|
|
1858
|
+
/\bwith\b/g,
|
|
1859
|
+
/\?/g
|
|
1860
|
+
// Ternary
|
|
1861
|
+
];
|
|
1862
|
+
for (const pattern of patterns) {
|
|
1863
|
+
const matches = code.match(pattern);
|
|
1864
|
+
if (matches) {
|
|
1865
|
+
complexity += matches.length;
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
return complexity;
|
|
1869
|
+
}
|
|
1870
|
+
};
|
|
1871
|
+
var PythonTypeHintsScanner = class {
|
|
1872
|
+
constructor() {
|
|
1873
|
+
this.name = "python-type-hints";
|
|
1874
|
+
}
|
|
1875
|
+
async scan(rootDir) {
|
|
1876
|
+
const findings = [];
|
|
1877
|
+
const files = await fg13(["**/*.py"], {
|
|
1878
|
+
cwd: rootDir,
|
|
1879
|
+
ignore: IGNORE_PATTERNS,
|
|
1880
|
+
absolute: true
|
|
1881
|
+
});
|
|
1882
|
+
let totalFunctions = 0;
|
|
1883
|
+
let untypedFunctions = 0;
|
|
1884
|
+
for (const file of files) {
|
|
1885
|
+
try {
|
|
1886
|
+
const content = fs10.readFileSync(file, "utf-8");
|
|
1887
|
+
const rel = relativePath(rootDir, file);
|
|
1888
|
+
const funcPattern = /def\s+(\w+)\s*\([^)]*\)(?:\s*->\s*\w+)?:/g;
|
|
1889
|
+
const functions = Array.from(content.matchAll(funcPattern));
|
|
1890
|
+
for (const match of functions) {
|
|
1891
|
+
totalFunctions++;
|
|
1892
|
+
const fullDef = match[0];
|
|
1893
|
+
const hasParamTypes = /:\s*\w+/.test(fullDef);
|
|
1894
|
+
const hasReturnType = /->/.test(fullDef);
|
|
1895
|
+
if (!hasParamTypes && !hasReturnType) {
|
|
1896
|
+
untypedFunctions++;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
} catch {
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
if (totalFunctions > 0) {
|
|
1903
|
+
const untypedPercent = Math.round(
|
|
1904
|
+
untypedFunctions / totalFunctions * 100
|
|
1905
|
+
);
|
|
1906
|
+
if (untypedPercent > 70) {
|
|
1907
|
+
findings.push({
|
|
1908
|
+
id: "py-type-hints-low",
|
|
1909
|
+
severity: "warning",
|
|
1910
|
+
category: "type-safety",
|
|
1911
|
+
message: `${untypedPercent}% of Python functions lack type hints`,
|
|
1912
|
+
detail: `${untypedFunctions} of ${totalFunctions} functions`
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
return {
|
|
1917
|
+
findings,
|
|
1918
|
+
stats: {
|
|
1919
|
+
totalFunctions,
|
|
1920
|
+
untypedFunctions,
|
|
1921
|
+
untypedPercent: totalFunctions > 0 ? Math.round(untypedFunctions / totalFunctions * 100) : 0
|
|
1922
|
+
}
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
};
|
|
1926
|
+
var PythonImportsScanner = class {
|
|
1927
|
+
constructor() {
|
|
1928
|
+
this.name = "python-imports";
|
|
1929
|
+
}
|
|
1930
|
+
async scan(rootDir) {
|
|
1931
|
+
const findings = [];
|
|
1932
|
+
const files = await fg13(["**/*.py"], {
|
|
1933
|
+
cwd: rootDir,
|
|
1934
|
+
ignore: IGNORE_PATTERNS,
|
|
1935
|
+
absolute: true
|
|
1936
|
+
});
|
|
1937
|
+
for (const file of files) {
|
|
1938
|
+
try {
|
|
1939
|
+
const content = fs10.readFileSync(file, "utf-8");
|
|
1940
|
+
const rel = relativePath(rootDir, file);
|
|
1941
|
+
const wildcardImports = content.match(/from\s+[\w.]+\s+import\s+\*/g);
|
|
1942
|
+
if (wildcardImports && wildcardImports.length > 0) {
|
|
1943
|
+
findings.push({
|
|
1944
|
+
id: `py-wildcard-import-${rel}`,
|
|
1945
|
+
severity: "warning",
|
|
1946
|
+
category: "python-imports",
|
|
1947
|
+
message: `${rel} uses wildcard imports (${wildcardImports.length} found)`,
|
|
1948
|
+
file: rel,
|
|
1949
|
+
detail: "Wildcard imports pollute namespace"
|
|
1950
|
+
});
|
|
1951
|
+
}
|
|
1952
|
+
const deepRelative = content.match(/from\s+\.{3,}/g);
|
|
1953
|
+
if (deepRelative && deepRelative.length > 0) {
|
|
1954
|
+
findings.push({
|
|
1955
|
+
id: `py-deep-relative-${rel}`,
|
|
1956
|
+
severity: "info",
|
|
1957
|
+
category: "python-imports",
|
|
1958
|
+
message: `${rel} uses deep relative imports`,
|
|
1959
|
+
file: rel,
|
|
1960
|
+
detail: "Consider absolute imports"
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
} catch {
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
return { findings };
|
|
1967
|
+
}
|
|
1968
|
+
};
|
|
1969
|
+
|
|
1970
|
+
// src/languages/index.ts
|
|
1971
|
+
var LANGUAGE_CONFIGS = {
|
|
1972
|
+
javascript: {
|
|
1973
|
+
name: "JavaScript",
|
|
1974
|
+
extensions: [".js", ".jsx", ".mjs", ".cjs"],
|
|
1975
|
+
commentPatterns: [/\/\/.*/, /\/\*[\s\S]*?\*\//],
|
|
1976
|
+
packageFiles: ["package.json"],
|
|
1977
|
+
sourcePatterns: ["**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs"]
|
|
1978
|
+
},
|
|
1979
|
+
typescript: {
|
|
1980
|
+
name: "TypeScript",
|
|
1981
|
+
extensions: [".ts", ".tsx"],
|
|
1982
|
+
commentPatterns: [/\/\/.*/, /\/\*[\s\S]*?\*\//],
|
|
1983
|
+
packageFiles: ["package.json", "tsconfig.json"],
|
|
1984
|
+
sourcePatterns: ["**/*.ts", "**/*.tsx"]
|
|
1985
|
+
},
|
|
1986
|
+
python: {
|
|
1987
|
+
name: "Python",
|
|
1988
|
+
extensions: [".py"],
|
|
1989
|
+
commentPatterns: [/#.*/, /"""[\s\S]*?"""/, /'''[\s\S]*?'''/],
|
|
1990
|
+
packageFiles: [
|
|
1991
|
+
"requirements.txt",
|
|
1992
|
+
"setup.py",
|
|
1993
|
+
"pyproject.toml",
|
|
1994
|
+
"Pipfile",
|
|
1995
|
+
"poetry.lock"
|
|
1996
|
+
],
|
|
1997
|
+
sourcePatterns: ["**/*.py"]
|
|
1998
|
+
},
|
|
1999
|
+
go: {
|
|
2000
|
+
name: "Go",
|
|
2001
|
+
extensions: [".go"],
|
|
2002
|
+
commentPatterns: [/\/\/.*/, /\/\*[\s\S]*?\*\//],
|
|
2003
|
+
packageFiles: ["go.mod", "go.sum"],
|
|
2004
|
+
sourcePatterns: ["**/*.go"]
|
|
2005
|
+
},
|
|
2006
|
+
rust: {
|
|
2007
|
+
name: "Rust",
|
|
2008
|
+
extensions: [".rs"],
|
|
2009
|
+
commentPatterns: [/\/\/.*/, /\/\*[\s\S]*?\*\//, /\/\/\/.*/, /\/\*\*[\s\S]*?\*\//],
|
|
2010
|
+
packageFiles: ["Cargo.toml", "Cargo.lock"],
|
|
2011
|
+
sourcePatterns: ["**/*.rs"]
|
|
2012
|
+
},
|
|
2013
|
+
java: {
|
|
2014
|
+
name: "Java",
|
|
2015
|
+
extensions: [".java"],
|
|
2016
|
+
commentPatterns: [/\/\/.*/, /\/\*[\s\S]*?\*\//, /\/\*\*[\s\S]*?\*\//],
|
|
2017
|
+
packageFiles: ["pom.xml", "build.gradle", "build.gradle.kts"],
|
|
2018
|
+
sourcePatterns: ["**/*.java"]
|
|
2019
|
+
},
|
|
2020
|
+
csharp: {
|
|
2021
|
+
name: "C#",
|
|
2022
|
+
extensions: [".cs"],
|
|
2023
|
+
commentPatterns: [/\/\/.*/, /\/\*[\s\S]*?\*\//, /\/\/\/.*/, /\/\*\*[\s\S]*?\*\//],
|
|
2024
|
+
packageFiles: [".csproj", ".sln"],
|
|
2025
|
+
sourcePatterns: ["**/*.cs"]
|
|
2026
|
+
}
|
|
2027
|
+
};
|
|
2028
|
+
function detectProjectLanguage(rootDir, fs18) {
|
|
2029
|
+
const detectedLanguages = [];
|
|
2030
|
+
for (const [lang, config] of Object.entries(LANGUAGE_CONFIGS)) {
|
|
2031
|
+
for (const packageFile of config.packageFiles) {
|
|
2032
|
+
const fullPath = __require("path").join(rootDir, packageFile);
|
|
2033
|
+
if (fs18.existsSync(fullPath)) {
|
|
2034
|
+
detectedLanguages.push(lang);
|
|
2035
|
+
break;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
if (detectedLanguages.length === 0) {
|
|
2040
|
+
detectedLanguages.push("javascript", "typescript");
|
|
2041
|
+
}
|
|
2042
|
+
return [...new Set(detectedLanguages)];
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// src/scoring/index.ts
|
|
2046
|
+
function calculateHealth(findings) {
|
|
2047
|
+
let score = 100;
|
|
2048
|
+
for (const finding of findings) {
|
|
2049
|
+
switch (finding.category) {
|
|
2050
|
+
case "large-files":
|
|
2051
|
+
if (finding.severity === "critical") {
|
|
2052
|
+
score += HEALTH_DEDUCTIONS.extremeFile;
|
|
2053
|
+
} else {
|
|
2054
|
+
score += HEALTH_DEDUCTIONS.largeFile;
|
|
2055
|
+
}
|
|
2056
|
+
break;
|
|
2057
|
+
case "todos":
|
|
2058
|
+
score += HEALTH_DEDUCTIONS.todo * getTodoCount(finding);
|
|
2059
|
+
break;
|
|
2060
|
+
case "circular-deps":
|
|
2061
|
+
score += HEALTH_DEDUCTIONS.circularDependency;
|
|
2062
|
+
break;
|
|
2063
|
+
case "unused-deps":
|
|
2064
|
+
score += HEALTH_DEDUCTIONS.unusedDependency;
|
|
2065
|
+
break;
|
|
2066
|
+
case "dependencies":
|
|
2067
|
+
if (finding.severity === "critical") {
|
|
2068
|
+
score += HEALTH_DEDUCTIONS.excessiveDeps;
|
|
2069
|
+
}
|
|
2070
|
+
break;
|
|
2071
|
+
case "structure":
|
|
2072
|
+
if (finding.id === "deep-nesting") {
|
|
2073
|
+
score += HEALTH_DEDUCTIONS.deepNesting;
|
|
2074
|
+
}
|
|
2075
|
+
if (finding.id === "util-explosion") {
|
|
2076
|
+
score += HEALTH_DEDUCTIONS.utilExplosion;
|
|
2077
|
+
}
|
|
2078
|
+
break;
|
|
2079
|
+
case "complexity":
|
|
2080
|
+
if (finding.severity === "critical") {
|
|
2081
|
+
score += HEALTH_DEDUCTIONS.veryComplexFunction;
|
|
2082
|
+
} else if (finding.severity === "warning") {
|
|
2083
|
+
score += HEALTH_DEDUCTIONS.complexFunction;
|
|
2084
|
+
}
|
|
2085
|
+
break;
|
|
2086
|
+
case "duplicates":
|
|
2087
|
+
score += HEALTH_DEDUCTIONS.duplicateCode;
|
|
2088
|
+
break;
|
|
2089
|
+
case "dead-exports":
|
|
2090
|
+
score += HEALTH_DEDUCTIONS.deadExport;
|
|
2091
|
+
break;
|
|
2092
|
+
case "type-safety":
|
|
2093
|
+
if (finding.severity === "critical") {
|
|
2094
|
+
score += HEALTH_DEDUCTIONS.criticalTypeSafety;
|
|
2095
|
+
} else if (finding.severity === "warning") {
|
|
2096
|
+
score += HEALTH_DEDUCTIONS.typeSafetyIssue;
|
|
2097
|
+
}
|
|
2098
|
+
break;
|
|
2099
|
+
case "git-churn":
|
|
2100
|
+
score += HEALTH_DEDUCTIONS.gitChurn;
|
|
2101
|
+
break;
|
|
2102
|
+
case "pr-size":
|
|
2103
|
+
score += HEALTH_DEDUCTIONS.largePRSize;
|
|
2104
|
+
break;
|
|
2105
|
+
case "secrets":
|
|
2106
|
+
case "env-in-git":
|
|
2107
|
+
score += HEALTH_DEDUCTIONS.secret;
|
|
2108
|
+
break;
|
|
2109
|
+
case "eval-usage":
|
|
2110
|
+
score += HEALTH_DEDUCTIONS.evalUsage;
|
|
2111
|
+
break;
|
|
2112
|
+
case "test-coverage":
|
|
2113
|
+
score += HEALTH_DEDUCTIONS.missingTest;
|
|
2114
|
+
break;
|
|
2115
|
+
case "nextjs-metadata":
|
|
2116
|
+
case "nextjs-client-server":
|
|
2117
|
+
case "react-error-boundary":
|
|
2118
|
+
score += HEALTH_DEDUCTIONS.frameworkViolation;
|
|
2119
|
+
break;
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
score = Math.max(0, Math.min(100, Math.round(score)));
|
|
2123
|
+
return {
|
|
2124
|
+
score,
|
|
2125
|
+
grade: getGrade(score),
|
|
2126
|
+
label: getLabel(score)
|
|
2127
|
+
};
|
|
2128
|
+
}
|
|
2129
|
+
function getTodoCount(finding) {
|
|
2130
|
+
const match = finding.message.match(/Found (\d+)/);
|
|
2131
|
+
return match ? parseInt(match[1], 10) : 1;
|
|
2132
|
+
}
|
|
2133
|
+
function getGrade(score) {
|
|
2134
|
+
if (score >= 90) return "A";
|
|
2135
|
+
if (score >= 80) return "B";
|
|
2136
|
+
if (score >= 70) return "C";
|
|
2137
|
+
if (score >= 60) return "D";
|
|
2138
|
+
return "F";
|
|
2139
|
+
}
|
|
2140
|
+
function getLabel(score) {
|
|
2141
|
+
if (score >= 90) return "Excellent";
|
|
2142
|
+
if (score >= 80) return "Good";
|
|
2143
|
+
if (score >= 70) return "Fair";
|
|
2144
|
+
if (score >= 60) return "Risky";
|
|
2145
|
+
return "Chaotic";
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// src/ai/index.ts
|
|
2149
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2150
|
+
import fs11 from "fs";
|
|
2151
|
+
import path9 from "path";
|
|
2152
|
+
import crypto from "crypto";
|
|
2153
|
+
var DEFAULT_MODEL = "claude-3-5-sonnet-20241022";
|
|
2154
|
+
var DEFAULT_MAX_TOKENS = 150;
|
|
2155
|
+
var DEFAULT_TEMPERATURE = 1;
|
|
2156
|
+
var CACHE_FILE = ".roast-ai-cache.json";
|
|
2157
|
+
var CACHE_TTL = 7 * 24 * 60 * 60 * 1e3;
|
|
2158
|
+
async function generateAIRoast(finding, config, rootDir) {
|
|
2159
|
+
if (!config.enabled) {
|
|
2160
|
+
return null;
|
|
2161
|
+
}
|
|
2162
|
+
const apiKey = config.apiKey || process.env.ANTHROPIC_API_KEY;
|
|
2163
|
+
if (!apiKey) {
|
|
2164
|
+
console.warn(
|
|
2165
|
+
"Warning: AI roasts enabled but ANTHROPIC_API_KEY not found. Set via environment variable or .roastrc.json"
|
|
2166
|
+
);
|
|
2167
|
+
return null;
|
|
2168
|
+
}
|
|
2169
|
+
if (config.cacheEnabled !== false) {
|
|
2170
|
+
const cached = getCachedRoast(finding, rootDir, config.cachePath);
|
|
2171
|
+
if (cached) {
|
|
2172
|
+
return cached;
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
try {
|
|
2176
|
+
const anthropic = new Anthropic({ apiKey });
|
|
2177
|
+
const prompt = buildRoastPrompt(finding);
|
|
2178
|
+
const response = await anthropic.messages.create({
|
|
2179
|
+
model: config.model || DEFAULT_MODEL,
|
|
2180
|
+
max_tokens: config.maxTokens || DEFAULT_MAX_TOKENS,
|
|
2181
|
+
temperature: config.temperature || DEFAULT_TEMPERATURE,
|
|
2182
|
+
messages: [
|
|
2183
|
+
{
|
|
2184
|
+
role: "user",
|
|
2185
|
+
content: prompt
|
|
2186
|
+
}
|
|
2187
|
+
]
|
|
2188
|
+
});
|
|
2189
|
+
const roast = response.content[0].type === "text" ? response.content[0].text : null;
|
|
2190
|
+
if (roast && config.cacheEnabled !== false) {
|
|
2191
|
+
cacheRoast(finding, roast, rootDir, config.cachePath);
|
|
2192
|
+
}
|
|
2193
|
+
return roast;
|
|
2194
|
+
} catch (error) {
|
|
2195
|
+
console.warn(
|
|
2196
|
+
`Warning: Failed to generate AI roast: ${error instanceof Error ? error.message : String(error)}`
|
|
2197
|
+
);
|
|
2198
|
+
return null;
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
async function generateAIRoastsBatch(findings, config, rootDir, maxConcurrent = 3) {
|
|
2202
|
+
const roasts = /* @__PURE__ */ new Map();
|
|
2203
|
+
for (let i = 0; i < findings.length; i += maxConcurrent) {
|
|
2204
|
+
const batch = findings.slice(i, i + maxConcurrent);
|
|
2205
|
+
const promises = batch.map(async (finding) => {
|
|
2206
|
+
const roast = await generateAIRoast(finding, config, rootDir);
|
|
2207
|
+
if (roast) {
|
|
2208
|
+
roasts.set(finding.id, roast);
|
|
2209
|
+
}
|
|
2210
|
+
});
|
|
2211
|
+
await Promise.all(promises);
|
|
2212
|
+
}
|
|
2213
|
+
return roasts;
|
|
2214
|
+
}
|
|
2215
|
+
function buildRoastPrompt(finding) {
|
|
2216
|
+
const severityContext = finding.severity === "critical" ? "This is a critical issue that needs immediate attention." : finding.severity === "warning" ? "This is a warning-level issue." : "This is an informational finding.";
|
|
2217
|
+
let context = `Generate a witty, humorous but constructive roast for this code issue.
|
|
2218
|
+
|
|
2219
|
+
Category: ${finding.category}
|
|
2220
|
+
Severity: ${finding.severity}
|
|
2221
|
+
${severityContext}
|
|
2222
|
+
|
|
2223
|
+
Issue: ${finding.message}`;
|
|
2224
|
+
if (finding.file) {
|
|
2225
|
+
context += `
|
|
2226
|
+
File: ${finding.file}`;
|
|
2227
|
+
}
|
|
2228
|
+
if (finding.detail) {
|
|
2229
|
+
context += `
|
|
2230
|
+
Details: ${finding.detail}`;
|
|
2231
|
+
}
|
|
2232
|
+
context += `
|
|
2233
|
+
|
|
2234
|
+
Requirements:
|
|
2235
|
+
- Be funny and witty, but not mean-spirited
|
|
2236
|
+
- Make it specific to the actual issue
|
|
2237
|
+
- Keep it under 2 sentences
|
|
2238
|
+
- Use developer humor (references to coffee, late nights, etc. are welcome)
|
|
2239
|
+
- Be constructive - the roast should make the developer want to fix the issue
|
|
2240
|
+
- Don't use generic statements - reference the specific problem
|
|
2241
|
+
|
|
2242
|
+
Examples of good roasts:
|
|
2243
|
+
- For a 1,847-line file: "This file is doing more jobs than a Swiss Army knife. Pick a lane."
|
|
2244
|
+
- For circular dependencies: "These files are more codependent than a reality TV couple."
|
|
2245
|
+
- For 50 TODOs: "Your code has more TODOs than a grocery list. Maybe start with the ones from 2019?"
|
|
2246
|
+
|
|
2247
|
+
Generate only the roast, no explanations or meta-commentary.`;
|
|
2248
|
+
return context;
|
|
2249
|
+
}
|
|
2250
|
+
function getFindingHash(finding) {
|
|
2251
|
+
const key = `${finding.category}-${finding.severity}-${finding.message}-${finding.file || ""}`;
|
|
2252
|
+
return crypto.createHash("md5").update(key).digest("hex");
|
|
2253
|
+
}
|
|
2254
|
+
function getCachedRoast(finding, rootDir, cachePath) {
|
|
2255
|
+
const cacheFile = path9.join(rootDir, cachePath || CACHE_FILE);
|
|
2256
|
+
if (!fs11.existsSync(cacheFile)) {
|
|
2257
|
+
return null;
|
|
2258
|
+
}
|
|
2259
|
+
try {
|
|
2260
|
+
const content = fs11.readFileSync(cacheFile, "utf-8");
|
|
2261
|
+
const cache = JSON.parse(content);
|
|
2262
|
+
const findingHash = getFindingHash(finding);
|
|
2263
|
+
const now = Date.now();
|
|
2264
|
+
const cached = cache.find(
|
|
2265
|
+
(c) => c.findingHash === findingHash && now - c.timestamp < CACHE_TTL
|
|
2266
|
+
);
|
|
2267
|
+
return cached ? cached.roast : null;
|
|
2268
|
+
} catch {
|
|
2269
|
+
return null;
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
function cacheRoast(finding, roast, rootDir, cachePath) {
|
|
2273
|
+
const cacheFile = path9.join(rootDir, cachePath || CACHE_FILE);
|
|
2274
|
+
try {
|
|
2275
|
+
let cache = [];
|
|
2276
|
+
if (fs11.existsSync(cacheFile)) {
|
|
2277
|
+
const content = fs11.readFileSync(cacheFile, "utf-8");
|
|
2278
|
+
cache = JSON.parse(content);
|
|
2279
|
+
}
|
|
2280
|
+
const findingHash = getFindingHash(finding);
|
|
2281
|
+
cache = cache.filter((c) => c.findingHash !== findingHash);
|
|
2282
|
+
cache.push({
|
|
2283
|
+
findingHash,
|
|
2284
|
+
roast,
|
|
2285
|
+
timestamp: Date.now()
|
|
2286
|
+
});
|
|
2287
|
+
const now = Date.now();
|
|
2288
|
+
cache = cache.filter((c) => now - c.timestamp < CACHE_TTL);
|
|
2289
|
+
if (cache.length > 100) {
|
|
2290
|
+
cache = cache.slice(-100);
|
|
2291
|
+
}
|
|
2292
|
+
fs11.writeFileSync(cacheFile, JSON.stringify(cache, null, 2), "utf-8");
|
|
2293
|
+
} catch (error) {
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// src/roasts/index.ts
|
|
2298
|
+
var largeFileRoasts = [
|
|
2299
|
+
"This file has achieved sentience.",
|
|
2300
|
+
"At this size, this module should have its own roadmap.",
|
|
2301
|
+
"This file contains several geological layers.",
|
|
2302
|
+
"Future archaeologists will study this file.",
|
|
2303
|
+
"This file predates some of your dependencies.",
|
|
2304
|
+
"Scrolling through this file counts as cardio.",
|
|
2305
|
+
"This file has more lines than some microservices have total.",
|
|
2306
|
+
"This is less a file and more of a lifestyle."
|
|
2307
|
+
];
|
|
2308
|
+
var todoRoasts = [
|
|
2309
|
+
"Future You has requested fewer TODOs and more DO-NOs.",
|
|
2310
|
+
"Your codebase has more promises than a politician in election season.",
|
|
2311
|
+
"These TODOs have aged like fine wine \u2014 ignored and dusty.",
|
|
2312
|
+
"Every TODO is a tiny apology to your future self.",
|
|
2313
|
+
"These TODOs are now historical artifacts.",
|
|
2314
|
+
"Someone left breadcrumbs. Nobody followed them back."
|
|
2315
|
+
];
|
|
2316
|
+
var dependencyRoasts = [
|
|
2317
|
+
"This package.json reads like a phone book.",
|
|
2318
|
+
"Your node_modules folder needs its own zip code.",
|
|
2319
|
+
"This dependency appears to be paying rent for no reason.",
|
|
2320
|
+
"Your left-pad insurance policy is extensive.",
|
|
2321
|
+
"There are more dependencies here than features.",
|
|
2322
|
+
"npm install probably takes a lunch break."
|
|
2323
|
+
];
|
|
2324
|
+
var circularRoasts = [
|
|
2325
|
+
"These files are in a codependent relationship.",
|
|
2326
|
+
"This import cycle is an infinite loop of regret.",
|
|
2327
|
+
"These modules reference each other like they're in couples therapy.",
|
|
2328
|
+
"A dependency circle \u2014 the software equivalent of a dog chasing its tail.",
|
|
2329
|
+
"These files import each other like two mirrors facing each other."
|
|
2330
|
+
];
|
|
2331
|
+
var structureRoasts = [
|
|
2332
|
+
"This folder structure would make a spelunker nervous.",
|
|
2333
|
+
"Your project nesting goes deeper than the Mariana Trench.",
|
|
2334
|
+
"The junk drawer has evolved into a junk warehouse.",
|
|
2335
|
+
"Your utils folder has more career potential than a senior engineer.",
|
|
2336
|
+
"Someone really committed to the folder-per-thought architecture."
|
|
2337
|
+
];
|
|
2338
|
+
var complexityRoasts = [
|
|
2339
|
+
"This function has more branches than a forest.",
|
|
2340
|
+
"Cyclomatic complexity called \u2014 it wants its title back.",
|
|
2341
|
+
"This function does everything except make you coffee.",
|
|
2342
|
+
"Reading this function requires a trail guide and emergency supplies.",
|
|
2343
|
+
"This function's decision tree looks like a family tree from Game of Thrones."
|
|
2344
|
+
];
|
|
2345
|
+
var duplicateRoasts = [
|
|
2346
|
+
"Copy-paste is not a design pattern.",
|
|
2347
|
+
"This code has more twins than a soap opera.",
|
|
2348
|
+
"Someone discovered Ctrl+C but not abstraction.",
|
|
2349
|
+
"DRY principles died here.",
|
|
2350
|
+
"This code clones itself like a biological virus."
|
|
2351
|
+
];
|
|
2352
|
+
var deadExportRoasts = [
|
|
2353
|
+
"These exports are shouting into the void.",
|
|
2354
|
+
"This function is export-only, like a concept car that never ships.",
|
|
2355
|
+
"Nobody imports this. Nobody.",
|
|
2356
|
+
"This export is as useful as a chocolate teapot.",
|
|
2357
|
+
"These dead exports are the software equivalent of ghost towns."
|
|
2358
|
+
];
|
|
2359
|
+
var typeSafetyRoasts = [
|
|
2360
|
+
"TypeScript is just JavaScript with extra steps at this rate.",
|
|
2361
|
+
"The 'any' escape hatch has become the front door.",
|
|
2362
|
+
"Your type safety is more like type suggestions.",
|
|
2363
|
+
"@ts-ignore: because types are hard.",
|
|
2364
|
+
"This codebase treats TypeScript like a linter, not a language."
|
|
2365
|
+
];
|
|
2366
|
+
var gitChurnRoasts = [
|
|
2367
|
+
"This file changes more often than JavaScript frameworks.",
|
|
2368
|
+
"Version control or version chaos? You decide.",
|
|
2369
|
+
"This file has more revisions than a novel.",
|
|
2370
|
+
"At this rate of change, git log is your documentation."
|
|
2371
|
+
];
|
|
2372
|
+
var securityRoasts = [
|
|
2373
|
+
"Secrets in git. Because what could go wrong?",
|
|
2374
|
+
"Your API keys are public. Consider them compromised.",
|
|
2375
|
+
"eval() \u2014 when you want attackers to write your code for you.",
|
|
2376
|
+
"Hardcoded secrets: the gift that keeps on giving (to hackers)."
|
|
2377
|
+
];
|
|
2378
|
+
var testCoverageRoasts = [
|
|
2379
|
+
"Tests are optional, right? Right?",
|
|
2380
|
+
"This code is production-ready. Trust me.",
|
|
2381
|
+
"Writing tests is for people who make mistakes.",
|
|
2382
|
+
"YOLO-driven development at its finest."
|
|
2383
|
+
];
|
|
2384
|
+
var frameworkRoasts = [
|
|
2385
|
+
"Next.js best practices are more like Next.js suggestions, apparently.",
|
|
2386
|
+
"Missing metadata \u2014 search engines love mystery pages.",
|
|
2387
|
+
"Error boundaries are for people who expect errors.",
|
|
2388
|
+
"Client hooks in server components: bold strategy."
|
|
2389
|
+
];
|
|
2390
|
+
var verdicts = {
|
|
2391
|
+
excellent: [
|
|
2392
|
+
"Your codebase is suspiciously clean. Are you hiding something?",
|
|
2393
|
+
"Impressive. This codebase is either well-maintained or brand new.",
|
|
2394
|
+
"Nearly flawless. Your team might actually read each other's PRs."
|
|
2395
|
+
],
|
|
2396
|
+
good: [
|
|
2397
|
+
"Solid work. A few rough edges, but nothing that keeps you up at night.",
|
|
2398
|
+
"This codebase has good bones. The renovations can wait.",
|
|
2399
|
+
"Above average. Your tech debt is manageable, not existential."
|
|
2400
|
+
],
|
|
2401
|
+
fair: [
|
|
2402
|
+
"Your codebase is at that stage where 'refactor sprint' keeps getting postponed.",
|
|
2403
|
+
"Not terrible, not great. Like most software, it exists in a state of managed chaos.",
|
|
2404
|
+
"Some files are applying for monolithic status. Intervention recommended."
|
|
2405
|
+
],
|
|
2406
|
+
risky: [
|
|
2407
|
+
"Your codebase is one bad merge away from a support group.",
|
|
2408
|
+
"This repository has seen things. And done things. Questionable things.",
|
|
2409
|
+
"The technical debt here could qualify for its own line of credit."
|
|
2410
|
+
],
|
|
2411
|
+
chaotic: [
|
|
2412
|
+
"This codebase is held together by hope and string literals.",
|
|
2413
|
+
"Abandon all hope, ye who git clone here.",
|
|
2414
|
+
"This isn't a codebase. It's an archaeological dig site."
|
|
2415
|
+
]
|
|
2416
|
+
};
|
|
2417
|
+
var pythonTypeHintsRoasts = [
|
|
2418
|
+
"Type hints are optional in Python. So are brakes on a car, technically.",
|
|
2419
|
+
"Dynamic typing is great until your production server discovers the wrong type at 3 AM.",
|
|
2420
|
+
"Type hints: because debugging at runtime is what keeps us young."
|
|
2421
|
+
];
|
|
2422
|
+
var pythonImportRoasts = [
|
|
2423
|
+
"Wildcard imports: because you like playing 'guess which namespace that came from.'",
|
|
2424
|
+
"Deep relative imports: your codebase is spaghetti that imports other spaghetti.",
|
|
2425
|
+
"from module import * \u2014 the programming equivalent of 'throw everything in and hope.'"
|
|
2426
|
+
];
|
|
2427
|
+
function pick(arr) {
|
|
2428
|
+
return arr[Math.floor(Math.random() * arr.length)];
|
|
2429
|
+
}
|
|
2430
|
+
async function generateRoasts(findings, aiConfig, rootDir) {
|
|
2431
|
+
const roasts = [];
|
|
2432
|
+
let aiRoasts = /* @__PURE__ */ new Map();
|
|
2433
|
+
if (aiConfig?.enabled && rootDir) {
|
|
2434
|
+
try {
|
|
2435
|
+
const interestingFindings = [
|
|
2436
|
+
...findings.filter((f) => f.severity === "critical").slice(0, 3),
|
|
2437
|
+
...findings.filter((f) => f.severity === "warning").slice(0, 4),
|
|
2438
|
+
...findings.filter((f) => f.severity === "info").slice(0, 3)
|
|
2439
|
+
].slice(0, 10);
|
|
2440
|
+
aiRoasts = await generateAIRoastsBatch(
|
|
2441
|
+
interestingFindings,
|
|
2442
|
+
aiConfig,
|
|
2443
|
+
rootDir
|
|
2444
|
+
);
|
|
2445
|
+
} catch (error) {
|
|
2446
|
+
console.warn("Warning: AI roast generation failed, using predefined roasts");
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
const largeFiles = findings.filter((f) => f.category === "large-files" && f.severity !== "info");
|
|
2450
|
+
for (const finding of largeFiles.slice(0, 3)) {
|
|
2451
|
+
if (!finding.file) continue;
|
|
2452
|
+
const aiRoast = aiRoasts.get(finding.id);
|
|
2453
|
+
roasts.push({
|
|
2454
|
+
target: finding.file,
|
|
2455
|
+
message: aiRoast || pick(largeFileRoasts),
|
|
2456
|
+
category: "large-files"
|
|
2457
|
+
});
|
|
2458
|
+
}
|
|
2459
|
+
const todos = findings.filter((f) => f.category === "todos");
|
|
2460
|
+
if (todos.length > 0) {
|
|
2461
|
+
roasts.push({
|
|
2462
|
+
target: "TODOs",
|
|
2463
|
+
message: pick(todoRoasts),
|
|
2464
|
+
category: "todos"
|
|
2465
|
+
});
|
|
2466
|
+
}
|
|
2467
|
+
const depFindings = findings.filter(
|
|
2468
|
+
(f) => f.category === "dependencies" || f.category === "unused-deps"
|
|
2469
|
+
);
|
|
2470
|
+
if (depFindings.length > 0) {
|
|
2471
|
+
const unusedDeps = findings.filter((f) => f.category === "unused-deps");
|
|
2472
|
+
if (unusedDeps.length > 0) {
|
|
2473
|
+
roasts.push({
|
|
2474
|
+
target: unusedDeps[0].detail || "dependencies",
|
|
2475
|
+
message: pick(dependencyRoasts),
|
|
2476
|
+
category: "dependencies"
|
|
2477
|
+
});
|
|
2478
|
+
}
|
|
2479
|
+
if (findings.some((f) => f.id === "excessive-deps" || f.id === "many-deps")) {
|
|
2480
|
+
roasts.push({
|
|
2481
|
+
target: "package.json",
|
|
2482
|
+
message: pick(dependencyRoasts),
|
|
2483
|
+
category: "dependencies"
|
|
2484
|
+
});
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
const circular = findings.filter((f) => f.category === "circular-deps");
|
|
2488
|
+
if (circular.length > 0) {
|
|
2489
|
+
roasts.push({
|
|
2490
|
+
target: circular[0].file || "modules",
|
|
2491
|
+
message: pick(circularRoasts),
|
|
2492
|
+
category: "circular-deps"
|
|
2493
|
+
});
|
|
2494
|
+
}
|
|
2495
|
+
const structure = findings.filter((f) => f.category === "structure" && f.severity === "warning");
|
|
2496
|
+
if (structure.length > 0) {
|
|
2497
|
+
roasts.push({
|
|
2498
|
+
target: structure[0].file || "project structure",
|
|
2499
|
+
message: pick(structureRoasts),
|
|
2500
|
+
category: "structure"
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
const complexity = findings.filter((f) => f.category === "complexity" && f.severity !== "info");
|
|
2504
|
+
if (complexity.length > 0) {
|
|
2505
|
+
roasts.push({
|
|
2506
|
+
target: complexity[0].file || "functions",
|
|
2507
|
+
message: pick(complexityRoasts),
|
|
2508
|
+
category: "complexity"
|
|
2509
|
+
});
|
|
2510
|
+
}
|
|
2511
|
+
const duplicates = findings.filter((f) => f.category === "duplicates");
|
|
2512
|
+
if (duplicates.length > 0) {
|
|
2513
|
+
roasts.push({
|
|
2514
|
+
target: duplicates[0].file || "code",
|
|
2515
|
+
message: pick(duplicateRoasts),
|
|
2516
|
+
category: "duplicates"
|
|
2517
|
+
});
|
|
2518
|
+
}
|
|
2519
|
+
const deadExports = findings.filter((f) => f.category === "dead-exports");
|
|
2520
|
+
if (deadExports.length > 0) {
|
|
2521
|
+
roasts.push({
|
|
2522
|
+
target: deadExports[0].file || "exports",
|
|
2523
|
+
message: pick(deadExportRoasts),
|
|
2524
|
+
category: "dead-exports"
|
|
2525
|
+
});
|
|
2526
|
+
}
|
|
2527
|
+
const typeSafety = findings.filter((f) => f.category === "type-safety" && f.severity !== "info");
|
|
2528
|
+
if (typeSafety.length > 0) {
|
|
2529
|
+
roasts.push({
|
|
2530
|
+
target: typeSafety[0].file || "TypeScript",
|
|
2531
|
+
message: pick(typeSafetyRoasts),
|
|
2532
|
+
category: "type-safety"
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
const gitChurn = findings.filter((f) => f.category === "git-churn");
|
|
2536
|
+
if (gitChurn.length > 0) {
|
|
2537
|
+
roasts.push({
|
|
2538
|
+
target: gitChurn[0].file || "repository",
|
|
2539
|
+
message: pick(gitChurnRoasts),
|
|
2540
|
+
category: "git-churn"
|
|
2541
|
+
});
|
|
2542
|
+
}
|
|
2543
|
+
const security = findings.filter((f) => f.category === "secrets" || f.category === "env-in-git" || f.category === "eval-usage");
|
|
2544
|
+
if (security.length > 0) {
|
|
2545
|
+
roasts.push({
|
|
2546
|
+
target: security[0].file || "security",
|
|
2547
|
+
message: pick(securityRoasts),
|
|
2548
|
+
category: "security"
|
|
2549
|
+
});
|
|
2550
|
+
}
|
|
2551
|
+
const testCoverage = findings.filter((f) => f.category === "test-coverage");
|
|
2552
|
+
if (testCoverage.length > 0) {
|
|
2553
|
+
roasts.push({
|
|
2554
|
+
target: "test coverage",
|
|
2555
|
+
message: pick(testCoverageRoasts),
|
|
2556
|
+
category: "test-coverage"
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
const framework = findings.filter((f) => f.category === "nextjs-metadata" || f.category === "nextjs-client-server" || f.category === "react-error-boundary");
|
|
2560
|
+
if (framework.length > 0) {
|
|
2561
|
+
roasts.push({
|
|
2562
|
+
target: framework[0].file || "framework",
|
|
2563
|
+
message: pick(frameworkRoasts),
|
|
2564
|
+
category: "framework"
|
|
2565
|
+
});
|
|
2566
|
+
}
|
|
2567
|
+
const pythonTypeHints = findings.filter((f) => f.category === "type-safety" && f.message.includes("Python"));
|
|
2568
|
+
if (pythonTypeHints.length > 0) {
|
|
2569
|
+
roasts.push({
|
|
2570
|
+
target: "Python code",
|
|
2571
|
+
message: pick(pythonTypeHintsRoasts),
|
|
2572
|
+
category: "type-safety"
|
|
2573
|
+
});
|
|
2574
|
+
}
|
|
2575
|
+
const pythonImports = findings.filter((f) => f.category === "python-imports");
|
|
2576
|
+
if (pythonImports.length > 0) {
|
|
2577
|
+
roasts.push({
|
|
2578
|
+
target: pythonImports[0].file || "Python imports",
|
|
2579
|
+
message: pick(pythonImportRoasts),
|
|
2580
|
+
category: "python-imports"
|
|
2581
|
+
});
|
|
2582
|
+
}
|
|
2583
|
+
return roasts;
|
|
2584
|
+
}
|
|
2585
|
+
function generateVerdict(health) {
|
|
2586
|
+
if (health.score >= 90) return pick(verdicts.excellent);
|
|
2587
|
+
if (health.score >= 80) return pick(verdicts.good);
|
|
2588
|
+
if (health.score >= 70) return pick(verdicts.fair);
|
|
2589
|
+
if (health.score >= 60) return pick(verdicts.risky);
|
|
2590
|
+
return pick(verdicts.chaotic);
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// src/report/index.ts
|
|
2594
|
+
import chalk3 from "chalk";
|
|
2595
|
+
import boxen from "boxen";
|
|
2596
|
+
|
|
2597
|
+
// src/report/ascii-art.ts
|
|
2598
|
+
import chalk from "chalk";
|
|
2599
|
+
var ASCII_GRADES = {
|
|
2600
|
+
A: ` \u2588\u2588\u2588\u2588\u2588\u2557
|
|
2601
|
+
\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557
|
|
2602
|
+
\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551
|
|
2603
|
+
\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551
|
|
2604
|
+
\u2588\u2588\u2551 \u2588\u2588\u2551
|
|
2605
|
+
\u255A\u2550\u255D \u255A\u2550\u255D`,
|
|
2606
|
+
B: ` \u2588\u2588\u2588\u2588\u2588\u2588\u2557
|
|
2607
|
+
\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557
|
|
2608
|
+
\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D
|
|
2609
|
+
\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551
|
|
2610
|
+
\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D
|
|
2611
|
+
\u255A\u2550\u2550\u2550\u2550\u2550\u255D`,
|
|
2612
|
+
C: ` \u2588\u2588\u2588\u2588\u2588\u2588\u2557
|
|
2613
|
+
\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D
|
|
2614
|
+
\u2588\u2588\u2551
|
|
2615
|
+
\u2588\u2588\u2551
|
|
2616
|
+
\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2557
|
|
2617
|
+
\u255A\u2550\u2550\u2550\u2550\u2550\u255D`,
|
|
2618
|
+
D: ` \u2588\u2588\u2588\u2588\u2588\u2588\u2557
|
|
2619
|
+
\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557
|
|
2620
|
+
\u2588\u2588\u2551 \u2588\u2588\u2551
|
|
2621
|
+
\u2588\u2588\u2551 \u2588\u2588\u2551
|
|
2622
|
+
\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D
|
|
2623
|
+
\u255A\u2550\u2550\u2550\u2550\u2550\u255D`,
|
|
2624
|
+
F: ` \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557
|
|
2625
|
+
\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D
|
|
2626
|
+
\u2588\u2588\u2588\u2588\u2588\u2557
|
|
2627
|
+
\u2588\u2588\u2554\u2550\u2550\u255D
|
|
2628
|
+
\u2588\u2588\u2551
|
|
2629
|
+
\u255A\u2550\u255D`
|
|
2630
|
+
};
|
|
2631
|
+
function getAsciiGrade(grade) {
|
|
2632
|
+
return ASCII_GRADES[grade] || ASCII_GRADES.F;
|
|
2633
|
+
}
|
|
2634
|
+
function renderAsciiGrade(health) {
|
|
2635
|
+
const art = getAsciiGrade(health.grade);
|
|
2636
|
+
const color = getScoreColor(health.score);
|
|
2637
|
+
return color(art);
|
|
2638
|
+
}
|
|
2639
|
+
function getScoreColor(score) {
|
|
2640
|
+
if (score >= 90) return chalk.green;
|
|
2641
|
+
if (score >= 80) return chalk.greenBright;
|
|
2642
|
+
if (score >= 70) return chalk.yellow;
|
|
2643
|
+
if (score >= 60) return chalk.rgb(255, 165, 0);
|
|
2644
|
+
return chalk.red;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
// src/report/json.ts
|
|
2648
|
+
function renderJsonReport(report) {
|
|
2649
|
+
return JSON.stringify(report, null, 2);
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
// src/report/badge.ts
|
|
2653
|
+
import fs12 from "fs";
|
|
2654
|
+
import chalk2 from "chalk";
|
|
2655
|
+
|
|
2656
|
+
// src/utils/security.ts
|
|
2657
|
+
import path10 from "path";
|
|
2658
|
+
var MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
2659
|
+
function isValidBranchName(branch) {
|
|
2660
|
+
if (!branch || typeof branch !== "string") {
|
|
2661
|
+
return false;
|
|
2662
|
+
}
|
|
2663
|
+
const validPattern = /^[a-zA-Z0-9/_.-]+$/;
|
|
2664
|
+
return validPattern.test(branch) && !branch.includes("..") && !branch.startsWith("-") && !branch.includes(";") && !branch.includes("|") && !branch.includes("&") && !branch.includes("$") && !branch.includes("`");
|
|
2665
|
+
}
|
|
2666
|
+
function validateOutputPath(rootDir, filename) {
|
|
2667
|
+
const resolved = path10.resolve(rootDir);
|
|
2668
|
+
const outputPath = path10.resolve(rootDir, filename);
|
|
2669
|
+
const isInside = outputPath.startsWith(resolved + path10.sep) || outputPath === resolved;
|
|
2670
|
+
if (!isInside) {
|
|
2671
|
+
throw new Error(
|
|
2672
|
+
`Security: Output path "${filename}" escapes project directory`
|
|
2673
|
+
);
|
|
2674
|
+
}
|
|
2675
|
+
return outputPath;
|
|
2676
|
+
}
|
|
2677
|
+
function sanitizeError(error) {
|
|
2678
|
+
if (error instanceof Error) {
|
|
2679
|
+
let message = error.message.replace(/[A-Z]:\\[^\s"']+/g, "<path>");
|
|
2680
|
+
message = message.replace(/\/[^\s"']+/g, "<path>");
|
|
2681
|
+
message = message.replace(/password[=:]\s*\S+/gi, "password=<redacted>");
|
|
2682
|
+
message = message.replace(/token[=:]\s*\S+/gi, "token=<redacted>");
|
|
2683
|
+
message = message.replace(/api[_-]?key[=:]\s*\S+/gi, "apikey=<redacted>");
|
|
2684
|
+
return message;
|
|
2685
|
+
}
|
|
2686
|
+
return "An unexpected error occurred";
|
|
2687
|
+
}
|
|
2688
|
+
function escapeXml(unsafe) {
|
|
2689
|
+
return String(unsafe).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
2690
|
+
}
|
|
2691
|
+
function debounce(func, wait) {
|
|
2692
|
+
let timeout = null;
|
|
2693
|
+
return function(...args) {
|
|
2694
|
+
if (timeout) {
|
|
2695
|
+
clearTimeout(timeout);
|
|
2696
|
+
}
|
|
2697
|
+
timeout = setTimeout(() => {
|
|
2698
|
+
func.apply(this, args);
|
|
2699
|
+
}, wait);
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
// src/report/badge.ts
|
|
2704
|
+
function generateBadgeSvg(health) {
|
|
2705
|
+
const color = getBadgeColor(health.score);
|
|
2706
|
+
const score = escapeXml(health.score);
|
|
2707
|
+
const svg = `<svg width="150" height="20" xmlns="http://www.w3.org/2000/svg">
|
|
2708
|
+
<rect x="0" y="0" width="60" height="20" fill="#555" rx="3"/>
|
|
2709
|
+
<rect x="60" y="0" width="90" height="20" fill="${color}" rx="3"/>
|
|
2710
|
+
<text x="30" y="14" fill="#fff" font-family="Verdana" font-size="11" text-anchor="middle">Health</text>
|
|
2711
|
+
<text x="105" y="14" fill="#fff" font-family="Verdana" font-size="11" text-anchor="middle">${score}/100</text>
|
|
2712
|
+
</svg>`;
|
|
2713
|
+
return svg;
|
|
2714
|
+
}
|
|
2715
|
+
function getBadgeColor(score) {
|
|
2716
|
+
if (score >= 90) return "#44cc11";
|
|
2717
|
+
if (score >= 80) return "#97ca00";
|
|
2718
|
+
if (score >= 70) return "#dfb317";
|
|
2719
|
+
if (score >= 60) return "#fe7d37";
|
|
2720
|
+
return "#e05d44";
|
|
2721
|
+
}
|
|
2722
|
+
function saveBadge(svgContent, rootDir) {
|
|
2723
|
+
try {
|
|
2724
|
+
const badgePath = validateOutputPath(rootDir, ".roast-badge.svg");
|
|
2725
|
+
fs12.writeFileSync(badgePath, svgContent, "utf-8");
|
|
2726
|
+
console.log(chalk2.green("\u2713") + " Badge saved to .roast-badge.svg");
|
|
2727
|
+
} catch (error) {
|
|
2728
|
+
console.error(chalk2.red("Error saving badge:"), error instanceof Error ? error.message : String(error));
|
|
2729
|
+
throw error;
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
// src/report/markdown.ts
|
|
2734
|
+
function escapeMarkdown(text) {
|
|
2735
|
+
return text.replace(/\\/g, "\\\\").replace(/\*/g, "\\*").replace(/\_/g, "\\_").replace(/\{/g, "\\{").replace(/\}/g, "\\}").replace(/\[/g, "\\[").replace(/\]/g, "\\]").replace(/\(/g, "\\(").replace(/\)/g, "\\)").replace(/\#/g, "\\#").replace(/\+/g, "\\+").replace(/\-/g, "\\-").replace(/\./g, "\\.").replace(/\!/g, "\\!").replace(/\|/g, "\\|");
|
|
2736
|
+
}
|
|
2737
|
+
function renderMarkdownHealthBar(score) {
|
|
2738
|
+
const width = 30;
|
|
2739
|
+
const filled = Math.round(score / 100 * width);
|
|
2740
|
+
const empty = width - filled;
|
|
2741
|
+
return "\u2588".repeat(filled) + "\u2591".repeat(empty) + ` ${score}/100`;
|
|
2742
|
+
}
|
|
2743
|
+
function renderMarkdownFindings(findings) {
|
|
2744
|
+
const sections = ["## Findings\n"];
|
|
2745
|
+
const criticals = findings.filter((f) => f.severity === "critical");
|
|
2746
|
+
const warnings = findings.filter((f) => f.severity === "warning");
|
|
2747
|
+
const infos = findings.filter((f) => f.severity === "info");
|
|
2748
|
+
if (criticals.length === 0 && warnings.length === 0 && infos.length === 0) {
|
|
2749
|
+
sections.push("No issues found! \u{1F389}\n");
|
|
2750
|
+
return sections.join("\n");
|
|
2751
|
+
}
|
|
2752
|
+
if (criticals.length > 0) {
|
|
2753
|
+
sections.push(`### \u{1F534} Critical (${criticals.length})`);
|
|
2754
|
+
sections.push("<details>");
|
|
2755
|
+
sections.push("<summary>View critical issues</summary>\n");
|
|
2756
|
+
for (const f of criticals) {
|
|
2757
|
+
const location = f.file ? ` (${escapeMarkdown(f.file)})` : "";
|
|
2758
|
+
sections.push(`- **${escapeMarkdown(f.category)}** - ${escapeMarkdown(f.message)}${location}`);
|
|
2759
|
+
}
|
|
2760
|
+
sections.push("\n</details>\n");
|
|
2761
|
+
}
|
|
2762
|
+
if (warnings.length > 0) {
|
|
2763
|
+
sections.push(`### \u26A0\uFE0F Warnings (${warnings.length})`);
|
|
2764
|
+
sections.push("<details>");
|
|
2765
|
+
sections.push("<summary>View warnings</summary>\n");
|
|
2766
|
+
for (const f of warnings) {
|
|
2767
|
+
const location = f.file ? ` (${escapeMarkdown(f.file)})` : "";
|
|
2768
|
+
sections.push(`- **${escapeMarkdown(f.category)}** - ${escapeMarkdown(f.message)}${location}`);
|
|
2769
|
+
}
|
|
2770
|
+
sections.push("\n</details>\n");
|
|
2771
|
+
}
|
|
2772
|
+
if (infos.length > 0) {
|
|
2773
|
+
sections.push(`### \u2139\uFE0F Info (${infos.length})`);
|
|
2774
|
+
sections.push("<details open>");
|
|
2775
|
+
sections.push("<summary>View info</summary>\n");
|
|
2776
|
+
for (const f of infos) {
|
|
2777
|
+
const location = f.file ? ` (${escapeMarkdown(f.file)})` : "";
|
|
2778
|
+
sections.push(`- **${escapeMarkdown(f.category)}** - ${escapeMarkdown(f.message)}${location}`);
|
|
2779
|
+
}
|
|
2780
|
+
sections.push("\n</details>\n");
|
|
2781
|
+
}
|
|
2782
|
+
return sections.join("\n");
|
|
2783
|
+
}
|
|
2784
|
+
function renderMarkdownReport(report) {
|
|
2785
|
+
const sections = [];
|
|
2786
|
+
sections.push(`# \u{1F525} Roast Report: ${escapeMarkdown(report.projectName)}
|
|
2787
|
+
`);
|
|
2788
|
+
sections.push(`## Health Score: ${report.health.score}/100 (${report.health.grade})
|
|
2789
|
+
`);
|
|
2790
|
+
sections.push(renderMarkdownHealthBar(report.health.score) + "\n");
|
|
2791
|
+
sections.push("## Project Summary\n");
|
|
2792
|
+
sections.push("| Metric | Value |");
|
|
2793
|
+
sections.push("|--------|-------|");
|
|
2794
|
+
sections.push(`| Files Scanned | ${report.stats.sourceFiles.toLocaleString()} |`);
|
|
2795
|
+
sections.push(`| Total Files | ${report.stats.totalFiles.toLocaleString()} |`);
|
|
2796
|
+
sections.push(`| Lines of Code | ${report.stats.totalLines.toLocaleString()} |`);
|
|
2797
|
+
sections.push(`| Dependencies | ${report.stats.dependencies} |`);
|
|
2798
|
+
sections.push(`| Dev Dependencies | ${report.stats.devDependencies} |
|
|
2799
|
+
`);
|
|
2800
|
+
sections.push(renderMarkdownFindings(report.findings));
|
|
2801
|
+
if (report.roasts.length > 0) {
|
|
2802
|
+
sections.push("## \u{1F525} Roasts\n");
|
|
2803
|
+
for (const roast of report.roasts) {
|
|
2804
|
+
sections.push(`> **${escapeMarkdown(roast.target)}**`);
|
|
2805
|
+
sections.push(`> ${escapeMarkdown(roast.message)}
|
|
2806
|
+
`);
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
if (report.fixes && report.fixes.length > 0) {
|
|
2810
|
+
sections.push("## \u{1F4DD} Fix Suggestions\n");
|
|
2811
|
+
sections.push("<details>");
|
|
2812
|
+
sections.push("<summary>View actionable fixes</summary>\n");
|
|
2813
|
+
for (const fix of report.fixes) {
|
|
2814
|
+
const target = fix.finding.file || fix.finding.category;
|
|
2815
|
+
sections.push(`- **${escapeMarkdown(target)}** - ${escapeMarkdown(fix.suggestion)}`);
|
|
2816
|
+
}
|
|
2817
|
+
sections.push("\n</details>\n");
|
|
2818
|
+
}
|
|
2819
|
+
sections.push("## Verdict\n");
|
|
2820
|
+
sections.push(escapeMarkdown(report.verdict) + "\n");
|
|
2821
|
+
sections.push("---");
|
|
2822
|
+
sections.push("*Generated by [roast-my-codebase](https://github.com/your-username/roast-my-codebase)*");
|
|
2823
|
+
return sections.join("\n");
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
// src/report/index.ts
|
|
2827
|
+
function renderReport(report, options) {
|
|
2828
|
+
const sections = [];
|
|
2829
|
+
if (options?.ascii) {
|
|
2830
|
+
sections.push(renderAsciiGrade(report.health));
|
|
2831
|
+
sections.push("");
|
|
2832
|
+
}
|
|
2833
|
+
sections.push(
|
|
2834
|
+
boxen(chalk3.bold.white(" Roast My Codebase ") + " " + flame(), {
|
|
2835
|
+
padding: { top: 0, bottom: 0, left: 2, right: 2 },
|
|
2836
|
+
borderStyle: "double",
|
|
2837
|
+
borderColor: "yellow",
|
|
2838
|
+
textAlignment: "center"
|
|
2839
|
+
})
|
|
2840
|
+
);
|
|
2841
|
+
sections.push("");
|
|
2842
|
+
sections.push(chalk3.dim(` Project: ${report.projectName}`));
|
|
2843
|
+
sections.push("");
|
|
2844
|
+
const scoreColor = getScoreColor2(report.health.score);
|
|
2845
|
+
sections.push(
|
|
2846
|
+
chalk3.bold(" Health Score: ") + scoreColor(
|
|
2847
|
+
`${report.health.score}/100 ${report.health.grade} ${report.health.label}`
|
|
2848
|
+
)
|
|
2849
|
+
);
|
|
2850
|
+
sections.push("");
|
|
2851
|
+
sections.push(renderHealthBar(report.health.score));
|
|
2852
|
+
sections.push("");
|
|
2853
|
+
sections.push(chalk3.bold.white(" Project Summary"));
|
|
2854
|
+
sections.push(chalk3.dim(" " + "\u2500".repeat(40)));
|
|
2855
|
+
sections.push(
|
|
2856
|
+
` ${chalk3.cyan("Files Scanned")} ${report.stats.sourceFiles.toLocaleString()}`
|
|
2857
|
+
);
|
|
2858
|
+
sections.push(
|
|
2859
|
+
` ${chalk3.cyan("Total Files")} ${report.stats.totalFiles.toLocaleString()}`
|
|
2860
|
+
);
|
|
2861
|
+
sections.push(
|
|
2862
|
+
` ${chalk3.cyan("Lines of Code")} ${report.stats.totalLines.toLocaleString()}`
|
|
2863
|
+
);
|
|
2864
|
+
sections.push(
|
|
2865
|
+
` ${chalk3.cyan("Dependencies")} ${report.stats.dependencies}`
|
|
2866
|
+
);
|
|
2867
|
+
sections.push(
|
|
2868
|
+
` ${chalk3.cyan("Dev Dependencies")} ${report.stats.devDependencies}`
|
|
2869
|
+
);
|
|
2870
|
+
sections.push("");
|
|
2871
|
+
const warnings = report.findings.filter((f) => f.severity === "warning");
|
|
2872
|
+
const criticals = report.findings.filter((f) => f.severity === "critical");
|
|
2873
|
+
const infos = report.findings.filter((f) => f.severity === "info");
|
|
2874
|
+
if (criticals.length > 0 || warnings.length > 0 || infos.length > 0) {
|
|
2875
|
+
sections.push(chalk3.bold.white(" Findings"));
|
|
2876
|
+
sections.push(chalk3.dim(" " + "\u2500".repeat(40)));
|
|
2877
|
+
if (criticals.length > 0) {
|
|
2878
|
+
sections.push(
|
|
2879
|
+
` ${chalk3.red("\u25CF")} ${chalk3.red.bold(`${criticals.length} critical`)}`
|
|
2880
|
+
);
|
|
2881
|
+
}
|
|
2882
|
+
if (warnings.length > 0) {
|
|
2883
|
+
sections.push(
|
|
2884
|
+
` ${chalk3.yellow("\u25CF")} ${chalk3.yellow(`${warnings.length} warnings`)}`
|
|
2885
|
+
);
|
|
2886
|
+
}
|
|
2887
|
+
if (infos.length > 0) {
|
|
2888
|
+
sections.push(` ${chalk3.blue("\u25CF")} ${chalk3.blue(`${infos.length} info`)}`);
|
|
2889
|
+
}
|
|
2890
|
+
sections.push("");
|
|
2891
|
+
const keyFindings = [...criticals, ...warnings].slice(0, 8);
|
|
2892
|
+
for (const finding of keyFindings) {
|
|
2893
|
+
const icon = finding.severity === "critical" ? chalk3.red("\u2717") : chalk3.yellow("\u26A0");
|
|
2894
|
+
sections.push(` ${icon} ${chalk3.white(finding.message)}`);
|
|
2895
|
+
}
|
|
2896
|
+
sections.push("");
|
|
2897
|
+
}
|
|
2898
|
+
if (report.roasts.length > 0) {
|
|
2899
|
+
sections.push(
|
|
2900
|
+
boxen(chalk3.bold.yellow(" Roast Time ") + " " + flame(), {
|
|
2901
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
2902
|
+
borderStyle: "round",
|
|
2903
|
+
borderColor: "yellow"
|
|
2904
|
+
})
|
|
2905
|
+
);
|
|
2906
|
+
sections.push("");
|
|
2907
|
+
for (const roast of report.roasts) {
|
|
2908
|
+
sections.push(` ${chalk3.bold.white(roast.target)}`);
|
|
2909
|
+
sections.push(` ${chalk3.yellow(roast.message)}`);
|
|
2910
|
+
sections.push("");
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
if (report.fixes && report.fixes.length > 0) {
|
|
2914
|
+
sections.push(
|
|
2915
|
+
boxen(chalk3.bold.cyan(" Fix Suggestions ") + " \u{1F4DD}", {
|
|
2916
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
2917
|
+
borderStyle: "round",
|
|
2918
|
+
borderColor: "cyan"
|
|
2919
|
+
})
|
|
2920
|
+
);
|
|
2921
|
+
sections.push("");
|
|
2922
|
+
for (const fix of report.fixes) {
|
|
2923
|
+
const target = fix.finding.file || fix.finding.category;
|
|
2924
|
+
sections.push(` ${chalk3.cyan("\u{1F4DD}")} ${chalk3.dim(target)}`);
|
|
2925
|
+
sections.push(` ${chalk3.white(fix.suggestion)}`);
|
|
2926
|
+
sections.push("");
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
sections.push(chalk3.dim(" " + "\u2500".repeat(40)));
|
|
2930
|
+
sections.push("");
|
|
2931
|
+
sections.push(chalk3.bold.white(" Verdict"));
|
|
2932
|
+
sections.push("");
|
|
2933
|
+
sections.push(` ${chalk3.italic(report.verdict)}`);
|
|
2934
|
+
sections.push("");
|
|
2935
|
+
sections.push(
|
|
2936
|
+
chalk3.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")
|
|
2937
|
+
);
|
|
2938
|
+
sections.push(
|
|
2939
|
+
chalk3.dim(" roast-my-codebase \xB7 github.com/your-username/roast-my-codebase")
|
|
2940
|
+
);
|
|
2941
|
+
sections.push("");
|
|
2942
|
+
return sections.join("\n");
|
|
2943
|
+
}
|
|
2944
|
+
function flame() {
|
|
2945
|
+
return chalk3.redBright("\u{1F525}");
|
|
2946
|
+
}
|
|
2947
|
+
function getScoreColor2(score) {
|
|
2948
|
+
if (score >= 90) return chalk3.green;
|
|
2949
|
+
if (score >= 80) return chalk3.greenBright;
|
|
2950
|
+
if (score >= 70) return chalk3.yellow;
|
|
2951
|
+
if (score >= 60) return chalk3.rgb(255, 165, 0);
|
|
2952
|
+
return chalk3.red;
|
|
2953
|
+
}
|
|
2954
|
+
function renderHealthBar(score) {
|
|
2955
|
+
const width = 30;
|
|
2956
|
+
const filled = Math.round(score / 100 * width);
|
|
2957
|
+
const empty = width - filled;
|
|
2958
|
+
const color = getScoreColor2(score);
|
|
2959
|
+
const bar = color("\u2588".repeat(filled)) + chalk3.dim("\u2591".repeat(empty));
|
|
2960
|
+
return ` [${bar}]`;
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
// src/fixes/index.ts
|
|
2964
|
+
function generateFixSuggestions(findings) {
|
|
2965
|
+
const suggestions = [];
|
|
2966
|
+
for (const finding of findings) {
|
|
2967
|
+
let suggestion = "";
|
|
2968
|
+
let autoFixable = false;
|
|
2969
|
+
let command;
|
|
2970
|
+
switch (finding.category) {
|
|
2971
|
+
case "large-files":
|
|
2972
|
+
if (finding.severity === "critical") {
|
|
2973
|
+
suggestion = `Split ${finding.file} into smaller modules by extracting logical components`;
|
|
2974
|
+
} else {
|
|
2975
|
+
suggestion = `Consider refactoring ${finding.file} to reduce complexity`;
|
|
2976
|
+
}
|
|
2977
|
+
autoFixable = false;
|
|
2978
|
+
break;
|
|
2979
|
+
case "complexity":
|
|
2980
|
+
suggestion = `Refactor function by extracting nested logic into helper functions`;
|
|
2981
|
+
autoFixable = false;
|
|
2982
|
+
break;
|
|
2983
|
+
case "duplicates":
|
|
2984
|
+
suggestion = `Extract duplicated code into a shared utility function or module`;
|
|
2985
|
+
autoFixable = false;
|
|
2986
|
+
break;
|
|
2987
|
+
case "dead-exports":
|
|
2988
|
+
suggestion = `Remove unused export to clean up the API surface`;
|
|
2989
|
+
autoFixable = true;
|
|
2990
|
+
break;
|
|
2991
|
+
case "circular-deps":
|
|
2992
|
+
suggestion = `Break the cycle by introducing a shared interface/type file or inverting one dependency`;
|
|
2993
|
+
autoFixable = false;
|
|
2994
|
+
break;
|
|
2995
|
+
case "unused-dependencies":
|
|
2996
|
+
const packageName = finding.detail || finding.message.match(/`([^`]+)`/)?.[1];
|
|
2997
|
+
suggestion = `Remove unused dependency: ${packageName}`;
|
|
2998
|
+
command = `npm uninstall ${packageName}`;
|
|
2999
|
+
autoFixable = true;
|
|
3000
|
+
break;
|
|
3001
|
+
case "type-safety":
|
|
3002
|
+
if (finding.message.includes("any")) {
|
|
3003
|
+
suggestion = `Replace 'any' with specific types or use 'unknown' for safer type narrowing`;
|
|
3004
|
+
} else if (finding.message.includes("@ts-ignore")) {
|
|
3005
|
+
suggestion = `Address the underlying type error instead of suppressing it`;
|
|
3006
|
+
}
|
|
3007
|
+
autoFixable = false;
|
|
3008
|
+
break;
|
|
3009
|
+
case "todos":
|
|
3010
|
+
suggestion = `Add issue tracker references to TODO comments`;
|
|
3011
|
+
autoFixable = true;
|
|
3012
|
+
break;
|
|
3013
|
+
case "structure":
|
|
3014
|
+
if (finding.id === "deep-nesting") {
|
|
3015
|
+
suggestion = `Flatten directory structure by grouping related files at higher levels`;
|
|
3016
|
+
} else if (finding.id === "util-explosion") {
|
|
3017
|
+
suggestion = `Consolidate utility files by grouping related functions`;
|
|
3018
|
+
}
|
|
3019
|
+
autoFixable = false;
|
|
3020
|
+
break;
|
|
3021
|
+
}
|
|
3022
|
+
if (suggestion) {
|
|
3023
|
+
suggestions.push({
|
|
3024
|
+
findingId: finding.id,
|
|
3025
|
+
finding,
|
|
3026
|
+
suggestion,
|
|
3027
|
+
autoFixable,
|
|
3028
|
+
command
|
|
3029
|
+
});
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
return suggestions;
|
|
3033
|
+
}
|
|
3034
|
+
|
|
3035
|
+
// src/watch/index.ts
|
|
3036
|
+
import chokidar from "chokidar";
|
|
3037
|
+
import chalk4 from "chalk";
|
|
3038
|
+
async function startWatchMode(rootDir, scanners, onScan) {
|
|
3039
|
+
const state = {
|
|
3040
|
+
lastScore: 0,
|
|
3041
|
+
lastFindings: [],
|
|
3042
|
+
runCount: 0
|
|
3043
|
+
};
|
|
3044
|
+
const runScan = async () => {
|
|
3045
|
+
const allFindings = [];
|
|
3046
|
+
let stats;
|
|
3047
|
+
for (const scanner of scanners) {
|
|
3048
|
+
const result = await scanner.scan(rootDir);
|
|
3049
|
+
allFindings.push(...result.findings);
|
|
3050
|
+
if (result.stats) {
|
|
3051
|
+
stats = result.stats;
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
const health = calculateHealth(allFindings);
|
|
3055
|
+
const delta = state.runCount > 0 ? health.score - state.lastScore : null;
|
|
3056
|
+
state.lastScore = health.score;
|
|
3057
|
+
state.lastFindings = allFindings;
|
|
3058
|
+
state.runCount++;
|
|
3059
|
+
onScan(allFindings, health, delta, stats);
|
|
3060
|
+
};
|
|
3061
|
+
await runScan();
|
|
3062
|
+
const watcher = chokidar.watch(["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], {
|
|
3063
|
+
cwd: rootDir,
|
|
3064
|
+
ignored: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/.next/**"],
|
|
3065
|
+
ignoreInitial: true,
|
|
3066
|
+
persistent: true
|
|
3067
|
+
});
|
|
3068
|
+
console.log(chalk4.dim("\n\u{1F440} Watching for changes... (Press Ctrl+C to stop)\n"));
|
|
3069
|
+
const debouncedScan = debounce(async (filePath) => {
|
|
3070
|
+
console.log(chalk4.dim(`
|
|
3071
|
+
\u{1F4DD} Changed: ${filePath}`));
|
|
3072
|
+
await runScan();
|
|
3073
|
+
}, 500);
|
|
3074
|
+
watcher.on("change", (filePath) => {
|
|
3075
|
+
debouncedScan(filePath);
|
|
3076
|
+
});
|
|
3077
|
+
process.on("SIGINT", () => {
|
|
3078
|
+
watcher.close();
|
|
3079
|
+
process.exit(0);
|
|
3080
|
+
});
|
|
3081
|
+
}
|
|
3082
|
+
function renderWatchSummary(health, delta, findingCounts) {
|
|
3083
|
+
const deltaStr = delta !== null ? delta > 0 ? chalk4.green(` +${delta}`) : delta < 0 ? chalk4.red(` ${delta}`) : chalk4.dim(" \xB10") : "";
|
|
3084
|
+
console.log(`
|
|
3085
|
+
Health: ${health.score}/100${deltaStr} ${health.grade} ${health.label}`);
|
|
3086
|
+
if (findingCounts.critical > 0) {
|
|
3087
|
+
console.log(` ${chalk4.red("\u2717")} ${findingCounts.critical} critical`);
|
|
3088
|
+
}
|
|
3089
|
+
if (findingCounts.warning > 0) {
|
|
3090
|
+
console.log(` ${chalk4.yellow("\u26A0")} ${findingCounts.warning} warnings`);
|
|
3091
|
+
}
|
|
3092
|
+
if (findingCounts.info > 0) {
|
|
3093
|
+
console.log(` ${chalk4.blue("\u25CF")} ${findingCounts.info} info`);
|
|
3094
|
+
}
|
|
3095
|
+
console.log("");
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
// src/compare/index.ts
|
|
3099
|
+
import path11 from "path";
|
|
3100
|
+
import fs13 from "fs";
|
|
3101
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
3102
|
+
import { randomBytes } from "crypto";
|
|
3103
|
+
import { tmpdir } from "os";
|
|
3104
|
+
import chalk5 from "chalk";
|
|
3105
|
+
async function compareWithBranch(rootDir, branchName, scanFunc) {
|
|
3106
|
+
if (!isValidBranchName(branchName)) {
|
|
3107
|
+
throw new Error(
|
|
3108
|
+
`Invalid branch name: "${branchName}". Branch names must contain only alphanumeric characters, slashes, dashes, underscores, and dots.`
|
|
3109
|
+
);
|
|
3110
|
+
}
|
|
3111
|
+
const gitCheck = spawnSync3("git", ["rev-parse", "--git-dir"], {
|
|
3112
|
+
cwd: rootDir,
|
|
3113
|
+
stdio: "ignore"
|
|
3114
|
+
});
|
|
3115
|
+
if (gitCheck.status !== 0) {
|
|
3116
|
+
throw new Error("Not in a git repository. --compare requires git.");
|
|
3117
|
+
}
|
|
3118
|
+
const branchCheck = spawnSync3("git", ["rev-parse", "--verify", branchName], {
|
|
3119
|
+
cwd: rootDir,
|
|
3120
|
+
stdio: "ignore"
|
|
3121
|
+
});
|
|
3122
|
+
if (branchCheck.status !== 0) {
|
|
3123
|
+
throw new Error(`Branch "${branchName}" not found.`);
|
|
3124
|
+
}
|
|
3125
|
+
console.log(chalk5.dim(`Scanning current working directory...`));
|
|
3126
|
+
const currentResult = await scanFunc(rootDir);
|
|
3127
|
+
const randomId = randomBytes(8).toString("hex");
|
|
3128
|
+
const worktreePath = path11.join(tmpdir(), `.roast-worktree-${randomId}`);
|
|
3129
|
+
console.log(chalk5.dim(`Checking out ${branchName} to temporary worktree...`));
|
|
3130
|
+
let cleanupAttempted = false;
|
|
3131
|
+
const cleanupWorktree = async (retries = 3) => {
|
|
3132
|
+
if (cleanupAttempted) return;
|
|
3133
|
+
cleanupAttempted = true;
|
|
3134
|
+
for (let i = 0; i < retries; i++) {
|
|
3135
|
+
const result = spawnSync3("git", ["worktree", "remove", worktreePath, "--force"], {
|
|
3136
|
+
cwd: rootDir,
|
|
3137
|
+
stdio: "ignore"
|
|
3138
|
+
});
|
|
3139
|
+
if (result.status === 0) return;
|
|
3140
|
+
await new Promise((resolve) => setTimeout(resolve, 100 * (i + 1)));
|
|
3141
|
+
}
|
|
3142
|
+
if (fs13.existsSync(worktreePath)) {
|
|
3143
|
+
try {
|
|
3144
|
+
fs13.rmSync(worktreePath, { recursive: true, force: true });
|
|
3145
|
+
} catch {
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
};
|
|
3149
|
+
try {
|
|
3150
|
+
const addWorktree = spawnSync3("git", ["worktree", "add", worktreePath, branchName], {
|
|
3151
|
+
cwd: rootDir,
|
|
3152
|
+
stdio: "ignore"
|
|
3153
|
+
});
|
|
3154
|
+
if (addWorktree.status !== 0) {
|
|
3155
|
+
throw new Error(`Failed to create worktree for branch "${branchName}".`);
|
|
3156
|
+
}
|
|
3157
|
+
console.log(chalk5.dim(`Scanning ${branchName}...`));
|
|
3158
|
+
const branchResult = await scanFunc(worktreePath);
|
|
3159
|
+
const currentFindingIds = new Set(currentResult.findings.map((f) => f.id));
|
|
3160
|
+
const branchFindingIds = new Set(branchResult.findings.map((f) => f.id));
|
|
3161
|
+
const newFindings = currentResult.findings.filter(
|
|
3162
|
+
(f) => !branchFindingIds.has(f.id)
|
|
3163
|
+
);
|
|
3164
|
+
const resolvedFindings = branchResult.findings.filter(
|
|
3165
|
+
(f) => !currentFindingIds.has(f.id)
|
|
3166
|
+
);
|
|
3167
|
+
const unchangedCount = currentResult.findings.filter(
|
|
3168
|
+
(f) => branchFindingIds.has(f.id)
|
|
3169
|
+
).length;
|
|
3170
|
+
const result = {
|
|
3171
|
+
currentScore: currentResult.health.score,
|
|
3172
|
+
branchScore: branchResult.health.score,
|
|
3173
|
+
scoreDelta: currentResult.health.score - branchResult.health.score,
|
|
3174
|
+
newFindings,
|
|
3175
|
+
resolvedFindings,
|
|
3176
|
+
unchangedCount
|
|
3177
|
+
};
|
|
3178
|
+
await cleanupWorktree();
|
|
3179
|
+
return result;
|
|
3180
|
+
} catch (error) {
|
|
3181
|
+
await cleanupWorktree();
|
|
3182
|
+
throw error;
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
function renderComparison(comparison, branchName) {
|
|
3186
|
+
console.log("");
|
|
3187
|
+
console.log(chalk5.bold.white(" Comparison Results"));
|
|
3188
|
+
console.log(chalk5.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"));
|
|
3189
|
+
const deltaColor = comparison.scoreDelta > 0 ? chalk5.green : comparison.scoreDelta < 0 ? chalk5.red : chalk5.dim;
|
|
3190
|
+
const deltaSign = comparison.scoreDelta > 0 ? "+" : "";
|
|
3191
|
+
console.log(` Current: ${comparison.currentScore}/100`);
|
|
3192
|
+
console.log(` ${branchName.padEnd(8)}: ${comparison.branchScore}/100`);
|
|
3193
|
+
console.log(` Delta: ${deltaColor(deltaSign + comparison.scoreDelta)}`);
|
|
3194
|
+
console.log("");
|
|
3195
|
+
if (comparison.newFindings.length > 0) {
|
|
3196
|
+
console.log(chalk5.red(` \u2717 ${comparison.newFindings.length} new issues:`));
|
|
3197
|
+
for (const finding of comparison.newFindings.slice(0, 5)) {
|
|
3198
|
+
console.log(` ${chalk5.red("+")} ${finding.message}`);
|
|
3199
|
+
}
|
|
3200
|
+
if (comparison.newFindings.length > 5) {
|
|
3201
|
+
console.log(` ${chalk5.dim(`...and ${comparison.newFindings.length - 5} more`)}`);
|
|
3202
|
+
}
|
|
3203
|
+
console.log("");
|
|
3204
|
+
}
|
|
3205
|
+
if (comparison.resolvedFindings.length > 0) {
|
|
3206
|
+
console.log(chalk5.green(` \u2713 ${comparison.resolvedFindings.length} issues resolved:`));
|
|
3207
|
+
for (const finding of comparison.resolvedFindings.slice(0, 5)) {
|
|
3208
|
+
console.log(` ${chalk5.green("-")} ${finding.message}`);
|
|
3209
|
+
}
|
|
3210
|
+
if (comparison.resolvedFindings.length > 5) {
|
|
3211
|
+
console.log(` ${chalk5.dim(`...and ${comparison.resolvedFindings.length - 5} more`)}`);
|
|
3212
|
+
}
|
|
3213
|
+
console.log("");
|
|
3214
|
+
}
|
|
3215
|
+
console.log(chalk5.dim(` ${comparison.unchangedCount} unchanged issues`));
|
|
3216
|
+
console.log("");
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
// src/config/index.ts
|
|
3220
|
+
import fs14 from "fs";
|
|
3221
|
+
import path12 from "path";
|
|
3222
|
+
|
|
3223
|
+
// src/config/validation.ts
|
|
3224
|
+
function validateConfig(userConfig) {
|
|
3225
|
+
if (typeof userConfig !== "object" || userConfig === null) {
|
|
3226
|
+
throw new Error("Invalid config: must be an object");
|
|
3227
|
+
}
|
|
3228
|
+
const validated = {};
|
|
3229
|
+
const config = userConfig;
|
|
3230
|
+
if ("thresholds" in config && typeof config.thresholds === "object" && config.thresholds !== null) {
|
|
3231
|
+
const thresholds = config.thresholds;
|
|
3232
|
+
validated.thresholds = {};
|
|
3233
|
+
if ("largeFile" in thresholds && typeof thresholds.largeFile === "number" && thresholds.largeFile > 0) {
|
|
3234
|
+
validated.thresholds.largeFile = thresholds.largeFile;
|
|
3235
|
+
}
|
|
3236
|
+
if ("extremeFile" in thresholds && typeof thresholds.extremeFile === "number" && thresholds.extremeFile > 0) {
|
|
3237
|
+
validated.thresholds.extremeFile = thresholds.extremeFile;
|
|
3238
|
+
}
|
|
3239
|
+
if ("highChurn" in thresholds && typeof thresholds.highChurn === "number" && thresholds.highChurn > 0) {
|
|
3240
|
+
validated.thresholds.highChurn = thresholds.highChurn;
|
|
3241
|
+
}
|
|
3242
|
+
if ("criticalChurn" in thresholds && typeof thresholds.criticalChurn === "number" && thresholds.criticalChurn > 0) {
|
|
3243
|
+
validated.thresholds.criticalChurn = thresholds.criticalChurn;
|
|
3244
|
+
}
|
|
3245
|
+
if ("largePR" in thresholds && typeof thresholds.largePR === "number" && thresholds.largePR > 0) {
|
|
3246
|
+
validated.thresholds.largePR = thresholds.largePR;
|
|
3247
|
+
}
|
|
3248
|
+
if ("criticalPR" in thresholds && typeof thresholds.criticalPR === "number" && thresholds.criticalPR > 0) {
|
|
3249
|
+
validated.thresholds.criticalPR = thresholds.criticalPR;
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
if ("scanners" in config && typeof config.scanners === "object" && config.scanners !== null) {
|
|
3253
|
+
const scanners = config.scanners;
|
|
3254
|
+
validated.scanners = {};
|
|
3255
|
+
if ("disabled" in scanners && Array.isArray(scanners.disabled)) {
|
|
3256
|
+
validated.scanners.disabled = scanners.disabled.filter(
|
|
3257
|
+
(item) => typeof item === "string"
|
|
3258
|
+
);
|
|
3259
|
+
}
|
|
3260
|
+
}
|
|
3261
|
+
if ("ignore" in config && Array.isArray(config.ignore)) {
|
|
3262
|
+
validated.ignore = config.ignore.filter((item) => typeof item === "string");
|
|
3263
|
+
}
|
|
3264
|
+
if ("deductions" in config && typeof config.deductions === "object" && config.deductions !== null) {
|
|
3265
|
+
const deductions = config.deductions;
|
|
3266
|
+
validated.deductions = {};
|
|
3267
|
+
for (const [key, value] of Object.entries(deductions)) {
|
|
3268
|
+
if (typeof key === "string" && typeof value === "number") {
|
|
3269
|
+
if (key !== "__proto__" && key !== "constructor" && key !== "prototype") {
|
|
3270
|
+
validated.deductions[key] = value;
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
if ("plugins" in config && Array.isArray(config.plugins)) {
|
|
3276
|
+
validated.plugins = config.plugins.filter((item) => typeof item === "string");
|
|
3277
|
+
}
|
|
3278
|
+
if ("ai" in config && typeof config.ai === "object" && config.ai !== null) {
|
|
3279
|
+
const ai = config.ai;
|
|
3280
|
+
validated.ai = {};
|
|
3281
|
+
if ("enabled" in ai && typeof ai.enabled === "boolean") {
|
|
3282
|
+
validated.ai.enabled = ai.enabled;
|
|
3283
|
+
}
|
|
3284
|
+
if ("apiKey" in ai && typeof ai.apiKey === "string") {
|
|
3285
|
+
validated.ai.apiKey = ai.apiKey;
|
|
3286
|
+
}
|
|
3287
|
+
if ("model" in ai && typeof ai.model === "string") {
|
|
3288
|
+
validated.ai.model = ai.model;
|
|
3289
|
+
}
|
|
3290
|
+
if ("maxTokens" in ai && typeof ai.maxTokens === "number" && ai.maxTokens > 0) {
|
|
3291
|
+
validated.ai.maxTokens = ai.maxTokens;
|
|
3292
|
+
}
|
|
3293
|
+
if ("temperature" in ai && typeof ai.temperature === "number" && ai.temperature >= 0 && ai.temperature <= 2) {
|
|
3294
|
+
validated.ai.temperature = ai.temperature;
|
|
3295
|
+
}
|
|
3296
|
+
if ("cacheEnabled" in ai && typeof ai.cacheEnabled === "boolean") {
|
|
3297
|
+
validated.ai.cacheEnabled = ai.cacheEnabled;
|
|
3298
|
+
}
|
|
3299
|
+
if ("cachePath" in ai && typeof ai.cachePath === "string") {
|
|
3300
|
+
validated.ai.cachePath = ai.cachePath;
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
return validated;
|
|
3304
|
+
}
|
|
3305
|
+
function safeJsonParse(content) {
|
|
3306
|
+
try {
|
|
3307
|
+
const parsed = JSON.parse(content);
|
|
3308
|
+
if (parsed && typeof parsed === "object" && ("__proto__" in parsed || "constructor" in parsed || "prototype" in parsed)) {
|
|
3309
|
+
console.warn("Warning: Detected prototype pollution attempt in JSON");
|
|
3310
|
+
const cleaned = {};
|
|
3311
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
3312
|
+
if (key !== "__proto__" && key !== "constructor" && key !== "prototype") {
|
|
3313
|
+
cleaned[key] = value;
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
return cleaned;
|
|
3317
|
+
}
|
|
3318
|
+
return parsed;
|
|
3319
|
+
} catch (_error) {
|
|
3320
|
+
return null;
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
|
|
3324
|
+
// src/config/index.ts
|
|
3325
|
+
var DEFAULT_CONFIG = {
|
|
3326
|
+
thresholds: {
|
|
3327
|
+
largeFile: 500,
|
|
3328
|
+
extremeFile: 2e3,
|
|
3329
|
+
highChurn: 50,
|
|
3330
|
+
criticalChurn: 100,
|
|
3331
|
+
largePR: 20,
|
|
3332
|
+
criticalPR: 40
|
|
3333
|
+
},
|
|
3334
|
+
scanners: {
|
|
3335
|
+
disabled: []
|
|
3336
|
+
},
|
|
3337
|
+
ignore: [],
|
|
3338
|
+
deductions: {},
|
|
3339
|
+
plugins: []
|
|
3340
|
+
};
|
|
3341
|
+
function loadConfig(rootDir) {
|
|
3342
|
+
const configPath = path12.join(rootDir, ".roastrc.json");
|
|
3343
|
+
if (!fs14.existsSync(configPath)) {
|
|
3344
|
+
return DEFAULT_CONFIG;
|
|
3345
|
+
}
|
|
3346
|
+
try {
|
|
3347
|
+
const content = fs14.readFileSync(configPath, "utf-8");
|
|
3348
|
+
const parsed = safeJsonParse(content);
|
|
3349
|
+
if (!parsed) {
|
|
3350
|
+
console.warn("Warning: Failed to parse .roastrc.json");
|
|
3351
|
+
return DEFAULT_CONFIG;
|
|
3352
|
+
}
|
|
3353
|
+
const userConfig = validateConfig(parsed);
|
|
3354
|
+
return {
|
|
3355
|
+
thresholds: {
|
|
3356
|
+
...DEFAULT_CONFIG.thresholds,
|
|
3357
|
+
...userConfig.thresholds
|
|
3358
|
+
},
|
|
3359
|
+
scanners: {
|
|
3360
|
+
disabled: [
|
|
3361
|
+
...DEFAULT_CONFIG.scanners?.disabled || [],
|
|
3362
|
+
...userConfig.scanners?.disabled || []
|
|
3363
|
+
]
|
|
3364
|
+
},
|
|
3365
|
+
ignore: [
|
|
3366
|
+
...DEFAULT_CONFIG.ignore || [],
|
|
3367
|
+
...userConfig.ignore || []
|
|
3368
|
+
],
|
|
3369
|
+
deductions: {
|
|
3370
|
+
...DEFAULT_CONFIG.deductions,
|
|
3371
|
+
...userConfig.deductions
|
|
3372
|
+
},
|
|
3373
|
+
plugins: [
|
|
3374
|
+
...DEFAULT_CONFIG.plugins || [],
|
|
3375
|
+
...userConfig.plugins || []
|
|
3376
|
+
]
|
|
3377
|
+
};
|
|
3378
|
+
} catch (error) {
|
|
3379
|
+
console.warn(`Warning: Failed to load .roastrc.json: ${error}`);
|
|
3380
|
+
return DEFAULT_CONFIG;
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
// src/interactive/index.ts
|
|
3385
|
+
import { confirm, select } from "@inquirer/prompts";
|
|
3386
|
+
import chalk6 from "chalk";
|
|
3387
|
+
|
|
3388
|
+
// src/interactive/fixes.ts
|
|
3389
|
+
import fs15 from "fs";
|
|
3390
|
+
import path13 from "path";
|
|
3391
|
+
import { spawnSync as spawnSync4 } from "child_process";
|
|
3392
|
+
async function applyAutoFix(finding, fix, rootDir, dryRun = false) {
|
|
3393
|
+
switch (finding.category) {
|
|
3394
|
+
case "unused-dependencies":
|
|
3395
|
+
return fixUnusedDependency(finding, rootDir, dryRun);
|
|
3396
|
+
case "todos":
|
|
3397
|
+
return fixTodoComment(finding, rootDir, dryRun);
|
|
3398
|
+
case "dead-exports":
|
|
3399
|
+
return fixDeadExport(finding, rootDir, dryRun);
|
|
3400
|
+
default:
|
|
3401
|
+
return {
|
|
3402
|
+
success: false,
|
|
3403
|
+
message: `No auto-fix available for category: ${finding.category}`
|
|
3404
|
+
};
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
3407
|
+
function fixUnusedDependency(finding, rootDir, dryRun) {
|
|
3408
|
+
const match = finding.message.match(/`([^`]+)`/);
|
|
3409
|
+
if (!match) {
|
|
3410
|
+
return {
|
|
3411
|
+
success: false,
|
|
3412
|
+
message: "Could not extract package name from finding"
|
|
3413
|
+
};
|
|
3414
|
+
}
|
|
3415
|
+
const packageName = match[1];
|
|
3416
|
+
const pkgPath = path13.join(rootDir, "package.json");
|
|
3417
|
+
try {
|
|
3418
|
+
if (dryRun) {
|
|
3419
|
+
return {
|
|
3420
|
+
success: true,
|
|
3421
|
+
message: `Would run: npm uninstall ${packageName}`
|
|
3422
|
+
};
|
|
3423
|
+
}
|
|
3424
|
+
const result = spawnSync4("npm", ["uninstall", packageName], {
|
|
3425
|
+
cwd: rootDir,
|
|
3426
|
+
stdio: "pipe",
|
|
3427
|
+
encoding: "utf-8"
|
|
3428
|
+
});
|
|
3429
|
+
if (result.status === 0) {
|
|
3430
|
+
return {
|
|
3431
|
+
success: true,
|
|
3432
|
+
message: `Removed unused dependency: ${packageName}`
|
|
3433
|
+
};
|
|
3434
|
+
} else {
|
|
3435
|
+
return {
|
|
3436
|
+
success: false,
|
|
3437
|
+
message: `Failed to remove ${packageName}: ${result.stderr}`
|
|
3438
|
+
};
|
|
3439
|
+
}
|
|
3440
|
+
} catch (error) {
|
|
3441
|
+
return {
|
|
3442
|
+
success: false,
|
|
3443
|
+
message: `Error removing dependency: ${error instanceof Error ? error.message : String(error)}`
|
|
3444
|
+
};
|
|
3445
|
+
}
|
|
3446
|
+
}
|
|
3447
|
+
function fixTodoComment(finding, rootDir, dryRun) {
|
|
3448
|
+
if (!finding.file) {
|
|
3449
|
+
return {
|
|
3450
|
+
success: false,
|
|
3451
|
+
message: "No file specified for TODO fix"
|
|
3452
|
+
};
|
|
3453
|
+
}
|
|
3454
|
+
const filePath = path13.join(rootDir, finding.file);
|
|
3455
|
+
try {
|
|
3456
|
+
const content = fs15.readFileSync(filePath, "utf-8");
|
|
3457
|
+
const lines = content.split("\n");
|
|
3458
|
+
const todoPattern = /\/\/\s*(TODO|FIXME|HACK|XXX):\s*(.+)/i;
|
|
3459
|
+
let modified = false;
|
|
3460
|
+
const newLines = lines.map((line) => {
|
|
3461
|
+
const match = line.match(todoPattern);
|
|
3462
|
+
if (match && !line.includes("Issue:") && !line.includes("#")) {
|
|
3463
|
+
modified = true;
|
|
3464
|
+
const indent = line.match(/^\s*/)?.[0] || "";
|
|
3465
|
+
return `${indent}// ${match[1]}: ${match[2]} (Issue: #TODO - create issue)`;
|
|
3466
|
+
}
|
|
3467
|
+
return line;
|
|
3468
|
+
});
|
|
3469
|
+
if (!modified) {
|
|
3470
|
+
return {
|
|
3471
|
+
success: false,
|
|
3472
|
+
message: "No TODOs found to fix"
|
|
3473
|
+
};
|
|
3474
|
+
}
|
|
3475
|
+
if (dryRun) {
|
|
3476
|
+
const changedLines = newLines.filter(
|
|
3477
|
+
(line, i) => line !== lines[i]
|
|
3478
|
+
).length;
|
|
3479
|
+
return {
|
|
3480
|
+
success: true,
|
|
3481
|
+
message: `Would add issue references to ${changedLines} TODO comment(s)`
|
|
3482
|
+
};
|
|
3483
|
+
}
|
|
3484
|
+
fs15.writeFileSync(filePath, newLines.join("\n"), "utf-8");
|
|
3485
|
+
return {
|
|
3486
|
+
success: true,
|
|
3487
|
+
message: `Added issue references to TODO comments in ${finding.file}`
|
|
3488
|
+
};
|
|
3489
|
+
} catch (error) {
|
|
3490
|
+
return {
|
|
3491
|
+
success: false,
|
|
3492
|
+
message: `Error fixing TODOs: ${error instanceof Error ? error.message : String(error)}`
|
|
3493
|
+
};
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
function fixDeadExport(finding, rootDir, dryRun) {
|
|
3497
|
+
if (!finding.file) {
|
|
3498
|
+
return {
|
|
3499
|
+
success: false,
|
|
3500
|
+
message: "No file specified for dead export fix"
|
|
3501
|
+
};
|
|
3502
|
+
}
|
|
3503
|
+
const match = finding.message.match(/`([^`]+)`/);
|
|
3504
|
+
if (!match) {
|
|
3505
|
+
return {
|
|
3506
|
+
success: false,
|
|
3507
|
+
message: "Could not extract export name from finding"
|
|
3508
|
+
};
|
|
3509
|
+
}
|
|
3510
|
+
const exportName = match[1];
|
|
3511
|
+
const filePath = path13.join(rootDir, finding.file);
|
|
3512
|
+
try {
|
|
3513
|
+
const content = fs15.readFileSync(filePath, "utf-8");
|
|
3514
|
+
const lines = content.split("\n");
|
|
3515
|
+
const exportPattern = new RegExp(
|
|
3516
|
+
`export\\s+(const|let|var|function|class|type|interface)\\s+${exportName}\\b`,
|
|
3517
|
+
"i"
|
|
3518
|
+
);
|
|
3519
|
+
let modified = false;
|
|
3520
|
+
const newLines = lines.filter((line) => {
|
|
3521
|
+
if (exportPattern.test(line)) {
|
|
3522
|
+
modified = true;
|
|
3523
|
+
return false;
|
|
3524
|
+
}
|
|
3525
|
+
return true;
|
|
3526
|
+
});
|
|
3527
|
+
if (!modified) {
|
|
3528
|
+
return {
|
|
3529
|
+
success: false,
|
|
3530
|
+
message: `Export ${exportName} not found in ${finding.file}`
|
|
3531
|
+
};
|
|
3532
|
+
}
|
|
3533
|
+
if (dryRun) {
|
|
3534
|
+
return {
|
|
3535
|
+
success: true,
|
|
3536
|
+
message: `Would remove dead export: ${exportName} from ${finding.file}`
|
|
3537
|
+
};
|
|
3538
|
+
}
|
|
3539
|
+
fs15.writeFileSync(filePath, newLines.join("\n"), "utf-8");
|
|
3540
|
+
return {
|
|
3541
|
+
success: true,
|
|
3542
|
+
message: `Removed dead export: ${exportName} from ${finding.file}`
|
|
3543
|
+
};
|
|
3544
|
+
} catch (error) {
|
|
3545
|
+
return {
|
|
3546
|
+
success: false,
|
|
3547
|
+
message: `Error fixing dead export: ${error instanceof Error ? error.message : String(error)}`
|
|
3548
|
+
};
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
// src/interactive/index.ts
|
|
3553
|
+
async function runInteractiveMode(report, rootDir, dryRun = false) {
|
|
3554
|
+
const session = {
|
|
3555
|
+
totalFindings: 0,
|
|
3556
|
+
fixedCount: 0,
|
|
3557
|
+
skippedCount: 0,
|
|
3558
|
+
errorCount: 0
|
|
3559
|
+
};
|
|
3560
|
+
console.log(chalk6.bold.cyan("\n\u{1F527} Interactive Fix Mode\n"));
|
|
3561
|
+
console.log(chalk6.dim("Let's fix these issues together!\n"));
|
|
3562
|
+
const fixableFindings = report.findings.filter(
|
|
3563
|
+
(f) => report.fixes?.some((fix) => fix.findingId === f.id)
|
|
3564
|
+
);
|
|
3565
|
+
const nonFixableFindings = report.findings.filter(
|
|
3566
|
+
(f) => !report.fixes?.some((fix) => fix.findingId === f.id)
|
|
3567
|
+
);
|
|
3568
|
+
session.totalFindings = fixableFindings.length;
|
|
3569
|
+
if (fixableFindings.length === 0) {
|
|
3570
|
+
console.log(chalk6.green("\u2713 No fixable issues found!\n"));
|
|
3571
|
+
if (nonFixableFindings.length > 0) {
|
|
3572
|
+
console.log(
|
|
3573
|
+
chalk6.yellow(
|
|
3574
|
+
`\u26A0 ${nonFixableFindings.length} issues require manual fixes.
|
|
3575
|
+
`
|
|
3576
|
+
)
|
|
3577
|
+
);
|
|
3578
|
+
}
|
|
3579
|
+
return session;
|
|
3580
|
+
}
|
|
3581
|
+
console.log(
|
|
3582
|
+
chalk6.white(
|
|
3583
|
+
`Found ${chalk6.bold(fixableFindings.length)} fixable issues.
|
|
3584
|
+
`
|
|
3585
|
+
)
|
|
3586
|
+
);
|
|
3587
|
+
const critical = fixableFindings.filter((f) => f.severity === "critical");
|
|
3588
|
+
const warnings = fixableFindings.filter((f) => f.severity === "warning");
|
|
3589
|
+
const info = fixableFindings.filter((f) => f.severity === "info");
|
|
3590
|
+
console.log(chalk6.red(` \u{1F534} ${critical.length} critical`));
|
|
3591
|
+
console.log(chalk6.yellow(` \u26A0\uFE0F ${warnings.length} warnings`));
|
|
3592
|
+
console.log(chalk6.blue(` \u2139\uFE0F ${info.length} info
|
|
3593
|
+
`));
|
|
3594
|
+
const shouldContinue = await confirm({
|
|
3595
|
+
message: "Start fixing issues?",
|
|
3596
|
+
default: true
|
|
3597
|
+
});
|
|
3598
|
+
if (!shouldContinue) {
|
|
3599
|
+
console.log(chalk6.dim("\nExiting interactive mode.\n"));
|
|
3600
|
+
return session;
|
|
3601
|
+
}
|
|
3602
|
+
const orderedFindings = [...critical, ...warnings, ...info];
|
|
3603
|
+
for (let i = 0; i < orderedFindings.length; i++) {
|
|
3604
|
+
const finding = orderedFindings[i];
|
|
3605
|
+
const fix = report.fixes?.find((f) => f.findingId === finding.id);
|
|
3606
|
+
if (!fix) continue;
|
|
3607
|
+
console.log(chalk6.dim(`
|
|
3608
|
+
\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`));
|
|
3609
|
+
console.log(
|
|
3610
|
+
chalk6.white(
|
|
3611
|
+
`Issue ${i + 1}/${orderedFindings.length} - ${getSeverityIcon(finding.severity)}`
|
|
3612
|
+
)
|
|
3613
|
+
);
|
|
3614
|
+
console.log(chalk6.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
|
|
3615
|
+
`));
|
|
3616
|
+
console.log(chalk6.bold(finding.message));
|
|
3617
|
+
if (finding.file) {
|
|
3618
|
+
console.log(chalk6.dim(` File: ${finding.file}`));
|
|
3619
|
+
}
|
|
3620
|
+
if (finding.detail) {
|
|
3621
|
+
console.log(chalk6.dim(` Detail: ${finding.detail}`));
|
|
3622
|
+
}
|
|
3623
|
+
console.log();
|
|
3624
|
+
console.log(chalk6.cyan("\u{1F4A1} Fix suggestion:"));
|
|
3625
|
+
console.log(chalk6.white(` ${fix.suggestion}`));
|
|
3626
|
+
console.log();
|
|
3627
|
+
const isAutoFixable = fix.autoFixable || false;
|
|
3628
|
+
if (isAutoFixable) {
|
|
3629
|
+
console.log(chalk6.green("\u2713 This can be fixed automatically!\n"));
|
|
3630
|
+
const action = await select({
|
|
3631
|
+
message: "What would you like to do?",
|
|
3632
|
+
choices: [
|
|
3633
|
+
{
|
|
3634
|
+
name: dryRun ? "Preview fix (dry run)" : "Apply fix automatically",
|
|
3635
|
+
value: "apply",
|
|
3636
|
+
description: dryRun ? "Show what would be fixed" : "Let the tool fix this for you"
|
|
3637
|
+
},
|
|
3638
|
+
{
|
|
3639
|
+
name: "Show details",
|
|
3640
|
+
value: "details",
|
|
3641
|
+
description: "See more information about this issue"
|
|
3642
|
+
},
|
|
3643
|
+
{
|
|
3644
|
+
name: "Skip",
|
|
3645
|
+
value: "skip",
|
|
3646
|
+
description: "Leave this for later"
|
|
3647
|
+
},
|
|
3648
|
+
{
|
|
3649
|
+
name: "Exit interactive mode",
|
|
3650
|
+
value: "exit",
|
|
3651
|
+
description: "Stop fixing issues"
|
|
3652
|
+
}
|
|
3653
|
+
]
|
|
3654
|
+
});
|
|
3655
|
+
if (action === "exit") {
|
|
3656
|
+
console.log(
|
|
3657
|
+
chalk6.dim(`
|
|
3658
|
+
Fixed ${session.fixedCount} issues. Goodbye!
|
|
3659
|
+
`)
|
|
3660
|
+
);
|
|
3661
|
+
break;
|
|
3662
|
+
}
|
|
3663
|
+
if (action === "skip") {
|
|
3664
|
+
session.skippedCount++;
|
|
3665
|
+
console.log(chalk6.yellow("\u23ED Skipped\n"));
|
|
3666
|
+
continue;
|
|
3667
|
+
}
|
|
3668
|
+
if (action === "details") {
|
|
3669
|
+
await showFindingDetails(finding, fix);
|
|
3670
|
+
i--;
|
|
3671
|
+
continue;
|
|
3672
|
+
}
|
|
3673
|
+
if (action === "apply") {
|
|
3674
|
+
try {
|
|
3675
|
+
const result = await applyAutoFix(finding, fix, rootDir, dryRun);
|
|
3676
|
+
if (result.success) {
|
|
3677
|
+
session.fixedCount++;
|
|
3678
|
+
if (dryRun) {
|
|
3679
|
+
console.log(chalk6.green("\n\u2713 Preview:"));
|
|
3680
|
+
console.log(chalk6.dim(result.message));
|
|
3681
|
+
} else {
|
|
3682
|
+
console.log(chalk6.green(`
|
|
3683
|
+
\u2713 ${result.message}`));
|
|
3684
|
+
}
|
|
3685
|
+
} else {
|
|
3686
|
+
session.errorCount++;
|
|
3687
|
+
console.log(chalk6.red(`
|
|
3688
|
+
\u2717 ${result.message}`));
|
|
3689
|
+
}
|
|
3690
|
+
} catch (error) {
|
|
3691
|
+
session.errorCount++;
|
|
3692
|
+
console.log(
|
|
3693
|
+
chalk6.red(
|
|
3694
|
+
`
|
|
3695
|
+
\u2717 Failed to apply fix: ${error instanceof Error ? error.message : String(error)}`
|
|
3696
|
+
)
|
|
3697
|
+
);
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
3700
|
+
} else {
|
|
3701
|
+
console.log(
|
|
3702
|
+
chalk6.yellow("\u26A0 This requires manual intervention.\n")
|
|
3703
|
+
);
|
|
3704
|
+
const action = await select({
|
|
3705
|
+
message: "What would you like to do?",
|
|
3706
|
+
choices: [
|
|
3707
|
+
{
|
|
3708
|
+
name: "Mark as done (I fixed it manually)",
|
|
3709
|
+
value: "done",
|
|
3710
|
+
description: "You've fixed this issue yourself"
|
|
3711
|
+
},
|
|
3712
|
+
{
|
|
3713
|
+
name: "Show details",
|
|
3714
|
+
value: "details",
|
|
3715
|
+
description: "See more information about this issue"
|
|
3716
|
+
},
|
|
3717
|
+
{
|
|
3718
|
+
name: "Skip",
|
|
3719
|
+
value: "skip",
|
|
3720
|
+
description: "Leave this for later"
|
|
3721
|
+
},
|
|
3722
|
+
{
|
|
3723
|
+
name: "Exit interactive mode",
|
|
3724
|
+
value: "exit",
|
|
3725
|
+
description: "Stop fixing issues"
|
|
3726
|
+
}
|
|
3727
|
+
]
|
|
3728
|
+
});
|
|
3729
|
+
if (action === "exit") {
|
|
3730
|
+
console.log(
|
|
3731
|
+
chalk6.dim(`
|
|
3732
|
+
Fixed ${session.fixedCount} issues. Goodbye!
|
|
3733
|
+
`)
|
|
3734
|
+
);
|
|
3735
|
+
break;
|
|
3736
|
+
}
|
|
3737
|
+
if (action === "skip") {
|
|
3738
|
+
session.skippedCount++;
|
|
3739
|
+
console.log(chalk6.yellow("\u23ED Skipped\n"));
|
|
3740
|
+
continue;
|
|
3741
|
+
}
|
|
3742
|
+
if (action === "details") {
|
|
3743
|
+
await showFindingDetails(finding, fix);
|
|
3744
|
+
i--;
|
|
3745
|
+
continue;
|
|
3746
|
+
}
|
|
3747
|
+
if (action === "done") {
|
|
3748
|
+
session.fixedCount++;
|
|
3749
|
+
console.log(chalk6.green("\u2713 Marked as done\n"));
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3753
|
+
console.log(chalk6.dim("\n\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"));
|
|
3754
|
+
console.log(chalk6.bold.cyan("\n\u{1F4CA} Interactive Session Summary\n"));
|
|
3755
|
+
console.log(chalk6.white(` Total issues: ${session.totalFindings}`));
|
|
3756
|
+
console.log(chalk6.green(` \u2713 Fixed: ${session.fixedCount}`));
|
|
3757
|
+
console.log(chalk6.yellow(` \u23ED Skipped: ${session.skippedCount}`));
|
|
3758
|
+
if (session.errorCount > 0) {
|
|
3759
|
+
console.log(chalk6.red(` \u2717 Errors: ${session.errorCount}`));
|
|
3760
|
+
}
|
|
3761
|
+
console.log();
|
|
3762
|
+
if (session.fixedCount > 0 && !dryRun) {
|
|
3763
|
+
console.log(
|
|
3764
|
+
chalk6.green(`\u{1F389} Great work! You've improved your codebase.
|
|
3765
|
+
`)
|
|
3766
|
+
);
|
|
3767
|
+
}
|
|
3768
|
+
return session;
|
|
3769
|
+
}
|
|
3770
|
+
async function showFindingDetails(finding, fix) {
|
|
3771
|
+
console.log(chalk6.dim("\n\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"));
|
|
3772
|
+
console.log(chalk6.bold.cyan("\u{1F4CB} Detailed Information\n"));
|
|
3773
|
+
console.log(chalk6.white("Finding:"));
|
|
3774
|
+
console.log(` ID: ${chalk6.dim(finding.id)}`);
|
|
3775
|
+
console.log(` Category: ${chalk6.dim(finding.category)}`);
|
|
3776
|
+
console.log(` Severity: ${getSeverityBadge(finding.severity)}`);
|
|
3777
|
+
if (finding.file) {
|
|
3778
|
+
console.log(` File: ${chalk6.dim(finding.file)}`);
|
|
3779
|
+
}
|
|
3780
|
+
console.log();
|
|
3781
|
+
console.log(chalk6.white("Message:"));
|
|
3782
|
+
console.log(` ${finding.message}`);
|
|
3783
|
+
if (finding.detail) {
|
|
3784
|
+
console.log();
|
|
3785
|
+
console.log(chalk6.white("Details:"));
|
|
3786
|
+
console.log(` ${finding.detail}`);
|
|
3787
|
+
}
|
|
3788
|
+
console.log();
|
|
3789
|
+
console.log(chalk6.white("Fix Suggestion:"));
|
|
3790
|
+
console.log(` ${fix.suggestion}`);
|
|
3791
|
+
console.log();
|
|
3792
|
+
if (fix.command) {
|
|
3793
|
+
console.log(chalk6.white("Command:"));
|
|
3794
|
+
console.log(chalk6.dim(` ${fix.command}`));
|
|
3795
|
+
console.log();
|
|
3796
|
+
}
|
|
3797
|
+
await confirm({
|
|
3798
|
+
message: "Press Enter to continue",
|
|
3799
|
+
default: true
|
|
3800
|
+
});
|
|
3801
|
+
}
|
|
3802
|
+
function getSeverityIcon(severity) {
|
|
3803
|
+
switch (severity) {
|
|
3804
|
+
case "critical":
|
|
3805
|
+
return chalk6.red("\u{1F534} Critical");
|
|
3806
|
+
case "warning":
|
|
3807
|
+
return chalk6.yellow("\u26A0\uFE0F Warning");
|
|
3808
|
+
case "info":
|
|
3809
|
+
return chalk6.blue("\u2139\uFE0F Info");
|
|
3810
|
+
default:
|
|
3811
|
+
return severity;
|
|
3812
|
+
}
|
|
3813
|
+
}
|
|
3814
|
+
function getSeverityBadge(severity) {
|
|
3815
|
+
switch (severity) {
|
|
3816
|
+
case "critical":
|
|
3817
|
+
return chalk6.bgRed.white.bold(" CRITICAL ");
|
|
3818
|
+
case "warning":
|
|
3819
|
+
return chalk6.bgYellow.black.bold(" WARNING ");
|
|
3820
|
+
case "info":
|
|
3821
|
+
return chalk6.bgBlue.white.bold(" INFO ");
|
|
3822
|
+
default:
|
|
3823
|
+
return severity;
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
|
|
3827
|
+
// src/history/index.ts
|
|
3828
|
+
import fs16 from "fs";
|
|
3829
|
+
import path14 from "path";
|
|
3830
|
+
import { spawnSync as spawnSync5 } from "child_process";
|
|
3831
|
+
var HISTORY_FILE = ".roast-history.json";
|
|
3832
|
+
function loadHistory(rootDir) {
|
|
3833
|
+
const historyPath = path14.join(rootDir, HISTORY_FILE);
|
|
3834
|
+
if (!fs16.existsSync(historyPath)) {
|
|
3835
|
+
return null;
|
|
3836
|
+
}
|
|
3837
|
+
try {
|
|
3838
|
+
const content = fs16.readFileSync(historyPath, "utf-8");
|
|
3839
|
+
return JSON.parse(content);
|
|
3840
|
+
} catch (error) {
|
|
3841
|
+
console.warn(`Warning: Failed to load health history: ${error}`);
|
|
3842
|
+
return null;
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
function saveHistory(rootDir, history) {
|
|
3846
|
+
const historyPath = path14.join(rootDir, HISTORY_FILE);
|
|
3847
|
+
try {
|
|
3848
|
+
fs16.writeFileSync(historyPath, JSON.stringify(history, null, 2), "utf-8");
|
|
3849
|
+
} catch (error) {
|
|
3850
|
+
console.warn(`Warning: Failed to save health history: ${error}`);
|
|
3851
|
+
}
|
|
3852
|
+
}
|
|
3853
|
+
function createSnapshot(health, findings, rootDir) {
|
|
3854
|
+
let commitHash;
|
|
3855
|
+
let commitMessage;
|
|
3856
|
+
try {
|
|
3857
|
+
const hashResult = spawnSync5("git", ["rev-parse", "--short", "HEAD"], {
|
|
3858
|
+
cwd: rootDir,
|
|
3859
|
+
encoding: "utf-8",
|
|
3860
|
+
stdio: "pipe"
|
|
3861
|
+
});
|
|
3862
|
+
if (hashResult.status === 0) {
|
|
3863
|
+
commitHash = hashResult.stdout.trim();
|
|
3864
|
+
const messageResult = spawnSync5(
|
|
3865
|
+
"git",
|
|
3866
|
+
["log", "-1", "--pretty=%B"],
|
|
3867
|
+
{
|
|
3868
|
+
cwd: rootDir,
|
|
3869
|
+
encoding: "utf-8",
|
|
3870
|
+
stdio: "pipe"
|
|
3871
|
+
}
|
|
3872
|
+
);
|
|
3873
|
+
if (messageResult.status === 0) {
|
|
3874
|
+
commitMessage = messageResult.stdout.trim().split("\n")[0];
|
|
3875
|
+
}
|
|
3876
|
+
}
|
|
3877
|
+
} catch {
|
|
3878
|
+
}
|
|
3879
|
+
const categoryCounts = {};
|
|
3880
|
+
findings.forEach((finding) => {
|
|
3881
|
+
categoryCounts[finding.category] = (categoryCounts[finding.category] || 0) + 1;
|
|
3882
|
+
});
|
|
3883
|
+
return {
|
|
3884
|
+
timestamp: Date.now(),
|
|
3885
|
+
date: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3886
|
+
score: health.score,
|
|
3887
|
+
grade: health.grade,
|
|
3888
|
+
totalFindings: findings.length,
|
|
3889
|
+
criticalCount: findings.filter((f) => f.severity === "critical").length,
|
|
3890
|
+
warningCount: findings.filter((f) => f.severity === "warning").length,
|
|
3891
|
+
infoCount: findings.filter((f) => f.severity === "info").length,
|
|
3892
|
+
categoryCounts,
|
|
3893
|
+
commitHash,
|
|
3894
|
+
commitMessage
|
|
3895
|
+
};
|
|
3896
|
+
}
|
|
3897
|
+
function addSnapshot(rootDir, projectName, snapshot) {
|
|
3898
|
+
let history = loadHistory(rootDir);
|
|
3899
|
+
if (!history) {
|
|
3900
|
+
history = {
|
|
3901
|
+
projectName,
|
|
3902
|
+
snapshots: []
|
|
3903
|
+
};
|
|
3904
|
+
}
|
|
3905
|
+
history.snapshots.push(snapshot);
|
|
3906
|
+
if (history.snapshots.length > 100) {
|
|
3907
|
+
history.snapshots = history.snapshots.slice(-100);
|
|
3908
|
+
}
|
|
3909
|
+
saveHistory(rootDir, history);
|
|
3910
|
+
return history;
|
|
3911
|
+
}
|
|
3912
|
+
function analyzeTrend(history, days = 30) {
|
|
3913
|
+
if (history.snapshots.length < 2) {
|
|
3914
|
+
return null;
|
|
3915
|
+
}
|
|
3916
|
+
const now = Date.now();
|
|
3917
|
+
const cutoff = now - days * 24 * 60 * 60 * 1e3;
|
|
3918
|
+
const recentSnapshots = history.snapshots.filter(
|
|
3919
|
+
(s) => s.timestamp >= cutoff
|
|
3920
|
+
);
|
|
3921
|
+
if (recentSnapshots.length < 2) {
|
|
3922
|
+
return null;
|
|
3923
|
+
}
|
|
3924
|
+
const scores = recentSnapshots.map((s) => s.score);
|
|
3925
|
+
const firstScore = recentSnapshots[0].score;
|
|
3926
|
+
const lastScore = recentSnapshots[recentSnapshots.length - 1].score;
|
|
3927
|
+
const scoreChange = lastScore - firstScore;
|
|
3928
|
+
const averageScore = scores.reduce((sum, score) => sum + score, 0) / scores.length;
|
|
3929
|
+
const bestScore = Math.max(...scores);
|
|
3930
|
+
const worstScore = Math.min(...scores);
|
|
3931
|
+
const actualDays = (recentSnapshots[recentSnapshots.length - 1].timestamp - recentSnapshots[0].timestamp) / (24 * 60 * 60 * 1e3);
|
|
3932
|
+
const improvementRate = actualDays > 0 ? scoreChange / actualDays : 0;
|
|
3933
|
+
let trend;
|
|
3934
|
+
if (scoreChange > 5) {
|
|
3935
|
+
trend = "improving";
|
|
3936
|
+
} else if (scoreChange < -5) {
|
|
3937
|
+
trend = "declining";
|
|
3938
|
+
} else {
|
|
3939
|
+
trend = "stable";
|
|
3940
|
+
}
|
|
3941
|
+
return {
|
|
3942
|
+
trend,
|
|
3943
|
+
scoreChange,
|
|
3944
|
+
periodDays: Math.round(actualDays),
|
|
3945
|
+
averageScore: Math.round(averageScore * 10) / 10,
|
|
3946
|
+
bestScore,
|
|
3947
|
+
worstScore,
|
|
3948
|
+
improvementRate: Math.round(improvementRate * 100) / 100
|
|
3949
|
+
};
|
|
3950
|
+
}
|
|
3951
|
+
function getSnapshotsByPeriod(history, days) {
|
|
3952
|
+
const now = Date.now();
|
|
3953
|
+
const cutoff = now - days * 24 * 60 * 60 * 1e3;
|
|
3954
|
+
return history.snapshots.filter((s) => s.timestamp >= cutoff);
|
|
3955
|
+
}
|
|
3956
|
+
function generateTrendChart(snapshots, width = 50, height = 10) {
|
|
3957
|
+
if (snapshots.length < 2) {
|
|
3958
|
+
return "Not enough data for chart";
|
|
3959
|
+
}
|
|
3960
|
+
const scores = snapshots.map((s) => s.score);
|
|
3961
|
+
const minScore = Math.min(...scores, 0);
|
|
3962
|
+
const maxScore = Math.max(...scores, 100);
|
|
3963
|
+
const range = maxScore - minScore;
|
|
3964
|
+
const lines = [];
|
|
3965
|
+
for (let i = height; i >= 0; i--) {
|
|
3966
|
+
const value = minScore + range * i / height;
|
|
3967
|
+
let line = `${Math.round(value).toString().padStart(3)} \u2524`;
|
|
3968
|
+
for (let j = 0; j < width; j++) {
|
|
3969
|
+
const snapshotIndex = Math.floor(j / width * snapshots.length);
|
|
3970
|
+
const snapshot = snapshots[snapshotIndex];
|
|
3971
|
+
const normalizedScore = (snapshot.score - minScore) / range * height;
|
|
3972
|
+
if (Math.abs(normalizedScore - i) < 0.5) {
|
|
3973
|
+
line += "\u25CF";
|
|
3974
|
+
} else if (normalizedScore > i) {
|
|
3975
|
+
line += " ";
|
|
3976
|
+
} else {
|
|
3977
|
+
line += " ";
|
|
3978
|
+
}
|
|
3979
|
+
}
|
|
3980
|
+
lines.push(line);
|
|
3981
|
+
}
|
|
3982
|
+
const firstDate = new Date(snapshots[0].timestamp);
|
|
3983
|
+
const lastDate = new Date(snapshots[snapshots.length - 1].timestamp);
|
|
3984
|
+
const xAxis = " \u2514" + "\u2500".repeat(width) + "\u2192";
|
|
3985
|
+
lines.push(xAxis);
|
|
3986
|
+
const dateLabel = " " + firstDate.toLocaleDateString().padEnd(width - 15) + lastDate.toLocaleDateString();
|
|
3987
|
+
lines.push(dateLabel);
|
|
3988
|
+
return lines.join("\n");
|
|
3989
|
+
}
|
|
3990
|
+
function getCategoryTrends(history, days = 30) {
|
|
3991
|
+
const recentSnapshots = getSnapshotsByPeriod(history, days);
|
|
3992
|
+
if (recentSnapshots.length < 2) {
|
|
3993
|
+
return {};
|
|
3994
|
+
}
|
|
3995
|
+
const firstSnapshot = recentSnapshots[0];
|
|
3996
|
+
const lastSnapshot = recentSnapshots[recentSnapshots.length - 1];
|
|
3997
|
+
const trends = {};
|
|
3998
|
+
const allCategories = /* @__PURE__ */ new Set([
|
|
3999
|
+
...Object.keys(firstSnapshot.categoryCounts),
|
|
4000
|
+
...Object.keys(lastSnapshot.categoryCounts)
|
|
4001
|
+
]);
|
|
4002
|
+
allCategories.forEach((category) => {
|
|
4003
|
+
const firstCount = firstSnapshot.categoryCounts[category] || 0;
|
|
4004
|
+
const lastCount = lastSnapshot.categoryCounts[category] || 0;
|
|
4005
|
+
trends[category] = lastCount - firstCount;
|
|
4006
|
+
});
|
|
4007
|
+
return trends;
|
|
4008
|
+
}
|
|
4009
|
+
|
|
4010
|
+
// src/history/render.ts
|
|
4011
|
+
import chalk7 from "chalk";
|
|
4012
|
+
function renderHistoryReport(history, days = 30) {
|
|
4013
|
+
const output = [];
|
|
4014
|
+
output.push(chalk7.bold.cyan("\n\u{1F4CA} Health History Report\n"));
|
|
4015
|
+
output.push(chalk7.dim("\u2500".repeat(60)));
|
|
4016
|
+
output.push("");
|
|
4017
|
+
output.push(chalk7.white(`Project: ${history.projectName}`));
|
|
4018
|
+
output.push(
|
|
4019
|
+
chalk7.white(`Total snapshots: ${history.snapshots.length}`)
|
|
4020
|
+
);
|
|
4021
|
+
output.push("");
|
|
4022
|
+
if (history.snapshots.length === 0) {
|
|
4023
|
+
output.push(chalk7.yellow("No health data recorded yet."));
|
|
4024
|
+
output.push(
|
|
4025
|
+
chalk7.dim("Run with --track flag to start tracking health over time.")
|
|
4026
|
+
);
|
|
4027
|
+
return output.join("\n");
|
|
4028
|
+
}
|
|
4029
|
+
const latest = history.snapshots[history.snapshots.length - 1];
|
|
4030
|
+
output.push(chalk7.bold("Current Health"));
|
|
4031
|
+
output.push(
|
|
4032
|
+
` Score: ${getScoreColor3(latest.score)}${latest.score}/100${chalk7.reset()} (${latest.grade})`
|
|
4033
|
+
);
|
|
4034
|
+
output.push(` Findings: ${latest.totalFindings}`);
|
|
4035
|
+
output.push(
|
|
4036
|
+
` ${chalk7.red("\u25CF")} ${latest.criticalCount} critical ${chalk7.yellow("\u25CF")} ${latest.warningCount} warnings ${chalk7.blue("\u25CF")} ${latest.infoCount} info`
|
|
4037
|
+
);
|
|
4038
|
+
if (latest.commitHash) {
|
|
4039
|
+
output.push(chalk7.dim(` Commit: ${latest.commitHash}`));
|
|
4040
|
+
}
|
|
4041
|
+
output.push("");
|
|
4042
|
+
const trend = analyzeTrend(history, days);
|
|
4043
|
+
if (trend) {
|
|
4044
|
+
output.push(chalk7.bold(`Trend Analysis (Last ${trend.periodDays} days)`));
|
|
4045
|
+
output.push("");
|
|
4046
|
+
const trendIcon = getTrendIcon(trend.trend);
|
|
4047
|
+
const trendColor = getTrendColor(trend.trend);
|
|
4048
|
+
output.push(
|
|
4049
|
+
` ${trendIcon} ${trendColor(trend.trend.toUpperCase())} ${chalk7.dim(`(${trend.scoreChange > 0 ? "+" : ""}${trend.scoreChange} points)`)}`
|
|
4050
|
+
);
|
|
4051
|
+
output.push("");
|
|
4052
|
+
output.push(chalk7.white(" Statistics:"));
|
|
4053
|
+
output.push(` Average score: ${trend.averageScore}/100`);
|
|
4054
|
+
output.push(` Best score: ${chalk7.green(trend.bestScore)}/100`);
|
|
4055
|
+
output.push(` Worst score: ${chalk7.red(trend.worstScore)}/100`);
|
|
4056
|
+
output.push(
|
|
4057
|
+
` Improvement rate: ${trend.improvementRate > 0 ? chalk7.green("+") : chalk7.red("")}${trend.improvementRate} points/day`
|
|
4058
|
+
);
|
|
4059
|
+
output.push("");
|
|
4060
|
+
output.push(chalk7.bold(" Score Trend"));
|
|
4061
|
+
output.push("");
|
|
4062
|
+
const recentSnapshots = history.snapshots.filter(
|
|
4063
|
+
(s) => s.timestamp >= Date.now() - days * 24 * 60 * 60 * 1e3
|
|
4064
|
+
);
|
|
4065
|
+
const chart = generateTrendChart(recentSnapshots, 50, 8);
|
|
4066
|
+
output.push(
|
|
4067
|
+
chart.split("\n").map((line) => " " + line).join("\n")
|
|
4068
|
+
);
|
|
4069
|
+
output.push("");
|
|
4070
|
+
const categoryTrends = getCategoryTrends(history, days);
|
|
4071
|
+
const improving = Object.entries(categoryTrends).filter(([_, change]) => change < 0).sort(([_, a], [__, b]) => a - b);
|
|
4072
|
+
const declining = Object.entries(categoryTrends).filter(([_, change]) => change > 0).sort(([_, a], [__, b]) => b - a);
|
|
4073
|
+
if (improving.length > 0 || declining.length > 0) {
|
|
4074
|
+
output.push(chalk7.bold(" Category Changes"));
|
|
4075
|
+
output.push("");
|
|
4076
|
+
if (improving.length > 0) {
|
|
4077
|
+
output.push(chalk7.green(" \u2193 Improving:"));
|
|
4078
|
+
improving.slice(0, 5).forEach(([category, change]) => {
|
|
4079
|
+
output.push(
|
|
4080
|
+
` ${chalk7.green("\u2713")} ${category}: ${chalk7.green(change)} issues`
|
|
4081
|
+
);
|
|
4082
|
+
});
|
|
4083
|
+
if (improving.length > 5) {
|
|
4084
|
+
output.push(chalk7.dim(` ...and ${improving.length - 5} more`));
|
|
4085
|
+
}
|
|
4086
|
+
output.push("");
|
|
4087
|
+
}
|
|
4088
|
+
if (declining.length > 0) {
|
|
4089
|
+
output.push(chalk7.red(" \u2191 Declining:"));
|
|
4090
|
+
declining.slice(0, 5).forEach(([category, change]) => {
|
|
4091
|
+
output.push(
|
|
4092
|
+
` ${chalk7.red("\u2717")} ${category}: ${chalk7.red("+" + change)} issues`
|
|
4093
|
+
);
|
|
4094
|
+
});
|
|
4095
|
+
if (declining.length > 5) {
|
|
4096
|
+
output.push(chalk7.dim(` ...and ${declining.length - 5} more`));
|
|
4097
|
+
}
|
|
4098
|
+
output.push("");
|
|
4099
|
+
}
|
|
4100
|
+
}
|
|
4101
|
+
}
|
|
4102
|
+
const recentCount = Math.min(10, history.snapshots.length);
|
|
4103
|
+
output.push(chalk7.bold(`Recent Snapshots (Last ${recentCount})`));
|
|
4104
|
+
output.push("");
|
|
4105
|
+
history.snapshots.slice(-recentCount).reverse().forEach((snapshot) => {
|
|
4106
|
+
const date = new Date(snapshot.timestamp);
|
|
4107
|
+
const dateStr = date.toLocaleString();
|
|
4108
|
+
const scoreColor = getScoreColor3(snapshot.score);
|
|
4109
|
+
output.push(
|
|
4110
|
+
` ${dateStr} - ${scoreColor}${snapshot.score}${chalk7.reset()} (${snapshot.grade}) - ${snapshot.totalFindings} issues`
|
|
4111
|
+
);
|
|
4112
|
+
if (snapshot.commitHash) {
|
|
4113
|
+
output.push(
|
|
4114
|
+
chalk7.dim(` ${snapshot.commitHash}: ${snapshot.commitMessage}`)
|
|
4115
|
+
);
|
|
4116
|
+
}
|
|
4117
|
+
});
|
|
4118
|
+
output.push("");
|
|
4119
|
+
output.push(chalk7.dim("\u2500".repeat(60)));
|
|
4120
|
+
output.push("");
|
|
4121
|
+
return output.join("\n");
|
|
4122
|
+
}
|
|
4123
|
+
function renderTrendSummary(history, days = 7) {
|
|
4124
|
+
const trend = analyzeTrend(history, days);
|
|
4125
|
+
if (!trend) {
|
|
4126
|
+
return chalk7.dim("No trend data available yet");
|
|
4127
|
+
}
|
|
4128
|
+
const trendIcon = getTrendIcon(trend.trend);
|
|
4129
|
+
const trendColor = getTrendColor(trend.trend);
|
|
4130
|
+
const changeStr = trend.scoreChange > 0 ? chalk7.green(`+${trend.scoreChange}`) : trend.scoreChange < 0 ? chalk7.red(`${trend.scoreChange}`) : chalk7.dim("\xB10");
|
|
4131
|
+
return `${trendIcon} ${trendColor(trend.trend)} (${changeStr} over ${trend.periodDays} days)`;
|
|
4132
|
+
}
|
|
4133
|
+
function getScoreColor3(score) {
|
|
4134
|
+
if (score >= 90) return chalk7.green;
|
|
4135
|
+
if (score >= 80) return chalk7.greenBright;
|
|
4136
|
+
if (score >= 70) return chalk7.yellow;
|
|
4137
|
+
if (score >= 60) return chalk7.hex("#FFA500");
|
|
4138
|
+
return chalk7.red;
|
|
4139
|
+
}
|
|
4140
|
+
function getTrendIcon(trend) {
|
|
4141
|
+
switch (trend) {
|
|
4142
|
+
case "improving":
|
|
4143
|
+
return chalk7.green("\u2197");
|
|
4144
|
+
case "declining":
|
|
4145
|
+
return chalk7.red("\u2198");
|
|
4146
|
+
case "stable":
|
|
4147
|
+
return chalk7.blue("\u2192");
|
|
4148
|
+
}
|
|
4149
|
+
}
|
|
4150
|
+
function getTrendColor(trend) {
|
|
4151
|
+
switch (trend) {
|
|
4152
|
+
case "improving":
|
|
4153
|
+
return chalk7.green;
|
|
4154
|
+
case "declining":
|
|
4155
|
+
return chalk7.red;
|
|
4156
|
+
case "stable":
|
|
4157
|
+
return chalk7.blue;
|
|
4158
|
+
}
|
|
4159
|
+
}
|
|
4160
|
+
|
|
4161
|
+
// src/cli/index.ts
|
|
4162
|
+
function loadPackageVersion() {
|
|
4163
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
4164
|
+
let dir = path15.dirname(__filename);
|
|
4165
|
+
while (dir !== path15.dirname(dir)) {
|
|
4166
|
+
const pkgPath = path15.join(dir, "package.json");
|
|
4167
|
+
if (fs17.existsSync(pkgPath)) {
|
|
4168
|
+
const pkg = JSON.parse(fs17.readFileSync(pkgPath, "utf-8"));
|
|
4169
|
+
if (pkg.name === "roast-my-codebase") return pkg.version;
|
|
4170
|
+
}
|
|
4171
|
+
dir = path15.dirname(dir);
|
|
4172
|
+
}
|
|
4173
|
+
return "1.0.0";
|
|
4174
|
+
}
|
|
4175
|
+
function createCli() {
|
|
4176
|
+
const program2 = new Command();
|
|
4177
|
+
program2.name("roast-my-codebase").description("Get roasted. Get better. Ship faster.").version(loadPackageVersion()).argument("[path]", "path to scan", ".").option("--json", "Output results as JSON").option("--markdown", "Output results as markdown").option("--markdown-file", "Save markdown report to .roast-report.md").option("--fix", "Show actionable fix suggestions").option("--ai-roasts", "Generate AI-powered contextual roasts (requires ANTHROPIC_API_KEY)").option("--interactive", "Interactive mode - walk through fixing issues").option("--dry-run", "Preview fixes without applying them (use with --interactive)").option("--track", "Track health over time in .roast-history.json").option("--history [days]", "Show health history (default: last 30 days)", parseInt).option("--watch", "Watch for file changes and re-run analysis").option("--compare <branch>", "Compare current codebase with a git branch").option("--badge", "Generate health badge SVG (.roast-badge.svg)").option("--ascii", "Show ASCII art grade in output").option(
|
|
4178
|
+
"--threshold <score>",
|
|
4179
|
+
"Exit with code 1 if health score is below threshold (use with --json)",
|
|
4180
|
+
parseInt
|
|
4181
|
+
).action(async (targetPath, options) => {
|
|
4182
|
+
const rootDir = path15.resolve(targetPath);
|
|
4183
|
+
if (!fs17.existsSync(rootDir)) {
|
|
4184
|
+
console.error(`Error: "${rootDir}" does not exist.`);
|
|
4185
|
+
process.exit(1);
|
|
4186
|
+
}
|
|
4187
|
+
if (!fs17.statSync(rootDir).isDirectory()) {
|
|
4188
|
+
console.error(`Error: "${rootDir}" is not a directory.`);
|
|
4189
|
+
process.exit(1);
|
|
4190
|
+
}
|
|
4191
|
+
if (options.history !== void 0) {
|
|
4192
|
+
const history = loadHistory(rootDir);
|
|
4193
|
+
if (!history) {
|
|
4194
|
+
console.log(
|
|
4195
|
+
chalk8.yellow("\n\u26A0 No health history found.\n")
|
|
4196
|
+
);
|
|
4197
|
+
console.log(
|
|
4198
|
+
chalk8.dim("Run with --track flag to start tracking health over time.\n")
|
|
4199
|
+
);
|
|
4200
|
+
process.exit(0);
|
|
4201
|
+
}
|
|
4202
|
+
const days = typeof options.history === "number" ? options.history : 30;
|
|
4203
|
+
console.log(renderHistoryReport(history, days));
|
|
4204
|
+
process.exit(0);
|
|
4205
|
+
}
|
|
4206
|
+
const config = loadConfig(rootDir);
|
|
4207
|
+
const aiConfig = {
|
|
4208
|
+
enabled: options.aiRoasts || config.ai?.enabled || false,
|
|
4209
|
+
apiKey: config.ai?.apiKey,
|
|
4210
|
+
model: config.ai?.model,
|
|
4211
|
+
maxTokens: config.ai?.maxTokens,
|
|
4212
|
+
temperature: config.ai?.temperature,
|
|
4213
|
+
cacheEnabled: config.ai?.cacheEnabled,
|
|
4214
|
+
cachePath: config.ai?.cachePath
|
|
4215
|
+
};
|
|
4216
|
+
const runScanners = async (scanRootDir) => {
|
|
4217
|
+
const allFindings = [];
|
|
4218
|
+
const fileScanner = new FileScanner();
|
|
4219
|
+
const fileResult = await fileScanner.scan(scanRootDir);
|
|
4220
|
+
allFindings.push(...fileResult.findings);
|
|
4221
|
+
const todoScanner = new TodoScanner();
|
|
4222
|
+
const todoResult = await todoScanner.scan(scanRootDir);
|
|
4223
|
+
allFindings.push(...todoResult.findings);
|
|
4224
|
+
const depScanner = new DependencyScanner();
|
|
4225
|
+
const depResult = await depScanner.scan(scanRootDir);
|
|
4226
|
+
allFindings.push(...depResult.findings);
|
|
4227
|
+
const circularScanner = new CircularDependencyScanner();
|
|
4228
|
+
const circularResult = await circularScanner.scan(scanRootDir);
|
|
4229
|
+
allFindings.push(...circularResult.findings);
|
|
4230
|
+
const structureScanner = new StructureScanner();
|
|
4231
|
+
const structureResult = await structureScanner.scan(scanRootDir);
|
|
4232
|
+
allFindings.push(...structureResult.findings);
|
|
4233
|
+
const complexityScanner = new ComplexityScanner();
|
|
4234
|
+
const complexityResult = await complexityScanner.scan(scanRootDir);
|
|
4235
|
+
allFindings.push(...complexityResult.findings);
|
|
4236
|
+
const duplicateScanner = new DuplicateScanner();
|
|
4237
|
+
const duplicateResult = await duplicateScanner.scan(scanRootDir);
|
|
4238
|
+
allFindings.push(...duplicateResult.findings);
|
|
4239
|
+
const deadExportScanner = new DeadExportScanner();
|
|
4240
|
+
const deadExportResult = await deadExportScanner.scan(scanRootDir);
|
|
4241
|
+
allFindings.push(...deadExportResult.findings);
|
|
4242
|
+
const typeSafetyScanner = new TypeSafetyScanner();
|
|
4243
|
+
const typeSafetyResult = await typeSafetyScanner.scan(scanRootDir);
|
|
4244
|
+
allFindings.push(...typeSafetyResult.findings);
|
|
4245
|
+
const testCoverageScanner = new TestCoverageScanner();
|
|
4246
|
+
const testCoverageResult = await testCoverageScanner.scan(scanRootDir);
|
|
4247
|
+
allFindings.push(...testCoverageResult.findings);
|
|
4248
|
+
const gitInsightsScanner = new GitInsightsScanner();
|
|
4249
|
+
const gitInsightsResult = await gitInsightsScanner.scan(scanRootDir);
|
|
4250
|
+
allFindings.push(...gitInsightsResult.findings);
|
|
4251
|
+
const securityScanner = new SecurityScanner();
|
|
4252
|
+
const securityResult = await securityScanner.scan(scanRootDir);
|
|
4253
|
+
allFindings.push(...securityResult.findings);
|
|
4254
|
+
const frameworkScanner = new FrameworkScanner();
|
|
4255
|
+
const frameworkResult = await frameworkScanner.scan(scanRootDir);
|
|
4256
|
+
allFindings.push(...frameworkResult.findings);
|
|
4257
|
+
const health = calculateHealth(allFindings);
|
|
4258
|
+
return { findings: allFindings, health };
|
|
4259
|
+
};
|
|
4260
|
+
if (options.compare) {
|
|
4261
|
+
try {
|
|
4262
|
+
const comparison = await compareWithBranch(rootDir, options.compare, runScanners);
|
|
4263
|
+
renderComparison(comparison, options.compare);
|
|
4264
|
+
} catch (error) {
|
|
4265
|
+
console.error(`
|
|
4266
|
+
Comparison failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
4267
|
+
process.exit(1);
|
|
4268
|
+
}
|
|
4269
|
+
return;
|
|
4270
|
+
}
|
|
4271
|
+
if (options.watch) {
|
|
4272
|
+
const scanners = [
|
|
4273
|
+
new FileScanner(),
|
|
4274
|
+
new TodoScanner(),
|
|
4275
|
+
new DependencyScanner(),
|
|
4276
|
+
new CircularDependencyScanner(),
|
|
4277
|
+
new StructureScanner(),
|
|
4278
|
+
new ComplexityScanner(),
|
|
4279
|
+
new DuplicateScanner(),
|
|
4280
|
+
new DeadExportScanner(),
|
|
4281
|
+
new TypeSafetyScanner(),
|
|
4282
|
+
new TestCoverageScanner(),
|
|
4283
|
+
new GitInsightsScanner(),
|
|
4284
|
+
new SecurityScanner(),
|
|
4285
|
+
new FrameworkScanner(),
|
|
4286
|
+
new TypeSafetyScanner()
|
|
4287
|
+
];
|
|
4288
|
+
const projectName = getProjectName(rootDir);
|
|
4289
|
+
let isFirstRun = true;
|
|
4290
|
+
await startWatchMode(rootDir, scanners, async (findings, health, delta, stats) => {
|
|
4291
|
+
if (isFirstRun) {
|
|
4292
|
+
const roasts = await generateRoasts(findings, aiConfig, rootDir);
|
|
4293
|
+
const verdict = generateVerdict(health);
|
|
4294
|
+
const fixes = options.fix ? generateFixSuggestions(findings) : void 0;
|
|
4295
|
+
const report = {
|
|
4296
|
+
projectName,
|
|
4297
|
+
stats,
|
|
4298
|
+
health,
|
|
4299
|
+
findings,
|
|
4300
|
+
roasts,
|
|
4301
|
+
verdict,
|
|
4302
|
+
fixes
|
|
4303
|
+
};
|
|
4304
|
+
console.log(renderReport(report, { ascii: options.ascii }));
|
|
4305
|
+
isFirstRun = false;
|
|
4306
|
+
} else {
|
|
4307
|
+
const findingCounts = {
|
|
4308
|
+
critical: findings.filter((f) => f.severity === "critical").length,
|
|
4309
|
+
warning: findings.filter((f) => f.severity === "warning").length,
|
|
4310
|
+
info: findings.filter((f) => f.severity === "info").length
|
|
4311
|
+
};
|
|
4312
|
+
renderWatchSummary(health, delta, findingCounts);
|
|
4313
|
+
}
|
|
4314
|
+
});
|
|
4315
|
+
return;
|
|
4316
|
+
}
|
|
4317
|
+
console.log("");
|
|
4318
|
+
const spinner = ora({
|
|
4319
|
+
text: "Scanning your codebase...",
|
|
4320
|
+
spinner: "dots"
|
|
4321
|
+
}).start();
|
|
4322
|
+
try {
|
|
4323
|
+
const allFindings = [];
|
|
4324
|
+
const fileScanner = new FileScanner();
|
|
4325
|
+
const fileResult = await fileScanner.scan(rootDir);
|
|
4326
|
+
allFindings.push(...fileResult.findings);
|
|
4327
|
+
const stats = fileResult.stats;
|
|
4328
|
+
spinner.text = "Detecting TODOs...";
|
|
4329
|
+
const todoScanner = new TodoScanner();
|
|
4330
|
+
const todoResult = await todoScanner.scan(rootDir);
|
|
4331
|
+
allFindings.push(...todoResult.findings);
|
|
4332
|
+
spinner.text = "Analyzing dependencies...";
|
|
4333
|
+
const depScanner = new DependencyScanner();
|
|
4334
|
+
const depResult = await depScanner.scan(rootDir);
|
|
4335
|
+
allFindings.push(...depResult.findings);
|
|
4336
|
+
spinner.text = "Checking for circular dependencies...";
|
|
4337
|
+
const circularScanner = new CircularDependencyScanner();
|
|
4338
|
+
const circularResult = await circularScanner.scan(rootDir);
|
|
4339
|
+
allFindings.push(...circularResult.findings);
|
|
4340
|
+
spinner.text = "Analyzing project structure...";
|
|
4341
|
+
const structureScanner = new StructureScanner();
|
|
4342
|
+
const structureResult = await structureScanner.scan(rootDir);
|
|
4343
|
+
allFindings.push(...structureResult.findings);
|
|
4344
|
+
spinner.text = "Calculating code complexity...";
|
|
4345
|
+
const complexityScanner = new ComplexityScanner();
|
|
4346
|
+
const complexityResult = await complexityScanner.scan(rootDir);
|
|
4347
|
+
allFindings.push(...complexityResult.findings);
|
|
4348
|
+
spinner.text = "Detecting duplicate code...";
|
|
4349
|
+
const duplicateScanner = new DuplicateScanner();
|
|
4350
|
+
const duplicateResult = await duplicateScanner.scan(rootDir);
|
|
4351
|
+
allFindings.push(...duplicateResult.findings);
|
|
4352
|
+
spinner.text = "Finding dead exports...";
|
|
4353
|
+
const deadExportScanner = new DeadExportScanner();
|
|
4354
|
+
const deadExportResult = await deadExportScanner.scan(rootDir);
|
|
4355
|
+
allFindings.push(...deadExportResult.findings);
|
|
4356
|
+
spinner.text = "Auditing type safety...";
|
|
4357
|
+
const typeSafetyScanner = new TypeSafetyScanner();
|
|
4358
|
+
const typeSafetyResult = await typeSafetyScanner.scan(rootDir);
|
|
4359
|
+
allFindings.push(...typeSafetyResult.findings);
|
|
4360
|
+
spinner.text = "Checking test coverage...";
|
|
4361
|
+
const testCoverageScanner = new TestCoverageScanner();
|
|
4362
|
+
const testCoverageResult = await testCoverageScanner.scan(rootDir);
|
|
4363
|
+
allFindings.push(...testCoverageResult.findings);
|
|
4364
|
+
spinner.text = "Analyzing git history...";
|
|
4365
|
+
const gitInsightsScanner = new GitInsightsScanner();
|
|
4366
|
+
const gitInsightsResult = await gitInsightsScanner.scan(rootDir);
|
|
4367
|
+
allFindings.push(...gitInsightsResult.findings);
|
|
4368
|
+
spinner.text = "Scanning security surface...";
|
|
4369
|
+
const securityScanner = new SecurityScanner();
|
|
4370
|
+
const securityResult = await securityScanner.scan(rootDir);
|
|
4371
|
+
allFindings.push(...securityResult.findings);
|
|
4372
|
+
spinner.text = "Checking framework best practices...";
|
|
4373
|
+
const frameworkScanner = new FrameworkScanner();
|
|
4374
|
+
const frameworkResult = await frameworkScanner.scan(rootDir);
|
|
4375
|
+
allFindings.push(...frameworkResult.findings);
|
|
4376
|
+
const detectedLanguages = detectProjectLanguage(rootDir, fs17);
|
|
4377
|
+
if (detectedLanguages.includes("python")) {
|
|
4378
|
+
spinner.text = "Analyzing Python complexity...";
|
|
4379
|
+
const pyComplexityScanner = new PythonComplexityScanner();
|
|
4380
|
+
const pyComplexityResult = await pyComplexityScanner.scan(rootDir);
|
|
4381
|
+
allFindings.push(...pyComplexityResult.findings);
|
|
4382
|
+
spinner.text = "Checking Python type hints...";
|
|
4383
|
+
const pyTypeHintsScanner = new PythonTypeHintsScanner();
|
|
4384
|
+
const pyTypeHintsResult = await pyTypeHintsScanner.scan(rootDir);
|
|
4385
|
+
allFindings.push(...pyTypeHintsResult.findings);
|
|
4386
|
+
spinner.text = "Analyzing Python imports...";
|
|
4387
|
+
const pyImportsScanner = new PythonImportsScanner();
|
|
4388
|
+
const pyImportsResult = await pyImportsScanner.scan(rootDir);
|
|
4389
|
+
allFindings.push(...pyImportsResult.findings);
|
|
4390
|
+
}
|
|
4391
|
+
spinner.stop();
|
|
4392
|
+
const health = calculateHealth(allFindings);
|
|
4393
|
+
const roasts = await generateRoasts(allFindings, aiConfig, rootDir);
|
|
4394
|
+
const verdict = generateVerdict(health);
|
|
4395
|
+
const projectName = getProjectName(rootDir);
|
|
4396
|
+
const fixes = options.fix ? generateFixSuggestions(allFindings) : void 0;
|
|
4397
|
+
const report = {
|
|
4398
|
+
projectName,
|
|
4399
|
+
stats,
|
|
4400
|
+
health,
|
|
4401
|
+
findings: allFindings,
|
|
4402
|
+
roasts,
|
|
4403
|
+
verdict,
|
|
4404
|
+
fixes
|
|
4405
|
+
};
|
|
4406
|
+
if (options.track) {
|
|
4407
|
+
const snapshot = createSnapshot(health, allFindings, rootDir);
|
|
4408
|
+
const history = addSnapshot(rootDir, projectName, snapshot);
|
|
4409
|
+
console.log(chalk8.green("\n\u2713 Health snapshot saved to .roast-history.json"));
|
|
4410
|
+
const trendSummary = renderTrendSummary(history, 7);
|
|
4411
|
+
if (trendSummary) {
|
|
4412
|
+
console.log(chalk8.dim(" Last 7 days: ") + trendSummary);
|
|
4413
|
+
}
|
|
4414
|
+
console.log();
|
|
4415
|
+
}
|
|
4416
|
+
if (options.interactive) {
|
|
4417
|
+
console.log(renderReport(report, { ascii: options.ascii }));
|
|
4418
|
+
await runInteractiveMode(report, rootDir, options.dryRun || false);
|
|
4419
|
+
return;
|
|
4420
|
+
}
|
|
4421
|
+
if (options.json) {
|
|
4422
|
+
console.log(renderJsonReport(report));
|
|
4423
|
+
if (options.threshold !== void 0 && report.health.score < options.threshold) {
|
|
4424
|
+
process.exit(1);
|
|
4425
|
+
}
|
|
4426
|
+
} else if (options.markdown || options.markdownFile) {
|
|
4427
|
+
const markdownOutput = renderMarkdownReport(report);
|
|
4428
|
+
if (options.markdownFile) {
|
|
4429
|
+
try {
|
|
4430
|
+
const outputPath = validateOutputPath(rootDir, ".roast-report.md");
|
|
4431
|
+
fs17.writeFileSync(outputPath, markdownOutput, "utf-8");
|
|
4432
|
+
console.log(`
|
|
4433
|
+
\u2713 Markdown report saved to ${outputPath}
|
|
4434
|
+
`);
|
|
4435
|
+
} catch (error) {
|
|
4436
|
+
console.error(`Failed to save markdown report: ${sanitizeError(error)}`);
|
|
4437
|
+
process.exit(1);
|
|
4438
|
+
}
|
|
4439
|
+
} else {
|
|
4440
|
+
console.log(markdownOutput);
|
|
4441
|
+
}
|
|
4442
|
+
} else {
|
|
4443
|
+
console.log(renderReport(report, { ascii: options.ascii }));
|
|
4444
|
+
}
|
|
4445
|
+
if (options.badge) {
|
|
4446
|
+
const badgeSvg = generateBadgeSvg(health);
|
|
4447
|
+
saveBadge(badgeSvg, rootDir);
|
|
4448
|
+
}
|
|
4449
|
+
} catch (error) {
|
|
4450
|
+
spinner.stop();
|
|
4451
|
+
console.error("Analysis failed:", sanitizeError(error));
|
|
4452
|
+
if (process.env.DEBUG) {
|
|
4453
|
+
try {
|
|
4454
|
+
const debugPath = path15.join(rootDir, ".roast-debug.log");
|
|
4455
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
4456
|
+
const errorStr = error instanceof Error ? error.stack : String(error);
|
|
4457
|
+
fs17.appendFileSync(debugPath, `${timestamp}: ${errorStr}
|
|
4458
|
+
`);
|
|
4459
|
+
console.log(`Debug info saved to ${debugPath}`);
|
|
4460
|
+
} catch {
|
|
4461
|
+
}
|
|
4462
|
+
}
|
|
4463
|
+
process.exit(1);
|
|
4464
|
+
}
|
|
4465
|
+
});
|
|
4466
|
+
return program2;
|
|
4467
|
+
}
|
|
4468
|
+
function getProjectName(rootDir) {
|
|
4469
|
+
const pkgPath = path15.join(rootDir, "package.json");
|
|
4470
|
+
try {
|
|
4471
|
+
const pkg = JSON.parse(fs17.readFileSync(pkgPath, "utf-8"));
|
|
4472
|
+
return pkg.name || path15.basename(rootDir);
|
|
4473
|
+
} catch {
|
|
4474
|
+
return path15.basename(rootDir);
|
|
4475
|
+
}
|
|
4476
|
+
}
|
|
4477
|
+
|
|
4478
|
+
// src/index.ts
|
|
4479
|
+
var program = createCli();
|
|
4480
|
+
program.parse();
|