@nebulord/sickbay-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +136 -0
- package/dist/index.d.ts +184 -0
- package/dist/index.js +2992 -0
- package/package.json +51 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2992 @@
|
|
|
1
|
+
// src/runner.ts
|
|
2
|
+
import { relative } from "path";
|
|
3
|
+
|
|
4
|
+
// src/utils/detect-project.ts
|
|
5
|
+
import { readFileSync, existsSync } from "fs";
|
|
6
|
+
import { join, dirname } from "path";
|
|
7
|
+
async function detectProject(projectPath) {
|
|
8
|
+
const pkgPath = join(projectPath, "package.json");
|
|
9
|
+
if (!existsSync(pkgPath)) {
|
|
10
|
+
throw new Error(`No package.json found at ${projectPath}`);
|
|
11
|
+
}
|
|
12
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
13
|
+
const deps = pkg.dependencies ?? {};
|
|
14
|
+
const devDeps = pkg.devDependencies ?? {};
|
|
15
|
+
const catalog = loadPnpmCatalog(projectPath);
|
|
16
|
+
if (catalog) {
|
|
17
|
+
for (const [name, ver] of Object.entries(deps)) {
|
|
18
|
+
if (ver.startsWith("catalog:")) deps[name] = catalog[name] ?? ver;
|
|
19
|
+
}
|
|
20
|
+
for (const [name, ver] of Object.entries(devDeps)) {
|
|
21
|
+
if (ver.startsWith("catalog:")) devDeps[name] = catalog[name] ?? ver;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const allDeps = { ...deps, ...devDeps };
|
|
25
|
+
const rawOverrides = pkg.pnpm?.overrides ?? pkg.overrides ?? pkg.resolutions ?? void 0;
|
|
26
|
+
const overrides = rawOverrides && Object.keys(rawOverrides).length > 0 ? rawOverrides : void 0;
|
|
27
|
+
return {
|
|
28
|
+
name: pkg.name ?? "unknown",
|
|
29
|
+
version: pkg.version ?? "0.0.0",
|
|
30
|
+
hasTypeScript: existsSync(join(projectPath, "tsconfig.json")) || "typescript" in allDeps,
|
|
31
|
+
hasESLint: existsSync(join(projectPath, ".eslintrc.js")) || existsSync(join(projectPath, ".eslintrc.json")) || existsSync(join(projectPath, "eslint.config.js")) || "eslint" in allDeps,
|
|
32
|
+
hasPrettier: existsSync(join(projectPath, ".prettierrc")) || existsSync(join(projectPath, ".prettierrc.json")) || "prettier" in allDeps,
|
|
33
|
+
framework: detectFramework(allDeps),
|
|
34
|
+
packageManager: detectPackageManager(projectPath),
|
|
35
|
+
totalDependencies: Object.keys(allDeps).length,
|
|
36
|
+
dependencies: deps,
|
|
37
|
+
devDependencies: devDeps,
|
|
38
|
+
overrides
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function detectFramework(deps) {
|
|
42
|
+
if ("next" in deps) return "next";
|
|
43
|
+
if ("@vitejs/plugin-react" in deps || "vite" in deps) return "vite";
|
|
44
|
+
if ("react-scripts" in deps) return "cra";
|
|
45
|
+
if ("react" in deps) return "react";
|
|
46
|
+
if ("express" in deps) return "express";
|
|
47
|
+
if ("fastify" in deps) return "fastify";
|
|
48
|
+
if ("koa" in deps) return "koa";
|
|
49
|
+
if ("hono" in deps) return "hono";
|
|
50
|
+
if ("@hapi/hapi" in deps || "hapi" in deps) return "hapi";
|
|
51
|
+
return "node";
|
|
52
|
+
}
|
|
53
|
+
function detectPackageManager(projectPath) {
|
|
54
|
+
let dir = projectPath;
|
|
55
|
+
while (true) {
|
|
56
|
+
if (existsSync(join(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
57
|
+
if (existsSync(join(dir, "yarn.lock"))) return "yarn";
|
|
58
|
+
if (existsSync(join(dir, "bun.lockb")) || existsSync(join(dir, "bun.lock"))) return "bun";
|
|
59
|
+
const parent = dirname(dir);
|
|
60
|
+
if (parent === dir) break;
|
|
61
|
+
dir = parent;
|
|
62
|
+
}
|
|
63
|
+
return "npm";
|
|
64
|
+
}
|
|
65
|
+
async function detectContext(projectPath) {
|
|
66
|
+
const pkgPath = join(projectPath, "package.json");
|
|
67
|
+
if (!existsSync(pkgPath)) {
|
|
68
|
+
return { runtime: "unknown", frameworks: [], buildTool: "unknown", testFramework: null };
|
|
69
|
+
}
|
|
70
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
71
|
+
const deps = pkg.dependencies ?? {};
|
|
72
|
+
const devDeps = pkg.devDependencies ?? {};
|
|
73
|
+
const allDeps = { ...deps, ...devDeps };
|
|
74
|
+
const frameworks = [];
|
|
75
|
+
if ("@angular/core" in allDeps) frameworks.push("angular");
|
|
76
|
+
if ("next" in allDeps) {
|
|
77
|
+
frameworks.push("react", "next");
|
|
78
|
+
} else if ("@remix-run/react" in allDeps) {
|
|
79
|
+
frameworks.push("react", "remix");
|
|
80
|
+
} else if ("react" in allDeps) {
|
|
81
|
+
frameworks.push("react");
|
|
82
|
+
}
|
|
83
|
+
if ("vue" in allDeps) frameworks.push("vue");
|
|
84
|
+
if ("svelte" in allDeps) frameworks.push("svelte");
|
|
85
|
+
const runtime = frameworks.length === 0 ? "node" : "browser";
|
|
86
|
+
let buildTool = "unknown";
|
|
87
|
+
if ("vite" in allDeps || "@vitejs/plugin-react" in allDeps) buildTool = "vite";
|
|
88
|
+
else if ("webpack" in allDeps) buildTool = "webpack";
|
|
89
|
+
else if ("esbuild" in allDeps) buildTool = "esbuild";
|
|
90
|
+
else if ("rollup" in allDeps) buildTool = "rollup";
|
|
91
|
+
else if ("next" in allDeps) buildTool = "webpack";
|
|
92
|
+
else if ("typescript" in allDeps) buildTool = "tsc";
|
|
93
|
+
let testFramework = null;
|
|
94
|
+
if ("vitest" in allDeps) testFramework = "vitest";
|
|
95
|
+
else if ("jest" in allDeps) testFramework = "jest";
|
|
96
|
+
else if ("mocha" in allDeps) testFramework = "mocha";
|
|
97
|
+
return { runtime, frameworks, buildTool, testFramework };
|
|
98
|
+
}
|
|
99
|
+
function loadPnpmCatalog(projectPath) {
|
|
100
|
+
let dir = projectPath;
|
|
101
|
+
while (true) {
|
|
102
|
+
const wsPath = join(dir, "pnpm-workspace.yaml");
|
|
103
|
+
if (existsSync(wsPath)) {
|
|
104
|
+
try {
|
|
105
|
+
const content = readFileSync(wsPath, "utf-8");
|
|
106
|
+
return parseCatalogFromYaml(content);
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const parent = dirname(dir);
|
|
112
|
+
if (parent === dir) break;
|
|
113
|
+
dir = parent;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
function parseCatalogFromYaml(content) {
|
|
118
|
+
const lines = content.split("\n");
|
|
119
|
+
const catalog = {};
|
|
120
|
+
let inCatalog = false;
|
|
121
|
+
for (const line of lines) {
|
|
122
|
+
if (/^catalog:\s*$/.test(line)) {
|
|
123
|
+
inCatalog = true;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (inCatalog && /^\S/.test(line)) {
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
if (inCatalog) {
|
|
130
|
+
const match = line.match(/^\s+['"]?([^'":\s][^'":]*?)['"]?\s*:\s*(.+)$/);
|
|
131
|
+
if (match) {
|
|
132
|
+
catalog[match[1].trim()] = match[2].trim();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return Object.keys(catalog).length > 0 ? catalog : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/utils/detect-monorepo.ts
|
|
140
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
141
|
+
import { join as join2, resolve } from "path";
|
|
142
|
+
import { globby } from "globby";
|
|
143
|
+
function parseYamlPackagesArray(content) {
|
|
144
|
+
const results = [];
|
|
145
|
+
const lines = content.split("\n");
|
|
146
|
+
let inPackages = false;
|
|
147
|
+
for (const line of lines) {
|
|
148
|
+
if (/^packages\s*:/.test(line)) {
|
|
149
|
+
inPackages = true;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (inPackages) {
|
|
153
|
+
const match = /^\s+-\s+['"]?([^'"#\n]+?)['"]?\s*$/.exec(line);
|
|
154
|
+
if (match) {
|
|
155
|
+
results.push(match[1].trim());
|
|
156
|
+
} else if (/^\S/.test(line)) {
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return results;
|
|
162
|
+
}
|
|
163
|
+
async function discoverPackages(rootPath, patterns) {
|
|
164
|
+
if (patterns.length === 0) return [];
|
|
165
|
+
const matched = await globby(patterns, {
|
|
166
|
+
cwd: rootPath,
|
|
167
|
+
onlyDirectories: true,
|
|
168
|
+
expandDirectories: false,
|
|
169
|
+
absolute: true,
|
|
170
|
+
ignore: ["**/node_modules/**"]
|
|
171
|
+
});
|
|
172
|
+
return matched.filter((dir) => {
|
|
173
|
+
return dir !== resolve(rootPath) && existsSync2(join2(dir, "package.json"));
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function detectSignals(rootPath) {
|
|
177
|
+
const pnpmWs = join2(rootPath, "pnpm-workspace.yaml");
|
|
178
|
+
if (existsSync2(pnpmWs)) {
|
|
179
|
+
try {
|
|
180
|
+
const content = readFileSync2(pnpmWs, "utf-8");
|
|
181
|
+
const patterns = parseYamlPackagesArray(content);
|
|
182
|
+
return { type: "pnpm", patterns: patterns.length > 0 ? patterns : ["packages/*", "apps/*"] };
|
|
183
|
+
} catch {
|
|
184
|
+
return { type: "pnpm", patterns: ["packages/*", "apps/*"] };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const lernaJson = join2(rootPath, "lerna.json");
|
|
188
|
+
if (existsSync2(lernaJson)) {
|
|
189
|
+
try {
|
|
190
|
+
const lerna = JSON.parse(readFileSync2(lernaJson, "utf-8"));
|
|
191
|
+
const patterns = Array.isArray(lerna.packages) ? lerna.packages : ["packages/*"];
|
|
192
|
+
return { type: "lerna", patterns };
|
|
193
|
+
} catch {
|
|
194
|
+
return { type: "lerna", patterns: ["packages/*"] };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const pkgJson = join2(rootPath, "package.json");
|
|
198
|
+
if (existsSync2(pkgJson)) {
|
|
199
|
+
try {
|
|
200
|
+
const pkg = JSON.parse(readFileSync2(pkgJson, "utf-8"));
|
|
201
|
+
const workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces : Array.isArray(pkg.workspaces?.packages) ? pkg.workspaces.packages : void 0;
|
|
202
|
+
if (workspaces && workspaces.length > 0) {
|
|
203
|
+
if (existsSync2(join2(rootPath, "turbo.json"))) {
|
|
204
|
+
return { type: "turbo", patterns: workspaces };
|
|
205
|
+
}
|
|
206
|
+
if (existsSync2(join2(rootPath, "nx.json"))) {
|
|
207
|
+
return { type: "nx", patterns: workspaces };
|
|
208
|
+
}
|
|
209
|
+
const hasYarn = existsSync2(join2(rootPath, "yarn.lock"));
|
|
210
|
+
return { type: hasYarn ? "yarn" : "npm", patterns: workspaces };
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (existsSync2(join2(rootPath, "turbo.json"))) {
|
|
216
|
+
return { type: "turbo", patterns: ["packages/*", "apps/*"] };
|
|
217
|
+
}
|
|
218
|
+
if (existsSync2(join2(rootPath, "nx.json"))) {
|
|
219
|
+
return { type: "nx", patterns: ["packages/*", "apps/*", "libs/*"] };
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
async function detectMonorepo(rootPath) {
|
|
224
|
+
const signals = detectSignals(rootPath);
|
|
225
|
+
if (!signals) return { isMonorepo: false };
|
|
226
|
+
const packagePaths = await discoverPackages(rootPath, signals.patterns);
|
|
227
|
+
if (packagePaths.length === 0) return { isMonorepo: false };
|
|
228
|
+
const packageManager = detectPackageManager(rootPath);
|
|
229
|
+
return {
|
|
230
|
+
isMonorepo: true,
|
|
231
|
+
type: signals.type,
|
|
232
|
+
packageManager,
|
|
233
|
+
packagePaths
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// src/constants.ts
|
|
238
|
+
var WARN_LINES = 400;
|
|
239
|
+
var CRITICAL_LINES = 600;
|
|
240
|
+
var SCORE_EXCELLENT = 90;
|
|
241
|
+
var SCORE_GOOD = 80;
|
|
242
|
+
var SCORE_FAIR = 60;
|
|
243
|
+
|
|
244
|
+
// src/scoring.ts
|
|
245
|
+
var CATEGORY_WEIGHTS = {
|
|
246
|
+
dependencies: 0.25,
|
|
247
|
+
security: 0.3,
|
|
248
|
+
"code-quality": 0.25,
|
|
249
|
+
performance: 0.15,
|
|
250
|
+
git: 0.05
|
|
251
|
+
};
|
|
252
|
+
function calculateOverallScore(checks) {
|
|
253
|
+
const active = checks.filter((c) => c.status !== "skipped");
|
|
254
|
+
if (active.length === 0) return 0;
|
|
255
|
+
let totalWeight = 0;
|
|
256
|
+
let weightedScore = 0;
|
|
257
|
+
for (const check of active) {
|
|
258
|
+
const weight = CATEGORY_WEIGHTS[check.category] ?? 0.1;
|
|
259
|
+
weightedScore += check.score * weight;
|
|
260
|
+
totalWeight += weight;
|
|
261
|
+
}
|
|
262
|
+
return totalWeight > 0 ? Math.round(weightedScore / totalWeight) : 0;
|
|
263
|
+
}
|
|
264
|
+
function buildSummary(checks) {
|
|
265
|
+
const summary = { critical: 0, warnings: 0, info: 0 };
|
|
266
|
+
for (const check of checks) {
|
|
267
|
+
for (const issue of check.issues) {
|
|
268
|
+
if (issue.severity === "critical") summary.critical++;
|
|
269
|
+
else if (issue.severity === "warning") summary.warnings++;
|
|
270
|
+
else summary.info++;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return summary;
|
|
274
|
+
}
|
|
275
|
+
function getScoreColor(score) {
|
|
276
|
+
if (score >= SCORE_GOOD) return "green";
|
|
277
|
+
if (score >= SCORE_FAIR) return "yellow";
|
|
278
|
+
return "red";
|
|
279
|
+
}
|
|
280
|
+
function getScoreEmoji(score) {
|
|
281
|
+
if (score >= SCORE_EXCELLENT) return "Good";
|
|
282
|
+
if (score >= SCORE_GOOD) return "Fair";
|
|
283
|
+
if (score >= SCORE_FAIR) return "Poor";
|
|
284
|
+
return "Bad";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// src/quotes/startrek.json
|
|
288
|
+
var startrek_default = {
|
|
289
|
+
critical: [
|
|
290
|
+
{ text: "He's dead, Jim.", source: "Dr. McCoy" },
|
|
291
|
+
{ text: "I'm a doctor, not a miracle worker!", source: "Dr. McCoy" },
|
|
292
|
+
{ text: "It's worse than that. He's really dead, Jim.", source: "Dr. McCoy" },
|
|
293
|
+
{ text: "Please state the nature of the medical emergency.", source: "The Doctor" },
|
|
294
|
+
{ text: "I swear, this project is held together with spit and baling wire.", source: "Dr. McCoy" },
|
|
295
|
+
{ text: "If we don't operate immediately, the patient will die.", source: "Dr. Crusher" },
|
|
296
|
+
{ text: "I'm going to have to ask you to get the hell out of my sickbay.", source: "T'Ana" },
|
|
297
|
+
{ text: "What you're describing is medically impossible, yet here we are.", source: "Dr. Bashir" },
|
|
298
|
+
{ text: "The patient is coding! Clear the area!", source: "Dr. Crusher" },
|
|
299
|
+
{ text: "This isn't a patient, it's a catastrophe with a pulse.", source: "T'Ana" }
|
|
300
|
+
],
|
|
301
|
+
warning: [
|
|
302
|
+
{ text: "I'm a doctor, not an engineer!", source: "Dr. McCoy" },
|
|
303
|
+
{ text: "You're not seriously going to deploy this, are you?", source: "The Doctor" },
|
|
304
|
+
{ text: "I don't have a magic wand. I need time and proper resources.", source: "Dr. Crusher" },
|
|
305
|
+
{ text: "I would advise caution, Captain. The readings are... concerning.", source: "Dr. Bashir" },
|
|
306
|
+
{ text: "In my medical opinion, this needs bed rest. A lot of it.", source: "Dr. McCoy" },
|
|
307
|
+
{ text: "The patient will survive, but I wouldn't call this thriving.", source: "The Doctor" },
|
|
308
|
+
{ text: "Optimism is wonderful, but have you looked at these charts?", source: "Dr. Phlox" },
|
|
309
|
+
{ text: "I've seen healthier-looking specimens in the morgue.", source: "T'Ana" },
|
|
310
|
+
{ text: "The prognosis is guarded. I'd recommend close monitoring.", source: "Dr. Crusher" },
|
|
311
|
+
{ text: "I must say, I've treated Klingon targs in better shape than this.", source: "Dr. Bashir" }
|
|
312
|
+
],
|
|
313
|
+
info: [
|
|
314
|
+
{ text: "I'm a doctor, not a DevOps engineer.", source: "Dr. McCoy" },
|
|
315
|
+
{ text: "The prognosis is... acceptable.", source: "The Doctor" },
|
|
316
|
+
{ text: "A little suffering is good for the soul.", source: "Dr. McCoy" },
|
|
317
|
+
{ text: "I've noticed some minor irregularities, but nothing life-threatening.", source: "Dr. Crusher" },
|
|
318
|
+
{ text: "Fascinating. From a medical perspective, of course.", source: "Dr. Bashir" },
|
|
319
|
+
{ text: "I find the resilience of this organism quite remarkable!", source: "Dr. Phlox" },
|
|
320
|
+
{ text: "My diagnosis: mildly irritating, but you'll live.", source: "T'Ana" },
|
|
321
|
+
{ text: "I've noted some areas for improvement in my medical log.", source: "The Doctor" },
|
|
322
|
+
{ text: "You're in fair health, but don't let that go to your head.", source: "Dr. McCoy" },
|
|
323
|
+
{ text: "The patient is stable, though I'd like to run a few more scans.", source: "Dr. Phlox" }
|
|
324
|
+
],
|
|
325
|
+
allClear: [
|
|
326
|
+
{ text: "Vital signs are stable. For now.", source: "Dr. McCoy" },
|
|
327
|
+
{ text: "Optimism, Captain! It's what keeps me going.", source: "Dr. Phlox" },
|
|
328
|
+
{ text: "I must say, I'm impressed. Don't let it happen again.", source: "The Doctor" },
|
|
329
|
+
{ text: "Well, would you look at that. A clean bill of health.", source: "Dr. Crusher" },
|
|
330
|
+
{ text: "I'm not sure what you did, but keep doing it.", source: "T'Ana" },
|
|
331
|
+
{ text: "For once, I have nothing to complain about. Medically speaking.", source: "Dr. McCoy" },
|
|
332
|
+
{ text: "The patient is in remarkable condition. Textbook, even.", source: "Dr. Bashir" },
|
|
333
|
+
{ text: "This calls for a celebration! Perhaps a nice Denobulan sausage.", source: "Dr. Phlox" },
|
|
334
|
+
{ text: "All systems nominal. I almost don't know what to do with myself.", source: "The Doctor" },
|
|
335
|
+
{ text: "Full marks. Now get out of my sickbay before something breaks.", source: "T'Ana" }
|
|
336
|
+
]
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// src/quotes/index.ts
|
|
340
|
+
function getQuote(overallScore) {
|
|
341
|
+
const severity = scoreToTier(overallScore);
|
|
342
|
+
const pool = startrek_default[severity];
|
|
343
|
+
const entry = pool[Math.floor(Math.random() * pool.length)];
|
|
344
|
+
return {
|
|
345
|
+
text: entry.text,
|
|
346
|
+
source: entry.source,
|
|
347
|
+
severity
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
function scoreToTier(score) {
|
|
351
|
+
if (score < 60) return "critical";
|
|
352
|
+
if (score < 80) return "warning";
|
|
353
|
+
if (score < 90) return "info";
|
|
354
|
+
return "allClear";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/integrations/knip.ts
|
|
358
|
+
import { execa as execa2 } from "execa";
|
|
359
|
+
import { readFileSync as readFileSync4, existsSync as existsSync4 } from "fs";
|
|
360
|
+
import { join as join4 } from "path";
|
|
361
|
+
|
|
362
|
+
// src/utils/file-helpers.ts
|
|
363
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
364
|
+
import { join as join3, dirname as dirname2 } from "path";
|
|
365
|
+
import { fileURLToPath } from "url";
|
|
366
|
+
import { execa } from "execa";
|
|
367
|
+
var coreLocalDir = dirname2(dirname2(fileURLToPath(import.meta.url)));
|
|
368
|
+
function readPackageJson(projectPath) {
|
|
369
|
+
const pkgPath = join3(projectPath, "package.json");
|
|
370
|
+
return JSON.parse(readFileSync3(pkgPath, "utf-8"));
|
|
371
|
+
}
|
|
372
|
+
async function isCommandAvailable(cmd) {
|
|
373
|
+
if (existsSync3(join3(coreLocalDir, "node_modules", ".bin", cmd))) return true;
|
|
374
|
+
try {
|
|
375
|
+
await execa("which", [cmd]);
|
|
376
|
+
return true;
|
|
377
|
+
} catch {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function fileExists(projectPath, ...parts) {
|
|
382
|
+
return existsSync3(join3(projectPath, ...parts));
|
|
383
|
+
}
|
|
384
|
+
function timer() {
|
|
385
|
+
const start = Date.now();
|
|
386
|
+
return () => Date.now() - start;
|
|
387
|
+
}
|
|
388
|
+
function parseJsonOutput(stdout, fallback = "{}") {
|
|
389
|
+
if (!stdout || !stdout.trim()) {
|
|
390
|
+
return JSON.parse(fallback);
|
|
391
|
+
}
|
|
392
|
+
const cleaned = stdout.replace(/\u001b\[[0-9;]*m/g, "");
|
|
393
|
+
try {
|
|
394
|
+
return JSON.parse(cleaned);
|
|
395
|
+
} catch {
|
|
396
|
+
}
|
|
397
|
+
const lines = cleaned.split("\n");
|
|
398
|
+
const jsonLines = [];
|
|
399
|
+
let foundStart = false;
|
|
400
|
+
for (const line of lines) {
|
|
401
|
+
const trimmed = line.trim();
|
|
402
|
+
if (!foundStart && (trimmed.startsWith("{") || trimmed.startsWith("["))) {
|
|
403
|
+
foundStart = true;
|
|
404
|
+
}
|
|
405
|
+
if (foundStart) {
|
|
406
|
+
jsonLines.push(line);
|
|
407
|
+
const candidate = jsonLines.join("\n");
|
|
408
|
+
try {
|
|
409
|
+
return JSON.parse(candidate);
|
|
410
|
+
} catch {
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (jsonLines.length > 0) {
|
|
415
|
+
try {
|
|
416
|
+
return JSON.parse(jsonLines.join("\n"));
|
|
417
|
+
} catch {
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return JSON.parse(fallback);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// src/integrations/base.ts
|
|
424
|
+
var BaseRunner = class {
|
|
425
|
+
applicableFrameworks;
|
|
426
|
+
applicableRuntimes;
|
|
427
|
+
isApplicableToContext(context) {
|
|
428
|
+
if (this.applicableFrameworks) {
|
|
429
|
+
const hasMatch = this.applicableFrameworks.some((f) => context.frameworks.includes(f));
|
|
430
|
+
if (!hasMatch) return false;
|
|
431
|
+
}
|
|
432
|
+
if (this.applicableRuntimes) {
|
|
433
|
+
if (!this.applicableRuntimes.includes(context.runtime)) return false;
|
|
434
|
+
}
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
async isApplicable(_projectPath, _context) {
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
elapsed = timer;
|
|
441
|
+
skipped(reason) {
|
|
442
|
+
return {
|
|
443
|
+
id: this.name,
|
|
444
|
+
category: this.category,
|
|
445
|
+
name: this.name,
|
|
446
|
+
score: 100,
|
|
447
|
+
status: "skipped",
|
|
448
|
+
issues: [],
|
|
449
|
+
toolsUsed: [this.name],
|
|
450
|
+
duration: 0,
|
|
451
|
+
metadata: { reason }
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// src/integrations/knip.ts
|
|
457
|
+
var TEST_ONLY_PACKAGE_PREFIXES = ["@testing-library/"];
|
|
458
|
+
var TEST_RUNNER_PACKAGES = ["vitest", "jest", "@jest/core", "@jest/globals"];
|
|
459
|
+
var KnipRunner = class extends BaseRunner {
|
|
460
|
+
name = "knip";
|
|
461
|
+
category = "dependencies";
|
|
462
|
+
async run(projectPath) {
|
|
463
|
+
const elapsed = timer();
|
|
464
|
+
const available = await isCommandAvailable("knip");
|
|
465
|
+
if (!available) {
|
|
466
|
+
return this.skipped("knip not installed \u2014 run: npm i -g knip");
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
const { stdout } = await execa2("knip", ["--reporter", "json", "--no-progress"], {
|
|
470
|
+
cwd: projectPath,
|
|
471
|
+
reject: false,
|
|
472
|
+
preferLocal: true,
|
|
473
|
+
localDir: coreLocalDir
|
|
474
|
+
});
|
|
475
|
+
const data = parseJsonOutput(stdout, "{}");
|
|
476
|
+
const issues = [];
|
|
477
|
+
let hasTestRunner = false;
|
|
478
|
+
let workspaceScope = null;
|
|
479
|
+
const pkgPath = join4(projectPath, "package.json");
|
|
480
|
+
if (existsSync4(pkgPath)) {
|
|
481
|
+
const pkg = JSON.parse(readFileSync4(pkgPath, "utf-8"));
|
|
482
|
+
const allDeps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
483
|
+
hasTestRunner = TEST_RUNNER_PACKAGES.some((p) => p in allDeps);
|
|
484
|
+
const scopeMatch = pkg.name?.match(/^(@[^/]+)\//);
|
|
485
|
+
if (scopeMatch) workspaceScope = scopeMatch[1];
|
|
486
|
+
}
|
|
487
|
+
const deps = /* @__PURE__ */ new Set();
|
|
488
|
+
const devDeps = /* @__PURE__ */ new Set();
|
|
489
|
+
const unusedExports = [];
|
|
490
|
+
const unusedFiles = [];
|
|
491
|
+
for (const fileIssue of data.issues ?? []) {
|
|
492
|
+
(fileIssue.files ?? []).forEach((f) => {
|
|
493
|
+
const filePath = f.name || fileIssue.file;
|
|
494
|
+
unusedFiles.push(filePath);
|
|
495
|
+
issues.push({
|
|
496
|
+
severity: "warning",
|
|
497
|
+
message: `Unused file: ${filePath}`,
|
|
498
|
+
file: filePath,
|
|
499
|
+
fix: { description: `Remove ${filePath}` },
|
|
500
|
+
reportedBy: ["knip"]
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
(fileIssue.dependencies ?? []).forEach((d) => {
|
|
504
|
+
if (workspaceScope && d.name.startsWith(`${workspaceScope}/`)) return;
|
|
505
|
+
deps.add(d.name);
|
|
506
|
+
});
|
|
507
|
+
(fileIssue.devDependencies ?? []).forEach((d) => {
|
|
508
|
+
if (workspaceScope && d.name.startsWith(`${workspaceScope}/`)) return;
|
|
509
|
+
const isTestOnly = TEST_ONLY_PACKAGE_PREFIXES.some(
|
|
510
|
+
(prefix) => d.name.startsWith(prefix)
|
|
511
|
+
);
|
|
512
|
+
if (!isTestOnly || !hasTestRunner) devDeps.add(d.name);
|
|
513
|
+
});
|
|
514
|
+
(fileIssue.exports ?? []).forEach(
|
|
515
|
+
(e) => unusedExports.push(`${fileIssue.file}: ${e.name}`)
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
deps.forEach(
|
|
519
|
+
(dep) => issues.push({
|
|
520
|
+
severity: "warning",
|
|
521
|
+
message: `Unused dependency: ${dep}`,
|
|
522
|
+
fix: { description: `Remove ${dep}` },
|
|
523
|
+
reportedBy: ["knip"]
|
|
524
|
+
})
|
|
525
|
+
);
|
|
526
|
+
devDeps.forEach(
|
|
527
|
+
(dep) => issues.push({
|
|
528
|
+
severity: "info",
|
|
529
|
+
message: `Unused devDependency: ${dep}`,
|
|
530
|
+
fix: { description: `Remove ${dep}` },
|
|
531
|
+
reportedBy: ["knip"]
|
|
532
|
+
})
|
|
533
|
+
);
|
|
534
|
+
unusedExports.slice(0, 5).forEach(
|
|
535
|
+
(exp) => issues.push({
|
|
536
|
+
severity: "info",
|
|
537
|
+
message: `Unused export: ${exp}`,
|
|
538
|
+
reportedBy: ["knip"]
|
|
539
|
+
})
|
|
540
|
+
);
|
|
541
|
+
if (unusedExports.length > 5) {
|
|
542
|
+
issues.push({
|
|
543
|
+
severity: "info",
|
|
544
|
+
message: `...and ${unusedExports.length - 5} more unused exports`,
|
|
545
|
+
reportedBy: ["knip"]
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
const totalIssues = issues.length;
|
|
549
|
+
const score = Math.max(0, 100 - totalIssues * 5);
|
|
550
|
+
return {
|
|
551
|
+
id: "knip",
|
|
552
|
+
category: this.category,
|
|
553
|
+
name: "Unused Code",
|
|
554
|
+
score,
|
|
555
|
+
status: totalIssues === 0 ? "pass" : totalIssues > 10 ? "fail" : "warning",
|
|
556
|
+
issues,
|
|
557
|
+
toolsUsed: ["knip"],
|
|
558
|
+
duration: elapsed(),
|
|
559
|
+
metadata: {
|
|
560
|
+
unusedFiles: unusedFiles.length,
|
|
561
|
+
unusedDeps: deps.size,
|
|
562
|
+
unusedDevDeps: devDeps.size,
|
|
563
|
+
unusedExports: unusedExports.length
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
} catch (err) {
|
|
567
|
+
return {
|
|
568
|
+
id: "knip",
|
|
569
|
+
category: this.category,
|
|
570
|
+
name: "Unused Code",
|
|
571
|
+
score: 0,
|
|
572
|
+
status: "fail",
|
|
573
|
+
issues: [{ severity: "critical", message: `knip failed: ${err}`, reportedBy: ["knip"] }],
|
|
574
|
+
toolsUsed: ["knip"],
|
|
575
|
+
duration: elapsed()
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
// src/integrations/outdated.ts
|
|
582
|
+
import { execa as execa3 } from "execa";
|
|
583
|
+
var OutdatedRunner = class extends BaseRunner {
|
|
584
|
+
name = "outdated";
|
|
585
|
+
category = "dependencies";
|
|
586
|
+
async run(projectPath) {
|
|
587
|
+
const elapsed = timer();
|
|
588
|
+
const pm = detectPackageManager(projectPath);
|
|
589
|
+
if (pm === "yarn" || pm === "bun") {
|
|
590
|
+
return this.skipped(`${pm} outdated not supported \u2014 run: ${pm} outdated`);
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
const { stdout } = await execa3(pm, ["outdated", "--json"], {
|
|
594
|
+
cwd: projectPath,
|
|
595
|
+
reject: false,
|
|
596
|
+
timeout: 6e4
|
|
597
|
+
});
|
|
598
|
+
const entries = parseOutdated(stdout);
|
|
599
|
+
const issues = entries.map((e) => {
|
|
600
|
+
const updateType = getUpdateType(e.current, e.latest);
|
|
601
|
+
const isMajor = updateType === "major";
|
|
602
|
+
return {
|
|
603
|
+
severity: isMajor ? "warning" : "info",
|
|
604
|
+
message: `${e.name}: ${e.current} \u2192 ${e.latest} (${updateType})`,
|
|
605
|
+
fix: isMajor ? {
|
|
606
|
+
description: `Update ${e.name} to ${e.latest} (major \u2014 review changelog before upgrading)`
|
|
607
|
+
} : {
|
|
608
|
+
description: `Update ${e.name} to ${e.latest}`,
|
|
609
|
+
command: `${pm} update ${e.name}`,
|
|
610
|
+
nextSteps: "Run tests to verify nothing broke"
|
|
611
|
+
},
|
|
612
|
+
reportedBy: ["outdated"]
|
|
613
|
+
};
|
|
614
|
+
});
|
|
615
|
+
const count = issues.length;
|
|
616
|
+
return {
|
|
617
|
+
id: "outdated",
|
|
618
|
+
category: this.category,
|
|
619
|
+
name: "Outdated Packages",
|
|
620
|
+
score: Math.max(0, 100 - count * 3),
|
|
621
|
+
status: count === 0 ? "pass" : count > 15 ? "fail" : "warning",
|
|
622
|
+
issues,
|
|
623
|
+
toolsUsed: [pm],
|
|
624
|
+
duration: elapsed(),
|
|
625
|
+
metadata: { outdatedCount: count }
|
|
626
|
+
};
|
|
627
|
+
} catch (err) {
|
|
628
|
+
return {
|
|
629
|
+
id: "outdated",
|
|
630
|
+
category: this.category,
|
|
631
|
+
name: "Outdated Packages",
|
|
632
|
+
score: 0,
|
|
633
|
+
status: "fail",
|
|
634
|
+
issues: [{ severity: "critical", message: `${pm} outdated failed: ${err}`, reportedBy: ["outdated"] }],
|
|
635
|
+
toolsUsed: [pm],
|
|
636
|
+
duration: elapsed()
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
function parseOutdated(stdout) {
|
|
642
|
+
if (!stdout.trim()) return [];
|
|
643
|
+
try {
|
|
644
|
+
const raw = JSON.parse(stdout);
|
|
645
|
+
return Object.entries(raw).filter(([_, info]) => info.current && info.latest).map(([name, info]) => ({
|
|
646
|
+
name,
|
|
647
|
+
current: info.current,
|
|
648
|
+
latest: info.latest,
|
|
649
|
+
dev: (info.type ?? info.dependencyType ?? "") === "devDependencies"
|
|
650
|
+
}));
|
|
651
|
+
} catch {
|
|
652
|
+
return [];
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
function getVersionParts(version) {
|
|
656
|
+
if (!version) return [0, 0, 0];
|
|
657
|
+
const cleaned = version.replace(/^[^0-9]*/, "");
|
|
658
|
+
const parts = cleaned.split(".");
|
|
659
|
+
return [
|
|
660
|
+
parseInt(parts[0] ?? "0", 10),
|
|
661
|
+
parseInt(parts[1] ?? "0", 10),
|
|
662
|
+
parseInt(parts[2] ?? "0", 10)
|
|
663
|
+
];
|
|
664
|
+
}
|
|
665
|
+
function getUpdateType(current, latest) {
|
|
666
|
+
const [curMaj, curMin] = getVersionParts(current);
|
|
667
|
+
const [latMaj, latMin] = getVersionParts(latest);
|
|
668
|
+
if (curMaj < latMaj) return "major";
|
|
669
|
+
if (curMin < latMin) return "minor";
|
|
670
|
+
return "patch";
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// src/integrations/npm-audit.ts
|
|
674
|
+
import { execa as execa4 } from "execa";
|
|
675
|
+
var NpmAuditRunner = class extends BaseRunner {
|
|
676
|
+
name = "npm-audit";
|
|
677
|
+
category = "security";
|
|
678
|
+
async run(projectPath) {
|
|
679
|
+
const elapsed = timer();
|
|
680
|
+
try {
|
|
681
|
+
const { stdout } = await execa4("npm", ["audit", "--json"], {
|
|
682
|
+
cwd: projectPath,
|
|
683
|
+
reject: false
|
|
684
|
+
});
|
|
685
|
+
const data = parseJsonOutput(stdout, "{}");
|
|
686
|
+
const issues = [];
|
|
687
|
+
const meta = data.metadata?.vulnerabilities;
|
|
688
|
+
const vulnerablePackages = {};
|
|
689
|
+
for (const [pkgName, vuln] of Object.entries(data.vulnerabilities ?? {})) {
|
|
690
|
+
const advisoryCount = Array.isArray(vuln.via) ? vuln.via.filter((v) => typeof v === "object" && v !== null && "title" in v).length : 0;
|
|
691
|
+
vulnerablePackages[pkgName] = Math.max(advisoryCount, 1);
|
|
692
|
+
}
|
|
693
|
+
for (const [, vuln] of Object.entries(data.vulnerabilities ?? {})) {
|
|
694
|
+
const via = Array.isArray(vuln.via) ? vuln.via[0] : null;
|
|
695
|
+
const title = typeof via === "object" && via?.title ? via.title : `Vulnerability in ${vuln.name}`;
|
|
696
|
+
const url = typeof via === "object" && via?.url ? via.url : void 0;
|
|
697
|
+
issues.push({
|
|
698
|
+
severity: vuln.severity === "critical" || vuln.severity === "high" ? "critical" : "warning",
|
|
699
|
+
message: title,
|
|
700
|
+
fix: typeof vuln.fixAvailable === "object" ? { description: `Upgrade to ${vuln.fixAvailable.name}@${vuln.fixAvailable.version}` } : { description: "No automatic fix available" },
|
|
701
|
+
reportedBy: ["npm-audit"],
|
|
702
|
+
...url && { file: url }
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
const critical = (meta?.critical ?? 0) + (meta?.high ?? 0);
|
|
706
|
+
const score = critical > 0 ? Math.max(0, 60 - critical * 15) : Math.max(0, 100 - (meta?.moderate ?? 0) * 10 - (meta?.low ?? 0) * 2);
|
|
707
|
+
return {
|
|
708
|
+
id: "npm-audit",
|
|
709
|
+
category: this.category,
|
|
710
|
+
name: "Security Vulnerabilities",
|
|
711
|
+
score,
|
|
712
|
+
status: critical > 0 ? "fail" : issues.length > 0 ? "warning" : "pass",
|
|
713
|
+
issues,
|
|
714
|
+
toolsUsed: ["npm-audit"],
|
|
715
|
+
duration: elapsed(),
|
|
716
|
+
metadata: { ...meta, vulnerablePackages }
|
|
717
|
+
};
|
|
718
|
+
} catch (err) {
|
|
719
|
+
return {
|
|
720
|
+
id: "npm-audit",
|
|
721
|
+
category: this.category,
|
|
722
|
+
name: "Security Vulnerabilities",
|
|
723
|
+
score: 0,
|
|
724
|
+
status: "fail",
|
|
725
|
+
issues: [{ severity: "critical", message: `npm audit failed: ${err}`, reportedBy: ["npm-audit"] }],
|
|
726
|
+
toolsUsed: ["npm-audit"],
|
|
727
|
+
duration: elapsed()
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
// src/integrations/depcheck.ts
|
|
734
|
+
import { execa as execa5 } from "execa";
|
|
735
|
+
var DepcheckRunner = class extends BaseRunner {
|
|
736
|
+
name = "depcheck";
|
|
737
|
+
category = "dependencies";
|
|
738
|
+
async run(projectPath) {
|
|
739
|
+
const elapsed = timer();
|
|
740
|
+
const available = await isCommandAvailable("depcheck");
|
|
741
|
+
if (!available) {
|
|
742
|
+
return this.skipped("depcheck not installed \u2014 run: npm i -g depcheck");
|
|
743
|
+
}
|
|
744
|
+
try {
|
|
745
|
+
const { stdout } = await execa5("depcheck", ["--json"], {
|
|
746
|
+
cwd: projectPath,
|
|
747
|
+
reject: false,
|
|
748
|
+
preferLocal: true,
|
|
749
|
+
localDir: coreLocalDir
|
|
750
|
+
});
|
|
751
|
+
const data = parseJsonOutput(stdout, "{}");
|
|
752
|
+
const issues = [];
|
|
753
|
+
for (const [dep, files] of Object.entries(data.missing ?? {})) {
|
|
754
|
+
if (dep.startsWith("virtual:") || dep.startsWith("node:")) {
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
issues.push({
|
|
758
|
+
severity: "critical",
|
|
759
|
+
message: `Missing dependency: ${dep} (used in ${files.length} file${files.length > 1 ? "s" : ""})`,
|
|
760
|
+
fix: { description: `Install ${dep}` },
|
|
761
|
+
reportedBy: ["depcheck"]
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
const score = Math.max(0, 100 - issues.length * 5);
|
|
765
|
+
return {
|
|
766
|
+
id: "depcheck",
|
|
767
|
+
category: this.category,
|
|
768
|
+
name: "Dependency Health",
|
|
769
|
+
score,
|
|
770
|
+
status: issues.length === 0 ? "pass" : issues[0].severity === "critical" ? "fail" : "warning",
|
|
771
|
+
issues,
|
|
772
|
+
toolsUsed: ["depcheck"],
|
|
773
|
+
duration: elapsed(),
|
|
774
|
+
metadata: {
|
|
775
|
+
unused: data.dependencies?.length ?? 0,
|
|
776
|
+
missing: Object.keys(data.missing ?? {}).length
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
} catch (err) {
|
|
780
|
+
return {
|
|
781
|
+
id: "depcheck",
|
|
782
|
+
category: this.category,
|
|
783
|
+
name: "Dependency Health",
|
|
784
|
+
score: 0,
|
|
785
|
+
status: "fail",
|
|
786
|
+
issues: [{ severity: "critical", message: `depcheck failed: ${err}`, reportedBy: ["depcheck"] }],
|
|
787
|
+
toolsUsed: ["depcheck"],
|
|
788
|
+
duration: elapsed()
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
// src/integrations/madge.ts
|
|
795
|
+
import { execa as execa6 } from "execa";
|
|
796
|
+
function findCircularDeps(graph) {
|
|
797
|
+
const circles = [];
|
|
798
|
+
const visited = /* @__PURE__ */ new Set();
|
|
799
|
+
const stack = /* @__PURE__ */ new Set();
|
|
800
|
+
function dfs(node, path) {
|
|
801
|
+
if (stack.has(node)) {
|
|
802
|
+
const cycleStart = path.indexOf(node);
|
|
803
|
+
if (cycleStart !== -1) {
|
|
804
|
+
circles.push(path.slice(cycleStart));
|
|
805
|
+
}
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (visited.has(node)) return;
|
|
809
|
+
visited.add(node);
|
|
810
|
+
stack.add(node);
|
|
811
|
+
path.push(node);
|
|
812
|
+
for (const dep of graph[node] ?? []) {
|
|
813
|
+
dfs(dep, [...path]);
|
|
814
|
+
}
|
|
815
|
+
stack.delete(node);
|
|
816
|
+
}
|
|
817
|
+
for (const node of Object.keys(graph)) {
|
|
818
|
+
dfs(node, []);
|
|
819
|
+
}
|
|
820
|
+
const seen = /* @__PURE__ */ new Set();
|
|
821
|
+
return circles.filter((cycle) => {
|
|
822
|
+
const sorted = [...cycle].sort().join("|");
|
|
823
|
+
if (seen.has(sorted)) return false;
|
|
824
|
+
seen.add(sorted);
|
|
825
|
+
return true;
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
var MadgeRunner = class extends BaseRunner {
|
|
829
|
+
name = "madge";
|
|
830
|
+
category = "code-quality";
|
|
831
|
+
async run(projectPath) {
|
|
832
|
+
const elapsed = timer();
|
|
833
|
+
const available = await isCommandAvailable("madge");
|
|
834
|
+
if (!available) {
|
|
835
|
+
return this.skipped("madge not installed \u2014 run: npm i -g madge");
|
|
836
|
+
}
|
|
837
|
+
try {
|
|
838
|
+
const tsConfig = fileExists(projectPath, "tsconfig.app.json") ? "tsconfig.app.json" : fileExists(projectPath, "tsconfig.json") ? "tsconfig.json" : null;
|
|
839
|
+
const args = ["--json", "--extensions", "ts,tsx,js,jsx"];
|
|
840
|
+
if (tsConfig) args.push("--ts-config", tsConfig);
|
|
841
|
+
args.push("src");
|
|
842
|
+
const { stdout } = await execa6("madge", args, {
|
|
843
|
+
cwd: projectPath,
|
|
844
|
+
reject: false,
|
|
845
|
+
preferLocal: true,
|
|
846
|
+
localDir: coreLocalDir
|
|
847
|
+
});
|
|
848
|
+
let graph;
|
|
849
|
+
try {
|
|
850
|
+
graph = parseJsonOutput(stdout, "{}");
|
|
851
|
+
} catch {
|
|
852
|
+
graph = {};
|
|
853
|
+
}
|
|
854
|
+
const circles = findCircularDeps(graph);
|
|
855
|
+
const issues = circles.map((cycle) => ({
|
|
856
|
+
severity: "warning",
|
|
857
|
+
message: `Circular dependency: ${cycle.join(" \u2192 ")}`,
|
|
858
|
+
fix: { description: "Refactor to break the circular dependency cycle" },
|
|
859
|
+
reportedBy: ["madge"]
|
|
860
|
+
}));
|
|
861
|
+
return {
|
|
862
|
+
id: "madge",
|
|
863
|
+
category: this.category,
|
|
864
|
+
name: "Circular Dependencies",
|
|
865
|
+
score: circles.length === 0 ? 100 : Math.max(0, 100 - circles.length * 10),
|
|
866
|
+
status: circles.length === 0 ? "pass" : circles.length > 5 ? "fail" : "warning",
|
|
867
|
+
issues,
|
|
868
|
+
toolsUsed: ["madge"],
|
|
869
|
+
duration: elapsed(),
|
|
870
|
+
metadata: { circularCount: circles.length, graph }
|
|
871
|
+
};
|
|
872
|
+
} catch (err) {
|
|
873
|
+
return {
|
|
874
|
+
id: "madge",
|
|
875
|
+
category: this.category,
|
|
876
|
+
name: "Circular Dependencies",
|
|
877
|
+
score: 0,
|
|
878
|
+
status: "fail",
|
|
879
|
+
issues: [{ severity: "critical", message: `madge failed: ${err}`, reportedBy: ["madge"] }],
|
|
880
|
+
toolsUsed: ["madge"],
|
|
881
|
+
duration: elapsed()
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
// src/integrations/source-map-explorer.ts
|
|
888
|
+
import { execa as execa7 } from "execa";
|
|
889
|
+
import { globby as globby2 } from "globby";
|
|
890
|
+
import { statSync, readFileSync as readFileSync5 } from "fs";
|
|
891
|
+
import { join as join5 } from "path";
|
|
892
|
+
var SIZE_THRESHOLD_WARN = 500 * 1024;
|
|
893
|
+
var SIZE_THRESHOLD_FAIL = 1024 * 1024;
|
|
894
|
+
function findEntryChunks(buildPath) {
|
|
895
|
+
try {
|
|
896
|
+
const html = readFileSync5(join5(buildPath, "index.html"), "utf8");
|
|
897
|
+
const matches = [...html.matchAll(/<script[^>]+src="([^"]+\.js)"[^>]*>/gi)];
|
|
898
|
+
return matches.map((m) => join5(buildPath, m[1].replace(/^\//, "")));
|
|
899
|
+
} catch {
|
|
900
|
+
return [];
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
var SourceMapExplorerRunner = class extends BaseRunner {
|
|
904
|
+
name = "source-map-explorer";
|
|
905
|
+
category = "performance";
|
|
906
|
+
applicableRuntimes = ["browser"];
|
|
907
|
+
async run(projectPath) {
|
|
908
|
+
const elapsed = timer();
|
|
909
|
+
const buildDir = fileExists(projectPath, "dist") ? "dist" : "build";
|
|
910
|
+
const buildPath = join5(projectPath, buildDir);
|
|
911
|
+
const mapFiles = await globby2("**/*.js.map", {
|
|
912
|
+
cwd: buildPath,
|
|
913
|
+
absolute: false
|
|
914
|
+
});
|
|
915
|
+
const hasSourceMaps = mapFiles.length > 0;
|
|
916
|
+
if (hasSourceMaps) {
|
|
917
|
+
const available = await isCommandAvailable("source-map-explorer");
|
|
918
|
+
if (available) {
|
|
919
|
+
try {
|
|
920
|
+
const { stdout } = await execa7(
|
|
921
|
+
"source-map-explorer",
|
|
922
|
+
[`${buildDir}/**/*.js`, "--json"],
|
|
923
|
+
{
|
|
924
|
+
cwd: projectPath,
|
|
925
|
+
reject: false,
|
|
926
|
+
preferLocal: true,
|
|
927
|
+
localDir: coreLocalDir
|
|
928
|
+
}
|
|
929
|
+
);
|
|
930
|
+
if (stdout && stdout.trim().startsWith("{")) {
|
|
931
|
+
const data = parseJsonOutput(stdout, "{}");
|
|
932
|
+
const results = data.results ?? [];
|
|
933
|
+
if (results.length > 0) {
|
|
934
|
+
const totalBytes = results.reduce((sum, r) => sum + r.totalBytes, 0);
|
|
935
|
+
const largestBytes = Math.max(...results.map((r) => r.totalBytes));
|
|
936
|
+
const totalKB = Math.round(totalBytes / 1024);
|
|
937
|
+
const largestKB = Math.round(largestBytes / 1024);
|
|
938
|
+
const issues = [];
|
|
939
|
+
if (largestBytes > SIZE_THRESHOLD_FAIL) {
|
|
940
|
+
issues.push({
|
|
941
|
+
severity: "critical",
|
|
942
|
+
message: `Largest bundle is ${largestKB}KB \u2014 exceeds 1MB threshold`,
|
|
943
|
+
fix: {
|
|
944
|
+
description: "Use code splitting and lazy imports to reduce bundle size"
|
|
945
|
+
},
|
|
946
|
+
reportedBy: ["source-map-explorer"]
|
|
947
|
+
});
|
|
948
|
+
} else if (largestBytes > SIZE_THRESHOLD_WARN) {
|
|
949
|
+
issues.push({
|
|
950
|
+
severity: "warning",
|
|
951
|
+
message: `Largest bundle is ${largestKB}KB \u2014 consider optimizing`,
|
|
952
|
+
fix: {
|
|
953
|
+
description: "Review large dependencies and consider tree-shaking"
|
|
954
|
+
},
|
|
955
|
+
reportedBy: ["source-map-explorer"]
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
const score = largestBytes > SIZE_THRESHOLD_FAIL ? 40 : largestBytes > SIZE_THRESHOLD_WARN ? 70 : 100;
|
|
959
|
+
return {
|
|
960
|
+
id: "source-map-explorer",
|
|
961
|
+
category: this.category,
|
|
962
|
+
name: "Bundle Size",
|
|
963
|
+
score,
|
|
964
|
+
status: issues.length === 0 ? "pass" : issues[0].severity === "critical" ? "fail" : "warning",
|
|
965
|
+
issues,
|
|
966
|
+
toolsUsed: ["source-map-explorer"],
|
|
967
|
+
duration: elapsed(),
|
|
968
|
+
metadata: {
|
|
969
|
+
largestBytes,
|
|
970
|
+
largestKB,
|
|
971
|
+
totalBytes,
|
|
972
|
+
totalKB,
|
|
973
|
+
bundleCount: results.length,
|
|
974
|
+
method: "source-map-explorer"
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
} catch {
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
try {
|
|
984
|
+
const jsFiles = await globby2("**/*.js", {
|
|
985
|
+
cwd: buildPath,
|
|
986
|
+
absolute: true,
|
|
987
|
+
ignore: ["**/*.map"]
|
|
988
|
+
});
|
|
989
|
+
if (jsFiles.length === 0) {
|
|
990
|
+
return this.skipped(`No JavaScript files found in ${buildDir}/`);
|
|
991
|
+
}
|
|
992
|
+
const totalBytes = jsFiles.reduce((sum, file) => {
|
|
993
|
+
try {
|
|
994
|
+
return sum + statSync(file).size;
|
|
995
|
+
} catch {
|
|
996
|
+
return sum;
|
|
997
|
+
}
|
|
998
|
+
}, 0);
|
|
999
|
+
const entryChunks = findEntryChunks(buildPath);
|
|
1000
|
+
const hasEntryChunkInfo = entryChunks.length > 0;
|
|
1001
|
+
const initialBytes = hasEntryChunkInfo ? entryChunks.reduce((sum, file) => {
|
|
1002
|
+
try {
|
|
1003
|
+
return sum + statSync(file).size;
|
|
1004
|
+
} catch {
|
|
1005
|
+
return sum;
|
|
1006
|
+
}
|
|
1007
|
+
}, 0) : totalBytes;
|
|
1008
|
+
const totalKB = Math.round(totalBytes / 1024);
|
|
1009
|
+
const initialKB = Math.round(initialBytes / 1024);
|
|
1010
|
+
const issues = [];
|
|
1011
|
+
if (initialBytes > SIZE_THRESHOLD_FAIL) {
|
|
1012
|
+
issues.push({
|
|
1013
|
+
severity: "critical",
|
|
1014
|
+
message: `Initial bundle is ${initialKB}KB \u2014 exceeds 1MB threshold`,
|
|
1015
|
+
fix: {
|
|
1016
|
+
description: hasSourceMaps ? "Use code splitting and lazy imports to reduce bundle size" : "Use code splitting and lazy imports to reduce bundle size. Enable source maps (sourcemap: true) for detailed analysis"
|
|
1017
|
+
},
|
|
1018
|
+
reportedBy: ["bundle-size-check"]
|
|
1019
|
+
});
|
|
1020
|
+
} else if (initialBytes > SIZE_THRESHOLD_WARN) {
|
|
1021
|
+
issues.push({
|
|
1022
|
+
severity: "warning",
|
|
1023
|
+
message: `Initial bundle is ${initialKB}KB \u2014 consider optimizing`,
|
|
1024
|
+
fix: {
|
|
1025
|
+
description: hasSourceMaps ? "Review large dependencies and consider tree-shaking" : "Review large dependencies and consider tree-shaking. Enable source maps (sourcemap: true) for detailed analysis"
|
|
1026
|
+
},
|
|
1027
|
+
reportedBy: ["bundle-size-check"]
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
const score = initialBytes > SIZE_THRESHOLD_FAIL ? 40 : initialBytes > SIZE_THRESHOLD_WARN ? 70 : 100;
|
|
1031
|
+
return {
|
|
1032
|
+
id: "source-map-explorer",
|
|
1033
|
+
category: this.category,
|
|
1034
|
+
name: "Bundle Size",
|
|
1035
|
+
score,
|
|
1036
|
+
status: issues.length === 0 ? "pass" : issues[0].severity === "critical" ? "fail" : "warning",
|
|
1037
|
+
issues,
|
|
1038
|
+
toolsUsed: ["file-size-analysis"],
|
|
1039
|
+
duration: elapsed(),
|
|
1040
|
+
metadata: {
|
|
1041
|
+
initialBytes,
|
|
1042
|
+
initialKB,
|
|
1043
|
+
totalBytes,
|
|
1044
|
+
totalKB,
|
|
1045
|
+
fileCount: jsFiles.length,
|
|
1046
|
+
entryChunks: entryChunks.length,
|
|
1047
|
+
method: "file-size-analysis",
|
|
1048
|
+
note: hasEntryChunkInfo ? `Total bundle: ${totalKB}KB across ${jsFiles.length} chunks` : hasSourceMaps ? "Source maps found but analysis failed" : "No source maps \u2014 using total bundle size"
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
} catch (err) {
|
|
1052
|
+
return {
|
|
1053
|
+
id: "source-map-explorer",
|
|
1054
|
+
category: this.category,
|
|
1055
|
+
name: "Bundle Size",
|
|
1056
|
+
score: 0,
|
|
1057
|
+
status: "fail",
|
|
1058
|
+
issues: [
|
|
1059
|
+
{
|
|
1060
|
+
severity: "critical",
|
|
1061
|
+
message: `Bundle analysis failed: ${err}`,
|
|
1062
|
+
reportedBy: ["bundle-size-check"]
|
|
1063
|
+
}
|
|
1064
|
+
],
|
|
1065
|
+
toolsUsed: ["file-size-analysis"],
|
|
1066
|
+
duration: elapsed()
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
// src/integrations/coverage.ts
|
|
1073
|
+
import { readFileSync as readFileSync6, existsSync as existsSync5, unlinkSync } from "fs";
|
|
1074
|
+
import { join as join6, dirname as dirname3 } from "path";
|
|
1075
|
+
import { tmpdir } from "os";
|
|
1076
|
+
import { execa as execa8 } from "execa";
|
|
1077
|
+
var COVERAGE_PATHS = [
|
|
1078
|
+
"coverage/coverage-summary.json",
|
|
1079
|
+
"coverage/coverage-final.json"
|
|
1080
|
+
];
|
|
1081
|
+
var CoverageRunner = class extends BaseRunner {
|
|
1082
|
+
name = "coverage";
|
|
1083
|
+
category = "code-quality";
|
|
1084
|
+
async isApplicable(projectPath) {
|
|
1085
|
+
return COVERAGE_PATHS.some((p) => existsSync5(join6(projectPath, p))) || this.detectTestRunner(projectPath) !== null;
|
|
1086
|
+
}
|
|
1087
|
+
detectTestRunner(projectPath) {
|
|
1088
|
+
try {
|
|
1089
|
+
const pkg = readPackageJson(projectPath);
|
|
1090
|
+
const deps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
1091
|
+
if ("vitest" in deps) return "vitest";
|
|
1092
|
+
if ("jest" in deps || "@jest/core" in deps) return "jest";
|
|
1093
|
+
} catch {
|
|
1094
|
+
}
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1097
|
+
hasCoverageProvider(projectPath, runner) {
|
|
1098
|
+
if (runner === "vitest") {
|
|
1099
|
+
let dir = projectPath;
|
|
1100
|
+
while (true) {
|
|
1101
|
+
if (existsSync5(join6(dir, "node_modules", "@vitest", "coverage-v8")) || existsSync5(join6(dir, "node_modules", "@vitest", "coverage-istanbul"))) {
|
|
1102
|
+
return true;
|
|
1103
|
+
}
|
|
1104
|
+
const parent = dirname3(dir);
|
|
1105
|
+
if (parent === dir) break;
|
|
1106
|
+
dir = parent;
|
|
1107
|
+
}
|
|
1108
|
+
return false;
|
|
1109
|
+
}
|
|
1110
|
+
return true;
|
|
1111
|
+
}
|
|
1112
|
+
async run(projectPath) {
|
|
1113
|
+
const elapsed = timer();
|
|
1114
|
+
const runner = this.detectTestRunner(projectPath);
|
|
1115
|
+
if (!runner) {
|
|
1116
|
+
return this.readExistingCoverage(projectPath, elapsed);
|
|
1117
|
+
}
|
|
1118
|
+
try {
|
|
1119
|
+
const hasCoverage = this.hasCoverageProvider(projectPath, runner);
|
|
1120
|
+
const tmpFile = join6(tmpdir(), `sickbay-test-${Date.now()}.json`);
|
|
1121
|
+
const args = runner === "vitest" ? ["run", "--reporter=json", `--outputFile=${tmpFile}`, ...hasCoverage ? ["--coverage", "--coverage.reporter=json-summary"] : []] : ["--json", `--outputFile=${tmpFile}`, ...hasCoverage ? ["--coverage"] : []];
|
|
1122
|
+
await execa8(runner, args, {
|
|
1123
|
+
cwd: projectPath,
|
|
1124
|
+
reject: false,
|
|
1125
|
+
// localDir ensures we use the project's own vitest, not sickbay' hoisted binary
|
|
1126
|
+
localDir: projectPath,
|
|
1127
|
+
preferLocal: true,
|
|
1128
|
+
timeout: 12e4
|
|
1129
|
+
});
|
|
1130
|
+
let testCounts = { total: 0, passed: 0, failed: 0, skipped: 0 };
|
|
1131
|
+
if (existsSync5(tmpFile)) {
|
|
1132
|
+
try {
|
|
1133
|
+
const parsed = JSON.parse(readFileSync6(tmpFile, "utf-8"));
|
|
1134
|
+
testCounts = {
|
|
1135
|
+
total: parsed.numTotalTests ?? 0,
|
|
1136
|
+
passed: parsed.numPassedTests ?? 0,
|
|
1137
|
+
failed: parsed.numFailedTests ?? 0,
|
|
1138
|
+
skipped: parsed.numSkippedTests ?? 0
|
|
1139
|
+
};
|
|
1140
|
+
} finally {
|
|
1141
|
+
try {
|
|
1142
|
+
unlinkSync(tmpFile);
|
|
1143
|
+
} catch {
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
const coveragePath = COVERAGE_PATHS.map((p) => join6(projectPath, p)).find(existsSync5);
|
|
1148
|
+
let coverageData = null;
|
|
1149
|
+
if (coveragePath) {
|
|
1150
|
+
try {
|
|
1151
|
+
const raw = JSON.parse(readFileSync6(coveragePath, "utf-8"));
|
|
1152
|
+
const candidate = raw.total ?? raw;
|
|
1153
|
+
if (candidate?.lines?.pct !== void 0 && candidate?.statements?.pct !== void 0) {
|
|
1154
|
+
coverageData = candidate;
|
|
1155
|
+
}
|
|
1156
|
+
} catch {
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
const packageManager = detectPackageManager(projectPath);
|
|
1160
|
+
return this.buildResult(elapsed, testCounts, coverageData, runner, hasCoverage, packageManager);
|
|
1161
|
+
} catch (err) {
|
|
1162
|
+
return {
|
|
1163
|
+
id: "coverage",
|
|
1164
|
+
category: this.category,
|
|
1165
|
+
name: "Tests & Coverage",
|
|
1166
|
+
score: 0,
|
|
1167
|
+
status: "fail",
|
|
1168
|
+
issues: [{ severity: "critical", message: `Test run failed: ${err}`, reportedBy: ["coverage"] }],
|
|
1169
|
+
toolsUsed: [runner],
|
|
1170
|
+
duration: elapsed()
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
buildResult(elapsed, counts, coverage, runner, hasCoverage, packageManager = "npm") {
|
|
1175
|
+
const issues = [];
|
|
1176
|
+
if (counts.failed > 0) {
|
|
1177
|
+
issues.push({
|
|
1178
|
+
severity: "critical",
|
|
1179
|
+
message: `${counts.failed} test${counts.failed > 1 ? "s" : ""} failing (${counts.passed}/${counts.total} passing)`,
|
|
1180
|
+
fix: { description: "Fix failing tests" },
|
|
1181
|
+
reportedBy: ["coverage"]
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
if (coverage) {
|
|
1185
|
+
if (coverage.lines.pct < 80) {
|
|
1186
|
+
issues.push({
|
|
1187
|
+
severity: coverage.lines.pct < 50 ? "critical" : "warning",
|
|
1188
|
+
message: `Line coverage: ${coverage.lines.pct.toFixed(1)}% (target: 80%)`,
|
|
1189
|
+
fix: { description: "Add tests to improve coverage" },
|
|
1190
|
+
reportedBy: ["coverage"]
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
if (coverage.functions.pct < 80) {
|
|
1194
|
+
issues.push({
|
|
1195
|
+
severity: "warning",
|
|
1196
|
+
message: `Function coverage: ${coverage.functions.pct.toFixed(1)}% (target: 80%)`,
|
|
1197
|
+
fix: { description: "Add tests for uncovered functions" },
|
|
1198
|
+
reportedBy: ["coverage"]
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
} else if (!hasCoverage && counts.total > 0) {
|
|
1202
|
+
issues.push({
|
|
1203
|
+
severity: "info",
|
|
1204
|
+
message: "Coverage data unavailable \u2014 install @vitest/coverage-v8 for coverage reporting",
|
|
1205
|
+
fix: { description: "Add coverage provider", command: `${packageManager} add -D @vitest/coverage-v8` },
|
|
1206
|
+
reportedBy: ["coverage"]
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
let score = 100;
|
|
1210
|
+
if (counts.total > 0 && counts.failed > 0) {
|
|
1211
|
+
score = Math.round(100 * (counts.passed / counts.total));
|
|
1212
|
+
}
|
|
1213
|
+
if (coverage) {
|
|
1214
|
+
const covAvg = (coverage.lines.pct + coverage.statements.pct + coverage.functions.pct + coverage.branches.pct) / 4;
|
|
1215
|
+
score = Math.round(score * 0.6 + covAvg * 0.4);
|
|
1216
|
+
}
|
|
1217
|
+
const status = counts.failed > 0 ? "fail" : coverage && coverage.lines.pct < 50 ? "fail" : coverage && coverage.lines.pct < 80 ? "warning" : issues.length > 0 ? "warning" : "pass";
|
|
1218
|
+
return {
|
|
1219
|
+
id: "coverage",
|
|
1220
|
+
category: this.category,
|
|
1221
|
+
name: "Tests & Coverage",
|
|
1222
|
+
score,
|
|
1223
|
+
status,
|
|
1224
|
+
issues,
|
|
1225
|
+
toolsUsed: [runner],
|
|
1226
|
+
duration: elapsed(),
|
|
1227
|
+
metadata: {
|
|
1228
|
+
testRunner: runner,
|
|
1229
|
+
totalTests: counts.total,
|
|
1230
|
+
passed: counts.passed,
|
|
1231
|
+
failed: counts.failed,
|
|
1232
|
+
skipped: counts.skipped,
|
|
1233
|
+
...coverage ? {
|
|
1234
|
+
lines: coverage.lines.pct,
|
|
1235
|
+
statements: coverage.statements.pct,
|
|
1236
|
+
functions: coverage.functions.pct,
|
|
1237
|
+
branches: coverage.branches.pct
|
|
1238
|
+
} : {}
|
|
1239
|
+
}
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
readExistingCoverage(projectPath, elapsed) {
|
|
1243
|
+
const coveragePath = COVERAGE_PATHS.map((p) => join6(projectPath, p)).find(existsSync5);
|
|
1244
|
+
if (!coveragePath) {
|
|
1245
|
+
return this.skipped("No test runner or coverage report found");
|
|
1246
|
+
}
|
|
1247
|
+
try {
|
|
1248
|
+
const raw = JSON.parse(readFileSync6(coveragePath, "utf-8"));
|
|
1249
|
+
const candidate = raw.total ?? raw;
|
|
1250
|
+
if (!candidate?.lines?.pct || !candidate?.statements?.pct) {
|
|
1251
|
+
return this.skipped("Coverage report format not recognized");
|
|
1252
|
+
}
|
|
1253
|
+
const { lines, statements, functions, branches } = candidate;
|
|
1254
|
+
const avg = (lines.pct + statements.pct + functions.pct + branches.pct) / 4;
|
|
1255
|
+
const issues = [];
|
|
1256
|
+
if (lines.pct < 80) {
|
|
1257
|
+
issues.push({
|
|
1258
|
+
severity: lines.pct < 50 ? "critical" : "warning",
|
|
1259
|
+
message: `Line coverage: ${lines.pct.toFixed(1)}% (target: 80%)`,
|
|
1260
|
+
reportedBy: ["coverage"]
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
return {
|
|
1264
|
+
id: "coverage",
|
|
1265
|
+
category: this.category,
|
|
1266
|
+
name: "Tests & Coverage",
|
|
1267
|
+
score: Math.round(avg),
|
|
1268
|
+
status: avg >= 80 ? "pass" : avg >= 50 ? "warning" : "fail",
|
|
1269
|
+
issues,
|
|
1270
|
+
toolsUsed: ["coverage"],
|
|
1271
|
+
duration: elapsed(),
|
|
1272
|
+
metadata: { lines: lines.pct, statements: statements.pct, functions: functions.pct, branches: branches.pct }
|
|
1273
|
+
};
|
|
1274
|
+
} catch (err) {
|
|
1275
|
+
return {
|
|
1276
|
+
id: "coverage",
|
|
1277
|
+
category: this.category,
|
|
1278
|
+
name: "Tests & Coverage",
|
|
1279
|
+
score: 0,
|
|
1280
|
+
status: "fail",
|
|
1281
|
+
issues: [{ severity: "critical", message: `Failed to parse coverage: ${err}`, reportedBy: ["coverage"] }],
|
|
1282
|
+
toolsUsed: ["coverage"],
|
|
1283
|
+
duration: elapsed()
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
};
|
|
1288
|
+
|
|
1289
|
+
// src/integrations/license-checker.ts
|
|
1290
|
+
import { execa as execa9 } from "execa";
|
|
1291
|
+
var PROBLEMATIC_LICENSES = ["GPL-2.0", "GPL-3.0", "AGPL-3.0", "LGPL-2.1", "LGPL-3.0", "CC-BY-NC"];
|
|
1292
|
+
var LicenseCheckerRunner = class extends BaseRunner {
|
|
1293
|
+
name = "license-checker";
|
|
1294
|
+
category = "security";
|
|
1295
|
+
async run(projectPath) {
|
|
1296
|
+
const elapsed = timer();
|
|
1297
|
+
const available = await isCommandAvailable("license-checker");
|
|
1298
|
+
if (!available) {
|
|
1299
|
+
return this.skipped("license-checker not installed \u2014 run: npm i -g license-checker");
|
|
1300
|
+
}
|
|
1301
|
+
try {
|
|
1302
|
+
const { stdout } = await execa9("license-checker", ["--json", "--production"], {
|
|
1303
|
+
cwd: projectPath,
|
|
1304
|
+
reject: false,
|
|
1305
|
+
preferLocal: true,
|
|
1306
|
+
localDir: coreLocalDir
|
|
1307
|
+
});
|
|
1308
|
+
const licenses = parseJsonOutput(stdout, "{}");
|
|
1309
|
+
const issues = [];
|
|
1310
|
+
for (const [pkg, info] of Object.entries(licenses)) {
|
|
1311
|
+
const license = info.licenses;
|
|
1312
|
+
if (PROBLEMATIC_LICENSES.some((l) => license.includes(l))) {
|
|
1313
|
+
issues.push({
|
|
1314
|
+
severity: "warning",
|
|
1315
|
+
message: `${pkg} uses ${license} license \u2014 may be incompatible with commercial use`,
|
|
1316
|
+
fix: { description: `Review or replace ${pkg.split("@")[0]}` },
|
|
1317
|
+
reportedBy: ["license-checker"]
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
return {
|
|
1322
|
+
id: "license-checker",
|
|
1323
|
+
category: this.category,
|
|
1324
|
+
name: "License Compliance",
|
|
1325
|
+
score: issues.length === 0 ? 100 : Math.max(60, 100 - issues.length * 10),
|
|
1326
|
+
status: issues.length === 0 ? "pass" : "warning",
|
|
1327
|
+
issues,
|
|
1328
|
+
toolsUsed: ["license-checker"],
|
|
1329
|
+
duration: elapsed(),
|
|
1330
|
+
metadata: { totalPackages: Object.keys(licenses).length, flagged: issues.length }
|
|
1331
|
+
};
|
|
1332
|
+
} catch (err) {
|
|
1333
|
+
return {
|
|
1334
|
+
id: "license-checker",
|
|
1335
|
+
category: this.category,
|
|
1336
|
+
name: "License Compliance",
|
|
1337
|
+
score: 0,
|
|
1338
|
+
status: "fail",
|
|
1339
|
+
issues: [{ severity: "critical", message: `license-checker failed: ${err}`, reportedBy: ["license-checker"] }],
|
|
1340
|
+
toolsUsed: ["license-checker"],
|
|
1341
|
+
duration: elapsed()
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
|
|
1347
|
+
// src/integrations/jscpd.ts
|
|
1348
|
+
import { execa as execa10 } from "execa";
|
|
1349
|
+
var JscpdRunner = class extends BaseRunner {
|
|
1350
|
+
name = "jscpd";
|
|
1351
|
+
category = "code-quality";
|
|
1352
|
+
async run(projectPath) {
|
|
1353
|
+
const elapsed = timer();
|
|
1354
|
+
const available = await isCommandAvailable("jscpd");
|
|
1355
|
+
if (!available) {
|
|
1356
|
+
return this.skipped("jscpd not installed \u2014 run: npm i -g jscpd");
|
|
1357
|
+
}
|
|
1358
|
+
try {
|
|
1359
|
+
const { stdout } = await execa10(
|
|
1360
|
+
"jscpd",
|
|
1361
|
+
[
|
|
1362
|
+
"src",
|
|
1363
|
+
"--reporters",
|
|
1364
|
+
"json",
|
|
1365
|
+
"--output",
|
|
1366
|
+
"/tmp/jscpd-sickbay",
|
|
1367
|
+
"--silent"
|
|
1368
|
+
],
|
|
1369
|
+
{
|
|
1370
|
+
cwd: projectPath,
|
|
1371
|
+
reject: false,
|
|
1372
|
+
preferLocal: true,
|
|
1373
|
+
localDir: coreLocalDir
|
|
1374
|
+
}
|
|
1375
|
+
);
|
|
1376
|
+
let data = {};
|
|
1377
|
+
try {
|
|
1378
|
+
data = parseJsonOutput(stdout, "{}");
|
|
1379
|
+
} catch {
|
|
1380
|
+
}
|
|
1381
|
+
const percentage = data.statistics?.total.percentage ?? 0;
|
|
1382
|
+
const clones = data.statistics?.total.clones ?? 0;
|
|
1383
|
+
const issues = [];
|
|
1384
|
+
if (percentage > 5) {
|
|
1385
|
+
issues.push({
|
|
1386
|
+
severity: percentage > 20 ? "critical" : "warning",
|
|
1387
|
+
message: `${percentage.toFixed(1)}% code duplication detected (${clones} clone${clones !== 1 ? "s" : ""})`,
|
|
1388
|
+
fix: {
|
|
1389
|
+
description: "Extract duplicated code into shared utilities or components"
|
|
1390
|
+
},
|
|
1391
|
+
reportedBy: ["jscpd"]
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
return {
|
|
1395
|
+
id: "jscpd",
|
|
1396
|
+
category: this.category,
|
|
1397
|
+
name: "Code Duplication",
|
|
1398
|
+
score: Math.max(0, 100 - Math.round(percentage * 3)),
|
|
1399
|
+
status: percentage === 0 ? "pass" : percentage > 20 ? "fail" : percentage > 5 ? "warning" : "pass",
|
|
1400
|
+
issues,
|
|
1401
|
+
toolsUsed: ["jscpd"],
|
|
1402
|
+
duration: elapsed(),
|
|
1403
|
+
metadata: { percentage, clones }
|
|
1404
|
+
};
|
|
1405
|
+
} catch (err) {
|
|
1406
|
+
return {
|
|
1407
|
+
id: "jscpd",
|
|
1408
|
+
category: this.category,
|
|
1409
|
+
name: "Code Duplication",
|
|
1410
|
+
score: 0,
|
|
1411
|
+
status: "fail",
|
|
1412
|
+
issues: [
|
|
1413
|
+
{
|
|
1414
|
+
severity: "critical",
|
|
1415
|
+
message: `jscpd failed: ${err}`,
|
|
1416
|
+
reportedBy: ["jscpd"]
|
|
1417
|
+
}
|
|
1418
|
+
],
|
|
1419
|
+
toolsUsed: ["jscpd"],
|
|
1420
|
+
duration: elapsed()
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
};
|
|
1425
|
+
|
|
1426
|
+
// src/integrations/git.ts
|
|
1427
|
+
import { execa as execa11 } from "execa";
|
|
1428
|
+
var GitRunner = class extends BaseRunner {
|
|
1429
|
+
name = "git";
|
|
1430
|
+
category = "git";
|
|
1431
|
+
async isApplicable(projectPath) {
|
|
1432
|
+
return fileExists(projectPath, ".git");
|
|
1433
|
+
}
|
|
1434
|
+
async run(projectPath) {
|
|
1435
|
+
const elapsed = timer();
|
|
1436
|
+
try {
|
|
1437
|
+
const [lastCommitResult, logCountResult, contributorsResult] = await Promise.allSettled([
|
|
1438
|
+
execa11("git", ["log", "-1", "--format=%cr"], { cwd: projectPath }),
|
|
1439
|
+
execa11("git", ["rev-list", "--count", "HEAD"], { cwd: projectPath }),
|
|
1440
|
+
execa11("git", ["shortlog", "-sn", "--no-merges", "HEAD"], { cwd: projectPath })
|
|
1441
|
+
]);
|
|
1442
|
+
const remotesResult = await execa11("git", ["remote"], { cwd: projectPath, reject: false });
|
|
1443
|
+
const hasRemote = remotesResult.stdout.trim().length > 0;
|
|
1444
|
+
let remoteBranches = 0;
|
|
1445
|
+
if (hasRemote) {
|
|
1446
|
+
const branchResult = await execa11("git", ["branch", "-r"], { cwd: projectPath, reject: false });
|
|
1447
|
+
remoteBranches = branchResult.stdout.trim().split("\n").filter(Boolean).length;
|
|
1448
|
+
}
|
|
1449
|
+
const lastCommit = lastCommitResult.status === "fulfilled" ? lastCommitResult.value.stdout.trim() : "unknown";
|
|
1450
|
+
const commitCount = logCountResult.status === "fulfilled" ? parseInt(logCountResult.value.stdout.trim(), 10) : 0;
|
|
1451
|
+
const contributorCount = contributorsResult.status === "fulfilled" ? contributorsResult.value.stdout.trim().split("\n").filter(Boolean).length : 0;
|
|
1452
|
+
const issues = [];
|
|
1453
|
+
const isStale = lastCommit.includes("year") || lastCommit.includes("month") && parseInt(lastCommit) > 6;
|
|
1454
|
+
if (isStale) {
|
|
1455
|
+
issues.push({
|
|
1456
|
+
severity: "warning",
|
|
1457
|
+
message: `Last commit was ${lastCommit} \u2014 project may be stale`,
|
|
1458
|
+
reportedBy: ["git"]
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
if (remoteBranches > 20) {
|
|
1462
|
+
issues.push({
|
|
1463
|
+
severity: "info",
|
|
1464
|
+
message: `${remoteBranches} remote branches \u2014 consider pruning stale branches`,
|
|
1465
|
+
fix: { description: "Clean up merged branches", command: "git remote prune origin" },
|
|
1466
|
+
reportedBy: ["git"]
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
return {
|
|
1470
|
+
id: "git",
|
|
1471
|
+
category: this.category,
|
|
1472
|
+
name: "Git Health",
|
|
1473
|
+
score: issues.length === 0 ? 100 : 80,
|
|
1474
|
+
status: issues.length === 0 ? "pass" : "warning",
|
|
1475
|
+
issues,
|
|
1476
|
+
toolsUsed: ["git"],
|
|
1477
|
+
duration: elapsed(),
|
|
1478
|
+
metadata: { lastCommit, commitCount, contributorCount, remoteBranches }
|
|
1479
|
+
};
|
|
1480
|
+
} catch (err) {
|
|
1481
|
+
return {
|
|
1482
|
+
id: "git",
|
|
1483
|
+
category: this.category,
|
|
1484
|
+
name: "Git Health",
|
|
1485
|
+
score: 0,
|
|
1486
|
+
status: "fail",
|
|
1487
|
+
issues: [{ severity: "critical", message: `git analysis failed: ${err}`, reportedBy: ["git"] }],
|
|
1488
|
+
toolsUsed: ["git"],
|
|
1489
|
+
duration: elapsed()
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1494
|
+
|
|
1495
|
+
// src/integrations/eslint.ts
|
|
1496
|
+
import { execa as execa12 } from "execa";
|
|
1497
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1498
|
+
import { join as join7 } from "path";
|
|
1499
|
+
var ESLintRunner = class extends BaseRunner {
|
|
1500
|
+
name = "eslint";
|
|
1501
|
+
category = "code-quality";
|
|
1502
|
+
async isApplicable(projectPath) {
|
|
1503
|
+
return existsSync6(join7(projectPath, ".eslintrc.js")) || existsSync6(join7(projectPath, ".eslintrc.cjs")) || existsSync6(join7(projectPath, ".eslintrc.json")) || existsSync6(join7(projectPath, ".eslintrc.yml")) || existsSync6(join7(projectPath, "eslint.config.js")) || existsSync6(join7(projectPath, "eslint.config.mjs")) || existsSync6(join7(projectPath, "eslint.config.cjs"));
|
|
1504
|
+
}
|
|
1505
|
+
async run(projectPath) {
|
|
1506
|
+
const elapsed = timer();
|
|
1507
|
+
try {
|
|
1508
|
+
const candidateDirs = ["src", "lib", "app"];
|
|
1509
|
+
const dirsToScan = candidateDirs.filter((d) => existsSync6(join7(projectPath, d)));
|
|
1510
|
+
if (dirsToScan.length === 0) {
|
|
1511
|
+
return this.skipped("No source directory found (looked for src, lib, app)");
|
|
1512
|
+
}
|
|
1513
|
+
const { stdout } = await execa12(
|
|
1514
|
+
"eslint",
|
|
1515
|
+
[...dirsToScan, "--format", "json", "--no-error-on-unmatched-pattern"],
|
|
1516
|
+
{ cwd: projectPath, reject: false, preferLocal: true, timeout: 6e4 }
|
|
1517
|
+
);
|
|
1518
|
+
const results = parseJsonOutput(stdout, "[]");
|
|
1519
|
+
let totalErrors = 0;
|
|
1520
|
+
let totalWarnings = 0;
|
|
1521
|
+
const issues = [];
|
|
1522
|
+
for (const file of results) {
|
|
1523
|
+
totalErrors += file.errorCount;
|
|
1524
|
+
totalWarnings += file.warningCount;
|
|
1525
|
+
if (file.errorCount > 0 || file.warningCount > 0) {
|
|
1526
|
+
const relPath = file.filePath.replace(projectPath + "/", "");
|
|
1527
|
+
const parts = [];
|
|
1528
|
+
if (file.errorCount > 0) parts.push(`${file.errorCount} error${file.errorCount > 1 ? "s" : ""}`);
|
|
1529
|
+
if (file.warningCount > 0) parts.push(`${file.warningCount} warning${file.warningCount > 1 ? "s" : ""}`);
|
|
1530
|
+
issues.push({
|
|
1531
|
+
severity: file.errorCount > 0 ? "warning" : "info",
|
|
1532
|
+
message: `${relPath}: ${parts.join(", ")}`,
|
|
1533
|
+
fix: { description: `Fix ESLint issues in ${relPath}`, command: `eslint ${relPath} --fix`, modifiesSource: true },
|
|
1534
|
+
reportedBy: ["eslint"]
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
const score = Math.max(0, 100 - totalErrors * 5 - Math.round(totalWarnings * 0.5));
|
|
1539
|
+
return {
|
|
1540
|
+
id: "eslint",
|
|
1541
|
+
category: this.category,
|
|
1542
|
+
name: "Lint",
|
|
1543
|
+
score,
|
|
1544
|
+
status: totalErrors > 10 ? "fail" : totalErrors > 0 ? "warning" : totalWarnings > 0 ? "warning" : "pass",
|
|
1545
|
+
issues,
|
|
1546
|
+
toolsUsed: ["eslint"],
|
|
1547
|
+
duration: elapsed(),
|
|
1548
|
+
metadata: { errors: totalErrors, warnings: totalWarnings, files: results.length }
|
|
1549
|
+
};
|
|
1550
|
+
} catch (err) {
|
|
1551
|
+
return {
|
|
1552
|
+
id: "eslint",
|
|
1553
|
+
category: this.category,
|
|
1554
|
+
name: "Lint",
|
|
1555
|
+
score: 0,
|
|
1556
|
+
status: "fail",
|
|
1557
|
+
issues: [{ severity: "critical", message: `ESLint failed: ${err}`, reportedBy: ["eslint"] }],
|
|
1558
|
+
toolsUsed: ["eslint"],
|
|
1559
|
+
duration: elapsed()
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
};
|
|
1564
|
+
|
|
1565
|
+
// src/integrations/typescript.ts
|
|
1566
|
+
import { execa as execa13 } from "execa";
|
|
1567
|
+
import { existsSync as existsSync7 } from "fs";
|
|
1568
|
+
import { join as join8 } from "path";
|
|
1569
|
+
var TypeScriptRunner = class extends BaseRunner {
|
|
1570
|
+
name = "typescript";
|
|
1571
|
+
category = "code-quality";
|
|
1572
|
+
async isApplicable(projectPath) {
|
|
1573
|
+
return existsSync7(join8(projectPath, "tsconfig.json"));
|
|
1574
|
+
}
|
|
1575
|
+
async run(projectPath) {
|
|
1576
|
+
const elapsed = timer();
|
|
1577
|
+
try {
|
|
1578
|
+
const { stdout, stderr } = await execa13(
|
|
1579
|
+
"tsc",
|
|
1580
|
+
["--noEmit", "--pretty", "false"],
|
|
1581
|
+
{
|
|
1582
|
+
cwd: projectPath,
|
|
1583
|
+
reject: false,
|
|
1584
|
+
preferLocal: true,
|
|
1585
|
+
timeout: 12e4
|
|
1586
|
+
}
|
|
1587
|
+
);
|
|
1588
|
+
const output = (stdout + stderr).trim();
|
|
1589
|
+
const errorLines = output.split("\n").map((l) => l.trim()).filter((l) => l.includes(": error TS"));
|
|
1590
|
+
const count = errorLines.length;
|
|
1591
|
+
const issues = errorLines.slice(0, 25).map((line) => {
|
|
1592
|
+
const match = line.match(/^(.+)\(\d+,\d+\): error (TS\d+: .+)$/);
|
|
1593
|
+
return {
|
|
1594
|
+
severity: "warning",
|
|
1595
|
+
message: match ? `${match[1]}: ${match[2]}` : line,
|
|
1596
|
+
reportedBy: ["tsc"]
|
|
1597
|
+
};
|
|
1598
|
+
});
|
|
1599
|
+
if (count > 25) {
|
|
1600
|
+
issues.push({
|
|
1601
|
+
severity: "info",
|
|
1602
|
+
message: `...and ${count - 25} more type errors`,
|
|
1603
|
+
reportedBy: ["tsc"]
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
const score = Math.max(0, 100 - count * 5);
|
|
1607
|
+
return {
|
|
1608
|
+
id: "typescript",
|
|
1609
|
+
category: this.category,
|
|
1610
|
+
name: "Type Safety",
|
|
1611
|
+
score,
|
|
1612
|
+
status: count === 0 ? "pass" : count > 20 ? "fail" : "warning",
|
|
1613
|
+
issues,
|
|
1614
|
+
toolsUsed: ["tsc"],
|
|
1615
|
+
duration: elapsed(),
|
|
1616
|
+
metadata: { errors: count }
|
|
1617
|
+
};
|
|
1618
|
+
} catch (err) {
|
|
1619
|
+
return {
|
|
1620
|
+
id: "typescript",
|
|
1621
|
+
category: this.category,
|
|
1622
|
+
name: "Type Safety",
|
|
1623
|
+
score: 0,
|
|
1624
|
+
status: "fail",
|
|
1625
|
+
issues: [
|
|
1626
|
+
{
|
|
1627
|
+
severity: "critical",
|
|
1628
|
+
message: `tsc failed: ${err}`,
|
|
1629
|
+
reportedBy: ["tsc"]
|
|
1630
|
+
}
|
|
1631
|
+
],
|
|
1632
|
+
toolsUsed: ["tsc"],
|
|
1633
|
+
duration: elapsed()
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
};
|
|
1638
|
+
|
|
1639
|
+
// src/integrations/todo-scanner.ts
|
|
1640
|
+
import { readFileSync as readFileSync7, readdirSync, statSync as statSync2, existsSync as existsSync8 } from "fs";
|
|
1641
|
+
import { join as join9, extname } from "path";
|
|
1642
|
+
var TODO_PATTERN = /\b(TODO|FIXME|HACK)\b[:\s]*(.*)/i;
|
|
1643
|
+
var STRING_LITERAL_RE = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g;
|
|
1644
|
+
function stripStringLiterals(line) {
|
|
1645
|
+
return line.replace(STRING_LITERAL_RE, (m) => " ".repeat(m.length));
|
|
1646
|
+
}
|
|
1647
|
+
var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
1648
|
+
".ts",
|
|
1649
|
+
".tsx",
|
|
1650
|
+
".js",
|
|
1651
|
+
".jsx",
|
|
1652
|
+
".mts",
|
|
1653
|
+
".cts"
|
|
1654
|
+
]);
|
|
1655
|
+
var TodoScannerRunner = class extends BaseRunner {
|
|
1656
|
+
name = "todo-scanner";
|
|
1657
|
+
category = "code-quality";
|
|
1658
|
+
async isApplicable(projectPath) {
|
|
1659
|
+
return existsSync8(join9(projectPath, "src"));
|
|
1660
|
+
}
|
|
1661
|
+
async run(projectPath) {
|
|
1662
|
+
const elapsed = timer();
|
|
1663
|
+
try {
|
|
1664
|
+
const todos = scanDirectory(join9(projectPath, "src"), projectPath);
|
|
1665
|
+
const issues = todos.map((t) => ({
|
|
1666
|
+
severity: t.kind === "FIXME" || t.kind === "HACK" ? "warning" : "info",
|
|
1667
|
+
message: `${t.file}:${t.line} \u2014 ${t.kind}: ${t.text || "(no description)"}`,
|
|
1668
|
+
reportedBy: ["todo-scanner"]
|
|
1669
|
+
}));
|
|
1670
|
+
const fixmeCount = todos.filter(
|
|
1671
|
+
(t) => t.kind === "FIXME" || t.kind === "HACK"
|
|
1672
|
+
).length;
|
|
1673
|
+
const score = Math.max(50, 100 - todos.length * 3);
|
|
1674
|
+
return {
|
|
1675
|
+
id: "todo-scanner",
|
|
1676
|
+
category: this.category,
|
|
1677
|
+
name: "Technical Debt",
|
|
1678
|
+
score,
|
|
1679
|
+
status: todos.length === 0 ? "pass" : fixmeCount > 5 || todos.length > 20 ? "warning" : "pass",
|
|
1680
|
+
issues,
|
|
1681
|
+
toolsUsed: ["todo-scanner"],
|
|
1682
|
+
duration: elapsed(),
|
|
1683
|
+
metadata: {
|
|
1684
|
+
total: todos.length,
|
|
1685
|
+
todo: todos.filter((t) => t.kind === "TODO").length,
|
|
1686
|
+
fixme: fixmeCount,
|
|
1687
|
+
hack: todos.filter((t) => t.kind === "HACK").length
|
|
1688
|
+
}
|
|
1689
|
+
};
|
|
1690
|
+
} catch (err) {
|
|
1691
|
+
return {
|
|
1692
|
+
id: "todo-scanner",
|
|
1693
|
+
category: this.category,
|
|
1694
|
+
name: "Technical Debt",
|
|
1695
|
+
score: 0,
|
|
1696
|
+
status: "fail",
|
|
1697
|
+
issues: [
|
|
1698
|
+
{
|
|
1699
|
+
severity: "critical",
|
|
1700
|
+
message: `Todo scan failed: ${err}`,
|
|
1701
|
+
reportedBy: ["todo-scanner"]
|
|
1702
|
+
}
|
|
1703
|
+
],
|
|
1704
|
+
toolsUsed: ["todo-scanner"],
|
|
1705
|
+
duration: elapsed()
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
};
|
|
1710
|
+
var SELF_REFERENCING_FILES = /* @__PURE__ */ new Set(["todo-scanner.ts", "todo-scanner.test.ts"]);
|
|
1711
|
+
function scanDirectory(dir, projectRoot) {
|
|
1712
|
+
const todos = [];
|
|
1713
|
+
try {
|
|
1714
|
+
for (const entry of readdirSync(dir)) {
|
|
1715
|
+
if (entry.startsWith(".") || entry === "node_modules") continue;
|
|
1716
|
+
if (SELF_REFERENCING_FILES.has(entry)) continue;
|
|
1717
|
+
const fullPath = join9(dir, entry);
|
|
1718
|
+
const stat = statSync2(fullPath);
|
|
1719
|
+
if (stat.isDirectory()) {
|
|
1720
|
+
todos.push(...scanDirectory(fullPath, projectRoot));
|
|
1721
|
+
} else if (SOURCE_EXTENSIONS.has(extname(entry))) {
|
|
1722
|
+
todos.push(...scanFile(fullPath, projectRoot));
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
} catch {
|
|
1726
|
+
}
|
|
1727
|
+
return todos;
|
|
1728
|
+
}
|
|
1729
|
+
function scanFile(filePath, projectRoot) {
|
|
1730
|
+
const todos = [];
|
|
1731
|
+
try {
|
|
1732
|
+
const lines = readFileSync7(filePath, "utf-8").split("\n");
|
|
1733
|
+
const relPath = filePath.replace(projectRoot + "/", "");
|
|
1734
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1735
|
+
const match = stripStringLiterals(lines[i]).match(TODO_PATTERN);
|
|
1736
|
+
if (match) {
|
|
1737
|
+
todos.push({
|
|
1738
|
+
file: relPath,
|
|
1739
|
+
line: i + 1,
|
|
1740
|
+
kind: match[1].toUpperCase(),
|
|
1741
|
+
text: match[2].trim().slice(0, 100)
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
} catch {
|
|
1746
|
+
}
|
|
1747
|
+
return todos;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// src/integrations/complexity.ts
|
|
1751
|
+
import { readFileSync as readFileSync8, readdirSync as readdirSync2, statSync as statSync3, existsSync as existsSync9 } from "fs";
|
|
1752
|
+
import { join as join10, extname as extname2 } from "path";
|
|
1753
|
+
var SOURCE_EXTENSIONS2 = /* @__PURE__ */ new Set([
|
|
1754
|
+
".ts",
|
|
1755
|
+
".tsx",
|
|
1756
|
+
".js",
|
|
1757
|
+
".jsx",
|
|
1758
|
+
".mts",
|
|
1759
|
+
".cts"
|
|
1760
|
+
]);
|
|
1761
|
+
var ComplexityRunner = class extends BaseRunner {
|
|
1762
|
+
name = "complexity";
|
|
1763
|
+
category = "code-quality";
|
|
1764
|
+
async isApplicable(projectPath) {
|
|
1765
|
+
return existsSync9(join10(projectPath, "src"));
|
|
1766
|
+
}
|
|
1767
|
+
async run(projectPath) {
|
|
1768
|
+
const elapsed = timer();
|
|
1769
|
+
try {
|
|
1770
|
+
const files = scanDirectory2(join10(projectPath, "src"), projectPath);
|
|
1771
|
+
const oversized = files.filter((f) => f.lines >= WARN_LINES);
|
|
1772
|
+
const issues = oversized.map((f) => ({
|
|
1773
|
+
severity: f.lines >= CRITICAL_LINES ? "warning" : "info",
|
|
1774
|
+
message: `${f.path}: ${f.lines} lines \u2014 consider splitting into smaller modules`,
|
|
1775
|
+
fix: { description: "Extract concerns into smaller, focused files" },
|
|
1776
|
+
reportedBy: ["complexity"]
|
|
1777
|
+
}));
|
|
1778
|
+
const totalLines = files.reduce((sum, f) => sum + f.lines, 0);
|
|
1779
|
+
const avgLines = files.length > 0 ? Math.round(totalLines / files.length) : 0;
|
|
1780
|
+
const score = Math.max(0, 100 - oversized.length * 10);
|
|
1781
|
+
const topFiles = [...files].sort((a, b) => b.lines - a.lines).slice(0, 10);
|
|
1782
|
+
return {
|
|
1783
|
+
id: "complexity",
|
|
1784
|
+
category: this.category,
|
|
1785
|
+
name: "File Complexity",
|
|
1786
|
+
score,
|
|
1787
|
+
status: oversized.some((f) => f.lines >= CRITICAL_LINES) ? "warning" : oversized.length > 0 ? "warning" : "pass",
|
|
1788
|
+
issues,
|
|
1789
|
+
toolsUsed: ["complexity"],
|
|
1790
|
+
duration: elapsed(),
|
|
1791
|
+
metadata: {
|
|
1792
|
+
totalFiles: files.length,
|
|
1793
|
+
totalLines,
|
|
1794
|
+
avgLines,
|
|
1795
|
+
oversizedCount: oversized.length,
|
|
1796
|
+
topFiles
|
|
1797
|
+
}
|
|
1798
|
+
};
|
|
1799
|
+
} catch (err) {
|
|
1800
|
+
return {
|
|
1801
|
+
id: "complexity",
|
|
1802
|
+
category: this.category,
|
|
1803
|
+
name: "File Complexity",
|
|
1804
|
+
score: 0,
|
|
1805
|
+
status: "fail",
|
|
1806
|
+
issues: [
|
|
1807
|
+
{
|
|
1808
|
+
severity: "critical",
|
|
1809
|
+
message: `Complexity scan failed: ${err}`,
|
|
1810
|
+
reportedBy: ["complexity"]
|
|
1811
|
+
}
|
|
1812
|
+
],
|
|
1813
|
+
toolsUsed: ["complexity"],
|
|
1814
|
+
duration: elapsed()
|
|
1815
|
+
};
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
};
|
|
1819
|
+
function scanDirectory2(dir, projectRoot) {
|
|
1820
|
+
const files = [];
|
|
1821
|
+
try {
|
|
1822
|
+
for (const entry of readdirSync2(dir)) {
|
|
1823
|
+
if (entry.startsWith(".") || entry === "node_modules" || entry === "__tests__" || entry === "test" || entry === "tests")
|
|
1824
|
+
continue;
|
|
1825
|
+
const fullPath = join10(dir, entry);
|
|
1826
|
+
const stat = statSync3(fullPath);
|
|
1827
|
+
if (stat.isDirectory()) {
|
|
1828
|
+
files.push(...scanDirectory2(fullPath, projectRoot));
|
|
1829
|
+
} else if (SOURCE_EXTENSIONS2.has(extname2(entry)) && !isTestFile(entry)) {
|
|
1830
|
+
try {
|
|
1831
|
+
const lines = readFileSync8(fullPath, "utf-8").split("\n").filter((l) => l.trim()).length;
|
|
1832
|
+
files.push({ path: fullPath.replace(projectRoot + "/", ""), lines });
|
|
1833
|
+
} catch {
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
} catch {
|
|
1838
|
+
}
|
|
1839
|
+
return files;
|
|
1840
|
+
}
|
|
1841
|
+
function isTestFile(filename) {
|
|
1842
|
+
return /\.(test|spec)\.(ts|tsx|js|jsx|mts|cts)$/.test(filename);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// src/integrations/secrets.ts
|
|
1846
|
+
import { readFileSync as readFileSync9, readdirSync as readdirSync3, statSync as statSync4, existsSync as existsSync10 } from "fs";
|
|
1847
|
+
import { join as join11, extname as extname3, basename } from "path";
|
|
1848
|
+
var SCAN_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
1849
|
+
".ts",
|
|
1850
|
+
".tsx",
|
|
1851
|
+
".js",
|
|
1852
|
+
".jsx",
|
|
1853
|
+
".mts",
|
|
1854
|
+
".cts",
|
|
1855
|
+
".json",
|
|
1856
|
+
".yaml",
|
|
1857
|
+
".yml",
|
|
1858
|
+
".toml"
|
|
1859
|
+
]);
|
|
1860
|
+
var SKIP_FILES = /* @__PURE__ */ new Set([
|
|
1861
|
+
"package-lock.json",
|
|
1862
|
+
"pnpm-lock.yaml",
|
|
1863
|
+
"yarn.lock",
|
|
1864
|
+
".env.example",
|
|
1865
|
+
".env.sample",
|
|
1866
|
+
".env.template"
|
|
1867
|
+
]);
|
|
1868
|
+
var PATTERNS = [
|
|
1869
|
+
{ name: "AWS Access Key", regex: /AKIA[0-9A-Z]{16}/ },
|
|
1870
|
+
{ name: "GitHub Token", regex: /gh[pousr]_[A-Za-z0-9_]{36,}/ },
|
|
1871
|
+
{ name: "Slack Token", regex: /xox[baprs]-[0-9A-Za-z-]{10,}/ },
|
|
1872
|
+
{ name: "Google API Key", regex: /AIza[0-9A-Za-z\-_]{35}/ },
|
|
1873
|
+
{ name: "Stripe Key", regex: /[rs]k_live_[0-9a-zA-Z]{24}/ },
|
|
1874
|
+
{
|
|
1875
|
+
name: "Private Key",
|
|
1876
|
+
regex: /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/
|
|
1877
|
+
},
|
|
1878
|
+
{
|
|
1879
|
+
name: "Hardcoded password",
|
|
1880
|
+
regex: /(?:^|[^a-z])(?:password|passwd|pwd)\s*[=:]\s*["'][^"']{8,}["']/i
|
|
1881
|
+
},
|
|
1882
|
+
{
|
|
1883
|
+
name: "Hardcoded API key",
|
|
1884
|
+
regex: /(?:^|[^a-z])(?:api_?key|apikey)\s*[=:]\s*["'][A-Za-z0-9+/=_-]{16,}["']/i
|
|
1885
|
+
},
|
|
1886
|
+
{
|
|
1887
|
+
name: "Hardcoded secret",
|
|
1888
|
+
regex: /(?:^|[^a-z])(?:secret)\s*[=:]\s*["'][A-Za-z0-9+/=_-]{16,}["']/i
|
|
1889
|
+
}
|
|
1890
|
+
];
|
|
1891
|
+
var SecretsRunner = class extends BaseRunner {
|
|
1892
|
+
name = "secrets";
|
|
1893
|
+
category = "security";
|
|
1894
|
+
async run(projectPath) {
|
|
1895
|
+
const elapsed = timer();
|
|
1896
|
+
try {
|
|
1897
|
+
const findings = [];
|
|
1898
|
+
const envFiles = [".env", ".env.local", ".env.production"];
|
|
1899
|
+
for (const envFile of envFiles) {
|
|
1900
|
+
if (existsSync10(join11(projectPath, envFile))) {
|
|
1901
|
+
const gitignorePath = join11(projectPath, ".gitignore");
|
|
1902
|
+
let ignored = false;
|
|
1903
|
+
try {
|
|
1904
|
+
const gitignore = readFileSync9(gitignorePath, "utf-8");
|
|
1905
|
+
ignored = gitignore.includes(envFile) || gitignore.includes(".env");
|
|
1906
|
+
} catch {
|
|
1907
|
+
}
|
|
1908
|
+
if (!ignored) {
|
|
1909
|
+
findings.push({
|
|
1910
|
+
file: envFile,
|
|
1911
|
+
line: 0,
|
|
1912
|
+
pattern: ".env file not in .gitignore"
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
if (existsSync10(join11(projectPath, "src"))) {
|
|
1918
|
+
findings.push(...scanDirectory3(join11(projectPath, "src"), projectPath));
|
|
1919
|
+
}
|
|
1920
|
+
const issues = findings.map((f) => ({
|
|
1921
|
+
severity: "critical",
|
|
1922
|
+
message: f.line > 0 ? `${f.file}:${f.line} \u2014 ${f.pattern} detected` : `${f.file} \u2014 ${f.pattern}`,
|
|
1923
|
+
file: f.file,
|
|
1924
|
+
fix: {
|
|
1925
|
+
description: "Move secrets to environment variables",
|
|
1926
|
+
codeChange: f.codeSnippet ? {
|
|
1927
|
+
before: f.codeSnippet,
|
|
1928
|
+
after: "Use process.env.YOUR_SECRET_NAME instead"
|
|
1929
|
+
} : void 0
|
|
1930
|
+
},
|
|
1931
|
+
reportedBy: ["secrets"]
|
|
1932
|
+
}));
|
|
1933
|
+
const count = findings.length;
|
|
1934
|
+
const score = Math.max(0, 100 - count * 25);
|
|
1935
|
+
return {
|
|
1936
|
+
id: "secrets",
|
|
1937
|
+
category: this.category,
|
|
1938
|
+
name: "Secrets Detection",
|
|
1939
|
+
score,
|
|
1940
|
+
status: count === 0 ? "pass" : "fail",
|
|
1941
|
+
issues,
|
|
1942
|
+
toolsUsed: ["secrets"],
|
|
1943
|
+
duration: elapsed(),
|
|
1944
|
+
metadata: { findings: count }
|
|
1945
|
+
};
|
|
1946
|
+
} catch (err) {
|
|
1947
|
+
return {
|
|
1948
|
+
id: "secrets",
|
|
1949
|
+
category: this.category,
|
|
1950
|
+
name: "Secrets Detection",
|
|
1951
|
+
score: 0,
|
|
1952
|
+
status: "fail",
|
|
1953
|
+
issues: [
|
|
1954
|
+
{
|
|
1955
|
+
severity: "critical",
|
|
1956
|
+
message: `Secrets scan failed: ${err}`,
|
|
1957
|
+
reportedBy: ["secrets"]
|
|
1958
|
+
}
|
|
1959
|
+
],
|
|
1960
|
+
toolsUsed: ["secrets"],
|
|
1961
|
+
duration: elapsed()
|
|
1962
|
+
};
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
};
|
|
1966
|
+
function scanDirectory3(dir, projectRoot) {
|
|
1967
|
+
const findings = [];
|
|
1968
|
+
try {
|
|
1969
|
+
for (const entry of readdirSync3(dir)) {
|
|
1970
|
+
if (entry.startsWith(".") || entry === "node_modules" || entry === "__tests__" || entry === "test" || entry === "tests")
|
|
1971
|
+
continue;
|
|
1972
|
+
const fullPath = join11(dir, entry);
|
|
1973
|
+
const stat = statSync4(fullPath);
|
|
1974
|
+
if (stat.isDirectory()) {
|
|
1975
|
+
findings.push(...scanDirectory3(fullPath, projectRoot));
|
|
1976
|
+
} else if (SCAN_EXTENSIONS.has(extname3(entry)) && !SKIP_FILES.has(basename(entry)) && !isTestFile2(entry)) {
|
|
1977
|
+
findings.push(...scanFile2(fullPath, projectRoot));
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
} catch {
|
|
1981
|
+
}
|
|
1982
|
+
return findings;
|
|
1983
|
+
}
|
|
1984
|
+
function isTestFile2(filename) {
|
|
1985
|
+
return /\.(test|spec)\.(ts|tsx|js|jsx|mts|cts)$/.test(filename);
|
|
1986
|
+
}
|
|
1987
|
+
function scanFile2(filePath, projectRoot) {
|
|
1988
|
+
const findings = [];
|
|
1989
|
+
try {
|
|
1990
|
+
const content = readFileSync9(filePath, "utf-8");
|
|
1991
|
+
const lines = content.split("\n");
|
|
1992
|
+
const relPath = filePath.replace(projectRoot + "/", "");
|
|
1993
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1994
|
+
const line = lines[i];
|
|
1995
|
+
if (line.includes("process.env") || line.includes("import.meta.env"))
|
|
1996
|
+
continue;
|
|
1997
|
+
if (line.trim().startsWith("//") || line.trim().startsWith("#") || line.trim().startsWith("*"))
|
|
1998
|
+
continue;
|
|
1999
|
+
for (const pattern of PATTERNS) {
|
|
2000
|
+
if (pattern.regex.test(line)) {
|
|
2001
|
+
findings.push({
|
|
2002
|
+
file: relPath,
|
|
2003
|
+
line: i + 1,
|
|
2004
|
+
pattern: pattern.name,
|
|
2005
|
+
codeSnippet: line.trim()
|
|
2006
|
+
});
|
|
2007
|
+
break;
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
} catch {
|
|
2012
|
+
}
|
|
2013
|
+
return findings;
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
// src/integrations/heavy-deps.ts
|
|
2017
|
+
import { readFileSync as readFileSync10 } from "fs";
|
|
2018
|
+
import { join as join12 } from "path";
|
|
2019
|
+
var HEAVY_DEPS = {
|
|
2020
|
+
"moment": {
|
|
2021
|
+
alternative: "dayjs or date-fns",
|
|
2022
|
+
reason: "moment is 300KB+ and mutable \u2014 modern alternatives are ~2KB",
|
|
2023
|
+
severity: "warning"
|
|
2024
|
+
},
|
|
2025
|
+
"lodash": {
|
|
2026
|
+
alternative: "lodash-es or individual lodash/* imports",
|
|
2027
|
+
reason: "Full lodash bundle is 70KB+ \u2014 use tree-shakeable imports instead",
|
|
2028
|
+
severity: "warning"
|
|
2029
|
+
},
|
|
2030
|
+
"underscore": {
|
|
2031
|
+
alternative: "native JS methods (map, filter, reduce, etc.)",
|
|
2032
|
+
reason: "Most underscore utilities have native equivalents",
|
|
2033
|
+
severity: "warning"
|
|
2034
|
+
},
|
|
2035
|
+
"jquery": {
|
|
2036
|
+
alternative: "native DOM APIs (querySelector, fetch, classList, etc.)",
|
|
2037
|
+
reason: "Modern browsers cover virtually all jQuery use cases natively",
|
|
2038
|
+
severity: "warning"
|
|
2039
|
+
},
|
|
2040
|
+
"request": {
|
|
2041
|
+
alternative: "native fetch or undici",
|
|
2042
|
+
reason: "request is deprecated and heavy",
|
|
2043
|
+
severity: "warning"
|
|
2044
|
+
},
|
|
2045
|
+
"bluebird": {
|
|
2046
|
+
alternative: "native Promises",
|
|
2047
|
+
reason: "Native Promises are performant in modern Node/browsers",
|
|
2048
|
+
severity: "info"
|
|
2049
|
+
},
|
|
2050
|
+
"axios": {
|
|
2051
|
+
alternative: "native fetch",
|
|
2052
|
+
reason: "fetch is built-in to Node 18+ and all modern browsers",
|
|
2053
|
+
severity: "info"
|
|
2054
|
+
},
|
|
2055
|
+
"left-pad": {
|
|
2056
|
+
alternative: "String.prototype.padStart()",
|
|
2057
|
+
reason: "padStart is a native JS method",
|
|
2058
|
+
severity: "info"
|
|
2059
|
+
},
|
|
2060
|
+
"is-even": {
|
|
2061
|
+
alternative: "n % 2 === 0",
|
|
2062
|
+
reason: "Trivial one-liner \u2014 no package needed",
|
|
2063
|
+
severity: "info"
|
|
2064
|
+
},
|
|
2065
|
+
"is-odd": {
|
|
2066
|
+
alternative: "n % 2 !== 0",
|
|
2067
|
+
reason: "Trivial one-liner \u2014 no package needed",
|
|
2068
|
+
severity: "info"
|
|
2069
|
+
},
|
|
2070
|
+
"is-number": {
|
|
2071
|
+
alternative: 'typeof n === "number" or Number.isFinite()',
|
|
2072
|
+
reason: "Trivial check \u2014 no package needed",
|
|
2073
|
+
severity: "info"
|
|
2074
|
+
},
|
|
2075
|
+
"classnames": {
|
|
2076
|
+
alternative: "clsx (lighter drop-in replacement)",
|
|
2077
|
+
reason: "clsx is smaller and faster",
|
|
2078
|
+
severity: "info"
|
|
2079
|
+
},
|
|
2080
|
+
"node-fetch": {
|
|
2081
|
+
alternative: "native fetch (Node 18+)",
|
|
2082
|
+
reason: "fetch is built-in to Node 18+",
|
|
2083
|
+
severity: "info"
|
|
2084
|
+
},
|
|
2085
|
+
"moment-timezone": {
|
|
2086
|
+
alternative: "Intl.DateTimeFormat or date-fns-tz",
|
|
2087
|
+
reason: "moment-timezone bundles all IANA timezone data \u2014 500KB+ unminified",
|
|
2088
|
+
severity: "warning"
|
|
2089
|
+
},
|
|
2090
|
+
"uuid": {
|
|
2091
|
+
alternative: "crypto.randomUUID()",
|
|
2092
|
+
reason: "crypto.randomUUID() is native in Node 14.17+ and all modern browsers",
|
|
2093
|
+
severity: "info"
|
|
2094
|
+
},
|
|
2095
|
+
"rimraf": {
|
|
2096
|
+
alternative: "fs.rm(path, { recursive: true, force: true })",
|
|
2097
|
+
reason: "fs.rm with recursive option is built into Node 14.14+",
|
|
2098
|
+
severity: "info"
|
|
2099
|
+
},
|
|
2100
|
+
"mkdirp": {
|
|
2101
|
+
alternative: "fs.mkdir(path, { recursive: true })",
|
|
2102
|
+
reason: "fs.mkdir with recursive option is built into Node 10.12+",
|
|
2103
|
+
severity: "info"
|
|
2104
|
+
},
|
|
2105
|
+
"qs": {
|
|
2106
|
+
alternative: "URLSearchParams",
|
|
2107
|
+
reason: "URLSearchParams handles query string parsing natively in Node and browsers",
|
|
2108
|
+
severity: "info"
|
|
2109
|
+
}
|
|
2110
|
+
};
|
|
2111
|
+
var HeavyDepsRunner = class extends BaseRunner {
|
|
2112
|
+
name = "heavy-deps";
|
|
2113
|
+
category = "performance";
|
|
2114
|
+
async isApplicable(projectPath) {
|
|
2115
|
+
return fileExists(projectPath, "package.json");
|
|
2116
|
+
}
|
|
2117
|
+
async run(projectPath) {
|
|
2118
|
+
const elapsed = timer();
|
|
2119
|
+
try {
|
|
2120
|
+
const pkg = JSON.parse(readFileSync10(join12(projectPath, "package.json"), "utf-8"));
|
|
2121
|
+
const allDeps = {
|
|
2122
|
+
...pkg.dependencies,
|
|
2123
|
+
...pkg.devDependencies
|
|
2124
|
+
};
|
|
2125
|
+
const depNames = Object.keys(allDeps || {});
|
|
2126
|
+
const found = [];
|
|
2127
|
+
for (const dep of depNames) {
|
|
2128
|
+
const info = HEAVY_DEPS[dep];
|
|
2129
|
+
if (info) {
|
|
2130
|
+
found.push({ name: dep, info });
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
const issues = found.map((f) => ({
|
|
2134
|
+
severity: f.info.severity,
|
|
2135
|
+
message: `${f.name} \u2014 ${f.info.reason}`,
|
|
2136
|
+
fix: {
|
|
2137
|
+
description: `Consider replacing with ${f.info.alternative}`
|
|
2138
|
+
},
|
|
2139
|
+
reportedBy: ["heavy-deps"]
|
|
2140
|
+
}));
|
|
2141
|
+
const warningCount = found.filter((f) => f.info.severity === "warning").length;
|
|
2142
|
+
const infoCount = found.filter((f) => f.info.severity === "info").length;
|
|
2143
|
+
const score = Math.max(30, 100 - warningCount * 10 - infoCount * 5);
|
|
2144
|
+
return {
|
|
2145
|
+
id: "heavy-deps",
|
|
2146
|
+
category: this.category,
|
|
2147
|
+
name: "Heavy Dependencies",
|
|
2148
|
+
score,
|
|
2149
|
+
status: warningCount > 0 ? "warning" : infoCount > 0 ? "warning" : "pass",
|
|
2150
|
+
issues,
|
|
2151
|
+
toolsUsed: ["heavy-deps"],
|
|
2152
|
+
duration: elapsed(),
|
|
2153
|
+
metadata: {
|
|
2154
|
+
totalDeps: depNames.length,
|
|
2155
|
+
heavyDepsFound: found.length,
|
|
2156
|
+
heavyDeps: found.map((f) => f.name)
|
|
2157
|
+
}
|
|
2158
|
+
};
|
|
2159
|
+
} catch (err) {
|
|
2160
|
+
return {
|
|
2161
|
+
id: "heavy-deps",
|
|
2162
|
+
category: this.category,
|
|
2163
|
+
name: "Heavy Dependencies",
|
|
2164
|
+
score: 0,
|
|
2165
|
+
status: "fail",
|
|
2166
|
+
issues: [{ severity: "critical", message: `Heavy deps check failed: ${err}`, reportedBy: ["heavy-deps"] }],
|
|
2167
|
+
toolsUsed: ["heavy-deps"],
|
|
2168
|
+
duration: elapsed()
|
|
2169
|
+
};
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
};
|
|
2173
|
+
|
|
2174
|
+
// src/integrations/react-perf.ts
|
|
2175
|
+
import { readFileSync as readFileSync11, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
|
|
2176
|
+
import { join as join13, extname as extname4 } from "path";
|
|
2177
|
+
var COMPONENT_EXTENSIONS = /* @__PURE__ */ new Set([".tsx", ".jsx"]);
|
|
2178
|
+
var ReactPerfRunner = class extends BaseRunner {
|
|
2179
|
+
name = "react-perf";
|
|
2180
|
+
category = "performance";
|
|
2181
|
+
applicableFrameworks = ["react", "next", "remix"];
|
|
2182
|
+
async run(projectPath) {
|
|
2183
|
+
const elapsed = timer();
|
|
2184
|
+
try {
|
|
2185
|
+
const hasReactCompiler = detectReactCompiler(projectPath);
|
|
2186
|
+
const findings = [];
|
|
2187
|
+
const files = scanDirectory4(join13(projectPath, "src"), projectPath);
|
|
2188
|
+
for (const file of files) {
|
|
2189
|
+
findings.push(...analyzeFile(file.path, file.fullPath, file.lines));
|
|
2190
|
+
}
|
|
2191
|
+
const activeFindings = hasReactCompiler ? findings.filter((f) => !f.pattern.includes("Inline object")) : findings;
|
|
2192
|
+
const issues = activeFindings.map((f) => ({
|
|
2193
|
+
severity: f.severity,
|
|
2194
|
+
message: f.line > 0 ? `${f.file}:${f.line} \u2014 ${f.pattern}` : `${f.file} \u2014 ${f.pattern}`,
|
|
2195
|
+
file: f.file,
|
|
2196
|
+
fix: { description: getFixDescription(f.pattern) },
|
|
2197
|
+
reportedBy: ["react-perf"]
|
|
2198
|
+
}));
|
|
2199
|
+
const warningCount = activeFindings.filter(
|
|
2200
|
+
(f) => f.severity === "warning"
|
|
2201
|
+
).length;
|
|
2202
|
+
const infoCount = activeFindings.filter((f) => f.severity === "info").length;
|
|
2203
|
+
const score = Math.max(20, 100 - warningCount * 3 - infoCount * 1);
|
|
2204
|
+
return {
|
|
2205
|
+
id: "react-perf",
|
|
2206
|
+
category: this.category,
|
|
2207
|
+
name: "React Performance",
|
|
2208
|
+
score,
|
|
2209
|
+
status: warningCount > 0 ? "warning" : infoCount > 0 ? "warning" : "pass",
|
|
2210
|
+
issues,
|
|
2211
|
+
toolsUsed: ["react-perf"],
|
|
2212
|
+
duration: elapsed(),
|
|
2213
|
+
metadata: {
|
|
2214
|
+
filesScanned: files.length,
|
|
2215
|
+
reactCompiler: hasReactCompiler,
|
|
2216
|
+
totalFindings: activeFindings.length,
|
|
2217
|
+
inlineObjects: activeFindings.filter((f) => f.pattern.includes("Inline")).length,
|
|
2218
|
+
indexAsKey: activeFindings.filter((f) => f.pattern.includes("index as key")).length,
|
|
2219
|
+
largeComponents: activeFindings.filter(
|
|
2220
|
+
(f) => f.pattern.includes("Large component")
|
|
2221
|
+
).length
|
|
2222
|
+
}
|
|
2223
|
+
};
|
|
2224
|
+
} catch (err) {
|
|
2225
|
+
return {
|
|
2226
|
+
id: "react-perf",
|
|
2227
|
+
category: this.category,
|
|
2228
|
+
name: "React Performance",
|
|
2229
|
+
score: 0,
|
|
2230
|
+
status: "fail",
|
|
2231
|
+
issues: [
|
|
2232
|
+
{
|
|
2233
|
+
severity: "critical",
|
|
2234
|
+
message: `React perf check failed: ${err}`,
|
|
2235
|
+
reportedBy: ["react-perf"]
|
|
2236
|
+
}
|
|
2237
|
+
],
|
|
2238
|
+
toolsUsed: ["react-perf"],
|
|
2239
|
+
duration: elapsed()
|
|
2240
|
+
};
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
};
|
|
2244
|
+
function detectReactCompiler(projectPath) {
|
|
2245
|
+
try {
|
|
2246
|
+
const pkg = JSON.parse(
|
|
2247
|
+
readFileSync11(join13(projectPath, "package.json"), "utf-8")
|
|
2248
|
+
);
|
|
2249
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
2250
|
+
return "babel-plugin-react-compiler" in allDeps || "@react-compiler/babel" in allDeps;
|
|
2251
|
+
} catch {
|
|
2252
|
+
return false;
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
function scanDirectory4(dir, projectRoot) {
|
|
2256
|
+
const files = [];
|
|
2257
|
+
try {
|
|
2258
|
+
for (const entry of readdirSync4(dir)) {
|
|
2259
|
+
if (entry.startsWith(".") || entry === "node_modules" || entry === "__tests__" || entry === "__mocks__" || entry.includes(".test.") || entry.includes(".spec."))
|
|
2260
|
+
continue;
|
|
2261
|
+
const fullPath = join13(dir, entry);
|
|
2262
|
+
const stat = statSync5(fullPath);
|
|
2263
|
+
if (stat.isDirectory()) {
|
|
2264
|
+
files.push(...scanDirectory4(fullPath, projectRoot));
|
|
2265
|
+
} else if (COMPONENT_EXTENSIONS.has(extname4(entry))) {
|
|
2266
|
+
try {
|
|
2267
|
+
const content = readFileSync11(fullPath, "utf-8");
|
|
2268
|
+
const lineCount = content.split("\n").length;
|
|
2269
|
+
files.push({
|
|
2270
|
+
path: fullPath.replace(projectRoot + "/", ""),
|
|
2271
|
+
fullPath,
|
|
2272
|
+
lines: lineCount
|
|
2273
|
+
});
|
|
2274
|
+
} catch {
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
} catch {
|
|
2279
|
+
}
|
|
2280
|
+
return files;
|
|
2281
|
+
}
|
|
2282
|
+
function analyzeFile(relPath, fullPath, lineCount) {
|
|
2283
|
+
const findings = [];
|
|
2284
|
+
try {
|
|
2285
|
+
const content = readFileSync11(fullPath, "utf-8");
|
|
2286
|
+
const lines = content.split("\n");
|
|
2287
|
+
if (lineCount > WARN_LINES) {
|
|
2288
|
+
findings.push({
|
|
2289
|
+
file: relPath,
|
|
2290
|
+
line: 0,
|
|
2291
|
+
pattern: `Large component file (${lineCount} lines) \u2014 consider splitting`,
|
|
2292
|
+
severity: "info"
|
|
2293
|
+
});
|
|
2294
|
+
}
|
|
2295
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2296
|
+
const line = lines[i];
|
|
2297
|
+
const trimmed = line.trim();
|
|
2298
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*"))
|
|
2299
|
+
continue;
|
|
2300
|
+
if (/\w+=\{\{/.test(line) && !trimmed.startsWith("//")) {
|
|
2301
|
+
if (!/className=\{\{/.test(line)) {
|
|
2302
|
+
findings.push({
|
|
2303
|
+
file: relPath,
|
|
2304
|
+
line: i + 1,
|
|
2305
|
+
pattern: "Inline object in JSX prop \u2014 creates new reference every render",
|
|
2306
|
+
severity: "warning"
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
if (/\.map\s*\(/.test(line) || i > 0 && /\.map\s*\(/.test(lines[i - 1])) {
|
|
2311
|
+
if (/key=\{(?:index|i|idx)\}/.test(line)) {
|
|
2312
|
+
findings.push({
|
|
2313
|
+
file: relPath,
|
|
2314
|
+
line: i + 1,
|
|
2315
|
+
pattern: "Using index as key in list \u2014 can cause rendering issues with dynamic lists",
|
|
2316
|
+
severity: "warning"
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
checkLazyRoutes(content, relPath, findings);
|
|
2322
|
+
} catch {
|
|
2323
|
+
}
|
|
2324
|
+
return findings;
|
|
2325
|
+
}
|
|
2326
|
+
function checkLazyRoutes(content, relPath, findings) {
|
|
2327
|
+
if (!content.includes("<Route") && !content.includes("createBrowserRouter"))
|
|
2328
|
+
return;
|
|
2329
|
+
const hasLazy = content.includes("React.lazy") || content.includes("lazy(");
|
|
2330
|
+
const importLines = content.split("\n").filter((l) => l.startsWith("import ") && !l.includes("react-router"));
|
|
2331
|
+
if (!hasLazy && importLines.length > 3) {
|
|
2332
|
+
findings.push({
|
|
2333
|
+
file: relPath,
|
|
2334
|
+
line: 0,
|
|
2335
|
+
pattern: "Route file with static imports \u2014 consider React.lazy() for code splitting",
|
|
2336
|
+
severity: "info"
|
|
2337
|
+
});
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
function getFixDescription(pattern) {
|
|
2341
|
+
if (pattern.includes("Inline object")) {
|
|
2342
|
+
return "Extract the object to a constant outside the component or use useMemo()";
|
|
2343
|
+
}
|
|
2344
|
+
if (pattern.includes("index as key")) {
|
|
2345
|
+
return "Use a unique identifier (id, slug, etc.) as the key instead of the array index";
|
|
2346
|
+
}
|
|
2347
|
+
if (pattern.includes("Large component")) {
|
|
2348
|
+
return "Break into smaller, focused components to improve readability and render performance";
|
|
2349
|
+
}
|
|
2350
|
+
if (pattern.includes("lazy")) {
|
|
2351
|
+
return "Use React.lazy() and Suspense for route-level code splitting";
|
|
2352
|
+
}
|
|
2353
|
+
return "Review and optimize for better rendering performance";
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
// src/integrations/asset-size.ts
|
|
2357
|
+
import { readdirSync as readdirSync5, statSync as statSync6 } from "fs";
|
|
2358
|
+
import { join as join14, extname as extname5 } from "path";
|
|
2359
|
+
var ASSET_DIRS = ["public", "src/assets", "static", "assets"];
|
|
2360
|
+
var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico"]);
|
|
2361
|
+
var SVG_EXTENSION = ".svg";
|
|
2362
|
+
var FONT_EXTENSIONS = /* @__PURE__ */ new Set([".woff", ".woff2", ".ttf", ".otf", ".eot"]);
|
|
2363
|
+
var SKIP_EXTENSIONS = /* @__PURE__ */ new Set([".mp4", ".webm", ".ogg", ".mp3", ".wav", ".flac", ".avi", ".mov"]);
|
|
2364
|
+
var IMAGE_WARN = 500 * 1024;
|
|
2365
|
+
var IMAGE_CRITICAL = 2 * 1024 * 1024;
|
|
2366
|
+
var SVG_WARN = 100 * 1024;
|
|
2367
|
+
var FONT_WARN = 500 * 1024;
|
|
2368
|
+
var TOTAL_WARN = 5 * 1024 * 1024;
|
|
2369
|
+
var TOTAL_CRITICAL = 10 * 1024 * 1024;
|
|
2370
|
+
var AssetSizeRunner = class extends BaseRunner {
|
|
2371
|
+
name = "asset-size";
|
|
2372
|
+
category = "performance";
|
|
2373
|
+
applicableRuntimes = ["browser"];
|
|
2374
|
+
async run(projectPath) {
|
|
2375
|
+
const elapsed = timer();
|
|
2376
|
+
try {
|
|
2377
|
+
const assets = [];
|
|
2378
|
+
for (const dir of ASSET_DIRS) {
|
|
2379
|
+
if (fileExists(projectPath, dir)) {
|
|
2380
|
+
scanAssets(join14(projectPath, dir), projectPath, assets);
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
const issues = [];
|
|
2384
|
+
let totalSize = 0;
|
|
2385
|
+
for (const asset of assets) {
|
|
2386
|
+
totalSize += asset.size;
|
|
2387
|
+
const sizeKB = Math.round(asset.size / 1024);
|
|
2388
|
+
const sizeMB = (asset.size / (1024 * 1024)).toFixed(1);
|
|
2389
|
+
if (asset.type === "image") {
|
|
2390
|
+
if (asset.size > IMAGE_CRITICAL) {
|
|
2391
|
+
issues.push({
|
|
2392
|
+
severity: "critical",
|
|
2393
|
+
message: `${asset.path} \u2014 ${sizeMB}MB image (exceeds 2MB)`,
|
|
2394
|
+
file: asset.path,
|
|
2395
|
+
fix: { description: "Compress with tools like squoosh.app, tinypng.com, or convert to WebP/AVIF format" },
|
|
2396
|
+
reportedBy: ["asset-size"]
|
|
2397
|
+
});
|
|
2398
|
+
} else if (asset.size > IMAGE_WARN) {
|
|
2399
|
+
issues.push({
|
|
2400
|
+
severity: "warning",
|
|
2401
|
+
message: `${asset.path} \u2014 ${sizeKB}KB image (exceeds 500KB)`,
|
|
2402
|
+
file: asset.path,
|
|
2403
|
+
fix: { description: "Compress or convert to a more efficient format (WebP, AVIF)" },
|
|
2404
|
+
reportedBy: ["asset-size"]
|
|
2405
|
+
});
|
|
2406
|
+
}
|
|
2407
|
+
} else if (asset.type === "svg") {
|
|
2408
|
+
if (asset.size > SVG_WARN) {
|
|
2409
|
+
issues.push({
|
|
2410
|
+
severity: "warning",
|
|
2411
|
+
message: `${asset.path} \u2014 ${sizeKB}KB SVG (exceeds 100KB, likely unoptimized)`,
|
|
2412
|
+
file: asset.path,
|
|
2413
|
+
fix: { description: "Optimize with SVGO or svgomg.net \u2014 remove metadata, simplify paths" },
|
|
2414
|
+
reportedBy: ["asset-size"]
|
|
2415
|
+
});
|
|
2416
|
+
}
|
|
2417
|
+
} else if (asset.type === "font") {
|
|
2418
|
+
if (asset.size > FONT_WARN) {
|
|
2419
|
+
issues.push({
|
|
2420
|
+
severity: "warning",
|
|
2421
|
+
message: `${asset.path} \u2014 ${sizeKB}KB font (exceeds 500KB)`,
|
|
2422
|
+
file: asset.path,
|
|
2423
|
+
fix: { description: "Subset the font to include only needed characters, or use WOFF2 format" },
|
|
2424
|
+
reportedBy: ["asset-size"]
|
|
2425
|
+
});
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
const totalMB = (totalSize / (1024 * 1024)).toFixed(1);
|
|
2430
|
+
if (totalSize > TOTAL_CRITICAL) {
|
|
2431
|
+
issues.push({
|
|
2432
|
+
severity: "critical",
|
|
2433
|
+
message: `Total asset size is ${totalMB}MB \u2014 exceeds 10MB threshold`,
|
|
2434
|
+
fix: { description: "Review and optimize all static assets to reduce total payload" },
|
|
2435
|
+
reportedBy: ["asset-size"]
|
|
2436
|
+
});
|
|
2437
|
+
} else if (totalSize > TOTAL_WARN) {
|
|
2438
|
+
issues.push({
|
|
2439
|
+
severity: "warning",
|
|
2440
|
+
message: `Total asset size is ${totalMB}MB \u2014 consider optimizing`,
|
|
2441
|
+
fix: { description: "Compress images, subset fonts, and remove unused assets" },
|
|
2442
|
+
reportedBy: ["asset-size"]
|
|
2443
|
+
});
|
|
2444
|
+
}
|
|
2445
|
+
const criticalCount = issues.filter((i) => i.severity === "critical").length;
|
|
2446
|
+
const warningCount = issues.filter((i) => i.severity === "warning").length;
|
|
2447
|
+
const score = Math.max(20, 100 - criticalCount * 20 - warningCount * 8);
|
|
2448
|
+
return {
|
|
2449
|
+
id: "asset-size",
|
|
2450
|
+
category: this.category,
|
|
2451
|
+
name: "Asset Sizes",
|
|
2452
|
+
score,
|
|
2453
|
+
status: criticalCount > 0 ? "fail" : warningCount > 0 ? "warning" : "pass",
|
|
2454
|
+
issues,
|
|
2455
|
+
toolsUsed: ["asset-size"],
|
|
2456
|
+
duration: elapsed(),
|
|
2457
|
+
metadata: {
|
|
2458
|
+
totalAssets: assets.length,
|
|
2459
|
+
totalSizeBytes: totalSize,
|
|
2460
|
+
totalSizeMB: totalMB,
|
|
2461
|
+
images: assets.filter((a) => a.type === "image").length,
|
|
2462
|
+
svgs: assets.filter((a) => a.type === "svg").length,
|
|
2463
|
+
fonts: assets.filter((a) => a.type === "font").length
|
|
2464
|
+
}
|
|
2465
|
+
};
|
|
2466
|
+
} catch (err) {
|
|
2467
|
+
return {
|
|
2468
|
+
id: "asset-size",
|
|
2469
|
+
category: this.category,
|
|
2470
|
+
name: "Asset Sizes",
|
|
2471
|
+
score: 0,
|
|
2472
|
+
status: "fail",
|
|
2473
|
+
issues: [{ severity: "critical", message: `Asset size check failed: ${err}`, reportedBy: ["asset-size"] }],
|
|
2474
|
+
toolsUsed: ["asset-size"],
|
|
2475
|
+
duration: elapsed()
|
|
2476
|
+
};
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
};
|
|
2480
|
+
function scanAssets(dir, projectRoot, assets) {
|
|
2481
|
+
try {
|
|
2482
|
+
for (const entry of readdirSync5(dir)) {
|
|
2483
|
+
if (entry.startsWith(".") || entry === "node_modules") continue;
|
|
2484
|
+
const fullPath = join14(dir, entry);
|
|
2485
|
+
const stat = statSync6(fullPath);
|
|
2486
|
+
if (stat.isDirectory()) {
|
|
2487
|
+
scanAssets(fullPath, projectRoot, assets);
|
|
2488
|
+
} else {
|
|
2489
|
+
const ext = extname5(entry).toLowerCase();
|
|
2490
|
+
if (SKIP_EXTENSIONS.has(ext)) continue;
|
|
2491
|
+
let type = "other";
|
|
2492
|
+
if (IMAGE_EXTENSIONS.has(ext)) type = "image";
|
|
2493
|
+
else if (ext === SVG_EXTENSION) type = "svg";
|
|
2494
|
+
else if (FONT_EXTENSIONS.has(ext)) type = "font";
|
|
2495
|
+
else continue;
|
|
2496
|
+
assets.push({
|
|
2497
|
+
path: fullPath.replace(projectRoot + "/", ""),
|
|
2498
|
+
size: stat.size,
|
|
2499
|
+
type
|
|
2500
|
+
});
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
} catch {
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
// src/integrations/node-security.ts
|
|
2508
|
+
import { readFileSync as readFileSync12, existsSync as existsSync11 } from "fs";
|
|
2509
|
+
import { join as join15 } from "path";
|
|
2510
|
+
var HELMET_PACKAGES = ["helmet", "koa-helmet", "fastify-helmet"];
|
|
2511
|
+
var CORS_PACKAGES = ["cors", "@koa/cors", "@fastify/cors", "koa2-cors"];
|
|
2512
|
+
var RATE_LIMIT_PACKAGES = [
|
|
2513
|
+
"express-rate-limit",
|
|
2514
|
+
"rate-limiter-flexible",
|
|
2515
|
+
"@fastify/rate-limit",
|
|
2516
|
+
"koa-ratelimit"
|
|
2517
|
+
];
|
|
2518
|
+
var HTTP_SERVER_PACKAGES = [
|
|
2519
|
+
"express",
|
|
2520
|
+
"fastify",
|
|
2521
|
+
"koa",
|
|
2522
|
+
"hapi",
|
|
2523
|
+
"@hapi/hapi",
|
|
2524
|
+
"restify",
|
|
2525
|
+
"polka",
|
|
2526
|
+
"micro",
|
|
2527
|
+
"@nestjs/core",
|
|
2528
|
+
"h3"
|
|
2529
|
+
];
|
|
2530
|
+
var NodeSecurityRunner = class extends BaseRunner {
|
|
2531
|
+
name = "node-security";
|
|
2532
|
+
category = "security";
|
|
2533
|
+
applicableRuntimes = ["node"];
|
|
2534
|
+
async isApplicable(projectPath, _context) {
|
|
2535
|
+
const pkgPath = join15(projectPath, "package.json");
|
|
2536
|
+
if (!existsSync11(pkgPath)) return false;
|
|
2537
|
+
const pkg = JSON.parse(readFileSync12(pkgPath, "utf-8"));
|
|
2538
|
+
const allDeps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
2539
|
+
return HTTP_SERVER_PACKAGES.some((p) => p in allDeps);
|
|
2540
|
+
}
|
|
2541
|
+
async run(projectPath) {
|
|
2542
|
+
const elapsed = timer();
|
|
2543
|
+
const pkgPath = join15(projectPath, "package.json");
|
|
2544
|
+
if (!existsSync11(pkgPath)) {
|
|
2545
|
+
return this.skipped("No package.json found");
|
|
2546
|
+
}
|
|
2547
|
+
const pkg = JSON.parse(readFileSync12(pkgPath, "utf-8"));
|
|
2548
|
+
const allDeps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
2549
|
+
const hasHelmet = HELMET_PACKAGES.some((p) => p in allDeps);
|
|
2550
|
+
const hasCors = CORS_PACKAGES.some((p) => p in allDeps);
|
|
2551
|
+
const hasRateLimit = RATE_LIMIT_PACKAGES.some((p) => p in allDeps);
|
|
2552
|
+
const issues = [];
|
|
2553
|
+
let score = 0;
|
|
2554
|
+
if (hasHelmet) {
|
|
2555
|
+
score += 35;
|
|
2556
|
+
} else {
|
|
2557
|
+
issues.push({
|
|
2558
|
+
severity: "critical",
|
|
2559
|
+
message: "Missing security headers middleware (helmet). HTTP security headers protect against XSS, clickjacking, MIME sniffing, and other common attacks.",
|
|
2560
|
+
fix: {
|
|
2561
|
+
description: "Install helmet and add app.use(helmet()) before your routes",
|
|
2562
|
+
command: "npm install helmet",
|
|
2563
|
+
nextSteps: "Add app.use(helmet()) before your routes"
|
|
2564
|
+
},
|
|
2565
|
+
reportedBy: ["node-security"]
|
|
2566
|
+
});
|
|
2567
|
+
}
|
|
2568
|
+
if (hasCors) {
|
|
2569
|
+
score += 30;
|
|
2570
|
+
} else {
|
|
2571
|
+
issues.push({
|
|
2572
|
+
severity: "warning",
|
|
2573
|
+
message: "Missing CORS middleware. Without explicit CORS configuration your API may be inaccessible from browser clients or accept requests from any origin.",
|
|
2574
|
+
fix: {
|
|
2575
|
+
description: "Install cors and configure allowed origins explicitly",
|
|
2576
|
+
command: "npm install cors",
|
|
2577
|
+
nextSteps: "Configure allowed origins explicitly with cors({ origin: [...] })"
|
|
2578
|
+
},
|
|
2579
|
+
reportedBy: ["node-security"]
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
if (hasRateLimit) {
|
|
2583
|
+
score += 35;
|
|
2584
|
+
} else {
|
|
2585
|
+
issues.push({
|
|
2586
|
+
severity: "warning",
|
|
2587
|
+
message: "Missing rate limiting middleware. Without rate limiting your API is vulnerable to DoS attacks and credential brute-forcing.",
|
|
2588
|
+
fix: {
|
|
2589
|
+
description: "Install express-rate-limit and configure limits per route or globally",
|
|
2590
|
+
command: "npm install express-rate-limit",
|
|
2591
|
+
nextSteps: "Configure rate limits per route or globally"
|
|
2592
|
+
},
|
|
2593
|
+
reportedBy: ["node-security"]
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
2596
|
+
const status = score >= 80 ? "pass" : score >= 50 ? "warning" : "fail";
|
|
2597
|
+
return {
|
|
2598
|
+
id: "node-security",
|
|
2599
|
+
category: this.category,
|
|
2600
|
+
name: "Node Security Middleware",
|
|
2601
|
+
score,
|
|
2602
|
+
status,
|
|
2603
|
+
issues,
|
|
2604
|
+
toolsUsed: ["node-security"],
|
|
2605
|
+
duration: elapsed()
|
|
2606
|
+
};
|
|
2607
|
+
}
|
|
2608
|
+
};
|
|
2609
|
+
|
|
2610
|
+
// src/integrations/node-input-validation.ts
|
|
2611
|
+
import { readFileSync as readFileSync13, existsSync as existsSync12 } from "fs";
|
|
2612
|
+
import { join as join16 } from "path";
|
|
2613
|
+
var VALIDATION_PACKAGES = [
|
|
2614
|
+
"zod",
|
|
2615
|
+
"joi",
|
|
2616
|
+
"express-validator",
|
|
2617
|
+
"yup",
|
|
2618
|
+
"ajv",
|
|
2619
|
+
"@sinclair/typebox",
|
|
2620
|
+
"valibot"
|
|
2621
|
+
];
|
|
2622
|
+
var HTTP_SERVER_PACKAGES2 = [
|
|
2623
|
+
"express",
|
|
2624
|
+
"fastify",
|
|
2625
|
+
"koa",
|
|
2626
|
+
"hapi",
|
|
2627
|
+
"@hapi/hapi",
|
|
2628
|
+
"restify",
|
|
2629
|
+
"polka",
|
|
2630
|
+
"micro",
|
|
2631
|
+
"@nestjs/core",
|
|
2632
|
+
"h3"
|
|
2633
|
+
];
|
|
2634
|
+
var NodeInputValidationRunner = class extends BaseRunner {
|
|
2635
|
+
name = "node-input-validation";
|
|
2636
|
+
category = "code-quality";
|
|
2637
|
+
applicableRuntimes = ["node"];
|
|
2638
|
+
async isApplicable(projectPath, _context) {
|
|
2639
|
+
const pkgPath = join16(projectPath, "package.json");
|
|
2640
|
+
if (!existsSync12(pkgPath)) return false;
|
|
2641
|
+
const pkg = JSON.parse(readFileSync13(pkgPath, "utf-8"));
|
|
2642
|
+
const allDeps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
2643
|
+
return HTTP_SERVER_PACKAGES2.some((p) => p in allDeps);
|
|
2644
|
+
}
|
|
2645
|
+
async run(projectPath) {
|
|
2646
|
+
const elapsed = timer();
|
|
2647
|
+
const pkgPath = join16(projectPath, "package.json");
|
|
2648
|
+
if (!existsSync12(pkgPath)) {
|
|
2649
|
+
return this.skipped("No package.json found");
|
|
2650
|
+
}
|
|
2651
|
+
const pkg = JSON.parse(readFileSync13(pkgPath, "utf-8"));
|
|
2652
|
+
const allDeps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
2653
|
+
const found = VALIDATION_PACKAGES.find((p) => p in allDeps);
|
|
2654
|
+
if (found) {
|
|
2655
|
+
const issues2 = [
|
|
2656
|
+
{
|
|
2657
|
+
severity: "info",
|
|
2658
|
+
message: `Input validation library detected: ${found}`,
|
|
2659
|
+
reportedBy: ["node-input-validation"]
|
|
2660
|
+
}
|
|
2661
|
+
];
|
|
2662
|
+
return {
|
|
2663
|
+
id: "node-input-validation",
|
|
2664
|
+
category: this.category,
|
|
2665
|
+
name: "Input Validation",
|
|
2666
|
+
score: 85,
|
|
2667
|
+
status: "pass",
|
|
2668
|
+
issues: issues2,
|
|
2669
|
+
toolsUsed: ["node-input-validation"],
|
|
2670
|
+
duration: elapsed()
|
|
2671
|
+
};
|
|
2672
|
+
}
|
|
2673
|
+
const issues = [
|
|
2674
|
+
{
|
|
2675
|
+
severity: "warning",
|
|
2676
|
+
message: "No input validation library found. Without validation, your API may accept malformed data leading to runtime errors or security vulnerabilities.",
|
|
2677
|
+
fix: {
|
|
2678
|
+
description: "Add an input validation library and validate all incoming request data",
|
|
2679
|
+
command: "npm install zod",
|
|
2680
|
+
nextSteps: "Add input validation schemas to all incoming request data"
|
|
2681
|
+
},
|
|
2682
|
+
reportedBy: ["node-input-validation"]
|
|
2683
|
+
}
|
|
2684
|
+
];
|
|
2685
|
+
return {
|
|
2686
|
+
id: "node-input-validation",
|
|
2687
|
+
category: this.category,
|
|
2688
|
+
name: "Input Validation",
|
|
2689
|
+
score: 20,
|
|
2690
|
+
status: "warning",
|
|
2691
|
+
issues,
|
|
2692
|
+
toolsUsed: ["node-input-validation"],
|
|
2693
|
+
duration: elapsed()
|
|
2694
|
+
};
|
|
2695
|
+
}
|
|
2696
|
+
};
|
|
2697
|
+
|
|
2698
|
+
// src/integrations/node-async-errors.ts
|
|
2699
|
+
import { readFileSync as readFileSync14, existsSync as existsSync13, readdirSync as readdirSync6, statSync as statSync7 } from "fs";
|
|
2700
|
+
import { join as join17, extname as extname6 } from "path";
|
|
2701
|
+
var SOURCE_EXTENSIONS3 = /* @__PURE__ */ new Set([".js", ".ts", ".mjs", ".cjs"]);
|
|
2702
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", "dist", "build", "coverage", "test", "tests", "__tests__"]);
|
|
2703
|
+
var ASYNC_ROUTE_RE = /(?:app|router)\s*\.\s*(?:get|post|put|patch|delete|all)\s*\([^;]*\basync\b/s;
|
|
2704
|
+
var TRY_CATCH_RE = /\btry\s*\{/;
|
|
2705
|
+
var ASYNC_ERRORS_RE = /require\s*\(\s*['"]express-async-errors['"]\s*\)|from\s+['"]express-async-errors['"]/;
|
|
2706
|
+
var ERROR_MIDDLEWARE_RE = /\(\s*(?:err|error)\s*,\s*\w+\s*,\s*\w+\s*,\s*\w+\s*\)\s*(?:=>|\{)/;
|
|
2707
|
+
function collectSourceFiles(dir) {
|
|
2708
|
+
if (!existsSync13(dir)) return [];
|
|
2709
|
+
const files = [];
|
|
2710
|
+
for (const entry of readdirSync6(dir)) {
|
|
2711
|
+
if (SKIP_DIRS.has(entry)) continue;
|
|
2712
|
+
const full = join17(dir, entry);
|
|
2713
|
+
try {
|
|
2714
|
+
const stat = statSync7(full);
|
|
2715
|
+
if (stat.isDirectory()) {
|
|
2716
|
+
files.push(...collectSourceFiles(full));
|
|
2717
|
+
} else if (SOURCE_EXTENSIONS3.has(extname6(entry))) {
|
|
2718
|
+
files.push(full);
|
|
2719
|
+
}
|
|
2720
|
+
} catch {
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
return files;
|
|
2724
|
+
}
|
|
2725
|
+
var NodeAsyncErrorsRunner = class extends BaseRunner {
|
|
2726
|
+
name = "node-async-errors";
|
|
2727
|
+
category = "code-quality";
|
|
2728
|
+
applicableRuntimes = ["node"];
|
|
2729
|
+
async run(projectPath) {
|
|
2730
|
+
const elapsed = timer();
|
|
2731
|
+
const pkgPath = join17(projectPath, "package.json");
|
|
2732
|
+
if (!existsSync13(pkgPath)) {
|
|
2733
|
+
return this.skipped("No package.json found");
|
|
2734
|
+
}
|
|
2735
|
+
const pkg = JSON.parse(readFileSync14(pkgPath, "utf-8"));
|
|
2736
|
+
const allDeps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
2737
|
+
if ("express-async-errors" in allDeps) {
|
|
2738
|
+
return {
|
|
2739
|
+
id: "node-async-errors",
|
|
2740
|
+
category: this.category,
|
|
2741
|
+
name: "Async Error Handling",
|
|
2742
|
+
score: 100,
|
|
2743
|
+
status: "pass",
|
|
2744
|
+
issues: [
|
|
2745
|
+
{
|
|
2746
|
+
severity: "info",
|
|
2747
|
+
message: "express-async-errors detected \u2014 all async route handlers are automatically protected.",
|
|
2748
|
+
reportedBy: ["node-async-errors"]
|
|
2749
|
+
}
|
|
2750
|
+
],
|
|
2751
|
+
toolsUsed: ["node-async-errors"],
|
|
2752
|
+
duration: elapsed()
|
|
2753
|
+
};
|
|
2754
|
+
}
|
|
2755
|
+
const srcDir = join17(projectPath, "src");
|
|
2756
|
+
const files = collectSourceFiles(existsSync13(srcDir) ? srcDir : projectPath);
|
|
2757
|
+
let routeFiles = 0;
|
|
2758
|
+
let protectedFiles = 0;
|
|
2759
|
+
let hasErrorMiddleware = false;
|
|
2760
|
+
for (const file of files) {
|
|
2761
|
+
try {
|
|
2762
|
+
const content = readFileSync14(file, "utf-8");
|
|
2763
|
+
if (ASYNC_ERRORS_RE.test(content)) {
|
|
2764
|
+
hasErrorMiddleware = true;
|
|
2765
|
+
continue;
|
|
2766
|
+
}
|
|
2767
|
+
if (ERROR_MIDDLEWARE_RE.test(content)) hasErrorMiddleware = true;
|
|
2768
|
+
if (!ASYNC_ROUTE_RE.test(content)) continue;
|
|
2769
|
+
routeFiles++;
|
|
2770
|
+
if (TRY_CATCH_RE.test(content)) protectedFiles++;
|
|
2771
|
+
} catch {
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
const issues = [];
|
|
2775
|
+
if (routeFiles === 0) {
|
|
2776
|
+
return {
|
|
2777
|
+
id: "node-async-errors",
|
|
2778
|
+
category: this.category,
|
|
2779
|
+
name: "Async Error Handling",
|
|
2780
|
+
score: 100,
|
|
2781
|
+
status: "pass",
|
|
2782
|
+
issues,
|
|
2783
|
+
toolsUsed: ["node-async-errors"],
|
|
2784
|
+
duration: elapsed()
|
|
2785
|
+
};
|
|
2786
|
+
}
|
|
2787
|
+
const unprotectedFiles = routeFiles - protectedFiles;
|
|
2788
|
+
const protectionRatio = protectedFiles / routeFiles;
|
|
2789
|
+
if (unprotectedFiles > 0) {
|
|
2790
|
+
issues.push({
|
|
2791
|
+
severity: unprotectedFiles === routeFiles ? "critical" : "warning",
|
|
2792
|
+
message: `${unprotectedFiles} of ${routeFiles} route file(s) contain async handlers without try/catch. Unhandled promise rejections will crash the process in Node.js <15 or produce silent failures.`,
|
|
2793
|
+
fix: {
|
|
2794
|
+
description: "Wrap async route handlers in try/catch or use express-async-errors to auto-wrap all handlers"
|
|
2795
|
+
},
|
|
2796
|
+
reportedBy: ["node-async-errors"]
|
|
2797
|
+
});
|
|
2798
|
+
}
|
|
2799
|
+
if (!hasErrorMiddleware) {
|
|
2800
|
+
issues.push({
|
|
2801
|
+
severity: "warning",
|
|
2802
|
+
message: "No Express error handling middleware found (4-parameter function: err, req, res, next). Without it, errors passed to next() have no centralized handler.",
|
|
2803
|
+
fix: {
|
|
2804
|
+
description: "Add app.use((err, req, res, next) => { res.status(500).json({ error: err.message }); }) after all routes"
|
|
2805
|
+
},
|
|
2806
|
+
reportedBy: ["node-async-errors"]
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
2809
|
+
const baseScore = Math.round(protectionRatio * 80);
|
|
2810
|
+
const middlewareBonus = hasErrorMiddleware ? 20 : 0;
|
|
2811
|
+
const score = Math.min(100, baseScore + middlewareBonus);
|
|
2812
|
+
const status = score >= 80 ? "pass" : score >= 50 ? "warning" : "fail";
|
|
2813
|
+
return {
|
|
2814
|
+
id: "node-async-errors",
|
|
2815
|
+
category: this.category,
|
|
2816
|
+
name: "Async Error Handling",
|
|
2817
|
+
score,
|
|
2818
|
+
status,
|
|
2819
|
+
issues,
|
|
2820
|
+
toolsUsed: ["node-async-errors"],
|
|
2821
|
+
duration: elapsed()
|
|
2822
|
+
};
|
|
2823
|
+
}
|
|
2824
|
+
};
|
|
2825
|
+
|
|
2826
|
+
// src/runner.ts
|
|
2827
|
+
var ALL_RUNNERS = [
|
|
2828
|
+
new KnipRunner(),
|
|
2829
|
+
new DepcheckRunner(),
|
|
2830
|
+
new OutdatedRunner(),
|
|
2831
|
+
new NpmAuditRunner(),
|
|
2832
|
+
new MadgeRunner(),
|
|
2833
|
+
new SourceMapExplorerRunner(),
|
|
2834
|
+
new CoverageRunner(),
|
|
2835
|
+
new LicenseCheckerRunner(),
|
|
2836
|
+
new JscpdRunner(),
|
|
2837
|
+
new GitRunner(),
|
|
2838
|
+
new ESLintRunner(),
|
|
2839
|
+
new TypeScriptRunner(),
|
|
2840
|
+
new TodoScannerRunner(),
|
|
2841
|
+
new ComplexityRunner(),
|
|
2842
|
+
new SecretsRunner(),
|
|
2843
|
+
new HeavyDepsRunner(),
|
|
2844
|
+
new ReactPerfRunner(),
|
|
2845
|
+
new AssetSizeRunner(),
|
|
2846
|
+
new NodeSecurityRunner(),
|
|
2847
|
+
new NodeInputValidationRunner(),
|
|
2848
|
+
new NodeAsyncErrorsRunner()
|
|
2849
|
+
];
|
|
2850
|
+
async function runSickbay(options = {}) {
|
|
2851
|
+
const projectPath = options.projectPath ?? process.cwd();
|
|
2852
|
+
const projectInfo = await detectProject(projectPath);
|
|
2853
|
+
const context = await detectContext(projectPath);
|
|
2854
|
+
const candidateRunners = options.checks ? ALL_RUNNERS.filter((r) => options.checks.includes(r.name)) : ALL_RUNNERS;
|
|
2855
|
+
const runners = candidateRunners.filter((r) => r.isApplicableToContext(context));
|
|
2856
|
+
options.onRunnersReady?.(runners.map((r) => r.name));
|
|
2857
|
+
const checks = [];
|
|
2858
|
+
const results = await Promise.allSettled(
|
|
2859
|
+
runners.map(async (runner) => {
|
|
2860
|
+
const applicable = await runner.isApplicable(projectPath, context);
|
|
2861
|
+
if (!applicable) return null;
|
|
2862
|
+
options.onCheckStart?.(runner.name);
|
|
2863
|
+
const result = await runner.run(projectPath, { verbose: options.verbose });
|
|
2864
|
+
options.onCheckComplete?.(result);
|
|
2865
|
+
return result;
|
|
2866
|
+
})
|
|
2867
|
+
);
|
|
2868
|
+
for (const result of results) {
|
|
2869
|
+
if (result.status === "fulfilled" && result.value) {
|
|
2870
|
+
checks.push(result.value);
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
const overallScore = calculateOverallScore(checks);
|
|
2874
|
+
const summary = buildSummary(checks);
|
|
2875
|
+
return {
|
|
2876
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2877
|
+
projectPath,
|
|
2878
|
+
projectInfo,
|
|
2879
|
+
checks,
|
|
2880
|
+
overallScore,
|
|
2881
|
+
summary,
|
|
2882
|
+
quote: options.quotes !== false ? getQuote(overallScore) : void 0
|
|
2883
|
+
};
|
|
2884
|
+
}
|
|
2885
|
+
async function runSickbayMonorepo(options = {}) {
|
|
2886
|
+
const rootPath = options.projectPath ?? process.cwd();
|
|
2887
|
+
const monorepoInfo = await detectMonorepo(rootPath);
|
|
2888
|
+
if (!monorepoInfo.isMonorepo) {
|
|
2889
|
+
throw new Error(`Not a monorepo root: ${rootPath}`);
|
|
2890
|
+
}
|
|
2891
|
+
const packageReports = await Promise.all(
|
|
2892
|
+
monorepoInfo.packagePaths.map(async (pkgPath) => {
|
|
2893
|
+
const report = await runSickbay({ ...options, projectPath: pkgPath });
|
|
2894
|
+
const context = await detectContext(pkgPath);
|
|
2895
|
+
options.onPackageStart?.(report.projectInfo.name);
|
|
2896
|
+
const packageReport = {
|
|
2897
|
+
name: report.projectInfo.name,
|
|
2898
|
+
path: pkgPath,
|
|
2899
|
+
relativePath: relative(rootPath, pkgPath),
|
|
2900
|
+
framework: report.projectInfo.framework,
|
|
2901
|
+
runtime: context.runtime,
|
|
2902
|
+
checks: report.checks,
|
|
2903
|
+
score: report.overallScore,
|
|
2904
|
+
summary: report.summary,
|
|
2905
|
+
dependencies: report.projectInfo.dependencies,
|
|
2906
|
+
devDependencies: report.projectInfo.devDependencies
|
|
2907
|
+
};
|
|
2908
|
+
options.onPackageComplete?.(packageReport);
|
|
2909
|
+
return packageReport;
|
|
2910
|
+
})
|
|
2911
|
+
);
|
|
2912
|
+
const overallScore = packageReports.length > 0 ? Math.round(packageReports.reduce((sum, p) => sum + p.score, 0) / packageReports.length) : 0;
|
|
2913
|
+
const summary = packageReports.reduce(
|
|
2914
|
+
(acc, p) => ({
|
|
2915
|
+
critical: acc.critical + p.summary.critical,
|
|
2916
|
+
warnings: acc.warnings + p.summary.warnings,
|
|
2917
|
+
info: acc.info + p.summary.info
|
|
2918
|
+
}),
|
|
2919
|
+
{ critical: 0, warnings: 0, info: 0 }
|
|
2920
|
+
);
|
|
2921
|
+
return {
|
|
2922
|
+
isMonorepo: true,
|
|
2923
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2924
|
+
rootPath,
|
|
2925
|
+
monorepoType: monorepoInfo.type,
|
|
2926
|
+
packageManager: monorepoInfo.packageManager,
|
|
2927
|
+
packages: packageReports,
|
|
2928
|
+
overallScore,
|
|
2929
|
+
summary,
|
|
2930
|
+
quote: options.quotes !== false ? getQuote(overallScore) : void 0
|
|
2931
|
+
};
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
// src/utils/dep-tree.ts
|
|
2935
|
+
import { execa as execa14 } from "execa";
|
|
2936
|
+
function normalizeDeps(raw) {
|
|
2937
|
+
if (!raw) return {};
|
|
2938
|
+
const result = {};
|
|
2939
|
+
for (const [name, info] of Object.entries(raw)) {
|
|
2940
|
+
result[name] = {
|
|
2941
|
+
name,
|
|
2942
|
+
version: info.version ?? "unknown",
|
|
2943
|
+
...info.dependencies && Object.keys(info.dependencies).length > 0 ? { dependencies: normalizeDeps(info.dependencies) } : {}
|
|
2944
|
+
};
|
|
2945
|
+
}
|
|
2946
|
+
return result;
|
|
2947
|
+
}
|
|
2948
|
+
async function getDependencyTree(projectPath, packageManager) {
|
|
2949
|
+
const empty = {
|
|
2950
|
+
name: "",
|
|
2951
|
+
version: "",
|
|
2952
|
+
packageManager,
|
|
2953
|
+
dependencies: {}
|
|
2954
|
+
};
|
|
2955
|
+
if (packageManager === "bun") return empty;
|
|
2956
|
+
try {
|
|
2957
|
+
const args = packageManager === "yarn" ? ["list", "--json", "--depth", "1"] : ["ls", "--json", "--depth", "1"];
|
|
2958
|
+
const { stdout } = await execa14(packageManager, args, {
|
|
2959
|
+
cwd: projectPath,
|
|
2960
|
+
reject: false,
|
|
2961
|
+
timeout: 3e4
|
|
2962
|
+
});
|
|
2963
|
+
const parsed = JSON.parse(stdout);
|
|
2964
|
+
const root = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
2965
|
+
return {
|
|
2966
|
+
name: root.name ?? "",
|
|
2967
|
+
version: root.version ?? "",
|
|
2968
|
+
packageManager,
|
|
2969
|
+
dependencies: normalizeDeps(root.dependencies)
|
|
2970
|
+
};
|
|
2971
|
+
} catch {
|
|
2972
|
+
return empty;
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
export {
|
|
2976
|
+
CRITICAL_LINES,
|
|
2977
|
+
SCORE_EXCELLENT,
|
|
2978
|
+
SCORE_FAIR,
|
|
2979
|
+
SCORE_GOOD,
|
|
2980
|
+
WARN_LINES,
|
|
2981
|
+
buildSummary,
|
|
2982
|
+
calculateOverallScore,
|
|
2983
|
+
detectContext,
|
|
2984
|
+
detectMonorepo,
|
|
2985
|
+
detectPackageManager,
|
|
2986
|
+
detectProject,
|
|
2987
|
+
getDependencyTree,
|
|
2988
|
+
getScoreColor,
|
|
2989
|
+
getScoreEmoji,
|
|
2990
|
+
runSickbay,
|
|
2991
|
+
runSickbayMonorepo
|
|
2992
|
+
};
|