aislop 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/LICENSE +21 -0
- package/README.md +339 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +4349 -0
- package/dist/engine-info-DBG3uXLc.js +39 -0
- package/dist/expo-doctor-CGXGLgMJ.js +126 -0
- package/dist/expo-doctor-vDz4kh9-.js +127 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.js +3880 -0
- package/dist/json-DkpW9UQj.js +30 -0
- package/dist/json-L5x3hQdy.js +31 -0
- package/dist/subprocess-99puEEGl.js +59 -0
- package/package.json +81 -0
- package/scripts/postinstall-tools.mjs +235 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3880 @@
|
|
|
1
|
+
import { n as getEngineLabel, r as APP_VERSION, t as ENGINE_INFO } from "./engine-info-DBG3uXLc.js";
|
|
2
|
+
import { n as runSubprocess, t as isToolInstalled } from "./subprocess-99puEEGl.js";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import pc from "picocolors";
|
|
7
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { performance } from "node:perf_hooks";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import YAML from "yaml";
|
|
12
|
+
import { z } from "zod/v4";
|
|
13
|
+
import ora from "ora";
|
|
14
|
+
|
|
15
|
+
//#region src/utils/highlighter.ts
|
|
16
|
+
const highlighter = {
|
|
17
|
+
error: pc.red,
|
|
18
|
+
warn: pc.yellow,
|
|
19
|
+
info: pc.cyan,
|
|
20
|
+
success: pc.green,
|
|
21
|
+
dim: pc.dim,
|
|
22
|
+
bold: pc.bold
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/utils/logger.ts
|
|
27
|
+
const logger = {
|
|
28
|
+
error(...args) {
|
|
29
|
+
console.log(highlighter.error(args.join(" ")));
|
|
30
|
+
},
|
|
31
|
+
warn(...args) {
|
|
32
|
+
console.log(highlighter.warn(args.join(" ")));
|
|
33
|
+
},
|
|
34
|
+
info(...args) {
|
|
35
|
+
console.log(highlighter.info(args.join(" ")));
|
|
36
|
+
},
|
|
37
|
+
success(...args) {
|
|
38
|
+
console.log(highlighter.success(args.join(" ")));
|
|
39
|
+
},
|
|
40
|
+
dim(...args) {
|
|
41
|
+
console.log(highlighter.dim(args.join(" ")));
|
|
42
|
+
},
|
|
43
|
+
log(...args) {
|
|
44
|
+
console.log(args.join(" "));
|
|
45
|
+
},
|
|
46
|
+
break() {
|
|
47
|
+
console.log("");
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/output/layout.ts
|
|
53
|
+
const formatElapsed$1 = (elapsedMs) => elapsedMs < 1e3 ? `${Math.round(elapsedMs)}ms` : `${(elapsedMs / 1e3).toFixed(1)}s`;
|
|
54
|
+
const printCommandHeader = (commandName) => {
|
|
55
|
+
logger.log(highlighter.bold(`aislop ${commandName}`));
|
|
56
|
+
logger.log(highlighter.dim(`v${APP_VERSION}`));
|
|
57
|
+
logger.break();
|
|
58
|
+
};
|
|
59
|
+
const formatProjectSummary = (project) => `Project ${highlighter.info(project.projectName)} (${highlighter.info(project.languages.join(", "))})`;
|
|
60
|
+
const printProjectMetadata = (project) => {
|
|
61
|
+
logger.log(` Source files: ${highlighter.info(String(project.sourceFileCount))}`);
|
|
62
|
+
const frameworks = project.frameworks.filter((framework) => framework !== "none");
|
|
63
|
+
if (frameworks.length > 0) logger.log(` Frameworks: ${highlighter.info(frameworks.join(", "))}`);
|
|
64
|
+
logger.break();
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region src/utils/source-files.ts
|
|
69
|
+
const MAX_BUFFER$1 = 50 * 1024 * 1024;
|
|
70
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
71
|
+
".ts",
|
|
72
|
+
".tsx",
|
|
73
|
+
".js",
|
|
74
|
+
".jsx",
|
|
75
|
+
".mjs",
|
|
76
|
+
".cjs",
|
|
77
|
+
".py",
|
|
78
|
+
".go",
|
|
79
|
+
".rs",
|
|
80
|
+
".rb",
|
|
81
|
+
".java",
|
|
82
|
+
".php"
|
|
83
|
+
]);
|
|
84
|
+
const EXCLUDED_DIRS = [
|
|
85
|
+
"node_modules",
|
|
86
|
+
"dist",
|
|
87
|
+
"build",
|
|
88
|
+
".git",
|
|
89
|
+
"vendor",
|
|
90
|
+
"tests",
|
|
91
|
+
"test",
|
|
92
|
+
"__tests__",
|
|
93
|
+
"__test__",
|
|
94
|
+
"spec",
|
|
95
|
+
"__mocks__",
|
|
96
|
+
"fixtures",
|
|
97
|
+
"test_data",
|
|
98
|
+
".next",
|
|
99
|
+
".nuxt",
|
|
100
|
+
"coverage",
|
|
101
|
+
".turbo"
|
|
102
|
+
];
|
|
103
|
+
const FIND_PRUNE_DIRS = [
|
|
104
|
+
"node_modules",
|
|
105
|
+
"dist",
|
|
106
|
+
"build",
|
|
107
|
+
".git",
|
|
108
|
+
"vendor",
|
|
109
|
+
".next",
|
|
110
|
+
".nuxt",
|
|
111
|
+
"coverage",
|
|
112
|
+
".turbo"
|
|
113
|
+
];
|
|
114
|
+
const TEST_FILE_PATTERNS = [
|
|
115
|
+
/(?:^|\/).*\.test\.[^/]+$/i,
|
|
116
|
+
/(?:^|\/).*\.spec\.[^/]+$/i,
|
|
117
|
+
/(?:^|\/)test_[^/]+\.(?:py|rb|php|js|jsx|ts|tsx|java)$/i,
|
|
118
|
+
/(?:^|\/)[^/]+_test\.(?:py|go|rb|php|js|jsx|ts|tsx|java)$/i
|
|
119
|
+
];
|
|
120
|
+
const AUTO_GENERATED_PATTERNS = [
|
|
121
|
+
/auto-generated/i,
|
|
122
|
+
/@generated/i,
|
|
123
|
+
/DO NOT (?:EDIT|MODIFY)/i,
|
|
124
|
+
/this file (?:is|was) (?:auto-?)?generated/i,
|
|
125
|
+
/automatically generated/i,
|
|
126
|
+
/generated by/i
|
|
127
|
+
];
|
|
128
|
+
const toProjectPath = (rootDirectory, filePath) => {
|
|
129
|
+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(rootDirectory, filePath);
|
|
130
|
+
return path.relative(rootDirectory, absolutePath).split(path.sep).join("/");
|
|
131
|
+
};
|
|
132
|
+
const isWithinProject = (relativePath) => relativePath.length > 0 && !relativePath.startsWith("..");
|
|
133
|
+
const hasAllowedExtension = (filePath, extraExtensions) => {
|
|
134
|
+
const extension = path.extname(filePath);
|
|
135
|
+
return SOURCE_EXTENSIONS.has(extension) || extraExtensions.has(extension);
|
|
136
|
+
};
|
|
137
|
+
const isExcludedPath = (filePath) => EXCLUDED_DIRS.some((dir) => filePath === dir || filePath.startsWith(`${dir}/`) || filePath.includes(`/${dir}/`));
|
|
138
|
+
const isTestFile = (filePath) => TEST_FILE_PATTERNS.some((pattern) => pattern.test(filePath));
|
|
139
|
+
const getIgnoredPaths = (rootDirectory, files) => {
|
|
140
|
+
if (files.length === 0) return /* @__PURE__ */ new Set();
|
|
141
|
+
const result = spawnSync("git", [
|
|
142
|
+
"check-ignore",
|
|
143
|
+
"--no-index",
|
|
144
|
+
"--stdin"
|
|
145
|
+
], {
|
|
146
|
+
cwd: rootDirectory,
|
|
147
|
+
encoding: "utf-8",
|
|
148
|
+
input: files.join("\n"),
|
|
149
|
+
maxBuffer: MAX_BUFFER$1
|
|
150
|
+
});
|
|
151
|
+
if (result.error || result.status !== 0 && result.status !== 1) return /* @__PURE__ */ new Set();
|
|
152
|
+
return new Set(result.stdout.split("\n").map((file) => file.trim()).filter((file) => file.length > 0));
|
|
153
|
+
};
|
|
154
|
+
const listProjectFiles = (rootDirectory) => {
|
|
155
|
+
const result = spawnSync("git", [
|
|
156
|
+
"ls-files",
|
|
157
|
+
"--cached",
|
|
158
|
+
"--others",
|
|
159
|
+
"--exclude-standard"
|
|
160
|
+
], {
|
|
161
|
+
cwd: rootDirectory,
|
|
162
|
+
encoding: "utf-8",
|
|
163
|
+
maxBuffer: MAX_BUFFER$1
|
|
164
|
+
});
|
|
165
|
+
if (!result.error && result.status === 0) return result.stdout.split("\n").filter((file) => file.length > 0);
|
|
166
|
+
const findResult = spawnSync("find", [
|
|
167
|
+
".",
|
|
168
|
+
"(",
|
|
169
|
+
...FIND_PRUNE_DIRS.flatMap((dir, index) => index === 0 ? ["-name", dir] : [
|
|
170
|
+
"-o",
|
|
171
|
+
"-name",
|
|
172
|
+
dir
|
|
173
|
+
]),
|
|
174
|
+
")",
|
|
175
|
+
"-prune",
|
|
176
|
+
"-o",
|
|
177
|
+
"-type",
|
|
178
|
+
"f",
|
|
179
|
+
"-print"
|
|
180
|
+
], {
|
|
181
|
+
cwd: rootDirectory,
|
|
182
|
+
encoding: "utf-8",
|
|
183
|
+
maxBuffer: MAX_BUFFER$1
|
|
184
|
+
});
|
|
185
|
+
if (findResult.error || findResult.status !== 0) return [];
|
|
186
|
+
return findResult.stdout.split("\n").filter((file) => file.length > 0).map((file) => file.replace(/^\.\//, ""));
|
|
187
|
+
};
|
|
188
|
+
const filterProjectFiles = (rootDirectory, files, extraExtensions = []) => {
|
|
189
|
+
const extraSet = new Set(extraExtensions);
|
|
190
|
+
const normalizedFiles = files.map((file) => {
|
|
191
|
+
const absolutePath = path.isAbsolute(file) ? file : path.resolve(rootDirectory, file);
|
|
192
|
+
return {
|
|
193
|
+
absolutePath,
|
|
194
|
+
relativePath: toProjectPath(rootDirectory, absolutePath)
|
|
195
|
+
};
|
|
196
|
+
}).filter(({ relativePath }) => isWithinProject(relativePath));
|
|
197
|
+
const ignoredPaths = getIgnoredPaths(rootDirectory, normalizedFiles.map(({ relativePath }) => relativePath));
|
|
198
|
+
return normalizedFiles.filter(({ relativePath }) => hasAllowedExtension(relativePath, extraSet) && !isExcludedPath(relativePath) && !isTestFile(relativePath) && !ignoredPaths.has(relativePath)).map(({ absolutePath }) => absolutePath);
|
|
199
|
+
};
|
|
200
|
+
const filterExplicitFiles = (rootDirectory, files, extraExtensions = []) => {
|
|
201
|
+
const extraSet = new Set(extraExtensions);
|
|
202
|
+
return files.map((file) => {
|
|
203
|
+
const absolutePath = path.isAbsolute(file) ? file : path.resolve(rootDirectory, file);
|
|
204
|
+
return {
|
|
205
|
+
absolutePath,
|
|
206
|
+
relativePath: toProjectPath(rootDirectory, absolutePath)
|
|
207
|
+
};
|
|
208
|
+
}).filter(({ relativePath }) => isWithinProject(relativePath) && hasAllowedExtension(relativePath, extraSet)).map(({ absolutePath }) => absolutePath);
|
|
209
|
+
};
|
|
210
|
+
const isAutoGenerated = (filePath) => {
|
|
211
|
+
try {
|
|
212
|
+
const fd = fs.openSync(filePath, "r");
|
|
213
|
+
const buf = Buffer.alloc(512);
|
|
214
|
+
const bytesRead = fs.readSync(fd, buf, 0, 512, 0);
|
|
215
|
+
fs.closeSync(fd);
|
|
216
|
+
const header = buf.toString("utf-8", 0, bytesRead);
|
|
217
|
+
return AUTO_GENERATED_PATTERNS.some((pattern) => pattern.test(header));
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const getSourceFilesForRoot = (rootDirectory) => filterProjectFiles(rootDirectory, listProjectFiles(rootDirectory));
|
|
223
|
+
const getSourceFiles = (context) => {
|
|
224
|
+
if (context.files) return filterExplicitFiles(context.rootDirectory, context.files);
|
|
225
|
+
return getSourceFilesForRoot(context.rootDirectory);
|
|
226
|
+
};
|
|
227
|
+
const getSourceFilesWithExtras = (context, extraExtensions) => {
|
|
228
|
+
if (context.files) return filterExplicitFiles(context.rootDirectory, context.files, extraExtensions);
|
|
229
|
+
return filterProjectFiles(context.rootDirectory, listProjectFiles(context.rootDirectory), extraExtensions);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region src/utils/tooling.ts
|
|
234
|
+
const THIS_FILE = fileURLToPath(import.meta.url);
|
|
235
|
+
const esmRequire$2 = createRequire(import.meta.url);
|
|
236
|
+
const resolvePackageRoot = (startFile) => {
|
|
237
|
+
let current = path.dirname(startFile);
|
|
238
|
+
while (true) {
|
|
239
|
+
const packageJsonPath = path.join(current, "package.json");
|
|
240
|
+
if (fs.existsSync(packageJsonPath)) try {
|
|
241
|
+
if (JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")).name === "aislop") return current;
|
|
242
|
+
} catch {}
|
|
243
|
+
const parent = path.dirname(current);
|
|
244
|
+
if (parent === current) break;
|
|
245
|
+
current = parent;
|
|
246
|
+
}
|
|
247
|
+
return path.resolve(path.dirname(startFile), "..", "..");
|
|
248
|
+
};
|
|
249
|
+
const PACKAGE_ROOT = resolvePackageRoot(THIS_FILE);
|
|
250
|
+
const TOOLS_BIN_DIR = path.join(PACKAGE_ROOT, "tools", "bin");
|
|
251
|
+
const BUNDLED_TOOL_NAMES = new Set(["ruff", "golangci-lint"]);
|
|
252
|
+
const withExecutableExtension = (toolName) => process.platform === "win32" ? `${toolName}.exe` : toolName;
|
|
253
|
+
const getBundledToolPath = (toolName) => {
|
|
254
|
+
if (!BUNDLED_TOOL_NAMES.has(toolName)) return null;
|
|
255
|
+
const candidate = path.join(TOOLS_BIN_DIR, withExecutableExtension(toolName));
|
|
256
|
+
return fs.existsSync(candidate) ? candidate : null;
|
|
257
|
+
};
|
|
258
|
+
const resolveToolBinary = (toolName) => getBundledToolPath(toolName) ?? toolName;
|
|
259
|
+
const isBundledTool = (toolName) => getBundledToolPath(toolName) !== null;
|
|
260
|
+
const isToolAvailable = async (toolName) => {
|
|
261
|
+
if (isBundledTool(toolName)) return true;
|
|
262
|
+
return isToolInstalled(toolName);
|
|
263
|
+
};
|
|
264
|
+
const isNodePackageAvailable = (packageName) => {
|
|
265
|
+
try {
|
|
266
|
+
esmRequire$2.resolve(`${packageName}/package.json`);
|
|
267
|
+
return true;
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
//#endregion
|
|
274
|
+
//#region src/utils/discover.ts
|
|
275
|
+
const LANGUAGE_SIGNALS = {
|
|
276
|
+
"tsconfig.json": "typescript",
|
|
277
|
+
"go.mod": "go",
|
|
278
|
+
"Cargo.toml": "rust",
|
|
279
|
+
Gemfile: "ruby",
|
|
280
|
+
"composer.json": "php"
|
|
281
|
+
};
|
|
282
|
+
const PYTHON_SIGNALS = [
|
|
283
|
+
"requirements.txt",
|
|
284
|
+
"pyproject.toml",
|
|
285
|
+
"setup.py",
|
|
286
|
+
"setup.cfg",
|
|
287
|
+
"Pipfile",
|
|
288
|
+
"poetry.lock"
|
|
289
|
+
];
|
|
290
|
+
const JAVA_SIGNALS = [
|
|
291
|
+
"pom.xml",
|
|
292
|
+
"build.gradle",
|
|
293
|
+
"build.gradle.kts"
|
|
294
|
+
];
|
|
295
|
+
const FRAMEWORK_PACKAGES = {
|
|
296
|
+
next: "nextjs",
|
|
297
|
+
react: "react",
|
|
298
|
+
vite: "vite",
|
|
299
|
+
"@remix-run/react": "remix",
|
|
300
|
+
expo: "expo"
|
|
301
|
+
};
|
|
302
|
+
const PYTHON_FRAMEWORKS = {
|
|
303
|
+
django: "django",
|
|
304
|
+
flask: "flask",
|
|
305
|
+
fastapi: "fastapi"
|
|
306
|
+
};
|
|
307
|
+
const NEXT_CONFIG_FILENAMES = [
|
|
308
|
+
"next.config.js",
|
|
309
|
+
"next.config.mjs",
|
|
310
|
+
"next.config.ts",
|
|
311
|
+
"next.config.cjs"
|
|
312
|
+
];
|
|
313
|
+
const readPackageJson = (filePath) => {
|
|
314
|
+
try {
|
|
315
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
316
|
+
} catch {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
const countSourceFiles = (rootDirectory) => getSourceFilesForRoot(rootDirectory).length;
|
|
321
|
+
const detectLanguages = (directory) => {
|
|
322
|
+
const languages = /* @__PURE__ */ new Set();
|
|
323
|
+
for (const [file, lang] of Object.entries(LANGUAGE_SIGNALS)) if (fs.existsSync(path.join(directory, file))) languages.add(lang);
|
|
324
|
+
if (readPackageJson(path.join(directory, "package.json"))) if (fs.existsSync(path.join(directory, "tsconfig.json"))) languages.add("typescript");
|
|
325
|
+
else languages.add("javascript");
|
|
326
|
+
for (const signal of PYTHON_SIGNALS) if (fs.existsSync(path.join(directory, signal))) {
|
|
327
|
+
languages.add("python");
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
for (const signal of JAVA_SIGNALS) if (fs.existsSync(path.join(directory, signal))) {
|
|
331
|
+
languages.add("java");
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
return [...languages];
|
|
335
|
+
};
|
|
336
|
+
const detectFrameworks = (directory) => {
|
|
337
|
+
const frameworks = /* @__PURE__ */ new Set();
|
|
338
|
+
const packageJson = readPackageJson(path.join(directory, "package.json"));
|
|
339
|
+
if (packageJson) {
|
|
340
|
+
const allDeps = {
|
|
341
|
+
...packageJson.dependencies,
|
|
342
|
+
...packageJson.devDependencies
|
|
343
|
+
};
|
|
344
|
+
for (const [pkg, fw] of Object.entries(FRAMEWORK_PACKAGES)) if (allDeps[pkg]) frameworks.add(fw);
|
|
345
|
+
}
|
|
346
|
+
for (const configFile of NEXT_CONFIG_FILENAMES) if (fs.existsSync(path.join(directory, configFile))) {
|
|
347
|
+
frameworks.add("nextjs");
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
const requirementsPath = path.join(directory, "requirements.txt");
|
|
351
|
+
if (fs.existsSync(requirementsPath)) try {
|
|
352
|
+
const content = fs.readFileSync(requirementsPath, "utf-8").toLowerCase();
|
|
353
|
+
for (const [pkg, fw] of Object.entries(PYTHON_FRAMEWORKS)) if (content.includes(pkg)) frameworks.add(fw);
|
|
354
|
+
} catch {}
|
|
355
|
+
if (frameworks.size === 0) frameworks.add("none");
|
|
356
|
+
return [...frameworks];
|
|
357
|
+
};
|
|
358
|
+
const TOOLS_TO_CHECK = [
|
|
359
|
+
"oxlint",
|
|
360
|
+
"biome",
|
|
361
|
+
"ruff",
|
|
362
|
+
"golangci-lint",
|
|
363
|
+
"npm",
|
|
364
|
+
"pnpm",
|
|
365
|
+
"govulncheck",
|
|
366
|
+
"gofmt",
|
|
367
|
+
"pip-audit",
|
|
368
|
+
"cargo",
|
|
369
|
+
"clippy-driver",
|
|
370
|
+
"rustfmt",
|
|
371
|
+
"rubocop",
|
|
372
|
+
"phpcs",
|
|
373
|
+
"php-cs-fixer"
|
|
374
|
+
];
|
|
375
|
+
const checkInstalledTools = async () => {
|
|
376
|
+
const results = {};
|
|
377
|
+
await Promise.all(TOOLS_TO_CHECK.map(async (tool) => {
|
|
378
|
+
results[tool] = await isToolAvailable(tool);
|
|
379
|
+
}));
|
|
380
|
+
return results;
|
|
381
|
+
};
|
|
382
|
+
const discoverProject = async (directory) => {
|
|
383
|
+
const resolvedDir = path.resolve(directory);
|
|
384
|
+
const languages = detectLanguages(resolvedDir);
|
|
385
|
+
const frameworks = detectFrameworks(resolvedDir);
|
|
386
|
+
const sourceFileCount = countSourceFiles(resolvedDir);
|
|
387
|
+
const installedTools = await checkInstalledTools();
|
|
388
|
+
return {
|
|
389
|
+
rootDirectory: resolvedDir,
|
|
390
|
+
projectName: readPackageJson(path.join(resolvedDir, "package.json"))?.name ?? path.basename(resolvedDir),
|
|
391
|
+
languages,
|
|
392
|
+
frameworks,
|
|
393
|
+
sourceFileCount,
|
|
394
|
+
installedTools
|
|
395
|
+
};
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
//#endregion
|
|
399
|
+
//#region src/commands/doctor.ts
|
|
400
|
+
const LANGUAGE_TOOLS = {
|
|
401
|
+
typescript: [{
|
|
402
|
+
name: "oxlint",
|
|
403
|
+
purpose: "Lint (JS/TS)"
|
|
404
|
+
}, {
|
|
405
|
+
name: "biome",
|
|
406
|
+
purpose: "Format (JS/TS)"
|
|
407
|
+
}],
|
|
408
|
+
javascript: [{
|
|
409
|
+
name: "oxlint",
|
|
410
|
+
purpose: "Lint (JS)"
|
|
411
|
+
}, {
|
|
412
|
+
name: "biome",
|
|
413
|
+
purpose: "Format (JS)"
|
|
414
|
+
}],
|
|
415
|
+
python: [{
|
|
416
|
+
name: "ruff",
|
|
417
|
+
purpose: "Lint + Format (Python)"
|
|
418
|
+
}, {
|
|
419
|
+
name: "pip-audit",
|
|
420
|
+
purpose: "Dependency vulnerability scan (Python)"
|
|
421
|
+
}],
|
|
422
|
+
go: [
|
|
423
|
+
{
|
|
424
|
+
name: "golangci-lint",
|
|
425
|
+
purpose: "Lint (Go)"
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
name: "gofmt",
|
|
429
|
+
purpose: "Format (Go)"
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
name: "govulncheck",
|
|
433
|
+
purpose: "Dependency vulnerability scan (Go)"
|
|
434
|
+
}
|
|
435
|
+
],
|
|
436
|
+
rust: [{
|
|
437
|
+
name: "cargo",
|
|
438
|
+
purpose: "Lint + Format (Rust)"
|
|
439
|
+
}],
|
|
440
|
+
java: [],
|
|
441
|
+
ruby: [{
|
|
442
|
+
name: "rubocop",
|
|
443
|
+
purpose: "Lint + Format (Ruby)"
|
|
444
|
+
}],
|
|
445
|
+
php: [{
|
|
446
|
+
name: "phpcs",
|
|
447
|
+
purpose: "Lint (PHP)"
|
|
448
|
+
}, {
|
|
449
|
+
name: "php-cs-fixer",
|
|
450
|
+
purpose: "Format (PHP)"
|
|
451
|
+
}]
|
|
452
|
+
};
|
|
453
|
+
const printProjectDetails = (projectInfo) => {
|
|
454
|
+
logger.success(` ✓ ${formatProjectSummary(projectInfo)}`);
|
|
455
|
+
printProjectMetadata(projectInfo);
|
|
456
|
+
logger.log(" Checks");
|
|
457
|
+
logger.break();
|
|
458
|
+
};
|
|
459
|
+
const createToolReporter = () => {
|
|
460
|
+
let allGood = true;
|
|
461
|
+
const seenTools = /* @__PURE__ */ new Set();
|
|
462
|
+
const reportTool = (name, purpose, options = { installed: false }) => {
|
|
463
|
+
if (seenTools.has(name)) return;
|
|
464
|
+
seenTools.add(name);
|
|
465
|
+
if (options.installed) {
|
|
466
|
+
const sourceLabel = options.bundled ? " (bundled)" : "";
|
|
467
|
+
logger.success(` ✓ ${name}${sourceLabel} — ${purpose}`);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
logger.warn(` ✗ ${name} — ${purpose} (not installed)`);
|
|
471
|
+
allGood = false;
|
|
472
|
+
};
|
|
473
|
+
return {
|
|
474
|
+
reportTool,
|
|
475
|
+
isAllGood: () => allGood
|
|
476
|
+
};
|
|
477
|
+
};
|
|
478
|
+
const reportBundledTools = () => {
|
|
479
|
+
logger.success(" ✓ oxlint (bundled)");
|
|
480
|
+
logger.success(" ✓ biome (bundled)");
|
|
481
|
+
logger.success(" ✓ knip (bundled)");
|
|
482
|
+
};
|
|
483
|
+
const reportLanguageTools = (projectInfo, reportTool) => {
|
|
484
|
+
for (const lang of projectInfo.languages) for (const tool of LANGUAGE_TOOLS[lang] ?? []) {
|
|
485
|
+
if (tool.name === "oxlint" || tool.name === "biome") continue;
|
|
486
|
+
reportTool(tool.name, tool.purpose, {
|
|
487
|
+
installed: projectInfo.installedTools[tool.name] === true,
|
|
488
|
+
bundled: isBundledTool(tool.name)
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
const reportJsAuditTool = (resolvedDir, projectInfo, reportTool) => {
|
|
493
|
+
if (!(projectInfo.languages.includes("typescript") || projectInfo.languages.includes("javascript"))) return;
|
|
494
|
+
const hasPnpmLock = fs.existsSync(path.join(resolvedDir, "pnpm-lock.yaml"));
|
|
495
|
+
const hasNpmLock = fs.existsSync(path.join(resolvedDir, "package-lock.json"));
|
|
496
|
+
if (hasPnpmLock) {
|
|
497
|
+
reportTool("pnpm", "Dependency vulnerability scan (JS/TS via pnpm audit)", { installed: projectInfo.installedTools["pnpm"] === true });
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (hasNpmLock || fs.existsSync(path.join(resolvedDir, "package.json"))) reportTool("npm", "Dependency vulnerability scan (JS/TS via npm audit)", { installed: projectInfo.installedTools["npm"] === true });
|
|
501
|
+
};
|
|
502
|
+
const reportFrameworkTools = (projectInfo, reportTool) => {
|
|
503
|
+
if (!projectInfo.frameworks.includes("expo")) return;
|
|
504
|
+
const hasExpoDoctor = isNodePackageAvailable("expo-doctor");
|
|
505
|
+
reportTool("expo-doctor", "Expo project health checks", {
|
|
506
|
+
installed: hasExpoDoctor,
|
|
507
|
+
bundled: hasExpoDoctor
|
|
508
|
+
});
|
|
509
|
+
};
|
|
510
|
+
const printDoctorConclusion = (allGood) => {
|
|
511
|
+
logger.break();
|
|
512
|
+
if (allGood) logger.success(" All tools are available. You're good to go!");
|
|
513
|
+
else {
|
|
514
|
+
logger.warn(" Some tools are missing. Install them for full coverage.");
|
|
515
|
+
logger.dim(" Missing tools will be skipped during scans.");
|
|
516
|
+
}
|
|
517
|
+
logger.break();
|
|
518
|
+
};
|
|
519
|
+
const doctorCommand = async (directory) => {
|
|
520
|
+
const resolvedDir = path.resolve(directory);
|
|
521
|
+
printCommandHeader("Doctor");
|
|
522
|
+
const projectInfo = await discoverProject(resolvedDir);
|
|
523
|
+
printProjectDetails(projectInfo);
|
|
524
|
+
const { reportTool, isAllGood } = createToolReporter();
|
|
525
|
+
reportBundledTools();
|
|
526
|
+
reportLanguageTools(projectInfo, reportTool);
|
|
527
|
+
reportJsAuditTool(resolvedDir, projectInfo, reportTool);
|
|
528
|
+
reportFrameworkTools(projectInfo, reportTool);
|
|
529
|
+
printDoctorConclusion(isAllGood());
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
//#endregion
|
|
533
|
+
//#region src/engines/format/biome.ts
|
|
534
|
+
const esmRequire$1 = createRequire(import.meta.url);
|
|
535
|
+
const resolveLocalBiomeScript = () => {
|
|
536
|
+
try {
|
|
537
|
+
const packageJsonPath = esmRequire$1.resolve("@biomejs/biome/package.json");
|
|
538
|
+
return path.join(path.dirname(packageJsonPath), "bin", "biome");
|
|
539
|
+
} catch {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
const runBiome = async (args, rootDirectory, timeout) => {
|
|
544
|
+
const localScript = resolveLocalBiomeScript();
|
|
545
|
+
if (localScript) return runSubprocess(process.execPath, [localScript, ...args], {
|
|
546
|
+
cwd: rootDirectory,
|
|
547
|
+
timeout
|
|
548
|
+
});
|
|
549
|
+
return runSubprocess("biome", args, {
|
|
550
|
+
cwd: rootDirectory,
|
|
551
|
+
timeout
|
|
552
|
+
});
|
|
553
|
+
};
|
|
554
|
+
const BIOME_EXTENSIONS = new Set([
|
|
555
|
+
".js",
|
|
556
|
+
".jsx",
|
|
557
|
+
".ts",
|
|
558
|
+
".tsx",
|
|
559
|
+
".mjs",
|
|
560
|
+
".cjs"
|
|
561
|
+
]);
|
|
562
|
+
const getBiomeTargets = (context) => getSourceFiles(context).filter((filePath) => BIOME_EXTENSIONS.has(path.extname(filePath))).map((filePath) => path.relative(context.rootDirectory, filePath));
|
|
563
|
+
const projectUsesDecorators = (rootDir) => {
|
|
564
|
+
try {
|
|
565
|
+
const tsconfigPath = path.join(rootDir, "tsconfig.json");
|
|
566
|
+
if (!fs.existsSync(tsconfigPath)) return false;
|
|
567
|
+
const content = fs.readFileSync(tsconfigPath, "utf-8");
|
|
568
|
+
return /experimentalDecorators.*true/i.test(content);
|
|
569
|
+
} catch {
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
const runBiomeFormat = async (context) => {
|
|
574
|
+
const targets = getBiomeTargets(context);
|
|
575
|
+
if (targets.length === 0) return [];
|
|
576
|
+
const args = [
|
|
577
|
+
"format",
|
|
578
|
+
"--reporter=json",
|
|
579
|
+
...targets
|
|
580
|
+
];
|
|
581
|
+
try {
|
|
582
|
+
const result = await runBiome(args, context.rootDirectory, 6e4);
|
|
583
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
|
584
|
+
if (!output) return [];
|
|
585
|
+
let diagnostics = parseBiomeJsonOutput(output, context.rootDirectory);
|
|
586
|
+
if (projectUsesDecorators(context.rootDirectory)) diagnostics = diagnostics.filter((d) => {
|
|
587
|
+
const msg = d.message.toLowerCase();
|
|
588
|
+
return !msg.includes("decorator") && !msg.includes("parsing error");
|
|
589
|
+
});
|
|
590
|
+
return diagnostics;
|
|
591
|
+
} catch {
|
|
592
|
+
return [];
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
const parseBiomeJsonOutput = (output, rootDir) => {
|
|
596
|
+
const diagnostics = [];
|
|
597
|
+
for (const line of output.split("\n")) {
|
|
598
|
+
const trimmed = line.trim();
|
|
599
|
+
if (!trimmed.startsWith("{")) continue;
|
|
600
|
+
let parsed = null;
|
|
601
|
+
try {
|
|
602
|
+
parsed = JSON.parse(trimmed);
|
|
603
|
+
} catch {
|
|
604
|
+
parsed = null;
|
|
605
|
+
}
|
|
606
|
+
if (!parsed || !Array.isArray(parsed.diagnostics)) continue;
|
|
607
|
+
for (const entry of parsed.diagnostics) {
|
|
608
|
+
const rawPath = entry.location?.path;
|
|
609
|
+
if (!rawPath) continue;
|
|
610
|
+
const severity = entry.severity === "error" ? "error" : "warning";
|
|
611
|
+
diagnostics.push({
|
|
612
|
+
filePath: path.isAbsolute(rawPath) ? path.relative(rootDir, rawPath) : rawPath,
|
|
613
|
+
engine: "format",
|
|
614
|
+
rule: "formatting",
|
|
615
|
+
severity,
|
|
616
|
+
message: entry.message ?? "File is not formatted correctly",
|
|
617
|
+
help: "Run `aislop fix` to auto-format",
|
|
618
|
+
line: entry.location?.start?.line ?? 0,
|
|
619
|
+
column: entry.location?.start?.column ?? 0,
|
|
620
|
+
category: "Format",
|
|
621
|
+
fixable: true
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return diagnostics;
|
|
626
|
+
};
|
|
627
|
+
const fixBiomeFormat = async (context) => {
|
|
628
|
+
const targets = getBiomeTargets(context);
|
|
629
|
+
if (targets.length === 0) return;
|
|
630
|
+
const result = await runBiome([
|
|
631
|
+
"check",
|
|
632
|
+
"--write",
|
|
633
|
+
"--formatter-enabled=true",
|
|
634
|
+
"--linter-enabled=false",
|
|
635
|
+
...targets
|
|
636
|
+
], context.rootDirectory, 6e4);
|
|
637
|
+
if (result.exitCode !== 0) throw new Error(result.stderr || result.stdout || `Biome exited with code ${result.exitCode}`);
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
//#endregion
|
|
641
|
+
//#region src/engines/format/gofmt.ts
|
|
642
|
+
const runGofmt = async (context) => {
|
|
643
|
+
try {
|
|
644
|
+
const result = await runSubprocess("gofmt", ["-l", context.rootDirectory], {
|
|
645
|
+
cwd: context.rootDirectory,
|
|
646
|
+
timeout: 6e4
|
|
647
|
+
});
|
|
648
|
+
if (!result.stdout) return [];
|
|
649
|
+
return result.stdout.split("\n").filter((f) => f.length > 0).map((file) => ({
|
|
650
|
+
filePath: path.relative(context.rootDirectory, file),
|
|
651
|
+
engine: "format",
|
|
652
|
+
rule: "go-formatting",
|
|
653
|
+
severity: "warning",
|
|
654
|
+
message: "Go file is not formatted correctly",
|
|
655
|
+
help: "Run `aislop fix` to auto-format with gofmt",
|
|
656
|
+
line: 0,
|
|
657
|
+
column: 0,
|
|
658
|
+
category: "Format",
|
|
659
|
+
fixable: true
|
|
660
|
+
}));
|
|
661
|
+
} catch {
|
|
662
|
+
return [];
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
const fixGofmt = async (rootDirectory) => {
|
|
666
|
+
const result = await runSubprocess("gofmt", ["-w", rootDirectory], {
|
|
667
|
+
cwd: rootDirectory,
|
|
668
|
+
timeout: 6e4
|
|
669
|
+
});
|
|
670
|
+
if (result.exitCode !== 0) throw new Error(result.stderr || result.stdout || `gofmt exited with code ${result.exitCode}`);
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
//#endregion
|
|
674
|
+
//#region src/engines/format/ruff-format.ts
|
|
675
|
+
const runRuffFormat = async (context) => {
|
|
676
|
+
const ruffBinary = resolveToolBinary("ruff");
|
|
677
|
+
try {
|
|
678
|
+
const result = await runSubprocess(ruffBinary, [
|
|
679
|
+
"format",
|
|
680
|
+
"--check",
|
|
681
|
+
"--diff",
|
|
682
|
+
context.rootDirectory
|
|
683
|
+
], {
|
|
684
|
+
cwd: context.rootDirectory,
|
|
685
|
+
timeout: 6e4
|
|
686
|
+
});
|
|
687
|
+
if (result.exitCode === 0) return [];
|
|
688
|
+
return parseRuffFormatOutput(result.stdout || result.stderr, context.rootDirectory);
|
|
689
|
+
} catch {
|
|
690
|
+
return [];
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
const parseRuffFormatOutput = (output, rootDir) => {
|
|
694
|
+
const diagnostics = [];
|
|
695
|
+
const filePattern = /^--- (.+)$/gm;
|
|
696
|
+
let match;
|
|
697
|
+
while ((match = filePattern.exec(output)) !== null) {
|
|
698
|
+
const filePath = match[1].replace(/^a\//, "");
|
|
699
|
+
diagnostics.push({
|
|
700
|
+
filePath: path.relative(rootDir, filePath),
|
|
701
|
+
engine: "format",
|
|
702
|
+
rule: "python-formatting",
|
|
703
|
+
severity: "warning",
|
|
704
|
+
message: "Python file is not formatted correctly",
|
|
705
|
+
help: "Run `aislop fix` to auto-format with ruff",
|
|
706
|
+
line: 0,
|
|
707
|
+
column: 0,
|
|
708
|
+
category: "Format",
|
|
709
|
+
fixable: true
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
return diagnostics;
|
|
713
|
+
};
|
|
714
|
+
const fixRuffFormat = async (rootDirectory) => {
|
|
715
|
+
const result = await runSubprocess(resolveToolBinary("ruff"), ["format", rootDirectory], {
|
|
716
|
+
cwd: rootDirectory,
|
|
717
|
+
timeout: 6e4
|
|
718
|
+
});
|
|
719
|
+
if (result.exitCode !== 0) throw new Error(result.stderr || result.stdout || `ruff format exited with code ${result.exitCode}`);
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
//#endregion
|
|
723
|
+
//#region src/engines/lint/oxlint-config.ts
|
|
724
|
+
const createOxlintConfig = (options) => {
|
|
725
|
+
const plugins = [
|
|
726
|
+
"import",
|
|
727
|
+
"unicorn",
|
|
728
|
+
"typescript"
|
|
729
|
+
];
|
|
730
|
+
const rules = {
|
|
731
|
+
"no-unused-vars": "warn",
|
|
732
|
+
"no-undef": "error",
|
|
733
|
+
"no-constant-condition": "warn",
|
|
734
|
+
"no-debugger": "warn",
|
|
735
|
+
"no-empty": "warn",
|
|
736
|
+
"no-extra-boolean-cast": "warn",
|
|
737
|
+
"no-irregular-whitespace": "warn",
|
|
738
|
+
"no-loss-of-precision": "error",
|
|
739
|
+
"import/no-duplicates": "warn",
|
|
740
|
+
"unicorn/no-unnecessary-await": "warn"
|
|
741
|
+
};
|
|
742
|
+
if (options.framework === "react" || options.framework === "nextjs" || options.framework === "vite" || options.framework === "remix") {
|
|
743
|
+
plugins.push("react", "react-hooks", "jsx-a11y");
|
|
744
|
+
Object.assign(rules, {
|
|
745
|
+
"react/no-direct-mutation-state": "error",
|
|
746
|
+
"react-hooks/rules-of-hooks": "error",
|
|
747
|
+
"react-hooks/exhaustive-deps": "warn"
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
if (options.framework === "nextjs") plugins.push("nextjs");
|
|
751
|
+
const env = {
|
|
752
|
+
browser: true,
|
|
753
|
+
node: true,
|
|
754
|
+
es2022: true
|
|
755
|
+
};
|
|
756
|
+
const globals = {};
|
|
757
|
+
if (options.testFramework === "jest") {
|
|
758
|
+
globals.jest = "readonly";
|
|
759
|
+
globals.describe = "readonly";
|
|
760
|
+
globals.it = "readonly";
|
|
761
|
+
globals.expect = "readonly";
|
|
762
|
+
globals.test = "readonly";
|
|
763
|
+
globals.beforeAll = "readonly";
|
|
764
|
+
globals.afterAll = "readonly";
|
|
765
|
+
globals.beforeEach = "readonly";
|
|
766
|
+
globals.afterEach = "readonly";
|
|
767
|
+
} else if (options.testFramework === "vitest") {
|
|
768
|
+
globals.describe = "readonly";
|
|
769
|
+
globals.it = "readonly";
|
|
770
|
+
globals.expect = "readonly";
|
|
771
|
+
globals.test = "readonly";
|
|
772
|
+
globals.beforeAll = "readonly";
|
|
773
|
+
globals.afterAll = "readonly";
|
|
774
|
+
globals.beforeEach = "readonly";
|
|
775
|
+
globals.afterEach = "readonly";
|
|
776
|
+
globals.vi = "readonly";
|
|
777
|
+
} else if (options.testFramework === "mocha") {
|
|
778
|
+
globals.describe = "readonly";
|
|
779
|
+
globals.it = "readonly";
|
|
780
|
+
globals.before = "readonly";
|
|
781
|
+
globals.after = "readonly";
|
|
782
|
+
globals.beforeEach = "readonly";
|
|
783
|
+
globals.afterEach = "readonly";
|
|
784
|
+
}
|
|
785
|
+
if (options.framework === "expo" || options.framework === "react") globals.__DEV__ = "readonly";
|
|
786
|
+
return {
|
|
787
|
+
plugins,
|
|
788
|
+
rules,
|
|
789
|
+
env,
|
|
790
|
+
globals,
|
|
791
|
+
settings: {}
|
|
792
|
+
};
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
//#endregion
|
|
796
|
+
//#region src/engines/lint/oxlint.ts
|
|
797
|
+
const esmRequire = createRequire(import.meta.url);
|
|
798
|
+
const resolveOxlintBinary = () => {
|
|
799
|
+
try {
|
|
800
|
+
const oxlintMainPath = esmRequire.resolve("oxlint");
|
|
801
|
+
const oxlintDir = path.resolve(path.dirname(oxlintMainPath), "..");
|
|
802
|
+
return path.join(oxlintDir, "bin", "oxlint");
|
|
803
|
+
} catch {
|
|
804
|
+
return "oxlint";
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
const parseRuleCode = (code) => {
|
|
808
|
+
const match = code.match(/^(.+)\((.+)\)$/);
|
|
809
|
+
if (!match) return {
|
|
810
|
+
plugin: "unknown",
|
|
811
|
+
rule: code
|
|
812
|
+
};
|
|
813
|
+
return {
|
|
814
|
+
plugin: match[1].replace(/^eslint-plugin-/, ""),
|
|
815
|
+
rule: match[2]
|
|
816
|
+
};
|
|
817
|
+
};
|
|
818
|
+
const detectTestFramework = (rootDir) => {
|
|
819
|
+
try {
|
|
820
|
+
const raw = fs.readFileSync(path.join(rootDir, "package.json"), "utf-8");
|
|
821
|
+
const pkg = JSON.parse(raw);
|
|
822
|
+
const allDeps = {
|
|
823
|
+
...pkg.dependencies,
|
|
824
|
+
...pkg.devDependencies
|
|
825
|
+
};
|
|
826
|
+
if (allDeps.vitest) return "vitest";
|
|
827
|
+
if (allDeps.jest || allDeps["ts-jest"] || allDeps["@jest/core"]) return "jest";
|
|
828
|
+
if (allDeps.mocha) return "mocha";
|
|
829
|
+
if (fs.existsSync(path.join(rootDir, "jest.config.js")) || fs.existsSync(path.join(rootDir, "jest.config.ts")) || fs.existsSync(path.join(rootDir, "jest.config.mjs"))) return "jest";
|
|
830
|
+
if (fs.existsSync(path.join(rootDir, "vitest.config.ts")) || fs.existsSync(path.join(rootDir, "vitest.config.js"))) return "vitest";
|
|
831
|
+
if (fs.existsSync(path.join(rootDir, ".mocharc.yml"))) return "mocha";
|
|
832
|
+
} catch {}
|
|
833
|
+
return null;
|
|
834
|
+
};
|
|
835
|
+
const runOxlint = async (context) => {
|
|
836
|
+
const configPath = path.join(os.tmpdir(), `aislop-oxlintrc-${process.pid}.json`);
|
|
837
|
+
const config = createOxlintConfig({
|
|
838
|
+
framework: context.frameworks.find((f) => f !== "none"),
|
|
839
|
+
testFramework: detectTestFramework(context.rootDirectory)
|
|
840
|
+
});
|
|
841
|
+
try {
|
|
842
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
843
|
+
const args = [
|
|
844
|
+
resolveOxlintBinary(),
|
|
845
|
+
"-c",
|
|
846
|
+
configPath,
|
|
847
|
+
"--format",
|
|
848
|
+
"json"
|
|
849
|
+
];
|
|
850
|
+
if (context.languages.includes("typescript") && fs.existsSync(path.join(context.rootDirectory, "tsconfig.json"))) args.push("--tsconfig", "./tsconfig.json");
|
|
851
|
+
args.push(".");
|
|
852
|
+
const result = await runSubprocess(process.execPath, args, {
|
|
853
|
+
cwd: context.rootDirectory,
|
|
854
|
+
timeout: 12e4
|
|
855
|
+
});
|
|
856
|
+
if (!result.stdout) return [];
|
|
857
|
+
let output;
|
|
858
|
+
try {
|
|
859
|
+
output = JSON.parse(result.stdout);
|
|
860
|
+
} catch {
|
|
861
|
+
return [];
|
|
862
|
+
}
|
|
863
|
+
return output.diagnostics.map((d) => {
|
|
864
|
+
const { plugin, rule } = parseRuleCode(d.code);
|
|
865
|
+
const label = d.labels[0];
|
|
866
|
+
return {
|
|
867
|
+
filePath: d.filename,
|
|
868
|
+
engine: "lint",
|
|
869
|
+
rule: `${plugin}/${rule}`,
|
|
870
|
+
severity: d.severity,
|
|
871
|
+
message: d.message.replace(/\S+\.\w+:\d+:\d+[\s\S]*$/, "").trim() || d.message,
|
|
872
|
+
help: d.help || "",
|
|
873
|
+
line: label?.span.line ?? 0,
|
|
874
|
+
column: label?.span.column ?? 0,
|
|
875
|
+
category: plugin === "react" ? "React" : plugin === "import" ? "Imports" : "Lint",
|
|
876
|
+
fixable: false
|
|
877
|
+
};
|
|
878
|
+
});
|
|
879
|
+
} finally {
|
|
880
|
+
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
const fixOxlint = async (context) => {
|
|
884
|
+
const configPath = path.join(os.tmpdir(), `aislop-oxlintrc-fix-${process.pid}.json`);
|
|
885
|
+
const config = createOxlintConfig({
|
|
886
|
+
framework: context.frameworks.find((f) => f !== "none"),
|
|
887
|
+
testFramework: detectTestFramework(context.rootDirectory)
|
|
888
|
+
});
|
|
889
|
+
try {
|
|
890
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
891
|
+
const args = [
|
|
892
|
+
resolveOxlintBinary(),
|
|
893
|
+
"-c",
|
|
894
|
+
configPath,
|
|
895
|
+
"--fix",
|
|
896
|
+
"."
|
|
897
|
+
];
|
|
898
|
+
const result = await runSubprocess(process.execPath, args, {
|
|
899
|
+
cwd: context.rootDirectory,
|
|
900
|
+
timeout: 12e4
|
|
901
|
+
});
|
|
902
|
+
if (result.exitCode !== 0) throw new Error(result.stderr || result.stdout || `Oxlint exited with code ${result.exitCode}`);
|
|
903
|
+
} finally {
|
|
904
|
+
if (fs.existsSync(configPath)) fs.unlinkSync(configPath);
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
//#endregion
|
|
909
|
+
//#region src/engines/lint/ruff.ts
|
|
910
|
+
const runRuffLint = async (context) => {
|
|
911
|
+
const ruffBinary = resolveToolBinary("ruff");
|
|
912
|
+
try {
|
|
913
|
+
const output = (await runSubprocess(ruffBinary, [
|
|
914
|
+
"check",
|
|
915
|
+
"--output-format=json",
|
|
916
|
+
context.rootDirectory
|
|
917
|
+
], {
|
|
918
|
+
cwd: context.rootDirectory,
|
|
919
|
+
timeout: 6e4
|
|
920
|
+
})).stdout;
|
|
921
|
+
if (!output) return [];
|
|
922
|
+
return JSON.parse(output).map((d) => ({
|
|
923
|
+
filePath: path.relative(context.rootDirectory, d.filename),
|
|
924
|
+
engine: "lint",
|
|
925
|
+
rule: `ruff/${d.code}`,
|
|
926
|
+
severity: d.code.startsWith("E") || d.code.startsWith("F") ? "error" : "warning",
|
|
927
|
+
message: d.message,
|
|
928
|
+
help: "",
|
|
929
|
+
line: d.location.row,
|
|
930
|
+
column: d.location.column,
|
|
931
|
+
category: "Python Lint",
|
|
932
|
+
fixable: d.fix?.applicability === "safe"
|
|
933
|
+
}));
|
|
934
|
+
} catch {
|
|
935
|
+
return [];
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
const fixRuffLint = async (rootDirectory) => {
|
|
939
|
+
const result = await runSubprocess(resolveToolBinary("ruff"), [
|
|
940
|
+
"check",
|
|
941
|
+
"--fix",
|
|
942
|
+
rootDirectory
|
|
943
|
+
], {
|
|
944
|
+
cwd: rootDirectory,
|
|
945
|
+
timeout: 6e4
|
|
946
|
+
});
|
|
947
|
+
if (result.exitCode !== 0) throw new Error(result.stderr || result.stdout || `ruff check --fix exited with code ${result.exitCode}`);
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
//#endregion
|
|
951
|
+
//#region src/output/pager.ts
|
|
952
|
+
const DEFAULT_COLUMNS = 80;
|
|
953
|
+
const DEFAULT_ROWS = 24;
|
|
954
|
+
const ANSI_PATTERN = new RegExp(String.raw`\u001B\[[0-?]*[ -/]*[@-~]`, "g");
|
|
955
|
+
const stripAnsi = (text) => text.replace(ANSI_PATTERN, "");
|
|
956
|
+
const resolvePagerCommand = () => {
|
|
957
|
+
const pager = process.env.PAGER?.trim();
|
|
958
|
+
if (pager) {
|
|
959
|
+
const [command, ...args] = pager.split(/\s+/);
|
|
960
|
+
if (command) return {
|
|
961
|
+
command,
|
|
962
|
+
args
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
return {
|
|
966
|
+
command: "less",
|
|
967
|
+
args: [
|
|
968
|
+
"-R",
|
|
969
|
+
"-F",
|
|
970
|
+
"-X"
|
|
971
|
+
]
|
|
972
|
+
};
|
|
973
|
+
};
|
|
974
|
+
const writeToStdout = (text) => {
|
|
975
|
+
process.stdout.write(text);
|
|
976
|
+
};
|
|
977
|
+
const pipeToPager = async (command, args, text) => new Promise((resolve) => {
|
|
978
|
+
let settled = false;
|
|
979
|
+
const finish = (success) => {
|
|
980
|
+
if (settled) return;
|
|
981
|
+
settled = true;
|
|
982
|
+
resolve(success);
|
|
983
|
+
};
|
|
984
|
+
try {
|
|
985
|
+
const child = spawn(command, args, {
|
|
986
|
+
stdio: [
|
|
987
|
+
"pipe",
|
|
988
|
+
"inherit",
|
|
989
|
+
"inherit"
|
|
990
|
+
],
|
|
991
|
+
windowsHide: true
|
|
992
|
+
});
|
|
993
|
+
child.once("error", () => finish(false));
|
|
994
|
+
child.once("close", (code) => finish(code === 0));
|
|
995
|
+
child.stdin?.on("error", () => void 0);
|
|
996
|
+
child.stdin?.end(text);
|
|
997
|
+
} catch {
|
|
998
|
+
finish(false);
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
const countRenderedLines = (text, columns = DEFAULT_COLUMNS) => {
|
|
1002
|
+
const width = Math.max(1, columns);
|
|
1003
|
+
return text.split("\n").reduce((count, line) => {
|
|
1004
|
+
const visibleLine = stripAnsi(line).replaceAll(" ", " ");
|
|
1005
|
+
return count + Math.max(1, Math.ceil(visibleLine.length / width));
|
|
1006
|
+
}, 0);
|
|
1007
|
+
};
|
|
1008
|
+
const shouldPageOutput = (text, options = {}) => {
|
|
1009
|
+
if (text.trim().length === 0) return false;
|
|
1010
|
+
const stdinIsTTY = options.stdinIsTTY ?? Boolean(process.stdin.isTTY);
|
|
1011
|
+
const stdoutIsTTY = options.stdoutIsTTY ?? Boolean(process.stdout.isTTY);
|
|
1012
|
+
if (!stdinIsTTY || !stdoutIsTTY) return false;
|
|
1013
|
+
const rows = Math.max(1, options.rows ?? process.stdout.rows ?? DEFAULT_ROWS);
|
|
1014
|
+
return countRenderedLines(text, Math.max(1, options.columns ?? process.stdout.columns ?? DEFAULT_COLUMNS)) > rows - 1;
|
|
1015
|
+
};
|
|
1016
|
+
const printMaybePaged = async (text) => {
|
|
1017
|
+
if (!shouldPageOutput(text)) {
|
|
1018
|
+
writeToStdout(text);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const pager = resolvePagerCommand();
|
|
1022
|
+
if (!await pipeToPager(pager.command, pager.args, text)) writeToStdout(text);
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
//#endregion
|
|
1026
|
+
//#region src/commands/fix.ts
|
|
1027
|
+
const uniqueFiles = (diagnostics) => [...new Set(diagnostics.map((d) => d.filePath))];
|
|
1028
|
+
const uniqueFileCount = (diagnostics) => uniqueFiles(diagnostics).length;
|
|
1029
|
+
const getFilePreviewLines = (title, files, verbose) => {
|
|
1030
|
+
if (files.length === 0) return [];
|
|
1031
|
+
const lines = [highlighter.dim(` ${title}: ${files.length} file(s)`)];
|
|
1032
|
+
const preview = verbose ? files : files.slice(0, 5);
|
|
1033
|
+
for (const file of preview) lines.push(highlighter.dim(` ${file}`));
|
|
1034
|
+
if (!verbose && files.length > preview.length) lines.push(highlighter.dim(` +${files.length - preview.length} more file(s), use -d for full list`));
|
|
1035
|
+
return lines;
|
|
1036
|
+
};
|
|
1037
|
+
const getReasonLines = (reason) => {
|
|
1038
|
+
return {
|
|
1039
|
+
firstLine: reason.split("\n").find((line) => line.trim().length > 0) ?? reason,
|
|
1040
|
+
printable: reason
|
|
1041
|
+
};
|
|
1042
|
+
};
|
|
1043
|
+
const getStepStatusLine = (result, name, elapsedLabel) => {
|
|
1044
|
+
if (result.failed) return highlighter.error(` ✗ ${name}: failed (${result.afterIssues} issue${result.afterIssues === 1 ? "" : "s"} remain, ${elapsedLabel})`);
|
|
1045
|
+
if (result.beforeIssues === 0) return highlighter.success(` ✓ ${name}: done (0 issues, ${elapsedLabel})`);
|
|
1046
|
+
if (result.afterIssues === 0) return highlighter.success(` ✓ ${name}: done (${result.resolvedIssues} resolved across ${result.beforeFiles} file(s), ${elapsedLabel})`);
|
|
1047
|
+
if (result.resolvedIssues > 0) return highlighter.warn(` ! ${name}: done (${result.resolvedIssues} resolved, ${result.afterIssues} remaining, ${elapsedLabel})`);
|
|
1048
|
+
return highlighter.warn(` ! ${name}: done (no auto-fix changes, ${result.afterIssues} issue${result.afterIssues === 1 ? "" : "s"}, ${elapsedLabel})`);
|
|
1049
|
+
};
|
|
1050
|
+
const runFixStep = async (name, detect, applyFix, options) => {
|
|
1051
|
+
const stepStart = performance.now();
|
|
1052
|
+
const before = await detect();
|
|
1053
|
+
let applyError = null;
|
|
1054
|
+
try {
|
|
1055
|
+
await applyFix();
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
applyError = error;
|
|
1058
|
+
}
|
|
1059
|
+
const after = await detect();
|
|
1060
|
+
const elapsedMs = performance.now() - stepStart;
|
|
1061
|
+
const result = {
|
|
1062
|
+
name,
|
|
1063
|
+
beforeIssues: before.length,
|
|
1064
|
+
afterIssues: after.length,
|
|
1065
|
+
resolvedIssues: Math.max(0, before.length - after.length),
|
|
1066
|
+
beforeFiles: uniqueFileCount(before),
|
|
1067
|
+
failed: applyError !== null && before.length === after.length,
|
|
1068
|
+
elapsedMs
|
|
1069
|
+
};
|
|
1070
|
+
const lines = [getStepStatusLine(result, name, formatElapsed$1(result.elapsedMs))];
|
|
1071
|
+
if (applyError) {
|
|
1072
|
+
const reasonLines = getReasonLines(applyError instanceof Error ? applyError.message : String(applyError));
|
|
1073
|
+
const reasonToPrint = options.verbose ? reasonLines.printable : reasonLines.firstLine;
|
|
1074
|
+
for (const line of reasonToPrint.split("\n")) lines.push(highlighter.dim(` ${line}`));
|
|
1075
|
+
if (!options.verbose && reasonLines.printable !== reasonToPrint) lines.push(highlighter.dim(" Re-run with -d for full tool output."));
|
|
1076
|
+
}
|
|
1077
|
+
lines.push(...getFilePreviewLines("Affected", uniqueFiles(before), options.verbose));
|
|
1078
|
+
if (after.length > 0) lines.push(...getFilePreviewLines("Remaining", uniqueFiles(after), options.verbose));
|
|
1079
|
+
await printMaybePaged(`${lines.join("\n")}\n\n`);
|
|
1080
|
+
return result;
|
|
1081
|
+
};
|
|
1082
|
+
const createEngineContext = (rootDirectory, projectInfo, config) => ({
|
|
1083
|
+
rootDirectory,
|
|
1084
|
+
languages: projectInfo.languages,
|
|
1085
|
+
frameworks: projectInfo.frameworks,
|
|
1086
|
+
installedTools: projectInfo.installedTools,
|
|
1087
|
+
config: {
|
|
1088
|
+
quality: config.quality,
|
|
1089
|
+
security: config.security
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
const summarizeFixRun = (steps) => {
|
|
1093
|
+
const totals = steps.reduce((acc, step) => {
|
|
1094
|
+
acc.beforeIssues += step.beforeIssues;
|
|
1095
|
+
acc.afterIssues += step.afterIssues;
|
|
1096
|
+
acc.resolvedIssues += step.resolvedIssues;
|
|
1097
|
+
if (step.failed) acc.failedSteps += 1;
|
|
1098
|
+
return acc;
|
|
1099
|
+
}, {
|
|
1100
|
+
beforeIssues: 0,
|
|
1101
|
+
afterIssues: 0,
|
|
1102
|
+
resolvedIssues: 0,
|
|
1103
|
+
failedSteps: 0
|
|
1104
|
+
});
|
|
1105
|
+
if (totals.failedSteps > 0) {
|
|
1106
|
+
logger.log(` Fix summary: checked ${steps.length} step(s), resolved ${totals.resolvedIssues} issue(s).`);
|
|
1107
|
+
logger.warn(` ${totals.failedSteps} step(s) reported tool errors; unresolved issue count is unknown for failed steps.`);
|
|
1108
|
+
} else logger.log(` Fix summary: checked ${steps.length} step(s), resolved ${totals.resolvedIssues} issue(s), remaining ${totals.afterIssues}.`);
|
|
1109
|
+
if (totals.failedSteps === 0 && totals.beforeIssues > 0 && totals.resolvedIssues === 0) logger.dim(" No auto-fixable changes were applied. Current findings are likely manual-fix categories.");
|
|
1110
|
+
};
|
|
1111
|
+
const fixCommand = async (directory, config, options = {
|
|
1112
|
+
verbose: false,
|
|
1113
|
+
showHeader: true
|
|
1114
|
+
}) => {
|
|
1115
|
+
const resolvedDir = path.resolve(directory);
|
|
1116
|
+
if (options.showHeader !== false) printCommandHeader("Fix");
|
|
1117
|
+
const projectInfo = await discoverProject(resolvedDir);
|
|
1118
|
+
logger.success(` ✓ ${formatProjectSummary(projectInfo)}`);
|
|
1119
|
+
printProjectMetadata(projectInfo);
|
|
1120
|
+
const context = createEngineContext(resolvedDir, projectInfo, config);
|
|
1121
|
+
const steps = [];
|
|
1122
|
+
if (config.engines.format) {
|
|
1123
|
+
if (projectInfo.languages.includes("typescript") || projectInfo.languages.includes("javascript")) steps.push(await runFixStep("JS/TS formatting", () => runBiomeFormat(context), () => fixBiomeFormat(context), options));
|
|
1124
|
+
if (projectInfo.languages.includes("python") && projectInfo.installedTools.ruff) steps.push(await runFixStep("Python formatting", () => runRuffFormat(context), () => fixRuffFormat(resolvedDir), options));
|
|
1125
|
+
else if (projectInfo.languages.includes("python")) logger.warn(" Python detected but ruff is not installed; skipping Python formatting fixes.");
|
|
1126
|
+
if (projectInfo.languages.includes("go") && projectInfo.installedTools.gofmt) steps.push(await runFixStep("Go formatting", () => runGofmt(context), () => fixGofmt(resolvedDir), options));
|
|
1127
|
+
else if (projectInfo.languages.includes("go")) logger.warn(" Go detected but gofmt is not installed; skipping Go formatting fixes.");
|
|
1128
|
+
}
|
|
1129
|
+
if (config.engines.lint) {
|
|
1130
|
+
if (projectInfo.languages.includes("typescript") || projectInfo.languages.includes("javascript")) steps.push(await runFixStep("JS/TS lint fixes", () => runOxlint(context), () => fixOxlint(context), options));
|
|
1131
|
+
if (projectInfo.languages.includes("python") && projectInfo.installedTools.ruff) steps.push(await runFixStep("Python lint fixes", () => runRuffLint(context), () => fixRuffLint(resolvedDir), options));
|
|
1132
|
+
else if (projectInfo.languages.includes("python")) logger.warn(" Python detected but ruff is not installed; skipping Python lint fixes.");
|
|
1133
|
+
}
|
|
1134
|
+
if (steps.length === 0) logger.dim(" No applicable auto-fixers found for this project.");
|
|
1135
|
+
else {
|
|
1136
|
+
logger.break();
|
|
1137
|
+
summarizeFixRun(steps);
|
|
1138
|
+
}
|
|
1139
|
+
logger.break();
|
|
1140
|
+
logger.success(" ✓ Done. Run `aislop scan` to verify.");
|
|
1141
|
+
logger.break();
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
//#endregion
|
|
1145
|
+
//#region src/config/defaults.ts
|
|
1146
|
+
const DEFAULT_CONFIG = {
|
|
1147
|
+
version: 1,
|
|
1148
|
+
engines: {
|
|
1149
|
+
format: true,
|
|
1150
|
+
lint: true,
|
|
1151
|
+
"code-quality": true,
|
|
1152
|
+
"ai-slop": true,
|
|
1153
|
+
architecture: false,
|
|
1154
|
+
security: true
|
|
1155
|
+
},
|
|
1156
|
+
quality: {
|
|
1157
|
+
maxFunctionLoc: 80,
|
|
1158
|
+
maxFileLoc: 400,
|
|
1159
|
+
maxNesting: 5,
|
|
1160
|
+
maxParams: 6
|
|
1161
|
+
},
|
|
1162
|
+
security: {
|
|
1163
|
+
audit: true,
|
|
1164
|
+
auditTimeout: 25e3
|
|
1165
|
+
},
|
|
1166
|
+
scoring: {
|
|
1167
|
+
weights: {
|
|
1168
|
+
format: .5,
|
|
1169
|
+
lint: 1,
|
|
1170
|
+
"code-quality": 1.5,
|
|
1171
|
+
"ai-slop": 1,
|
|
1172
|
+
architecture: 1,
|
|
1173
|
+
security: 2
|
|
1174
|
+
},
|
|
1175
|
+
thresholds: {
|
|
1176
|
+
good: 75,
|
|
1177
|
+
ok: 50
|
|
1178
|
+
}
|
|
1179
|
+
},
|
|
1180
|
+
ci: {
|
|
1181
|
+
failBelow: 0,
|
|
1182
|
+
format: "json"
|
|
1183
|
+
}
|
|
1184
|
+
};
|
|
1185
|
+
const DEFAULT_CONFIG_YAML = `version: 1
|
|
1186
|
+
|
|
1187
|
+
engines:
|
|
1188
|
+
format: true
|
|
1189
|
+
lint: true
|
|
1190
|
+
code-quality: true
|
|
1191
|
+
ai-slop: true
|
|
1192
|
+
architecture: false
|
|
1193
|
+
security: true
|
|
1194
|
+
|
|
1195
|
+
quality:
|
|
1196
|
+
maxFunctionLoc: 80
|
|
1197
|
+
maxFileLoc: 400
|
|
1198
|
+
maxNesting: 5
|
|
1199
|
+
maxParams: 6
|
|
1200
|
+
|
|
1201
|
+
security:
|
|
1202
|
+
audit: true
|
|
1203
|
+
auditTimeout: 25000
|
|
1204
|
+
|
|
1205
|
+
scoring:
|
|
1206
|
+
weights:
|
|
1207
|
+
format: 0.5
|
|
1208
|
+
lint: 1.0
|
|
1209
|
+
code-quality: 1.5
|
|
1210
|
+
ai-slop: 1.0
|
|
1211
|
+
architecture: 1.0
|
|
1212
|
+
security: 2.0
|
|
1213
|
+
thresholds:
|
|
1214
|
+
good: 75
|
|
1215
|
+
ok: 50
|
|
1216
|
+
|
|
1217
|
+
ci:
|
|
1218
|
+
failBelow: 0
|
|
1219
|
+
format: json
|
|
1220
|
+
`;
|
|
1221
|
+
const DEFAULT_RULES_YAML = `# Architecture rules (BYO)
|
|
1222
|
+
# Uncomment and customize to enforce your project's conventions.
|
|
1223
|
+
#
|
|
1224
|
+
# rules:
|
|
1225
|
+
# - name: no-axios
|
|
1226
|
+
# type: forbid_import
|
|
1227
|
+
# match: "axios"
|
|
1228
|
+
# severity: error
|
|
1229
|
+
#
|
|
1230
|
+
# - name: controller-no-db
|
|
1231
|
+
# type: forbid_import_from_path
|
|
1232
|
+
# from: "src/controllers/**"
|
|
1233
|
+
# forbid: "src/db/**"
|
|
1234
|
+
# severity: error
|
|
1235
|
+
`;
|
|
1236
|
+
|
|
1237
|
+
//#endregion
|
|
1238
|
+
//#region src/config/schema.ts
|
|
1239
|
+
const DEFAULT_WEIGHTS = {
|
|
1240
|
+
format: .5,
|
|
1241
|
+
lint: 1,
|
|
1242
|
+
"code-quality": 1.5,
|
|
1243
|
+
"ai-slop": 1,
|
|
1244
|
+
architecture: 1,
|
|
1245
|
+
security: 2
|
|
1246
|
+
};
|
|
1247
|
+
const EnginesSchema = z.object({
|
|
1248
|
+
format: z.boolean().default(true),
|
|
1249
|
+
lint: z.boolean().default(true),
|
|
1250
|
+
"code-quality": z.boolean().default(true),
|
|
1251
|
+
"ai-slop": z.boolean().default(true),
|
|
1252
|
+
architecture: z.boolean().default(false),
|
|
1253
|
+
security: z.boolean().default(true)
|
|
1254
|
+
});
|
|
1255
|
+
const QualitySchema = z.object({
|
|
1256
|
+
maxFunctionLoc: z.number().positive().default(80),
|
|
1257
|
+
maxFileLoc: z.number().positive().default(400),
|
|
1258
|
+
maxNesting: z.number().positive().default(5),
|
|
1259
|
+
maxParams: z.number().positive().default(6)
|
|
1260
|
+
});
|
|
1261
|
+
const SecurityConfigSchema = z.object({
|
|
1262
|
+
audit: z.boolean().default(true),
|
|
1263
|
+
auditTimeout: z.number().positive().default(25e3)
|
|
1264
|
+
});
|
|
1265
|
+
const ThresholdsSchema = z.object({
|
|
1266
|
+
good: z.number().default(75),
|
|
1267
|
+
ok: z.number().default(50)
|
|
1268
|
+
});
|
|
1269
|
+
const ScoringSchema = z.object({
|
|
1270
|
+
weights: z.record(z.string(), z.number()).default(DEFAULT_WEIGHTS),
|
|
1271
|
+
thresholds: ThresholdsSchema.default(() => ({
|
|
1272
|
+
good: 75,
|
|
1273
|
+
ok: 50
|
|
1274
|
+
}))
|
|
1275
|
+
});
|
|
1276
|
+
const CiSchema = z.object({
|
|
1277
|
+
failBelow: z.number().default(0),
|
|
1278
|
+
format: z.enum(["json"]).default("json")
|
|
1279
|
+
});
|
|
1280
|
+
const AislopConfigSchema = z.object({
|
|
1281
|
+
version: z.number().default(1),
|
|
1282
|
+
engines: EnginesSchema.default(() => ({
|
|
1283
|
+
format: true,
|
|
1284
|
+
lint: true,
|
|
1285
|
+
"code-quality": true,
|
|
1286
|
+
"ai-slop": true,
|
|
1287
|
+
architecture: false,
|
|
1288
|
+
security: true
|
|
1289
|
+
})),
|
|
1290
|
+
quality: QualitySchema.default(() => ({
|
|
1291
|
+
maxFunctionLoc: 80,
|
|
1292
|
+
maxFileLoc: 400,
|
|
1293
|
+
maxNesting: 5,
|
|
1294
|
+
maxParams: 6
|
|
1295
|
+
})),
|
|
1296
|
+
security: SecurityConfigSchema.default(() => ({
|
|
1297
|
+
audit: true,
|
|
1298
|
+
auditTimeout: 25e3
|
|
1299
|
+
})),
|
|
1300
|
+
scoring: ScoringSchema.default(() => ({
|
|
1301
|
+
weights: { ...DEFAULT_WEIGHTS },
|
|
1302
|
+
thresholds: {
|
|
1303
|
+
good: 75,
|
|
1304
|
+
ok: 50
|
|
1305
|
+
}
|
|
1306
|
+
})),
|
|
1307
|
+
ci: CiSchema.default(() => ({
|
|
1308
|
+
failBelow: 0,
|
|
1309
|
+
format: "json"
|
|
1310
|
+
}))
|
|
1311
|
+
});
|
|
1312
|
+
const defaults = AislopConfigSchema.parse({});
|
|
1313
|
+
/**
|
|
1314
|
+
* Pre-merge scoring weights so partial overrides extend the defaults
|
|
1315
|
+
* rather than replacing them entirely (z.record replaces by default).
|
|
1316
|
+
*/
|
|
1317
|
+
const preMergeWeights = (raw) => {
|
|
1318
|
+
const scoring = raw.scoring;
|
|
1319
|
+
if (!scoring) return;
|
|
1320
|
+
const userWeights = scoring.weights;
|
|
1321
|
+
if (!userWeights || typeof userWeights !== "object") return;
|
|
1322
|
+
scoring.weights = {
|
|
1323
|
+
...DEFAULT_WEIGHTS,
|
|
1324
|
+
...userWeights
|
|
1325
|
+
};
|
|
1326
|
+
};
|
|
1327
|
+
const parseConfig = (raw) => {
|
|
1328
|
+
if (!raw || typeof raw !== "object") return defaults;
|
|
1329
|
+
try {
|
|
1330
|
+
const input = raw;
|
|
1331
|
+
preMergeWeights(input);
|
|
1332
|
+
return AislopConfigSchema.parse(input);
|
|
1333
|
+
} catch {
|
|
1334
|
+
return defaults;
|
|
1335
|
+
}
|
|
1336
|
+
};
|
|
1337
|
+
|
|
1338
|
+
//#endregion
|
|
1339
|
+
//#region src/config/index.ts
|
|
1340
|
+
const CONFIG_DIR = ".aislop";
|
|
1341
|
+
const CONFIG_FILE = "config.yml";
|
|
1342
|
+
const RULES_FILE = "rules.yml";
|
|
1343
|
+
const findConfigDir = (startDir) => {
|
|
1344
|
+
let current = path.resolve(startDir);
|
|
1345
|
+
while (true) {
|
|
1346
|
+
const candidate = path.join(current, CONFIG_DIR);
|
|
1347
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return candidate;
|
|
1348
|
+
const parent = path.dirname(current);
|
|
1349
|
+
if (parent === current) break;
|
|
1350
|
+
current = parent;
|
|
1351
|
+
}
|
|
1352
|
+
return null;
|
|
1353
|
+
};
|
|
1354
|
+
const loadConfig = (directory) => {
|
|
1355
|
+
const configDir = findConfigDir(directory);
|
|
1356
|
+
if (!configDir) return DEFAULT_CONFIG;
|
|
1357
|
+
const configPath = path.join(configDir, CONFIG_FILE);
|
|
1358
|
+
if (!fs.existsSync(configPath)) return DEFAULT_CONFIG;
|
|
1359
|
+
try {
|
|
1360
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
1361
|
+
return parseConfig(YAML.parse(raw));
|
|
1362
|
+
} catch {
|
|
1363
|
+
return DEFAULT_CONFIG;
|
|
1364
|
+
}
|
|
1365
|
+
};
|
|
1366
|
+
|
|
1367
|
+
//#endregion
|
|
1368
|
+
//#region src/utils/spinner.ts
|
|
1369
|
+
const createNoopHandle = () => ({
|
|
1370
|
+
succeed: () => void 0,
|
|
1371
|
+
fail: () => void 0,
|
|
1372
|
+
stop: () => void 0
|
|
1373
|
+
});
|
|
1374
|
+
const shouldRenderSpinner = () => Boolean(process.stderr.isTTY) && process.env.CI !== "true" && process.env.CI !== "1";
|
|
1375
|
+
const spinner = (text) => ({ start() {
|
|
1376
|
+
if (!shouldRenderSpinner()) return createNoopHandle();
|
|
1377
|
+
const instance = ora({ text }).start();
|
|
1378
|
+
return {
|
|
1379
|
+
succeed: (displayText) => instance.succeed(displayText),
|
|
1380
|
+
fail: (displayText) => instance.fail(displayText),
|
|
1381
|
+
stop: () => instance.stop()
|
|
1382
|
+
};
|
|
1383
|
+
} });
|
|
1384
|
+
|
|
1385
|
+
//#endregion
|
|
1386
|
+
//#region src/commands/init.ts
|
|
1387
|
+
const initCommand = async (directory) => {
|
|
1388
|
+
const resolvedDir = path.resolve(directory);
|
|
1389
|
+
printCommandHeader("Init");
|
|
1390
|
+
const s1 = spinner("Detecting project...").start();
|
|
1391
|
+
const projectInfo = await discoverProject(resolvedDir);
|
|
1392
|
+
s1.stop();
|
|
1393
|
+
logger.success(` ✓ ${formatProjectSummary(projectInfo)}`);
|
|
1394
|
+
printProjectMetadata(projectInfo);
|
|
1395
|
+
const configDir = path.join(resolvedDir, CONFIG_DIR);
|
|
1396
|
+
if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
|
|
1397
|
+
const configPath = path.join(configDir, CONFIG_FILE);
|
|
1398
|
+
if (fs.existsSync(configPath)) logger.dim(` ${CONFIG_DIR}/${CONFIG_FILE} already exists, skipping`);
|
|
1399
|
+
else {
|
|
1400
|
+
fs.writeFileSync(configPath, DEFAULT_CONFIG_YAML);
|
|
1401
|
+
spinner(`Creating ${CONFIG_DIR}/${CONFIG_FILE}...`).start().succeed(`Created ${highlighter.info(`${CONFIG_DIR}/${CONFIG_FILE}`)}`);
|
|
1402
|
+
}
|
|
1403
|
+
const rulesPath = path.join(configDir, RULES_FILE);
|
|
1404
|
+
if (fs.existsSync(rulesPath)) logger.dim(` ${CONFIG_DIR}/${RULES_FILE} already exists, skipping`);
|
|
1405
|
+
else {
|
|
1406
|
+
fs.writeFileSync(rulesPath, DEFAULT_RULES_YAML);
|
|
1407
|
+
spinner(`Creating ${CONFIG_DIR}/${RULES_FILE}...`).start().succeed(`Created ${highlighter.info(`${CONFIG_DIR}/${RULES_FILE}`)}`);
|
|
1408
|
+
}
|
|
1409
|
+
logger.break();
|
|
1410
|
+
logger.log(" Next steps:");
|
|
1411
|
+
logger.dim(" 1. Edit .aislop/config.yml to customize engines and thresholds");
|
|
1412
|
+
logger.dim(" 2. Edit .aislop/rules.yml to add architecture rules");
|
|
1413
|
+
logger.dim(" 3. Run `aislop scan` to see your score");
|
|
1414
|
+
logger.dim(" 4. Add `aislop scan --staged` to your pre-commit hook");
|
|
1415
|
+
logger.break();
|
|
1416
|
+
};
|
|
1417
|
+
|
|
1418
|
+
//#endregion
|
|
1419
|
+
//#region src/engines/ai-slop/abstractions.ts
|
|
1420
|
+
const THIN_WRAPPER_PATTERNS = [
|
|
1421
|
+
/(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\([^)]*\)\s*(?::\s*\w[^{]*)?\{\s*\n?\s*return\s+\w+\([^)]*\);\s*\n?\s*\}/g,
|
|
1422
|
+
/(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\([^)]*\)\s*(?::\s*\w[^=]*)?\s*=>\s*\w+\([^)]*\);/g,
|
|
1423
|
+
/def\s+(\w+)\s*\([^)]*\)(?:\s*->[^:]*)?:\s*\n\s+return\s+\w+\([^)]*\)\s*$/gm
|
|
1424
|
+
];
|
|
1425
|
+
const AI_NAMING_PATTERNS = [/(?:helper|util|handler|process|do|handle|execute|perform)_?\d+/i, /(?:data|temp|result|value|item|obj|arr|str|num|val)\d+/];
|
|
1426
|
+
const FRAMEWORK_METHOD_NAMES = /^(?:setUp|tearDown|setUpClass|tearDownClass|setUpModule|tearDownModule)$/;
|
|
1427
|
+
const DUNDER_PATTERN = /^__\w+__$/;
|
|
1428
|
+
const hasHardcodedArgs = (matchText) => {
|
|
1429
|
+
const innerCallMatch = matchText.match(/=>\s*\w+\(([^)]*)\)\s*;?\s*$/);
|
|
1430
|
+
if (!innerCallMatch) {
|
|
1431
|
+
const returnCallMatch = matchText.match(/return\s+\w+\(([^)]*)\)\s*;?\s*\}/);
|
|
1432
|
+
if (!returnCallMatch) return false;
|
|
1433
|
+
return /['"`]\w+['"`]|(?<!\w)\d+(?!\w)/.test(returnCallMatch[1]);
|
|
1434
|
+
}
|
|
1435
|
+
return /['"`]\w+['"`]|(?<!\w)\d+(?!\w)/.test(innerCallMatch[1]);
|
|
1436
|
+
};
|
|
1437
|
+
const isUseContextWrapper = (matchText) => /\buse\w+/.test(matchText) && /useContext\s*\(/.test(matchText);
|
|
1438
|
+
const detectThinWrappers = (content, relativePath) => {
|
|
1439
|
+
const diagnostics = [];
|
|
1440
|
+
const lines = content.split("\n");
|
|
1441
|
+
for (const pattern of THIN_WRAPPER_PATTERNS) {
|
|
1442
|
+
const regex = new RegExp(pattern.source, pattern.flags);
|
|
1443
|
+
let match;
|
|
1444
|
+
while ((match = regex.exec(content)) !== null) {
|
|
1445
|
+
const funcName = match[1];
|
|
1446
|
+
const matchText = match[0];
|
|
1447
|
+
const lineNumber = content.slice(0, match.index).split("\n").length;
|
|
1448
|
+
if (DUNDER_PATTERN.test(funcName)) continue;
|
|
1449
|
+
if (FRAMEWORK_METHOD_NAMES.test(funcName)) continue;
|
|
1450
|
+
if (lineNumber >= 2) {
|
|
1451
|
+
const prevLine = lines[lineNumber - 2]?.trim();
|
|
1452
|
+
if (prevLine && prevLine.startsWith("@")) continue;
|
|
1453
|
+
}
|
|
1454
|
+
if (hasHardcodedArgs(matchText)) continue;
|
|
1455
|
+
if (isUseContextWrapper(matchText)) continue;
|
|
1456
|
+
diagnostics.push({
|
|
1457
|
+
filePath: relativePath,
|
|
1458
|
+
engine: "ai-slop",
|
|
1459
|
+
rule: "ai-slop/thin-wrapper",
|
|
1460
|
+
severity: "warning",
|
|
1461
|
+
message: `Function '${funcName}' is a thin wrapper that only calls another function`,
|
|
1462
|
+
help: "Consider calling the inner function directly instead of wrapping it",
|
|
1463
|
+
line: lineNumber,
|
|
1464
|
+
column: 0,
|
|
1465
|
+
category: "AI Slop",
|
|
1466
|
+
fixable: false
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
return diagnostics;
|
|
1471
|
+
};
|
|
1472
|
+
const detectAiNaming = (content, relativePath) => {
|
|
1473
|
+
const diagnostics = [];
|
|
1474
|
+
const lines = content.split("\n");
|
|
1475
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1476
|
+
const declMatch = lines[i].match(/(?:const|let|var|function|def|func|fn)\s+(\w+)/);
|
|
1477
|
+
if (!declMatch) continue;
|
|
1478
|
+
const name = declMatch[1];
|
|
1479
|
+
if (!AI_NAMING_PATTERNS.some((pattern) => pattern.test(name))) continue;
|
|
1480
|
+
diagnostics.push({
|
|
1481
|
+
filePath: relativePath,
|
|
1482
|
+
engine: "ai-slop",
|
|
1483
|
+
rule: "ai-slop/generic-naming",
|
|
1484
|
+
severity: "info",
|
|
1485
|
+
message: `'${name}' uses generic AI-style naming`,
|
|
1486
|
+
help: "Use descriptive names that explain what the code does",
|
|
1487
|
+
line: i + 1,
|
|
1488
|
+
column: 0,
|
|
1489
|
+
category: "AI Slop",
|
|
1490
|
+
fixable: false
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
return diagnostics;
|
|
1494
|
+
};
|
|
1495
|
+
const detectOverAbstraction = async (context) => {
|
|
1496
|
+
const files = getSourceFiles(context);
|
|
1497
|
+
const diagnostics = [];
|
|
1498
|
+
for (const filePath of files) {
|
|
1499
|
+
if (isAutoGenerated(filePath)) continue;
|
|
1500
|
+
let content;
|
|
1501
|
+
try {
|
|
1502
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
1503
|
+
} catch {
|
|
1504
|
+
continue;
|
|
1505
|
+
}
|
|
1506
|
+
const relativePath = path.relative(context.rootDirectory, filePath);
|
|
1507
|
+
diagnostics.push(...detectThinWrappers(content, relativePath));
|
|
1508
|
+
diagnostics.push(...detectAiNaming(content, relativePath));
|
|
1509
|
+
}
|
|
1510
|
+
return diagnostics;
|
|
1511
|
+
};
|
|
1512
|
+
|
|
1513
|
+
//#endregion
|
|
1514
|
+
//#region src/engines/ai-slop/comments.ts
|
|
1515
|
+
const TRIVIAL_JS_COMMENT_PATTERNS = [
|
|
1516
|
+
/\/\/\s*This (?:function|method|class|variable|constant) (?:will |is used to |is responsible for )?/i,
|
|
1517
|
+
/\/\/\s*Import(?:ing|s)?\s+/i,
|
|
1518
|
+
/\/\/\s*Defin(?:e|ing)\s+(?:the\s+)?/i,
|
|
1519
|
+
/\/\/\s*Initializ(?:e|ing)\s+(?:the\s+)?/i,
|
|
1520
|
+
/\/\/\s*Set(?:ting)?\s+\w+\s+to\s+/i,
|
|
1521
|
+
/\/\/\s*Return(?:ing|s)?\s+(?:the\s+)?/i,
|
|
1522
|
+
/\/\/\s*Check(?:ing)?\s+(?:if|whether)\s+/i,
|
|
1523
|
+
/\/\/\s*(?:Loop(?:ing)?\s+through|Iterat(?:e|ing)\s+over)\s+/i,
|
|
1524
|
+
/\/\/\s*Creat(?:e|ing)\s+(?:a\s+(?:new\s+)?)?/i,
|
|
1525
|
+
/\/\/\s*Updat(?:e|ing)\s+(?:the\s+)?/i,
|
|
1526
|
+
/\/\/\s*(?:Delet|Remov)(?:e|ing)\s+(?:the\s+)?/i,
|
|
1527
|
+
/\/\/\s*Handl(?:e|ing)\s+(?:the\s+)?/i,
|
|
1528
|
+
/\/\/\s*(?:Get(?:ting)?|Fetch(?:ing)?)\s+(?:the\s+)?/i,
|
|
1529
|
+
/\/\/\s*(?:Increment|Decrement)(?:ing)?\s+/i
|
|
1530
|
+
];
|
|
1531
|
+
const TRIVIAL_PYTHON_COMMENT_PATTERNS = [/^#\s*This (?:function|method|class) (?:will |is used to )?/i, /^#\s*(?:Import|Define|Initialize|Return|Check|Create|Update|Delete|Handle|Get|Fetch)/i];
|
|
1532
|
+
const EXPLANATORY_KEYWORDS = /\b(?:because|since|note|todo|fixme|hack|warn|warning|workaround|caveat|important|assumes?)\b/i;
|
|
1533
|
+
const COMMENTED_CODE_CHARS = /[({=;}\]>]/;
|
|
1534
|
+
const MAX_TRIVIAL_COMMENT_LENGTH = 60;
|
|
1535
|
+
const isJsComment = (trimmed) => trimmed.startsWith("//");
|
|
1536
|
+
const isPythonComment = (trimmed) => trimmed.startsWith("#") && !trimmed.startsWith("#!");
|
|
1537
|
+
/**
|
|
1538
|
+
* Extract just the comment text after the comment marker.
|
|
1539
|
+
*/
|
|
1540
|
+
const getCommentBody = (trimmed) => {
|
|
1541
|
+
if (trimmed.startsWith("//")) return trimmed.slice(2).trim();
|
|
1542
|
+
if (trimmed.startsWith("#")) return trimmed.slice(1).trim();
|
|
1543
|
+
return trimmed;
|
|
1544
|
+
};
|
|
1545
|
+
const isTrivialComment = (trimmed, nextLine) => {
|
|
1546
|
+
const isJs = isJsComment(trimmed);
|
|
1547
|
+
const isPy = isPythonComment(trimmed);
|
|
1548
|
+
if (!isJs && !isPy) return false;
|
|
1549
|
+
const commentBody = getCommentBody(trimmed);
|
|
1550
|
+
if (commentBody.length > MAX_TRIVIAL_COMMENT_LENGTH) return false;
|
|
1551
|
+
if (EXPLANATORY_KEYWORDS.test(commentBody)) return false;
|
|
1552
|
+
if (commentBody.includes("(") && commentBody.includes(")")) return false;
|
|
1553
|
+
if (COMMENTED_CODE_CHARS.test(commentBody)) return false;
|
|
1554
|
+
if (nextLine !== void 0 && nextLine.trim() === "") return false;
|
|
1555
|
+
if (/[─━═╌╍┄┅│┃]/.test(commentBody)) return false;
|
|
1556
|
+
if (/^-{3,}|─{3,}/.test(commentBody)) return false;
|
|
1557
|
+
return (isJs ? TRIVIAL_JS_COMMENT_PATTERNS : TRIVIAL_PYTHON_COMMENT_PATTERNS).some((pattern) => pattern.test(trimmed));
|
|
1558
|
+
};
|
|
1559
|
+
const scanFileForTrivialComments = (content, relativePath) => {
|
|
1560
|
+
const diagnostics = [];
|
|
1561
|
+
const lines = content.split("\n");
|
|
1562
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1563
|
+
if (!isTrivialComment(lines[i].trim(), i + 1 < lines.length ? lines[i + 1] : void 0)) continue;
|
|
1564
|
+
diagnostics.push({
|
|
1565
|
+
filePath: relativePath,
|
|
1566
|
+
engine: "ai-slop",
|
|
1567
|
+
rule: "ai-slop/trivial-comment",
|
|
1568
|
+
severity: "warning",
|
|
1569
|
+
message: "Trivial comment that restates the code",
|
|
1570
|
+
help: "Remove comments that don't add information beyond what the code already expresses",
|
|
1571
|
+
line: i + 1,
|
|
1572
|
+
column: 0,
|
|
1573
|
+
category: "AI Slop",
|
|
1574
|
+
fixable: true
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
return diagnostics;
|
|
1578
|
+
};
|
|
1579
|
+
const detectTrivialComments = async (context) => {
|
|
1580
|
+
const files = getSourceFiles(context);
|
|
1581
|
+
const diagnostics = [];
|
|
1582
|
+
for (const filePath of files) {
|
|
1583
|
+
if (isAutoGenerated(filePath)) continue;
|
|
1584
|
+
let content;
|
|
1585
|
+
try {
|
|
1586
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
1587
|
+
} catch {
|
|
1588
|
+
continue;
|
|
1589
|
+
}
|
|
1590
|
+
const relativePath = path.relative(context.rootDirectory, filePath);
|
|
1591
|
+
diagnostics.push(...scanFileForTrivialComments(content, relativePath));
|
|
1592
|
+
}
|
|
1593
|
+
return diagnostics;
|
|
1594
|
+
};
|
|
1595
|
+
|
|
1596
|
+
//#endregion
|
|
1597
|
+
//#region src/engines/ai-slop/dead-patterns.ts
|
|
1598
|
+
const JS_EXTENSIONS$1 = new Set([
|
|
1599
|
+
".ts",
|
|
1600
|
+
".tsx",
|
|
1601
|
+
".js",
|
|
1602
|
+
".jsx",
|
|
1603
|
+
".mjs",
|
|
1604
|
+
".cjs"
|
|
1605
|
+
]);
|
|
1606
|
+
const CONSOLE_LOG_PATTERN = /\bconsole\.(?:log|debug|info|trace|dir|table)\s*\(/;
|
|
1607
|
+
const LOGGER_FILE_PATTERN = /(?:^|\/)(?:logger|logging|log)\.[^/]+$/i;
|
|
1608
|
+
const SCRIPT_DIR_PATTERN = /(?:^|\/)(scripts|bin)\//;
|
|
1609
|
+
const detectConsoleLeftovers = (content, relativePath, ext) => {
|
|
1610
|
+
if (!JS_EXTENSIONS$1.has(ext)) return [];
|
|
1611
|
+
if (LOGGER_FILE_PATTERN.test(relativePath)) return [];
|
|
1612
|
+
if (SCRIPT_DIR_PATTERN.test(relativePath)) return [];
|
|
1613
|
+
const diagnostics = [];
|
|
1614
|
+
const lines = content.split("\n");
|
|
1615
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1616
|
+
const trimmed = lines[i].trim();
|
|
1617
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
|
|
1618
|
+
if (CONSOLE_LOG_PATTERN.test(trimmed)) {
|
|
1619
|
+
if (/console\.(?:error|warn)\s*\(/.test(trimmed)) continue;
|
|
1620
|
+
if (/console\.log\(\s*JSON\.stringify\b/.test(trimmed)) continue;
|
|
1621
|
+
diagnostics.push({
|
|
1622
|
+
filePath: relativePath,
|
|
1623
|
+
engine: "ai-slop",
|
|
1624
|
+
rule: "ai-slop/console-leftover",
|
|
1625
|
+
severity: "warning",
|
|
1626
|
+
message: "console.log/debug/info statement left in production code",
|
|
1627
|
+
help: "Remove debugging console statements or replace with a proper logger",
|
|
1628
|
+
line: i + 1,
|
|
1629
|
+
column: 0,
|
|
1630
|
+
category: "AI Slop",
|
|
1631
|
+
fixable: true
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
return diagnostics;
|
|
1636
|
+
};
|
|
1637
|
+
const TODO_PATTERN = new RegExp(`\\b(?:${[
|
|
1638
|
+
"TODO",
|
|
1639
|
+
"FIXME",
|
|
1640
|
+
"HACK",
|
|
1641
|
+
"XXX"
|
|
1642
|
+
].join("|")}|TEMP|PLACEHOLDER|STUB)\\b[:\\s]`, "i");
|
|
1643
|
+
const detectTodoStubs = (content, relativePath) => {
|
|
1644
|
+
const diagnostics = [];
|
|
1645
|
+
const lines = content.split("\n");
|
|
1646
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1647
|
+
const trimmed = lines[i].trim();
|
|
1648
|
+
if (!trimmed.startsWith("//") && !trimmed.startsWith("#") && !trimmed.startsWith("*") && !trimmed.startsWith("/*")) continue;
|
|
1649
|
+
if (TODO_PATTERN.test(trimmed)) diagnostics.push({
|
|
1650
|
+
filePath: relativePath,
|
|
1651
|
+
engine: "ai-slop",
|
|
1652
|
+
rule: "ai-slop/todo-stub",
|
|
1653
|
+
severity: "info",
|
|
1654
|
+
message: "Unresolved TODO/FIXME/HACK comment indicates incomplete code",
|
|
1655
|
+
help: "Resolve the TODO or create a tracked issue for it",
|
|
1656
|
+
line: i + 1,
|
|
1657
|
+
column: 0,
|
|
1658
|
+
category: "AI Slop",
|
|
1659
|
+
fixable: false
|
|
1660
|
+
});
|
|
1661
|
+
}
|
|
1662
|
+
return diagnostics;
|
|
1663
|
+
};
|
|
1664
|
+
const detectDeadCodePatterns = (content, relativePath, ext) => {
|
|
1665
|
+
const diagnostics = [];
|
|
1666
|
+
const lines = content.split("\n");
|
|
1667
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1668
|
+
const trimmed = lines[i].trim();
|
|
1669
|
+
const nextLine = i + 1 < lines.length ? lines[i + 1]?.trim() : void 0;
|
|
1670
|
+
if (JS_EXTENSIONS$1.has(ext) && /^(?:return|throw)\b/.test(trimmed) && trimmed.endsWith(";") && nextLine && nextLine.length > 0 && !nextLine.startsWith("}") && !nextLine.startsWith("//") && !nextLine.startsWith("/*") && !nextLine.startsWith("case ") && !nextLine.startsWith("default:") && !nextLine.startsWith("if ") && !nextLine.startsWith("if(") && !nextLine.startsWith("else")) diagnostics.push({
|
|
1671
|
+
filePath: relativePath,
|
|
1672
|
+
engine: "ai-slop",
|
|
1673
|
+
rule: "ai-slop/unreachable-code",
|
|
1674
|
+
severity: "warning",
|
|
1675
|
+
message: "Code after return/throw statement is unreachable",
|
|
1676
|
+
help: "Remove the unreachable code or restructure the control flow",
|
|
1677
|
+
line: i + 2,
|
|
1678
|
+
column: 0,
|
|
1679
|
+
category: "AI Slop",
|
|
1680
|
+
fixable: false
|
|
1681
|
+
});
|
|
1682
|
+
if (/\bif\s*\(\s*(?:false|true|0|1)\s*\)/.test(trimmed) && !trimmed.startsWith("//") && !trimmed.startsWith("*") && !/["'`].*\bif\s*\(/.test(trimmed) && !/\/.*\bif\s*\(/.test(trimmed.replace(/\/\/.*$/, ""))) diagnostics.push({
|
|
1683
|
+
filePath: relativePath,
|
|
1684
|
+
engine: "ai-slop",
|
|
1685
|
+
rule: "ai-slop/constant-condition",
|
|
1686
|
+
severity: "warning",
|
|
1687
|
+
message: "Conditional with a constant value — likely debugging leftover",
|
|
1688
|
+
help: "Remove the constant condition or replace with proper logic",
|
|
1689
|
+
line: i + 1,
|
|
1690
|
+
column: 0,
|
|
1691
|
+
category: "AI Slop",
|
|
1692
|
+
fixable: false
|
|
1693
|
+
});
|
|
1694
|
+
if (JS_EXTENSIONS$1.has(ext) && /(?:function\s+\w+|=>\s*)\s*\{\s*\}\s*;?\s*$/.test(trimmed) && !trimmed.startsWith("interface") && !trimmed.startsWith("type ")) diagnostics.push({
|
|
1695
|
+
filePath: relativePath,
|
|
1696
|
+
engine: "ai-slop",
|
|
1697
|
+
rule: "ai-slop/empty-function",
|
|
1698
|
+
severity: "info",
|
|
1699
|
+
message: "Empty function body — possible stub or unfinished implementation",
|
|
1700
|
+
help: "Implement the function body or add a comment explaining why it's empty",
|
|
1701
|
+
line: i + 1,
|
|
1702
|
+
column: 0,
|
|
1703
|
+
category: "AI Slop",
|
|
1704
|
+
fixable: false
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
return diagnostics;
|
|
1708
|
+
};
|
|
1709
|
+
const asAnyPattern = new RegExp(`\\bas\\s+any\\b`);
|
|
1710
|
+
const doubleAssertPattern = new RegExp(`\\bas\\s+unknown\\s+as\\s+`);
|
|
1711
|
+
const detectUnsafeTypePatterns = (content, relativePath, ext) => {
|
|
1712
|
+
if (ext !== ".ts" && ext !== ".tsx") return [];
|
|
1713
|
+
const diagnostics = [];
|
|
1714
|
+
const lines = content.split("\n");
|
|
1715
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1716
|
+
const trimmed = lines[i].trim();
|
|
1717
|
+
if (/\/\/\s*@ts-(?:ignore|expect-error)/.test(trimmed) || /\/\*\s*@ts-(?:ignore|expect-error)/.test(trimmed)) diagnostics.push({
|
|
1718
|
+
filePath: relativePath,
|
|
1719
|
+
engine: "ai-slop",
|
|
1720
|
+
rule: "ai-slop/ts-directive",
|
|
1721
|
+
severity: "info",
|
|
1722
|
+
message: "@ts-ignore/@ts-expect-error suppresses type checking — review if still needed",
|
|
1723
|
+
help: "Fix the underlying type issue instead of suppressing the error",
|
|
1724
|
+
line: i + 1,
|
|
1725
|
+
column: 0,
|
|
1726
|
+
category: "AI Slop",
|
|
1727
|
+
fixable: false
|
|
1728
|
+
});
|
|
1729
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
|
|
1730
|
+
if (/\bRegExp\b|new\s+RegExp|\/.*\\b/.test(trimmed)) continue;
|
|
1731
|
+
if (/["'`].*\\b.*["'`]/.test(trimmed)) continue;
|
|
1732
|
+
if (asAnyPattern.test(trimmed)) diagnostics.push({
|
|
1733
|
+
filePath: relativePath,
|
|
1734
|
+
engine: "ai-slop",
|
|
1735
|
+
rule: "ai-slop/unsafe-type-assertion",
|
|
1736
|
+
severity: "warning",
|
|
1737
|
+
message: `'as any' bypasses type safety`,
|
|
1738
|
+
help: "Use a proper type or a more specific assertion",
|
|
1739
|
+
line: i + 1,
|
|
1740
|
+
column: 0,
|
|
1741
|
+
category: "AI Slop",
|
|
1742
|
+
fixable: false
|
|
1743
|
+
});
|
|
1744
|
+
if (doubleAssertPattern.test(trimmed)) diagnostics.push({
|
|
1745
|
+
filePath: relativePath,
|
|
1746
|
+
engine: "ai-slop",
|
|
1747
|
+
rule: "ai-slop/double-type-assertion",
|
|
1748
|
+
severity: "warning",
|
|
1749
|
+
message: `Double type assertion (as unknown as X) bypasses type checking`,
|
|
1750
|
+
help: "Refactor to avoid needing a double assertion — it usually indicates a design issue",
|
|
1751
|
+
line: i + 1,
|
|
1752
|
+
column: 0,
|
|
1753
|
+
category: "AI Slop",
|
|
1754
|
+
fixable: false
|
|
1755
|
+
});
|
|
1756
|
+
}
|
|
1757
|
+
return diagnostics;
|
|
1758
|
+
};
|
|
1759
|
+
const detectDeadPatterns = async (context) => {
|
|
1760
|
+
const files = getSourceFiles(context);
|
|
1761
|
+
const diagnostics = [];
|
|
1762
|
+
for (const filePath of files) {
|
|
1763
|
+
if (isAutoGenerated(filePath)) continue;
|
|
1764
|
+
let content;
|
|
1765
|
+
try {
|
|
1766
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
1767
|
+
} catch {
|
|
1768
|
+
continue;
|
|
1769
|
+
}
|
|
1770
|
+
const ext = path.extname(filePath);
|
|
1771
|
+
const relativePath = path.relative(context.rootDirectory, filePath);
|
|
1772
|
+
diagnostics.push(...detectConsoleLeftovers(content, relativePath, ext));
|
|
1773
|
+
diagnostics.push(...detectTodoStubs(content, relativePath));
|
|
1774
|
+
diagnostics.push(...detectDeadCodePatterns(content, relativePath, ext));
|
|
1775
|
+
diagnostics.push(...detectUnsafeTypePatterns(content, relativePath, ext));
|
|
1776
|
+
}
|
|
1777
|
+
return diagnostics;
|
|
1778
|
+
};
|
|
1779
|
+
|
|
1780
|
+
//#endregion
|
|
1781
|
+
//#region src/engines/ai-slop/exceptions.ts
|
|
1782
|
+
const SWALLOWED_EXCEPTION_PATTERNS = [
|
|
1783
|
+
{
|
|
1784
|
+
pattern: /catch\s*\([^)]*\)\s*\{\s*(?:\/\/[^\n]*)?\s*\}/,
|
|
1785
|
+
languages: [
|
|
1786
|
+
".ts",
|
|
1787
|
+
".tsx",
|
|
1788
|
+
".js",
|
|
1789
|
+
".jsx",
|
|
1790
|
+
".mjs",
|
|
1791
|
+
".cjs"
|
|
1792
|
+
],
|
|
1793
|
+
message: "Empty catch block swallows errors silently"
|
|
1794
|
+
},
|
|
1795
|
+
{
|
|
1796
|
+
pattern: /catch\s*\([^)]*\)\s*\{\s*console\.log\([^)]*\);\s*\}/,
|
|
1797
|
+
languages: [
|
|
1798
|
+
".ts",
|
|
1799
|
+
".tsx",
|
|
1800
|
+
".js",
|
|
1801
|
+
".jsx",
|
|
1802
|
+
".mjs",
|
|
1803
|
+
".cjs"
|
|
1804
|
+
],
|
|
1805
|
+
message: "Catch block only logs error without proper handling"
|
|
1806
|
+
},
|
|
1807
|
+
{
|
|
1808
|
+
pattern: /except(?:\s+\w+)?:\s*\n\s*pass/,
|
|
1809
|
+
languages: [".py"],
|
|
1810
|
+
message: "Bare except with pass swallows errors silently"
|
|
1811
|
+
},
|
|
1812
|
+
{
|
|
1813
|
+
pattern: /except\s+\w+(?:\s+as\s+\w+)?:\s*\n\s*print\(/,
|
|
1814
|
+
languages: [".py"],
|
|
1815
|
+
message: "Catch block only prints error without proper handling"
|
|
1816
|
+
},
|
|
1817
|
+
{
|
|
1818
|
+
pattern: /\w+,\s*_\s*:?=\s*\w+\(/,
|
|
1819
|
+
languages: [".go"],
|
|
1820
|
+
message: "Error return value is being ignored"
|
|
1821
|
+
},
|
|
1822
|
+
{
|
|
1823
|
+
pattern: /rescue(?:\s+\w+)?\s*(?:=>?\s*\w+)?\s*\n\s*(?:nil|#)/,
|
|
1824
|
+
languages: [".rb"],
|
|
1825
|
+
message: "Rescue block swallows errors silently"
|
|
1826
|
+
},
|
|
1827
|
+
{
|
|
1828
|
+
pattern: /catch\s*\(\w+\s+\w+\)\s*\{\s*(?:\/\/[^\n]*)?\s*\}/,
|
|
1829
|
+
languages: [".java"],
|
|
1830
|
+
message: "Empty catch block swallows errors silently"
|
|
1831
|
+
}
|
|
1832
|
+
];
|
|
1833
|
+
const detectSwallowedExceptions = async (context) => {
|
|
1834
|
+
const files = getSourceFiles(context);
|
|
1835
|
+
const diagnostics = [];
|
|
1836
|
+
for (const filePath of files) {
|
|
1837
|
+
if (isAutoGenerated(filePath)) continue;
|
|
1838
|
+
let content;
|
|
1839
|
+
try {
|
|
1840
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
1841
|
+
} catch {
|
|
1842
|
+
continue;
|
|
1843
|
+
}
|
|
1844
|
+
const ext = path.extname(filePath);
|
|
1845
|
+
const relativePath = path.relative(context.rootDirectory, filePath);
|
|
1846
|
+
for (const { pattern, languages, message } of SWALLOWED_EXCEPTION_PATTERNS) {
|
|
1847
|
+
if (!languages.includes(ext)) continue;
|
|
1848
|
+
let match;
|
|
1849
|
+
const regex = new RegExp(pattern.source, pattern.flags + (pattern.flags.includes("g") ? "" : "g"));
|
|
1850
|
+
while ((match = regex.exec(content)) !== null) {
|
|
1851
|
+
const line = content.slice(0, match.index).split("\n").length;
|
|
1852
|
+
diagnostics.push({
|
|
1853
|
+
filePath: relativePath,
|
|
1854
|
+
engine: "ai-slop",
|
|
1855
|
+
rule: "ai-slop/swallowed-exception",
|
|
1856
|
+
severity: "error",
|
|
1857
|
+
message,
|
|
1858
|
+
help: "Handle errors explicitly: log with context, rethrow, or return an error value",
|
|
1859
|
+
line,
|
|
1860
|
+
column: 0,
|
|
1861
|
+
category: "AI Slop",
|
|
1862
|
+
fixable: false
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
return diagnostics;
|
|
1868
|
+
};
|
|
1869
|
+
|
|
1870
|
+
//#endregion
|
|
1871
|
+
//#region src/engines/ai-slop/unused-imports.ts
|
|
1872
|
+
const JS_EXTENSIONS = new Set([
|
|
1873
|
+
".ts",
|
|
1874
|
+
".tsx",
|
|
1875
|
+
".js",
|
|
1876
|
+
".jsx",
|
|
1877
|
+
".mjs",
|
|
1878
|
+
".cjs"
|
|
1879
|
+
]);
|
|
1880
|
+
const PY_EXTENSIONS = new Set([".py"]);
|
|
1881
|
+
const extractJsImportedSymbols = (lines) => {
|
|
1882
|
+
const symbols = [];
|
|
1883
|
+
const importLines = /* @__PURE__ */ new Set();
|
|
1884
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1885
|
+
const trimmed = lines[i].trim();
|
|
1886
|
+
if (!trimmed.startsWith("import ")) continue;
|
|
1887
|
+
importLines.add(i);
|
|
1888
|
+
if (/^import\s+["']/.test(trimmed)) continue;
|
|
1889
|
+
if (/^import\s+type\s/.test(trimmed)) continue;
|
|
1890
|
+
let fullImport = trimmed;
|
|
1891
|
+
let endLine = i;
|
|
1892
|
+
while (!fullImport.includes("from") && endLine < lines.length - 1) {
|
|
1893
|
+
endLine++;
|
|
1894
|
+
fullImport += ` ${lines[endLine].trim()}`;
|
|
1895
|
+
importLines.add(endLine);
|
|
1896
|
+
}
|
|
1897
|
+
const namespaceMatch = fullImport.match(/import\s+\*\s+as\s+(\w+)\s+from/);
|
|
1898
|
+
if (namespaceMatch) {
|
|
1899
|
+
symbols.push({
|
|
1900
|
+
name: namespaceMatch[1],
|
|
1901
|
+
line: i + 1,
|
|
1902
|
+
isDefault: false,
|
|
1903
|
+
isNamespace: true
|
|
1904
|
+
});
|
|
1905
|
+
continue;
|
|
1906
|
+
}
|
|
1907
|
+
const defaultMatch = fullImport.match(/import\s+(\w+)\s*(?:,\s*\{[^}]*\})?\s+from/);
|
|
1908
|
+
if (defaultMatch && defaultMatch[1] !== "type") symbols.push({
|
|
1909
|
+
name: defaultMatch[1],
|
|
1910
|
+
line: i + 1,
|
|
1911
|
+
isDefault: true,
|
|
1912
|
+
isNamespace: false
|
|
1913
|
+
});
|
|
1914
|
+
const namedMatch = fullImport.match(/\{([^}]+)\}/);
|
|
1915
|
+
if (namedMatch) {
|
|
1916
|
+
const namedImports = namedMatch[1].split(",");
|
|
1917
|
+
for (const ni of namedImports) {
|
|
1918
|
+
const parts = ni.trim().split(/\s+as\s+/);
|
|
1919
|
+
if (parts.length === 0 || !parts[0]) continue;
|
|
1920
|
+
const cleanParts = parts.map((p) => p.trim().replace(/^type\s+/, ""));
|
|
1921
|
+
const localName = cleanParts.length > 1 ? cleanParts[1] : cleanParts[0];
|
|
1922
|
+
if (localName && /^\w+$/.test(localName)) symbols.push({
|
|
1923
|
+
name: localName,
|
|
1924
|
+
line: i + 1,
|
|
1925
|
+
isDefault: false,
|
|
1926
|
+
isNamespace: false
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
return {
|
|
1932
|
+
symbols,
|
|
1933
|
+
importLines
|
|
1934
|
+
};
|
|
1935
|
+
};
|
|
1936
|
+
const extractPyImportedSymbols = (lines) => {
|
|
1937
|
+
const symbols = [];
|
|
1938
|
+
const importLines = /* @__PURE__ */ new Set();
|
|
1939
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1940
|
+
const trimmed = lines[i].trim();
|
|
1941
|
+
const fromMatch = trimmed.match(/^from\s+[\w.]+\s+import\s+(.+)/);
|
|
1942
|
+
if (fromMatch) {
|
|
1943
|
+
importLines.add(i);
|
|
1944
|
+
const importPart = fromMatch[1].replace(/#.*$/, "").trim();
|
|
1945
|
+
if (importPart === "*") continue;
|
|
1946
|
+
const cleaned = importPart.replace(/[()]/g, "");
|
|
1947
|
+
for (const item of cleaned.split(",")) {
|
|
1948
|
+
const parts = item.trim().split(/\s+as\s+/);
|
|
1949
|
+
const localName = parts.length > 1 ? parts[1].trim() : parts[0].trim();
|
|
1950
|
+
if (localName && /^\w+$/.test(localName)) symbols.push({
|
|
1951
|
+
name: localName,
|
|
1952
|
+
line: i + 1,
|
|
1953
|
+
isDefault: false,
|
|
1954
|
+
isNamespace: false
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
continue;
|
|
1958
|
+
}
|
|
1959
|
+
const importMatch = trimmed.match(/^import\s+([\w.]+)(?:\s+as\s+(\w+))?/);
|
|
1960
|
+
if (importMatch) {
|
|
1961
|
+
importLines.add(i);
|
|
1962
|
+
const simpleName = (importMatch[2] ?? importMatch[1]).split(".")[0];
|
|
1963
|
+
if (simpleName && /^\w+$/.test(simpleName)) symbols.push({
|
|
1964
|
+
name: simpleName,
|
|
1965
|
+
line: i + 1,
|
|
1966
|
+
isDefault: false,
|
|
1967
|
+
isNamespace: true
|
|
1968
|
+
});
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
return {
|
|
1972
|
+
symbols,
|
|
1973
|
+
importLines
|
|
1974
|
+
};
|
|
1975
|
+
};
|
|
1976
|
+
const isSymbolUsed = (name, content, importLines, lines) => {
|
|
1977
|
+
const pattern = new RegExp(`\\b${name}\\b`, "g");
|
|
1978
|
+
let match;
|
|
1979
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
1980
|
+
const lineIndex = content.slice(0, match.index).split("\n").length - 1;
|
|
1981
|
+
if (!importLines.has(lineIndex)) return true;
|
|
1982
|
+
}
|
|
1983
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1984
|
+
if (importLines.has(i)) continue;
|
|
1985
|
+
if (lines[i].includes(name)) return true;
|
|
1986
|
+
}
|
|
1987
|
+
return false;
|
|
1988
|
+
};
|
|
1989
|
+
const detectUnusedImports = async (context) => {
|
|
1990
|
+
const files = getSourceFiles(context);
|
|
1991
|
+
const diagnostics = [];
|
|
1992
|
+
for (const filePath of files) {
|
|
1993
|
+
if (isAutoGenerated(filePath)) continue;
|
|
1994
|
+
let content;
|
|
1995
|
+
try {
|
|
1996
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
1997
|
+
} catch {
|
|
1998
|
+
continue;
|
|
1999
|
+
}
|
|
2000
|
+
const ext = path.extname(filePath);
|
|
2001
|
+
const relativePath = path.relative(context.rootDirectory, filePath);
|
|
2002
|
+
const lines = content.split("\n");
|
|
2003
|
+
let symbols;
|
|
2004
|
+
let importLinesSet;
|
|
2005
|
+
if (JS_EXTENSIONS.has(ext)) {
|
|
2006
|
+
const result = extractJsImportedSymbols(lines);
|
|
2007
|
+
symbols = result.symbols;
|
|
2008
|
+
importLinesSet = result.importLines;
|
|
2009
|
+
} else if (PY_EXTENSIONS.has(ext)) {
|
|
2010
|
+
const result = extractPyImportedSymbols(lines);
|
|
2011
|
+
symbols = result.symbols;
|
|
2012
|
+
importLinesSet = result.importLines;
|
|
2013
|
+
} else continue;
|
|
2014
|
+
for (const symbol of symbols) if (!isSymbolUsed(symbol.name, content, importLinesSet, lines)) diagnostics.push({
|
|
2015
|
+
filePath: relativePath,
|
|
2016
|
+
engine: "ai-slop",
|
|
2017
|
+
rule: "ai-slop/unused-import",
|
|
2018
|
+
severity: "warning",
|
|
2019
|
+
message: `Imported symbol '${symbol.name}' is never used`,
|
|
2020
|
+
help: "Remove unused imports to keep the code clean",
|
|
2021
|
+
line: symbol.line,
|
|
2022
|
+
column: 0,
|
|
2023
|
+
category: "AI Slop",
|
|
2024
|
+
fixable: true
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
2027
|
+
return diagnostics;
|
|
2028
|
+
};
|
|
2029
|
+
|
|
2030
|
+
//#endregion
|
|
2031
|
+
//#region src/engines/ai-slop/index.ts
|
|
2032
|
+
const aiSlopEngine = {
|
|
2033
|
+
name: "ai-slop",
|
|
2034
|
+
async run(context) {
|
|
2035
|
+
const diagnostics = [];
|
|
2036
|
+
const results = await Promise.allSettled([
|
|
2037
|
+
detectTrivialComments(context),
|
|
2038
|
+
detectSwallowedExceptions(context),
|
|
2039
|
+
detectOverAbstraction(context),
|
|
2040
|
+
detectDeadPatterns(context),
|
|
2041
|
+
detectUnusedImports(context)
|
|
2042
|
+
]);
|
|
2043
|
+
for (const result of results) if (result.status === "fulfilled") diagnostics.push(...result.value);
|
|
2044
|
+
return {
|
|
2045
|
+
engine: "ai-slop",
|
|
2046
|
+
diagnostics,
|
|
2047
|
+
elapsed: 0,
|
|
2048
|
+
skipped: false
|
|
2049
|
+
};
|
|
2050
|
+
}
|
|
2051
|
+
};
|
|
2052
|
+
|
|
2053
|
+
//#endregion
|
|
2054
|
+
//#region src/engines/architecture/matchers.ts
|
|
2055
|
+
const minimatch = (filePath, pattern) => {
|
|
2056
|
+
let regex = "";
|
|
2057
|
+
let i = 0;
|
|
2058
|
+
while (i < pattern.length) {
|
|
2059
|
+
const ch = pattern[i];
|
|
2060
|
+
if (ch === "*" && pattern[i + 1] === "*") {
|
|
2061
|
+
regex += ".*";
|
|
2062
|
+
i += 2;
|
|
2063
|
+
if (pattern[i] === "/") i++;
|
|
2064
|
+
} else if (ch === "*") {
|
|
2065
|
+
regex += "[^/]*";
|
|
2066
|
+
i++;
|
|
2067
|
+
} else if (ch === "?") {
|
|
2068
|
+
regex += "[^/]";
|
|
2069
|
+
i++;
|
|
2070
|
+
} else if (ch === "[") {
|
|
2071
|
+
const closeIndex = pattern.indexOf("]", i + 1);
|
|
2072
|
+
if (closeIndex === -1) {
|
|
2073
|
+
regex += "\\[";
|
|
2074
|
+
i++;
|
|
2075
|
+
} else {
|
|
2076
|
+
regex += pattern.slice(i, closeIndex + 1);
|
|
2077
|
+
i = closeIndex + 1;
|
|
2078
|
+
}
|
|
2079
|
+
} else if (".+^${}()|\\".includes(ch)) {
|
|
2080
|
+
regex += `\\${ch}`;
|
|
2081
|
+
i++;
|
|
2082
|
+
} else {
|
|
2083
|
+
regex += ch;
|
|
2084
|
+
i++;
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
return new RegExp(`^${regex}$`).test(filePath);
|
|
2088
|
+
};
|
|
2089
|
+
const extractImports = (content, ext) => {
|
|
2090
|
+
const imports = [];
|
|
2091
|
+
if ([
|
|
2092
|
+
".ts",
|
|
2093
|
+
".tsx",
|
|
2094
|
+
".js",
|
|
2095
|
+
".jsx",
|
|
2096
|
+
".mjs",
|
|
2097
|
+
".cjs"
|
|
2098
|
+
].includes(ext)) {
|
|
2099
|
+
const esPattern = /(?:import|from)\s+["']([^"']+)["']/g;
|
|
2100
|
+
let match;
|
|
2101
|
+
while ((match = esPattern.exec(content)) !== null) imports.push(match[1]);
|
|
2102
|
+
const reqPattern = /require\s*\(\s*["']([^"']+)["']\s*\)/g;
|
|
2103
|
+
while ((match = reqPattern.exec(content)) !== null) imports.push(match[1]);
|
|
2104
|
+
}
|
|
2105
|
+
if (ext === ".py") {
|
|
2106
|
+
const pyPattern = /(?:from|import)\s+([\w.]+)/g;
|
|
2107
|
+
let match;
|
|
2108
|
+
while ((match = pyPattern.exec(content)) !== null) imports.push(match[1]);
|
|
2109
|
+
}
|
|
2110
|
+
if (ext === ".go") {
|
|
2111
|
+
const goSingleImport = /^\s*import\s+"([^"]+)"/gm;
|
|
2112
|
+
let match;
|
|
2113
|
+
while ((match = goSingleImport.exec(content)) !== null) imports.push(match[1]);
|
|
2114
|
+
const goMultiImport = /import\s*\(([^)]*)\)/gs;
|
|
2115
|
+
while ((match = goMultiImport.exec(content)) !== null) {
|
|
2116
|
+
const block = match[1];
|
|
2117
|
+
const pkgPattern = /"([^"]+)"/g;
|
|
2118
|
+
let pkgMatch;
|
|
2119
|
+
while ((pkgMatch = pkgPattern.exec(block)) !== null) imports.push(pkgMatch[1]);
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
return imports;
|
|
2123
|
+
};
|
|
2124
|
+
const applyForbidImport = (rule, imports, content, relativePath) => {
|
|
2125
|
+
if (!rule.match) return [];
|
|
2126
|
+
return imports.filter((imp) => imp.includes(rule.match)).map((imp) => ({
|
|
2127
|
+
filePath: relativePath,
|
|
2128
|
+
engine: "architecture",
|
|
2129
|
+
rule: `arch/${rule.name}`,
|
|
2130
|
+
severity: rule.severity,
|
|
2131
|
+
message: `Forbidden import '${imp}' (rule: ${rule.name})`,
|
|
2132
|
+
help: `This import is not allowed by your architecture rules`,
|
|
2133
|
+
line: findImportLine(content, imp),
|
|
2134
|
+
column: 0,
|
|
2135
|
+
category: "Architecture",
|
|
2136
|
+
fixable: false
|
|
2137
|
+
}));
|
|
2138
|
+
};
|
|
2139
|
+
const applyForbidImportFromPath = (rule, imports, content, relativePath) => {
|
|
2140
|
+
if (!rule.from || !rule.forbid) return [];
|
|
2141
|
+
if (!minimatch(relativePath, rule.from)) return [];
|
|
2142
|
+
return imports.filter((imp) => minimatch(imp, rule.forbid) || imp.includes(rule.forbid.replace(/\*\*/g, ""))).map((imp) => ({
|
|
2143
|
+
filePath: relativePath,
|
|
2144
|
+
engine: "architecture",
|
|
2145
|
+
rule: `arch/${rule.name}`,
|
|
2146
|
+
severity: rule.severity,
|
|
2147
|
+
message: `Import '${imp}' is forbidden from '${rule.from}' (rule: ${rule.name})`,
|
|
2148
|
+
help: `Files in '${rule.from}' cannot import from '${rule.forbid}'`,
|
|
2149
|
+
line: findImportLine(content, imp),
|
|
2150
|
+
column: 0,
|
|
2151
|
+
category: "Architecture",
|
|
2152
|
+
fixable: false
|
|
2153
|
+
}));
|
|
2154
|
+
};
|
|
2155
|
+
const applyRequirePattern = (rule, content, relativePath) => {
|
|
2156
|
+
if (!rule.where || !rule.pattern) return [];
|
|
2157
|
+
if (!minimatch(relativePath, rule.where)) return [];
|
|
2158
|
+
if (content.includes(rule.pattern)) return [];
|
|
2159
|
+
return [{
|
|
2160
|
+
filePath: relativePath,
|
|
2161
|
+
engine: "architecture",
|
|
2162
|
+
rule: `arch/${rule.name}`,
|
|
2163
|
+
severity: rule.severity,
|
|
2164
|
+
message: `Required pattern '${rule.pattern}' not found (rule: ${rule.name})`,
|
|
2165
|
+
help: `Files matching '${rule.where}' must contain '${rule.pattern}'`,
|
|
2166
|
+
line: 0,
|
|
2167
|
+
column: 0,
|
|
2168
|
+
category: "Architecture",
|
|
2169
|
+
fixable: false
|
|
2170
|
+
}];
|
|
2171
|
+
};
|
|
2172
|
+
const applyRule = (rule, imports, content, relativePath) => {
|
|
2173
|
+
switch (rule.type) {
|
|
2174
|
+
case "forbid_import": return applyForbidImport(rule, imports, content, relativePath);
|
|
2175
|
+
case "forbid_import_from_path": return applyForbidImportFromPath(rule, imports, content, relativePath);
|
|
2176
|
+
case "require_pattern": return applyRequirePattern(rule, content, relativePath);
|
|
2177
|
+
default: return [];
|
|
2178
|
+
}
|
|
2179
|
+
};
|
|
2180
|
+
const checkRules = async (context, rules) => {
|
|
2181
|
+
const files = getSourceFiles(context);
|
|
2182
|
+
const diagnostics = [];
|
|
2183
|
+
for (const filePath of files) {
|
|
2184
|
+
let content;
|
|
2185
|
+
try {
|
|
2186
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
2187
|
+
} catch {
|
|
2188
|
+
continue;
|
|
2189
|
+
}
|
|
2190
|
+
const relativePath = path.relative(context.rootDirectory, filePath);
|
|
2191
|
+
const imports = extractImports(content, path.extname(filePath));
|
|
2192
|
+
for (const rule of rules) diagnostics.push(...applyRule(rule, imports, content, relativePath));
|
|
2193
|
+
}
|
|
2194
|
+
return diagnostics;
|
|
2195
|
+
};
|
|
2196
|
+
const findImportLine = (content, importPath) => {
|
|
2197
|
+
const lines = content.split("\n");
|
|
2198
|
+
for (let i = 0; i < lines.length; i++) if (lines[i].includes(importPath)) return i + 1;
|
|
2199
|
+
return 0;
|
|
2200
|
+
};
|
|
2201
|
+
|
|
2202
|
+
//#endregion
|
|
2203
|
+
//#region src/engines/architecture/rule-loader.ts
|
|
2204
|
+
const loadArchitectureRules = (rulesPath) => {
|
|
2205
|
+
if (!fs.existsSync(rulesPath)) return [];
|
|
2206
|
+
try {
|
|
2207
|
+
const content = fs.readFileSync(rulesPath, "utf-8");
|
|
2208
|
+
return YAML.parse(content)?.rules ?? [];
|
|
2209
|
+
} catch {
|
|
2210
|
+
return [];
|
|
2211
|
+
}
|
|
2212
|
+
};
|
|
2213
|
+
|
|
2214
|
+
//#endregion
|
|
2215
|
+
//#region src/engines/architecture/index.ts
|
|
2216
|
+
const architectureEngine = {
|
|
2217
|
+
name: "architecture",
|
|
2218
|
+
async run(context) {
|
|
2219
|
+
if (!context.config.architectureRulesPath) return {
|
|
2220
|
+
engine: "architecture",
|
|
2221
|
+
diagnostics: [],
|
|
2222
|
+
elapsed: 0,
|
|
2223
|
+
skipped: true,
|
|
2224
|
+
skipReason: "No architecture rules configured"
|
|
2225
|
+
};
|
|
2226
|
+
const rules = loadArchitectureRules(context.config.architectureRulesPath);
|
|
2227
|
+
if (rules.length === 0) return {
|
|
2228
|
+
engine: "architecture",
|
|
2229
|
+
diagnostics: [],
|
|
2230
|
+
elapsed: 0,
|
|
2231
|
+
skipped: true,
|
|
2232
|
+
skipReason: "No rules found in rules file"
|
|
2233
|
+
};
|
|
2234
|
+
return {
|
|
2235
|
+
engine: "architecture",
|
|
2236
|
+
diagnostics: await checkRules(context, rules),
|
|
2237
|
+
elapsed: 0,
|
|
2238
|
+
skipped: false
|
|
2239
|
+
};
|
|
2240
|
+
}
|
|
2241
|
+
};
|
|
2242
|
+
|
|
2243
|
+
//#endregion
|
|
2244
|
+
//#region src/engines/code-quality/complexity.ts
|
|
2245
|
+
const FUNCTION_PATTERNS = [
|
|
2246
|
+
{
|
|
2247
|
+
regex: /^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/,
|
|
2248
|
+
langFilter: [
|
|
2249
|
+
".js",
|
|
2250
|
+
".ts",
|
|
2251
|
+
".jsx",
|
|
2252
|
+
".tsx",
|
|
2253
|
+
".mjs",
|
|
2254
|
+
".cjs"
|
|
2255
|
+
]
|
|
2256
|
+
},
|
|
2257
|
+
{
|
|
2258
|
+
regex: /^\s*(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?:=>|:\s*\w)/,
|
|
2259
|
+
langFilter: [
|
|
2260
|
+
".js",
|
|
2261
|
+
".ts",
|
|
2262
|
+
".jsx",
|
|
2263
|
+
".tsx",
|
|
2264
|
+
".mjs",
|
|
2265
|
+
".cjs"
|
|
2266
|
+
]
|
|
2267
|
+
},
|
|
2268
|
+
{
|
|
2269
|
+
regex: /^\s*def\s+(\w+)\s*\(([^)]*)\)/,
|
|
2270
|
+
langFilter: [".py"]
|
|
2271
|
+
},
|
|
2272
|
+
{
|
|
2273
|
+
regex: /^\s*func\s+(?:\([^)]*\)\s+)?(\w+)\s*\(([^)]*)\)/,
|
|
2274
|
+
langFilter: [".go"]
|
|
2275
|
+
},
|
|
2276
|
+
{
|
|
2277
|
+
regex: /^\s*fn\s+(\w+)\s*\(([^)]*)\)/,
|
|
2278
|
+
langFilter: [".rs"]
|
|
2279
|
+
},
|
|
2280
|
+
{
|
|
2281
|
+
regex: /^\s*(?:public|private|protected)?\s*(?:static\s+)?(?:\w+\s+)(\w+)\s*\(([^)]*)\)/,
|
|
2282
|
+
langFilter: [
|
|
2283
|
+
".java",
|
|
2284
|
+
".cs",
|
|
2285
|
+
".cpp",
|
|
2286
|
+
".c",
|
|
2287
|
+
".php"
|
|
2288
|
+
]
|
|
2289
|
+
}
|
|
2290
|
+
];
|
|
2291
|
+
const countParams = (paramStr) => {
|
|
2292
|
+
if (!paramStr.trim()) return 0;
|
|
2293
|
+
return paramStr.split(",").length;
|
|
2294
|
+
};
|
|
2295
|
+
const matchFunctionOnLine = (line, ext) => {
|
|
2296
|
+
for (let i = 0; i < FUNCTION_PATTERNS.length; i++) {
|
|
2297
|
+
const pattern = FUNCTION_PATTERNS[i];
|
|
2298
|
+
if (!pattern.langFilter.includes(ext)) continue;
|
|
2299
|
+
const match = line.match(pattern.regex);
|
|
2300
|
+
if (match) return {
|
|
2301
|
+
name: match[1],
|
|
2302
|
+
params: match[2] ?? "",
|
|
2303
|
+
patternIndex: i
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2306
|
+
return null;
|
|
2307
|
+
};
|
|
2308
|
+
const PYTHON_CONTROL_FLOW_RE = /^\s*(?:if|for|while|with|try|except|else|elif|finally|def|class)\b/;
|
|
2309
|
+
const findFunctionEnd = (lines, startIndex, isPython) => {
|
|
2310
|
+
if (isPython) return findPythonFunctionEnd(lines, startIndex);
|
|
2311
|
+
return findBraceFunctionEnd(lines, startIndex);
|
|
2312
|
+
};
|
|
2313
|
+
const isControlFlowBrace = (lineText, braceIndex) => {
|
|
2314
|
+
const before = lineText.substring(0, braceIndex).trimEnd();
|
|
2315
|
+
if (before.endsWith(")")) return true;
|
|
2316
|
+
if (before.endsWith("=>")) return true;
|
|
2317
|
+
if (/\b(?:else|try|finally|do)$/.test(before)) return true;
|
|
2318
|
+
return false;
|
|
2319
|
+
};
|
|
2320
|
+
const findBraceFunctionEnd = (lines, startIndex) => {
|
|
2321
|
+
let depth = 0;
|
|
2322
|
+
let started = false;
|
|
2323
|
+
let endLine = startIndex;
|
|
2324
|
+
let maxNesting = 0;
|
|
2325
|
+
let functionStartDepth = 0;
|
|
2326
|
+
const braceStack = [];
|
|
2327
|
+
for (let j = startIndex; j < lines.length; j++) {
|
|
2328
|
+
const l = lines[j];
|
|
2329
|
+
for (let ci = 0; ci < l.length; ci++) {
|
|
2330
|
+
const ch = l[ci];
|
|
2331
|
+
if (ch === "{") {
|
|
2332
|
+
depth++;
|
|
2333
|
+
if (!started) {
|
|
2334
|
+
started = true;
|
|
2335
|
+
functionStartDepth = depth;
|
|
2336
|
+
braceStack.push(false);
|
|
2337
|
+
} else {
|
|
2338
|
+
const isCF = isControlFlowBrace(l, ci);
|
|
2339
|
+
braceStack.push(isCF);
|
|
2340
|
+
if (isCF) {
|
|
2341
|
+
let cfCount = 0;
|
|
2342
|
+
for (const b of braceStack) if (b) cfCount++;
|
|
2343
|
+
if (cfCount > maxNesting) maxNesting = cfCount;
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
} else if (ch === "}") {
|
|
2347
|
+
depth--;
|
|
2348
|
+
braceStack.pop();
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
if (started && depth < functionStartDepth && j > startIndex) {
|
|
2352
|
+
endLine = j;
|
|
2353
|
+
break;
|
|
2354
|
+
}
|
|
2355
|
+
if (j === lines.length - 1) endLine = j;
|
|
2356
|
+
}
|
|
2357
|
+
if (!started) return {
|
|
2358
|
+
endLine: startIndex,
|
|
2359
|
+
maxNesting: 0
|
|
2360
|
+
};
|
|
2361
|
+
return {
|
|
2362
|
+
endLine,
|
|
2363
|
+
maxNesting
|
|
2364
|
+
};
|
|
2365
|
+
};
|
|
2366
|
+
const findPythonFunctionEnd = (lines, startIndex) => {
|
|
2367
|
+
const baseIndent = lines[startIndex].match(/^(\s*)/)?.[1].length ?? 0;
|
|
2368
|
+
let endLine = startIndex;
|
|
2369
|
+
let maxNesting = 0;
|
|
2370
|
+
const controlIndentStack = [];
|
|
2371
|
+
for (let j = startIndex + 1; j < lines.length; j++) {
|
|
2372
|
+
const l = lines[j];
|
|
2373
|
+
if (l.trim() === "") {
|
|
2374
|
+
endLine = j;
|
|
2375
|
+
continue;
|
|
2376
|
+
}
|
|
2377
|
+
const currentIndent = l.match(/^(\s*)/)?.[1].length ?? 0;
|
|
2378
|
+
if (currentIndent <= baseIndent) break;
|
|
2379
|
+
endLine = j;
|
|
2380
|
+
while (controlIndentStack.length > 0 && currentIndent <= controlIndentStack[controlIndentStack.length - 1]) controlIndentStack.pop();
|
|
2381
|
+
if (PYTHON_CONTROL_FLOW_RE.test(l)) {
|
|
2382
|
+
controlIndentStack.push(currentIndent);
|
|
2383
|
+
const nesting = controlIndentStack.length;
|
|
2384
|
+
if (nesting > maxNesting) maxNesting = nesting;
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
return {
|
|
2388
|
+
endLine,
|
|
2389
|
+
maxNesting
|
|
2390
|
+
};
|
|
2391
|
+
};
|
|
2392
|
+
const isDataFile = (content) => {
|
|
2393
|
+
const nonEmpty = content.split("\n").filter((l) => l.trim().length > 0);
|
|
2394
|
+
if (nonEmpty.length === 0) return false;
|
|
2395
|
+
const dataLinePattern = /^\s*[{}[\]"']/;
|
|
2396
|
+
return nonEmpty.filter((l) => dataLinePattern.test(l)).length / nonEmpty.length > .8;
|
|
2397
|
+
};
|
|
2398
|
+
const isBlockArrow = (lines, startIndex) => {
|
|
2399
|
+
if (/=>\s*\{/.test(lines[startIndex])) return true;
|
|
2400
|
+
if (/=>\s*$/.test(lines[startIndex])) {
|
|
2401
|
+
const next = lines[startIndex + 1];
|
|
2402
|
+
if (next && /^\s*\{/.test(next)) return true;
|
|
2403
|
+
}
|
|
2404
|
+
for (let j = startIndex + 1; j < Math.min(startIndex + 3, lines.length); j++) {
|
|
2405
|
+
const l = lines[j];
|
|
2406
|
+
if (l.trim() === "" || /^(?:export\s+)?(?:const|let|var|function|class)\s/.test(l.trim())) break;
|
|
2407
|
+
if (/=>\s*\{/.test(l)) return true;
|
|
2408
|
+
if (/^\s*\{/.test(l)) return true;
|
|
2409
|
+
}
|
|
2410
|
+
return false;
|
|
2411
|
+
};
|
|
2412
|
+
const analyzeFunctions = (content, ext) => {
|
|
2413
|
+
const lines = content.split("\n");
|
|
2414
|
+
const functions = [];
|
|
2415
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2416
|
+
const fnMatch = matchFunctionOnLine(lines[i], ext);
|
|
2417
|
+
if (!fnMatch) continue;
|
|
2418
|
+
const isPython = fnMatch.patternIndex === 2;
|
|
2419
|
+
if (fnMatch.patternIndex === 1 && !isBlockArrow(lines, i)) continue;
|
|
2420
|
+
const { endLine, maxNesting } = findFunctionEnd(lines, i, isPython);
|
|
2421
|
+
functions.push({
|
|
2422
|
+
name: fnMatch.name,
|
|
2423
|
+
startLine: i + 1,
|
|
2424
|
+
lineCount: endLine - i + 1,
|
|
2425
|
+
maxNesting,
|
|
2426
|
+
paramCount: countParams(fnMatch.params)
|
|
2427
|
+
});
|
|
2428
|
+
}
|
|
2429
|
+
return functions;
|
|
2430
|
+
};
|
|
2431
|
+
const checkFileDiagnostics = (relativePath, content, limits) => {
|
|
2432
|
+
const results = [];
|
|
2433
|
+
const lineCount = content.split("\n").length;
|
|
2434
|
+
const ext = path.extname(relativePath).toLowerCase();
|
|
2435
|
+
if (isDataFile(content)) return results;
|
|
2436
|
+
const effectiveMax = ext === ".jsx" || ext === ".tsx" ? limits.maxFileLoc * 2 : limits.maxFileLoc;
|
|
2437
|
+
if (lineCount > effectiveMax) results.push({
|
|
2438
|
+
filePath: relativePath,
|
|
2439
|
+
engine: "code-quality",
|
|
2440
|
+
rule: "complexity/file-too-large",
|
|
2441
|
+
severity: "warning",
|
|
2442
|
+
message: `File has ${lineCount} lines (max: ${effectiveMax})`,
|
|
2443
|
+
help: "Consider splitting this file into smaller modules",
|
|
2444
|
+
line: 0,
|
|
2445
|
+
column: 0,
|
|
2446
|
+
category: "Complexity",
|
|
2447
|
+
fixable: false
|
|
2448
|
+
});
|
|
2449
|
+
return results;
|
|
2450
|
+
};
|
|
2451
|
+
const checkFunctionDiagnostics = (relativePath, fn, limits) => {
|
|
2452
|
+
const results = [];
|
|
2453
|
+
if (fn.lineCount > limits.maxFunctionLoc) results.push({
|
|
2454
|
+
filePath: relativePath,
|
|
2455
|
+
engine: "code-quality",
|
|
2456
|
+
rule: "complexity/function-too-long",
|
|
2457
|
+
severity: "warning",
|
|
2458
|
+
message: `Function '${fn.name}' has ${fn.lineCount} lines (max: ${limits.maxFunctionLoc})`,
|
|
2459
|
+
help: "Consider breaking this function into smaller pieces",
|
|
2460
|
+
line: fn.startLine,
|
|
2461
|
+
column: 0,
|
|
2462
|
+
category: "Complexity",
|
|
2463
|
+
fixable: false
|
|
2464
|
+
});
|
|
2465
|
+
if (fn.maxNesting > limits.maxNesting) results.push({
|
|
2466
|
+
filePath: relativePath,
|
|
2467
|
+
engine: "code-quality",
|
|
2468
|
+
rule: "complexity/deep-nesting",
|
|
2469
|
+
severity: "warning",
|
|
2470
|
+
message: `Function '${fn.name}' has nesting depth ${fn.maxNesting} (max: ${limits.maxNesting})`,
|
|
2471
|
+
help: "Consider using early returns or extracting nested logic",
|
|
2472
|
+
line: fn.startLine,
|
|
2473
|
+
column: 0,
|
|
2474
|
+
category: "Complexity",
|
|
2475
|
+
fixable: false
|
|
2476
|
+
});
|
|
2477
|
+
if (fn.paramCount > limits.maxParams) results.push({
|
|
2478
|
+
filePath: relativePath,
|
|
2479
|
+
engine: "code-quality",
|
|
2480
|
+
rule: "complexity/too-many-params",
|
|
2481
|
+
severity: "warning",
|
|
2482
|
+
message: `Function '${fn.name}' has ${fn.paramCount} parameters (max: ${limits.maxParams})`,
|
|
2483
|
+
help: "Consider using an options object parameter",
|
|
2484
|
+
line: fn.startLine,
|
|
2485
|
+
column: 0,
|
|
2486
|
+
category: "Complexity",
|
|
2487
|
+
fixable: false
|
|
2488
|
+
});
|
|
2489
|
+
return results;
|
|
2490
|
+
};
|
|
2491
|
+
const checkFileComplexity = (filePath, rootDirectory, limits) => {
|
|
2492
|
+
let content;
|
|
2493
|
+
try {
|
|
2494
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
2495
|
+
} catch {
|
|
2496
|
+
return [];
|
|
2497
|
+
}
|
|
2498
|
+
const relativePath = path.relative(rootDirectory, filePath);
|
|
2499
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
2500
|
+
const diagnostics = checkFileDiagnostics(relativePath, content, limits);
|
|
2501
|
+
for (const fn of analyzeFunctions(content, ext)) diagnostics.push(...checkFunctionDiagnostics(relativePath, fn, limits));
|
|
2502
|
+
return diagnostics;
|
|
2503
|
+
};
|
|
2504
|
+
const checkComplexity = async (context) => {
|
|
2505
|
+
const files = getSourceFiles(context);
|
|
2506
|
+
const limits = context.config.quality;
|
|
2507
|
+
const diagnostics = [];
|
|
2508
|
+
for (const filePath of files) {
|
|
2509
|
+
if (isAutoGenerated(filePath)) continue;
|
|
2510
|
+
diagnostics.push(...checkFileComplexity(filePath, context.rootDirectory, limits));
|
|
2511
|
+
}
|
|
2512
|
+
return diagnostics;
|
|
2513
|
+
};
|
|
2514
|
+
|
|
2515
|
+
//#endregion
|
|
2516
|
+
//#region src/engines/code-quality/duplication.ts
|
|
2517
|
+
const MIN_DUPLICATE_LINES = 12;
|
|
2518
|
+
const MIN_DUPLICATE_CHARS = 240;
|
|
2519
|
+
const MAX_DUPLICATE_REPORTS = 50;
|
|
2520
|
+
const isIgnorableLine = (line) => {
|
|
2521
|
+
const trimmed = line.trim();
|
|
2522
|
+
return trimmed.length === 0 || trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*") || trimmed.startsWith("#");
|
|
2523
|
+
};
|
|
2524
|
+
const normalizeLine = (line) => line.trim().replace(/\s+/g, " ");
|
|
2525
|
+
const extractDuplicateBlocks = (content) => {
|
|
2526
|
+
const blocks = [];
|
|
2527
|
+
const lines = content.split("\n");
|
|
2528
|
+
for (let i = 0; i <= lines.length - MIN_DUPLICATE_LINES; i++) {
|
|
2529
|
+
const segment = lines.slice(i, i + MIN_DUPLICATE_LINES);
|
|
2530
|
+
if (segment.some(isIgnorableLine)) continue;
|
|
2531
|
+
const key = segment.map(normalizeLine).join("\n");
|
|
2532
|
+
if (key.length < MIN_DUPLICATE_CHARS) continue;
|
|
2533
|
+
blocks.push({
|
|
2534
|
+
key,
|
|
2535
|
+
startLine: i + 1
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
return blocks;
|
|
2539
|
+
};
|
|
2540
|
+
const checkDuplication = async (context) => {
|
|
2541
|
+
const files = getSourceFiles(context);
|
|
2542
|
+
const duplicates = /* @__PURE__ */ new Map();
|
|
2543
|
+
for (const absoluteFilePath of files) {
|
|
2544
|
+
if (isAutoGenerated(absoluteFilePath)) continue;
|
|
2545
|
+
let content = "";
|
|
2546
|
+
try {
|
|
2547
|
+
content = fs.readFileSync(absoluteFilePath, "utf-8");
|
|
2548
|
+
} catch {
|
|
2549
|
+
continue;
|
|
2550
|
+
}
|
|
2551
|
+
const relativeFilePath = path.relative(context.rootDirectory, absoluteFilePath);
|
|
2552
|
+
for (const block of extractDuplicateBlocks(content)) {
|
|
2553
|
+
const occurrence = {
|
|
2554
|
+
filePath: relativeFilePath,
|
|
2555
|
+
startLine: block.startLine
|
|
2556
|
+
};
|
|
2557
|
+
const list = duplicates.get(block.key) ?? [];
|
|
2558
|
+
list.push(occurrence);
|
|
2559
|
+
duplicates.set(block.key, list);
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
const diagnostics = [];
|
|
2563
|
+
const reportedPairs = /* @__PURE__ */ new Set();
|
|
2564
|
+
for (const occurrences of duplicates.values()) {
|
|
2565
|
+
if (occurrences.length < 2) continue;
|
|
2566
|
+
const source = occurrences[0];
|
|
2567
|
+
for (const occurrence of occurrences.slice(1)) {
|
|
2568
|
+
if (diagnostics.length >= MAX_DUPLICATE_REPORTS) return diagnostics;
|
|
2569
|
+
if (occurrence.filePath === source.filePath && occurrence.startLine === source.startLine) continue;
|
|
2570
|
+
if (occurrence.filePath === source.filePath) continue;
|
|
2571
|
+
const pairKey = `${source.filePath}->${occurrence.filePath}`;
|
|
2572
|
+
if (reportedPairs.has(pairKey)) continue;
|
|
2573
|
+
reportedPairs.add(pairKey);
|
|
2574
|
+
diagnostics.push({
|
|
2575
|
+
filePath: occurrence.filePath,
|
|
2576
|
+
engine: "code-quality",
|
|
2577
|
+
rule: "duplication/block",
|
|
2578
|
+
severity: "warning",
|
|
2579
|
+
message: `Possible duplicated code block (${MIN_DUPLICATE_LINES}+ lines) also found at ${source.filePath}:${source.startLine}`,
|
|
2580
|
+
help: "Extract shared logic into a reusable function or module",
|
|
2581
|
+
line: occurrence.startLine,
|
|
2582
|
+
column: 0,
|
|
2583
|
+
category: "Duplication",
|
|
2584
|
+
fixable: false
|
|
2585
|
+
});
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
return diagnostics;
|
|
2589
|
+
};
|
|
2590
|
+
|
|
2591
|
+
//#endregion
|
|
2592
|
+
//#region src/engines/code-quality/knip.ts
|
|
2593
|
+
const KNIP_MESSAGE_MAP = {
|
|
2594
|
+
files: "Unused file",
|
|
2595
|
+
exports: "Unused export",
|
|
2596
|
+
types: "Unused type",
|
|
2597
|
+
duplicates: "Duplicate export"
|
|
2598
|
+
};
|
|
2599
|
+
const collectIssues = (fileIssue, issueType, rootDir, knipCwd) => {
|
|
2600
|
+
const diagnostics = [];
|
|
2601
|
+
const issues = issueType === "exports" ? fileIssue.exports ?? [] : issueType === "types" ? fileIssue.types ?? [] : fileIssue.duplicates ?? [];
|
|
2602
|
+
for (const issue of issues) {
|
|
2603
|
+
const symbol = issue.name ?? issue.symbol ?? "unknown";
|
|
2604
|
+
const absolutePath = path.resolve(knipCwd, fileIssue.file);
|
|
2605
|
+
diagnostics.push({
|
|
2606
|
+
filePath: path.relative(rootDir, absolutePath),
|
|
2607
|
+
engine: "code-quality",
|
|
2608
|
+
rule: `knip/${issueType}`,
|
|
2609
|
+
severity: "warning",
|
|
2610
|
+
message: `${KNIP_MESSAGE_MAP[issueType]}: ${symbol}`,
|
|
2611
|
+
help: "",
|
|
2612
|
+
line: issue.line ?? 0,
|
|
2613
|
+
column: issue.col ?? 0,
|
|
2614
|
+
category: "Dead Code",
|
|
2615
|
+
fixable: false
|
|
2616
|
+
});
|
|
2617
|
+
}
|
|
2618
|
+
return diagnostics;
|
|
2619
|
+
};
|
|
2620
|
+
const findMonorepoRoot = (directory) => {
|
|
2621
|
+
let current = path.dirname(directory);
|
|
2622
|
+
while (current !== path.dirname(current)) {
|
|
2623
|
+
if (fs.existsSync(path.join(current, "pnpm-workspace.yaml")) || (() => {
|
|
2624
|
+
const pkgPath = path.join(current, "package.json");
|
|
2625
|
+
if (!fs.existsSync(pkgPath)) return false;
|
|
2626
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
2627
|
+
return Array.isArray(pkg.workspaces) || pkg.workspaces?.packages;
|
|
2628
|
+
})()) return current;
|
|
2629
|
+
current = path.dirname(current);
|
|
2630
|
+
}
|
|
2631
|
+
return null;
|
|
2632
|
+
};
|
|
2633
|
+
const KNIP_RELATIVE_BIN = path.join("node_modules", "knip", "bin", "knip.js");
|
|
2634
|
+
const findKnipBin = (rootDirectory, monorepoRoot) => {
|
|
2635
|
+
const localPath = path.join(rootDirectory, KNIP_RELATIVE_BIN);
|
|
2636
|
+
if (fs.existsSync(localPath)) return {
|
|
2637
|
+
binPath: localPath,
|
|
2638
|
+
cwd: rootDirectory
|
|
2639
|
+
};
|
|
2640
|
+
if (monorepoRoot) {
|
|
2641
|
+
const monorepoPath = path.join(monorepoRoot, KNIP_RELATIVE_BIN);
|
|
2642
|
+
if (fs.existsSync(monorepoPath)) return {
|
|
2643
|
+
binPath: monorepoPath,
|
|
2644
|
+
cwd: monorepoRoot
|
|
2645
|
+
};
|
|
2646
|
+
}
|
|
2647
|
+
return null;
|
|
2648
|
+
};
|
|
2649
|
+
const runKnip = async (rootDirectory) => {
|
|
2650
|
+
const knipRuntime = findKnipBin(rootDirectory, findMonorepoRoot(rootDirectory));
|
|
2651
|
+
if (!knipRuntime) return [];
|
|
2652
|
+
try {
|
|
2653
|
+
const args = [
|
|
2654
|
+
knipRuntime.binPath,
|
|
2655
|
+
"--no-progress",
|
|
2656
|
+
"--reporter",
|
|
2657
|
+
"json",
|
|
2658
|
+
"--no-exit-code"
|
|
2659
|
+
];
|
|
2660
|
+
const result = await runSubprocess(process.execPath, args, {
|
|
2661
|
+
cwd: knipRuntime.cwd,
|
|
2662
|
+
timeout: 2e4,
|
|
2663
|
+
env: { FORCE_COLOR: "0" }
|
|
2664
|
+
});
|
|
2665
|
+
if (!result.stdout) return [];
|
|
2666
|
+
const parsed = JSON.parse(result.stdout);
|
|
2667
|
+
const diagnostics = [];
|
|
2668
|
+
const files = parsed.files ?? [];
|
|
2669
|
+
for (const unusedFile of files) diagnostics.push({
|
|
2670
|
+
filePath: path.relative(rootDirectory, path.resolve(knipRuntime.cwd, unusedFile)),
|
|
2671
|
+
engine: "code-quality",
|
|
2672
|
+
rule: "knip/files",
|
|
2673
|
+
severity: "warning",
|
|
2674
|
+
message: KNIP_MESSAGE_MAP.files,
|
|
2675
|
+
help: "This file is not imported by any other file in the project.",
|
|
2676
|
+
line: 0,
|
|
2677
|
+
column: 0,
|
|
2678
|
+
category: "Dead Code",
|
|
2679
|
+
fixable: false
|
|
2680
|
+
});
|
|
2681
|
+
const issues = parsed.issues ?? [];
|
|
2682
|
+
for (const fileIssue of issues) for (const type of [
|
|
2683
|
+
"exports",
|
|
2684
|
+
"types",
|
|
2685
|
+
"duplicates"
|
|
2686
|
+
]) diagnostics.push(...collectIssues(fileIssue, type, rootDirectory, knipRuntime.cwd));
|
|
2687
|
+
return diagnostics;
|
|
2688
|
+
} catch {
|
|
2689
|
+
return [];
|
|
2690
|
+
}
|
|
2691
|
+
};
|
|
2692
|
+
|
|
2693
|
+
//#endregion
|
|
2694
|
+
//#region src/engines/code-quality/index.ts
|
|
2695
|
+
const codeQualityEngine = {
|
|
2696
|
+
name: "code-quality",
|
|
2697
|
+
async run(context) {
|
|
2698
|
+
const diagnostics = [];
|
|
2699
|
+
const promises = [];
|
|
2700
|
+
if (context.languages.includes("typescript") || context.languages.includes("javascript")) promises.push(runKnip(context.rootDirectory));
|
|
2701
|
+
promises.push(checkComplexity(context));
|
|
2702
|
+
promises.push(checkDuplication(context));
|
|
2703
|
+
const results = await Promise.allSettled(promises);
|
|
2704
|
+
for (const result of results) if (result.status === "fulfilled") diagnostics.push(...result.value);
|
|
2705
|
+
return {
|
|
2706
|
+
engine: "code-quality",
|
|
2707
|
+
diagnostics,
|
|
2708
|
+
elapsed: 0,
|
|
2709
|
+
skipped: false
|
|
2710
|
+
};
|
|
2711
|
+
}
|
|
2712
|
+
};
|
|
2713
|
+
|
|
2714
|
+
//#endregion
|
|
2715
|
+
//#region src/engines/format/generic.ts
|
|
2716
|
+
const FORMATTERS = {
|
|
2717
|
+
rust: {
|
|
2718
|
+
command: "cargo",
|
|
2719
|
+
checkArgs: ["fmt", "--check"],
|
|
2720
|
+
fixArgs: ["fmt"],
|
|
2721
|
+
parseOutput: (output, _rootDir) => {
|
|
2722
|
+
const diagnostics = [];
|
|
2723
|
+
const lines = output.split("\n").filter((l) => l.startsWith("Diff in"));
|
|
2724
|
+
for (const line of lines) {
|
|
2725
|
+
const match = line.match(/Diff in (.+) at line (\d+)/);
|
|
2726
|
+
if (match) diagnostics.push({
|
|
2727
|
+
filePath: match[1],
|
|
2728
|
+
engine: "format",
|
|
2729
|
+
rule: "rust-formatting",
|
|
2730
|
+
severity: "warning",
|
|
2731
|
+
message: "Rust file is not formatted correctly",
|
|
2732
|
+
help: "Run `aislop fix` to auto-format with rustfmt",
|
|
2733
|
+
line: parseInt(match[2], 10),
|
|
2734
|
+
column: 0,
|
|
2735
|
+
category: "Format",
|
|
2736
|
+
fixable: true
|
|
2737
|
+
});
|
|
2738
|
+
}
|
|
2739
|
+
return diagnostics;
|
|
2740
|
+
}
|
|
2741
|
+
},
|
|
2742
|
+
ruby: {
|
|
2743
|
+
command: "rubocop",
|
|
2744
|
+
checkArgs: [
|
|
2745
|
+
"--format",
|
|
2746
|
+
"json",
|
|
2747
|
+
"--only",
|
|
2748
|
+
"Layout"
|
|
2749
|
+
],
|
|
2750
|
+
fixArgs: [
|
|
2751
|
+
"--auto-correct",
|
|
2752
|
+
"--only",
|
|
2753
|
+
"Layout"
|
|
2754
|
+
],
|
|
2755
|
+
parseOutput: (output) => {
|
|
2756
|
+
try {
|
|
2757
|
+
const parsed = JSON.parse(output);
|
|
2758
|
+
const diagnostics = [];
|
|
2759
|
+
for (const file of parsed.files ?? []) for (const offense of file.offenses ?? []) diagnostics.push({
|
|
2760
|
+
filePath: file.path,
|
|
2761
|
+
engine: "format",
|
|
2762
|
+
rule: offense.cop_name ?? "ruby-formatting",
|
|
2763
|
+
severity: "warning",
|
|
2764
|
+
message: offense.message ?? "Ruby formatting issue",
|
|
2765
|
+
help: "Run `aislop fix` to auto-format",
|
|
2766
|
+
line: offense.location?.start_line ?? 0,
|
|
2767
|
+
column: offense.location?.start_column ?? 0,
|
|
2768
|
+
category: "Format",
|
|
2769
|
+
fixable: offense.correctable ?? false
|
|
2770
|
+
});
|
|
2771
|
+
return diagnostics;
|
|
2772
|
+
} catch {
|
|
2773
|
+
return [];
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
},
|
|
2777
|
+
php: {
|
|
2778
|
+
command: "php-cs-fixer",
|
|
2779
|
+
checkArgs: [
|
|
2780
|
+
"fix",
|
|
2781
|
+
"--dry-run",
|
|
2782
|
+
"--format=json",
|
|
2783
|
+
"."
|
|
2784
|
+
],
|
|
2785
|
+
fixArgs: ["fix", "."],
|
|
2786
|
+
parseOutput: (output) => {
|
|
2787
|
+
try {
|
|
2788
|
+
const parsed = JSON.parse(output);
|
|
2789
|
+
const diagnostics = [];
|
|
2790
|
+
for (const file of parsed.files ?? []) diagnostics.push({
|
|
2791
|
+
filePath: file.name,
|
|
2792
|
+
engine: "format",
|
|
2793
|
+
rule: "php-formatting",
|
|
2794
|
+
severity: "warning",
|
|
2795
|
+
message: "PHP file is not formatted correctly",
|
|
2796
|
+
help: "Run `aislop fix` to auto-format",
|
|
2797
|
+
line: 0,
|
|
2798
|
+
column: 0,
|
|
2799
|
+
category: "Format",
|
|
2800
|
+
fixable: true
|
|
2801
|
+
});
|
|
2802
|
+
return diagnostics;
|
|
2803
|
+
} catch {
|
|
2804
|
+
return [];
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
};
|
|
2809
|
+
const runGenericFormatter = async (context, language) => {
|
|
2810
|
+
const config = FORMATTERS[language];
|
|
2811
|
+
if (!config) return [];
|
|
2812
|
+
try {
|
|
2813
|
+
const result = await runSubprocess(config.command, config.checkArgs, {
|
|
2814
|
+
cwd: context.rootDirectory,
|
|
2815
|
+
timeout: 6e4
|
|
2816
|
+
});
|
|
2817
|
+
const output = result.stdout || result.stderr;
|
|
2818
|
+
if (!output) return [];
|
|
2819
|
+
return config.parseOutput(output, context.rootDirectory);
|
|
2820
|
+
} catch {
|
|
2821
|
+
return [];
|
|
2822
|
+
}
|
|
2823
|
+
};
|
|
2824
|
+
|
|
2825
|
+
//#endregion
|
|
2826
|
+
//#region src/engines/format/index.ts
|
|
2827
|
+
const formatEngine = {
|
|
2828
|
+
name: "format",
|
|
2829
|
+
async run(context) {
|
|
2830
|
+
const diagnostics = [];
|
|
2831
|
+
const { languages, installedTools } = context;
|
|
2832
|
+
const promises = [];
|
|
2833
|
+
if (languages.includes("typescript") || languages.includes("javascript")) promises.push(runBiomeFormat(context));
|
|
2834
|
+
if (languages.includes("python") && installedTools["ruff"]) promises.push(runRuffFormat(context));
|
|
2835
|
+
if (languages.includes("go") && installedTools["gofmt"]) promises.push(runGofmt(context));
|
|
2836
|
+
if (languages.includes("rust") && installedTools["rustfmt"]) promises.push(runGenericFormatter(context, "rust"));
|
|
2837
|
+
if (languages.includes("ruby") && installedTools["rubocop"]) promises.push(runGenericFormatter(context, "ruby"));
|
|
2838
|
+
if (languages.includes("php") && installedTools["php-cs-fixer"]) promises.push(runGenericFormatter(context, "php"));
|
|
2839
|
+
const results = await Promise.allSettled(promises);
|
|
2840
|
+
for (const result of results) if (result.status === "fulfilled") diagnostics.push(...result.value);
|
|
2841
|
+
return {
|
|
2842
|
+
engine: "format",
|
|
2843
|
+
diagnostics,
|
|
2844
|
+
elapsed: 0,
|
|
2845
|
+
skipped: false
|
|
2846
|
+
};
|
|
2847
|
+
}
|
|
2848
|
+
};
|
|
2849
|
+
|
|
2850
|
+
//#endregion
|
|
2851
|
+
//#region src/engines/lint/generic.ts
|
|
2852
|
+
const runGenericLinter = async (context, language) => {
|
|
2853
|
+
switch (language) {
|
|
2854
|
+
case "rust": return runClippy(context);
|
|
2855
|
+
case "ruby": return runRubocop(context);
|
|
2856
|
+
default: return [];
|
|
2857
|
+
}
|
|
2858
|
+
};
|
|
2859
|
+
const runClippy = async (context) => {
|
|
2860
|
+
try {
|
|
2861
|
+
return parseClippyDiagnostics((await runSubprocess("cargo", [
|
|
2862
|
+
"clippy",
|
|
2863
|
+
"--message-format=json",
|
|
2864
|
+
"--quiet"
|
|
2865
|
+
], {
|
|
2866
|
+
cwd: context.rootDirectory,
|
|
2867
|
+
timeout: 12e4
|
|
2868
|
+
})).stdout);
|
|
2869
|
+
} catch {
|
|
2870
|
+
return [];
|
|
2871
|
+
}
|
|
2872
|
+
};
|
|
2873
|
+
const parseClippyEntry = (line) => {
|
|
2874
|
+
if (!line.startsWith("{")) return null;
|
|
2875
|
+
try {
|
|
2876
|
+
return JSON.parse(line);
|
|
2877
|
+
} catch {
|
|
2878
|
+
return null;
|
|
2879
|
+
}
|
|
2880
|
+
};
|
|
2881
|
+
const toClippyDiagnostic = (entry) => {
|
|
2882
|
+
if (entry.reason !== "compiler-message" || !entry.message) return null;
|
|
2883
|
+
const message = entry.message;
|
|
2884
|
+
const span = message.spans?.[0];
|
|
2885
|
+
return {
|
|
2886
|
+
filePath: span?.file_name ?? "",
|
|
2887
|
+
engine: "lint",
|
|
2888
|
+
rule: `clippy/${message.code?.code ?? "unknown"}`,
|
|
2889
|
+
severity: message.level === "error" ? "error" : "warning",
|
|
2890
|
+
message: message.message ?? "",
|
|
2891
|
+
help: message.children?.[0]?.message ?? "",
|
|
2892
|
+
line: span?.line_start ?? 0,
|
|
2893
|
+
column: span?.column_start ?? 0,
|
|
2894
|
+
category: "Rust Lint",
|
|
2895
|
+
fixable: false
|
|
2896
|
+
};
|
|
2897
|
+
};
|
|
2898
|
+
const parseClippyDiagnostics = (output) => {
|
|
2899
|
+
const diagnostics = [];
|
|
2900
|
+
for (const line of output.split("\n")) {
|
|
2901
|
+
const entry = parseClippyEntry(line);
|
|
2902
|
+
if (!entry) continue;
|
|
2903
|
+
const diagnostic = toClippyDiagnostic(entry);
|
|
2904
|
+
if (diagnostic) diagnostics.push(diagnostic);
|
|
2905
|
+
}
|
|
2906
|
+
return diagnostics;
|
|
2907
|
+
};
|
|
2908
|
+
const runRubocop = async (context) => {
|
|
2909
|
+
try {
|
|
2910
|
+
const output = (await runSubprocess("rubocop", [
|
|
2911
|
+
"--format",
|
|
2912
|
+
"json",
|
|
2913
|
+
"--except",
|
|
2914
|
+
"Layout"
|
|
2915
|
+
], {
|
|
2916
|
+
cwd: context.rootDirectory,
|
|
2917
|
+
timeout: 6e4
|
|
2918
|
+
})).stdout;
|
|
2919
|
+
if (!output) return [];
|
|
2920
|
+
const parsed = JSON.parse(output);
|
|
2921
|
+
const diagnostics = [];
|
|
2922
|
+
for (const file of parsed.files ?? []) for (const offense of file.offenses ?? []) diagnostics.push({
|
|
2923
|
+
filePath: file.path,
|
|
2924
|
+
engine: "lint",
|
|
2925
|
+
rule: `rubocop/${offense.cop_name}`,
|
|
2926
|
+
severity: offense.severity === "error" || offense.severity === "fatal" ? "error" : "warning",
|
|
2927
|
+
message: offense.message,
|
|
2928
|
+
help: "",
|
|
2929
|
+
line: offense.location?.start_line ?? 0,
|
|
2930
|
+
column: offense.location?.start_column ?? 0,
|
|
2931
|
+
category: "Ruby Lint",
|
|
2932
|
+
fixable: offense.correctable ?? false
|
|
2933
|
+
});
|
|
2934
|
+
return diagnostics;
|
|
2935
|
+
} catch {
|
|
2936
|
+
return [];
|
|
2937
|
+
}
|
|
2938
|
+
};
|
|
2939
|
+
|
|
2940
|
+
//#endregion
|
|
2941
|
+
//#region src/engines/lint/golangci.ts
|
|
2942
|
+
const runGolangciLint = async (context) => {
|
|
2943
|
+
const golangciBinary = resolveToolBinary("golangci-lint");
|
|
2944
|
+
try {
|
|
2945
|
+
const output = (await runSubprocess(golangciBinary, [
|
|
2946
|
+
"run",
|
|
2947
|
+
"--out-format=json",
|
|
2948
|
+
"./..."
|
|
2949
|
+
], {
|
|
2950
|
+
cwd: context.rootDirectory,
|
|
2951
|
+
timeout: 12e4
|
|
2952
|
+
})).stdout;
|
|
2953
|
+
if (!output) return [];
|
|
2954
|
+
let parsed;
|
|
2955
|
+
try {
|
|
2956
|
+
parsed = JSON.parse(output);
|
|
2957
|
+
} catch {
|
|
2958
|
+
return [];
|
|
2959
|
+
}
|
|
2960
|
+
return (parsed.Issues ?? []).map((issue) => ({
|
|
2961
|
+
filePath: path.relative(context.rootDirectory, issue.Pos.Filename),
|
|
2962
|
+
engine: "lint",
|
|
2963
|
+
rule: `go/${issue.FromLinter}`,
|
|
2964
|
+
severity: "warning",
|
|
2965
|
+
message: issue.Text,
|
|
2966
|
+
help: "",
|
|
2967
|
+
line: issue.Pos.Line,
|
|
2968
|
+
column: issue.Pos.Column,
|
|
2969
|
+
category: "Go Lint",
|
|
2970
|
+
fixable: false
|
|
2971
|
+
}));
|
|
2972
|
+
} catch {
|
|
2973
|
+
return [];
|
|
2974
|
+
}
|
|
2975
|
+
};
|
|
2976
|
+
|
|
2977
|
+
//#endregion
|
|
2978
|
+
//#region src/engines/lint/index.ts
|
|
2979
|
+
const lintEngine = {
|
|
2980
|
+
name: "lint",
|
|
2981
|
+
async run(context) {
|
|
2982
|
+
const diagnostics = [];
|
|
2983
|
+
const { languages, installedTools } = context;
|
|
2984
|
+
const promises = [];
|
|
2985
|
+
if (languages.includes("typescript") || languages.includes("javascript")) promises.push(runOxlint(context));
|
|
2986
|
+
if (context.frameworks.includes("expo")) promises.push(import("./expo-doctor-CGXGLgMJ.js").then((mod) => mod.runExpoDoctor(context)));
|
|
2987
|
+
if (languages.includes("python") && installedTools["ruff"]) promises.push(runRuffLint(context));
|
|
2988
|
+
if (languages.includes("go") && installedTools["golangci-lint"]) promises.push(runGolangciLint(context));
|
|
2989
|
+
if (languages.includes("rust") && installedTools["cargo"]) promises.push(runGenericLinter(context, "rust"));
|
|
2990
|
+
if (languages.includes("ruby") && installedTools["rubocop"]) promises.push(runGenericLinter(context, "ruby"));
|
|
2991
|
+
const results = await Promise.allSettled(promises);
|
|
2992
|
+
for (const result of results) if (result.status === "fulfilled") diagnostics.push(...result.value);
|
|
2993
|
+
return {
|
|
2994
|
+
engine: "lint",
|
|
2995
|
+
diagnostics,
|
|
2996
|
+
elapsed: 0,
|
|
2997
|
+
skipped: false
|
|
2998
|
+
};
|
|
2999
|
+
}
|
|
3000
|
+
};
|
|
3001
|
+
|
|
3002
|
+
//#endregion
|
|
3003
|
+
//#region src/engines/security/audit.ts
|
|
3004
|
+
const runDependencyAudit = async (context) => {
|
|
3005
|
+
const diagnostics = [];
|
|
3006
|
+
const timeout = context.config.security.auditTimeout;
|
|
3007
|
+
const promises = [];
|
|
3008
|
+
if (context.languages.includes("typescript") || context.languages.includes("javascript")) {
|
|
3009
|
+
if (fs.existsSync(path.join(context.rootDirectory, "pnpm-lock.yaml"))) promises.push(runPnpmAudit(context.rootDirectory, timeout));
|
|
3010
|
+
else if (fs.existsSync(path.join(context.rootDirectory, "package-lock.json")) || fs.existsSync(path.join(context.rootDirectory, "package.json"))) promises.push(runNpmAudit(context.rootDirectory, timeout));
|
|
3011
|
+
}
|
|
3012
|
+
if (context.languages.includes("python") && context.installedTools["pip-audit"]) promises.push(runPipAudit(context.rootDirectory, timeout));
|
|
3013
|
+
if (context.languages.includes("go") && context.installedTools["govulncheck"]) promises.push(runGovulncheck(context.rootDirectory, timeout));
|
|
3014
|
+
if (context.languages.includes("rust")) promises.push(runCargoAudit(context.rootDirectory, timeout));
|
|
3015
|
+
const results = await Promise.allSettled(promises);
|
|
3016
|
+
for (const result of results) if (result.status === "fulfilled") diagnostics.push(...result.value);
|
|
3017
|
+
return diagnostics;
|
|
3018
|
+
};
|
|
3019
|
+
const runNpmAudit = async (rootDir, timeout) => {
|
|
3020
|
+
try {
|
|
3021
|
+
return parseJsAudit((await runSubprocess("npm", ["audit", "--json"], {
|
|
3022
|
+
cwd: rootDir,
|
|
3023
|
+
timeout
|
|
3024
|
+
})).stdout, "npm audit");
|
|
3025
|
+
} catch {
|
|
3026
|
+
return [];
|
|
3027
|
+
}
|
|
3028
|
+
};
|
|
3029
|
+
const runPnpmAudit = async (rootDir, timeout) => {
|
|
3030
|
+
try {
|
|
3031
|
+
return parseJsAudit((await runSubprocess("pnpm", ["audit", "--json"], {
|
|
3032
|
+
cwd: rootDir,
|
|
3033
|
+
timeout
|
|
3034
|
+
})).stdout, "pnpm audit");
|
|
3035
|
+
} catch {
|
|
3036
|
+
return [];
|
|
3037
|
+
}
|
|
3038
|
+
};
|
|
3039
|
+
const toSeverity = (value) => value === "critical" || value === "high" ? "error" : "warning";
|
|
3040
|
+
const defaultAuditFixCommand = (source) => source === "pnpm audit" ? "pnpm audit --fix" : "npm audit fix";
|
|
3041
|
+
const parseLegacyAdvisories = (advisories, source) => {
|
|
3042
|
+
const diagnostics = [];
|
|
3043
|
+
for (const [key, advisory] of Object.entries(advisories)) {
|
|
3044
|
+
const packageName = advisory.module_name ?? advisory.name ?? advisory.package ?? key;
|
|
3045
|
+
const severity = (advisory.severity ?? "moderate").toLowerCase();
|
|
3046
|
+
const recommendation = advisory.recommendation ?? advisory.title ?? `Run \`${defaultAuditFixCommand(source)}\` to resolve`;
|
|
3047
|
+
diagnostics.push({
|
|
3048
|
+
filePath: "package.json",
|
|
3049
|
+
engine: "security",
|
|
3050
|
+
rule: "security/vulnerable-dependency",
|
|
3051
|
+
severity: toSeverity(severity),
|
|
3052
|
+
message: `Vulnerable dependency (${source}): ${packageName} (${severity})`,
|
|
3053
|
+
help: recommendation,
|
|
3054
|
+
line: 0,
|
|
3055
|
+
column: 0,
|
|
3056
|
+
category: "Security",
|
|
3057
|
+
fixable: false
|
|
3058
|
+
});
|
|
3059
|
+
}
|
|
3060
|
+
return diagnostics;
|
|
3061
|
+
};
|
|
3062
|
+
const parseModernVulnerabilities = (vulnerabilities, source) => {
|
|
3063
|
+
const diagnostics = [];
|
|
3064
|
+
for (const [packageName, vulnerability] of Object.entries(vulnerabilities)) {
|
|
3065
|
+
const severity = (vulnerability.severity ?? "moderate").toLowerCase();
|
|
3066
|
+
const fixAvailable = vulnerability.fixAvailable;
|
|
3067
|
+
let recommendation = `Run \`${defaultAuditFixCommand(source)}\` to resolve`;
|
|
3068
|
+
if (fixAvailable === false) recommendation = "No automatic fix available.";
|
|
3069
|
+
else if (fixAvailable && typeof fixAvailable === "object" && "name" in fixAvailable && "version" in fixAvailable) {
|
|
3070
|
+
const target = fixAvailable;
|
|
3071
|
+
if (target.name && target.version) recommendation = `Upgrade to ${target.name}@${target.version}.`;
|
|
3072
|
+
}
|
|
3073
|
+
diagnostics.push({
|
|
3074
|
+
filePath: "package.json",
|
|
3075
|
+
engine: "security",
|
|
3076
|
+
rule: "security/vulnerable-dependency",
|
|
3077
|
+
severity: toSeverity(severity),
|
|
3078
|
+
message: `Vulnerable dependency (${source}): ${packageName} (${severity})`,
|
|
3079
|
+
help: recommendation,
|
|
3080
|
+
line: 0,
|
|
3081
|
+
column: 0,
|
|
3082
|
+
category: "Security",
|
|
3083
|
+
fixable: false
|
|
3084
|
+
});
|
|
3085
|
+
}
|
|
3086
|
+
return diagnostics;
|
|
3087
|
+
};
|
|
3088
|
+
const parseJsAudit = (output, source) => {
|
|
3089
|
+
if (!output) return [];
|
|
3090
|
+
try {
|
|
3091
|
+
const parsed = JSON.parse(output);
|
|
3092
|
+
const error = parsed.error;
|
|
3093
|
+
if (error?.code === "ENOLOCK") return [{
|
|
3094
|
+
filePath: "package.json",
|
|
3095
|
+
engine: "security",
|
|
3096
|
+
rule: "security/dependency-audit-skipped",
|
|
3097
|
+
severity: "info",
|
|
3098
|
+
message: `Dependency audit skipped (${source}): lockfile is missing`,
|
|
3099
|
+
help: error.detail ?? "Generate a lockfile, then re-run `aislop scan` for dependency vulnerability checks.",
|
|
3100
|
+
line: 0,
|
|
3101
|
+
column: 0,
|
|
3102
|
+
category: "Security",
|
|
3103
|
+
fixable: false
|
|
3104
|
+
}];
|
|
3105
|
+
if (error?.summary || error?.code) return [{
|
|
3106
|
+
filePath: "package.json",
|
|
3107
|
+
engine: "security",
|
|
3108
|
+
rule: "security/dependency-audit-skipped",
|
|
3109
|
+
severity: "info",
|
|
3110
|
+
message: `Dependency audit did not complete (${source})`,
|
|
3111
|
+
help: error.detail ?? error.summary ?? "Re-run dependency audit directly to inspect the underlying error.",
|
|
3112
|
+
line: 0,
|
|
3113
|
+
column: 0,
|
|
3114
|
+
category: "Security",
|
|
3115
|
+
fixable: false
|
|
3116
|
+
}];
|
|
3117
|
+
const advisories = parsed.advisories;
|
|
3118
|
+
if (advisories && typeof advisories === "object") return parseLegacyAdvisories(advisories, source);
|
|
3119
|
+
const vulnerabilities = parsed.vulnerabilities;
|
|
3120
|
+
if (vulnerabilities && typeof vulnerabilities === "object") return parseModernVulnerabilities(vulnerabilities, source);
|
|
3121
|
+
return [];
|
|
3122
|
+
} catch {
|
|
3123
|
+
return [];
|
|
3124
|
+
}
|
|
3125
|
+
};
|
|
3126
|
+
const runPipAudit = async (rootDir, timeout) => {
|
|
3127
|
+
try {
|
|
3128
|
+
const result = await runSubprocess("pip-audit", ["--format=json"], {
|
|
3129
|
+
cwd: rootDir,
|
|
3130
|
+
timeout
|
|
3131
|
+
});
|
|
3132
|
+
if (!result.stdout) return [];
|
|
3133
|
+
return (JSON.parse(result.stdout).dependencies ?? []).filter((d) => Array.isArray(d.vulns) && d.vulns.length > 0).map((d) => ({
|
|
3134
|
+
filePath: "requirements.txt",
|
|
3135
|
+
engine: "security",
|
|
3136
|
+
rule: "security/vulnerable-dependency",
|
|
3137
|
+
severity: "error",
|
|
3138
|
+
message: `Vulnerable Python dependency: ${d.name}`,
|
|
3139
|
+
help: `Upgrade ${d.name} to fix known vulnerabilities`,
|
|
3140
|
+
line: 0,
|
|
3141
|
+
column: 0,
|
|
3142
|
+
category: "Security",
|
|
3143
|
+
fixable: false
|
|
3144
|
+
}));
|
|
3145
|
+
} catch {
|
|
3146
|
+
return [];
|
|
3147
|
+
}
|
|
3148
|
+
};
|
|
3149
|
+
const runGovulncheck = async (rootDir, timeout) => {
|
|
3150
|
+
try {
|
|
3151
|
+
const result = await runSubprocess("govulncheck", ["-json", "./..."], {
|
|
3152
|
+
cwd: rootDir,
|
|
3153
|
+
timeout
|
|
3154
|
+
});
|
|
3155
|
+
if (!result.stdout) return [];
|
|
3156
|
+
return parseGovulncheckOutput(result.stdout);
|
|
3157
|
+
} catch {
|
|
3158
|
+
return [];
|
|
3159
|
+
}
|
|
3160
|
+
};
|
|
3161
|
+
const toGovulnDiagnostic = (entry) => {
|
|
3162
|
+
if (!entry.vulnerability) return null;
|
|
3163
|
+
return {
|
|
3164
|
+
filePath: "go.mod",
|
|
3165
|
+
engine: "security",
|
|
3166
|
+
rule: "security/vulnerable-dependency",
|
|
3167
|
+
severity: "error",
|
|
3168
|
+
message: `Go vulnerability: ${entry.vulnerability.id ?? "unknown"}`,
|
|
3169
|
+
help: entry.vulnerability.details ?? "",
|
|
3170
|
+
line: 0,
|
|
3171
|
+
column: 0,
|
|
3172
|
+
category: "Security",
|
|
3173
|
+
fixable: false
|
|
3174
|
+
};
|
|
3175
|
+
};
|
|
3176
|
+
const parseGovulncheckOutput = (output) => {
|
|
3177
|
+
const diagnostics = [];
|
|
3178
|
+
for (const line of output.split("\n")) {
|
|
3179
|
+
if (!line.startsWith("{")) continue;
|
|
3180
|
+
let parsed = null;
|
|
3181
|
+
try {
|
|
3182
|
+
parsed = JSON.parse(line);
|
|
3183
|
+
} catch {
|
|
3184
|
+
parsed = null;
|
|
3185
|
+
}
|
|
3186
|
+
if (!parsed) continue;
|
|
3187
|
+
const diagnostic = toGovulnDiagnostic(parsed);
|
|
3188
|
+
if (diagnostic) diagnostics.push(diagnostic);
|
|
3189
|
+
}
|
|
3190
|
+
return diagnostics;
|
|
3191
|
+
};
|
|
3192
|
+
const runCargoAudit = async (rootDir, timeout) => {
|
|
3193
|
+
try {
|
|
3194
|
+
const result = await runSubprocess("cargo", ["audit", "--json"], {
|
|
3195
|
+
cwd: rootDir,
|
|
3196
|
+
timeout
|
|
3197
|
+
});
|
|
3198
|
+
if (!result.stdout) return [];
|
|
3199
|
+
return (JSON.parse(result.stdout).vulnerabilities?.list ?? []).map((v) => ({
|
|
3200
|
+
filePath: "Cargo.toml",
|
|
3201
|
+
engine: "security",
|
|
3202
|
+
rule: "security/vulnerable-dependency",
|
|
3203
|
+
severity: "error",
|
|
3204
|
+
message: `Rust vulnerability: ${v.advisory?.id ?? "unknown"}`,
|
|
3205
|
+
help: v.advisory?.title ?? "",
|
|
3206
|
+
line: 0,
|
|
3207
|
+
column: 0,
|
|
3208
|
+
category: "Security",
|
|
3209
|
+
fixable: false
|
|
3210
|
+
}));
|
|
3211
|
+
} catch {
|
|
3212
|
+
return [];
|
|
3213
|
+
}
|
|
3214
|
+
};
|
|
3215
|
+
|
|
3216
|
+
//#endregion
|
|
3217
|
+
//#region src/engines/security/risky.ts
|
|
3218
|
+
const ev = "eval";
|
|
3219
|
+
const Fn = "Function";
|
|
3220
|
+
const RISKY_PATTERNS = [
|
|
3221
|
+
{
|
|
3222
|
+
pattern: new RegExp(`\\b${ev}\\s*\\(`, "g"),
|
|
3223
|
+
extensions: [
|
|
3224
|
+
".ts",
|
|
3225
|
+
".tsx",
|
|
3226
|
+
".js",
|
|
3227
|
+
".jsx",
|
|
3228
|
+
".mjs",
|
|
3229
|
+
".cjs",
|
|
3230
|
+
".py",
|
|
3231
|
+
".rb",
|
|
3232
|
+
".php"
|
|
3233
|
+
],
|
|
3234
|
+
name: "eval",
|
|
3235
|
+
message: `Use of ${ev}() is a security risk`,
|
|
3236
|
+
help: `Avoid ${ev} — use safer alternatives like JSON.parse, Function constructor, or AST-based approaches`
|
|
3237
|
+
},
|
|
3238
|
+
{
|
|
3239
|
+
pattern: new RegExp(`new\\s+${Fn}\\s*\\(`, "g"),
|
|
3240
|
+
extensions: [
|
|
3241
|
+
".ts",
|
|
3242
|
+
".tsx",
|
|
3243
|
+
".js",
|
|
3244
|
+
".jsx",
|
|
3245
|
+
".mjs",
|
|
3246
|
+
".cjs"
|
|
3247
|
+
],
|
|
3248
|
+
name: "new-function",
|
|
3249
|
+
message: `Use of new ${Fn}() is similar to ${ev} and can be a security risk`,
|
|
3250
|
+
help: "Avoid dynamic code execution — refactor to use static code paths"
|
|
3251
|
+
},
|
|
3252
|
+
{
|
|
3253
|
+
pattern: /\.innerHTML\s*=/g,
|
|
3254
|
+
extensions: [
|
|
3255
|
+
".ts",
|
|
3256
|
+
".tsx",
|
|
3257
|
+
".js",
|
|
3258
|
+
".jsx"
|
|
3259
|
+
],
|
|
3260
|
+
name: "innerhtml",
|
|
3261
|
+
message: "Direct innerHTML assignment can lead to XSS",
|
|
3262
|
+
help: "Use textContent, DOM APIs, or a sanitization library instead"
|
|
3263
|
+
},
|
|
3264
|
+
{
|
|
3265
|
+
pattern: /dangerouslySetInnerHTML/g,
|
|
3266
|
+
extensions: [".tsx", ".jsx"],
|
|
3267
|
+
name: "dangerously-set-innerhtml",
|
|
3268
|
+
message: "dangerouslySetInnerHTML can lead to XSS if not sanitized",
|
|
3269
|
+
help: "Ensure the HTML is sanitized with DOMPurify or similar before rendering"
|
|
3270
|
+
},
|
|
3271
|
+
{
|
|
3272
|
+
pattern: /pickle\.loads?\s*\(/g,
|
|
3273
|
+
extensions: [".py"],
|
|
3274
|
+
name: "pickle-load",
|
|
3275
|
+
message: "pickle.load can execute arbitrary code — unsafe deserialization",
|
|
3276
|
+
help: "Use JSON, MessagePack, or other safe serialization formats for untrusted data"
|
|
3277
|
+
},
|
|
3278
|
+
{
|
|
3279
|
+
pattern: new RegExp(`\\bexec\\s*\\(`, "g"),
|
|
3280
|
+
extensions: [".py"],
|
|
3281
|
+
name: "python-exec",
|
|
3282
|
+
message: "Use of exec() can execute arbitrary code",
|
|
3283
|
+
help: "Avoid exec — use safer alternatives"
|
|
3284
|
+
},
|
|
3285
|
+
{
|
|
3286
|
+
pattern: /(?:child_process|subprocess|os\.system|exec|spawn)\s*\([^)]*\$\{/g,
|
|
3287
|
+
extensions: [
|
|
3288
|
+
".ts",
|
|
3289
|
+
".tsx",
|
|
3290
|
+
".js",
|
|
3291
|
+
".jsx",
|
|
3292
|
+
".mjs",
|
|
3293
|
+
".cjs",
|
|
3294
|
+
".py"
|
|
3295
|
+
],
|
|
3296
|
+
name: "shell-injection",
|
|
3297
|
+
message: "Possible shell injection — user input in command execution",
|
|
3298
|
+
help: "Use parameterized commands or a safe shell execution library"
|
|
3299
|
+
},
|
|
3300
|
+
{
|
|
3301
|
+
pattern: /(?:query|execute|raw)\s*\(\s*`[^`]*\$\{/g,
|
|
3302
|
+
extensions: [
|
|
3303
|
+
".ts",
|
|
3304
|
+
".tsx",
|
|
3305
|
+
".js",
|
|
3306
|
+
".jsx",
|
|
3307
|
+
".mjs",
|
|
3308
|
+
".cjs"
|
|
3309
|
+
],
|
|
3310
|
+
name: "sql-injection",
|
|
3311
|
+
message: "Possible SQL injection — template literal in query",
|
|
3312
|
+
help: "Use parameterized queries or an ORM instead of string interpolation"
|
|
3313
|
+
},
|
|
3314
|
+
{
|
|
3315
|
+
pattern: /(?:query|execute|raw)\s*\(\s*["'][^"']*["']\s*\+/g,
|
|
3316
|
+
extensions: [
|
|
3317
|
+
".ts",
|
|
3318
|
+
".tsx",
|
|
3319
|
+
".js",
|
|
3320
|
+
".jsx",
|
|
3321
|
+
".mjs",
|
|
3322
|
+
".cjs"
|
|
3323
|
+
],
|
|
3324
|
+
name: "sql-injection",
|
|
3325
|
+
message: "Possible SQL injection — string concatenation in query",
|
|
3326
|
+
help: "Use parameterized queries or an ORM instead of string concatenation"
|
|
3327
|
+
}
|
|
3328
|
+
];
|
|
3329
|
+
const detectRiskyConstructs = async (context) => {
|
|
3330
|
+
const files = getSourceFiles(context);
|
|
3331
|
+
const diagnostics = [];
|
|
3332
|
+
for (const filePath of files) {
|
|
3333
|
+
const ext = path.extname(filePath);
|
|
3334
|
+
let content;
|
|
3335
|
+
try {
|
|
3336
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
3337
|
+
} catch {
|
|
3338
|
+
continue;
|
|
3339
|
+
}
|
|
3340
|
+
const relativePath = path.relative(context.rootDirectory, filePath);
|
|
3341
|
+
const normalizedPath = relativePath.split(path.sep).join("/");
|
|
3342
|
+
const isMigrationOrSeeder = /(?:^|\/)(migrations|seeders|seeds|migrate)\//.test(normalizedPath);
|
|
3343
|
+
for (const { pattern, extensions, name, message, help } of RISKY_PATTERNS) {
|
|
3344
|
+
if (!extensions.includes(ext)) continue;
|
|
3345
|
+
if (isMigrationOrSeeder && name === "sql-injection") continue;
|
|
3346
|
+
const regex = new RegExp(pattern.source, pattern.flags);
|
|
3347
|
+
let match;
|
|
3348
|
+
while ((match = regex.exec(content)) !== null) {
|
|
3349
|
+
const line = content.slice(0, match.index).split("\n").length;
|
|
3350
|
+
if (name === "sql-injection") {
|
|
3351
|
+
const afterMatch = content.slice(match.index + match[0].length, match.index + match[0].length + 100);
|
|
3352
|
+
if (/^(?:\w+\.join\s*\(|[A-Z_]+\}|tableName\}|table\})/.test(afterMatch)) continue;
|
|
3353
|
+
}
|
|
3354
|
+
diagnostics.push({
|
|
3355
|
+
filePath: relativePath,
|
|
3356
|
+
engine: "security",
|
|
3357
|
+
rule: `security/${name}`,
|
|
3358
|
+
severity: "error",
|
|
3359
|
+
message,
|
|
3360
|
+
help,
|
|
3361
|
+
line,
|
|
3362
|
+
column: 0,
|
|
3363
|
+
category: "Security",
|
|
3364
|
+
fixable: false
|
|
3365
|
+
});
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
}
|
|
3369
|
+
return diagnostics;
|
|
3370
|
+
};
|
|
3371
|
+
|
|
3372
|
+
//#endregion
|
|
3373
|
+
//#region src/engines/security/secrets.ts
|
|
3374
|
+
const SECRET_PATTERNS = [
|
|
3375
|
+
{
|
|
3376
|
+
pattern: /(?:api[_-]?key|apikey)\s*[:=]\s*["']([A-Za-z0-9_-]{20,})["']/gi,
|
|
3377
|
+
name: "API key"
|
|
3378
|
+
},
|
|
3379
|
+
{
|
|
3380
|
+
pattern: /AKIA[0-9A-Z]{16}/g,
|
|
3381
|
+
name: "AWS Access Key"
|
|
3382
|
+
},
|
|
3383
|
+
{
|
|
3384
|
+
pattern: /(?:aws[_-]?secret|secret[_-]?key)\s*[:=]\s*["']([A-Za-z0-9/+=]{40})["']/gi,
|
|
3385
|
+
name: "AWS Secret Key"
|
|
3386
|
+
},
|
|
3387
|
+
{
|
|
3388
|
+
pattern: /(?:password|passwd|pwd|secret)\s*[:=]\s*["']([^"']{8,})["']/gi,
|
|
3389
|
+
name: "Hardcoded password/secret"
|
|
3390
|
+
},
|
|
3391
|
+
{
|
|
3392
|
+
pattern: /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/g,
|
|
3393
|
+
name: "Private key"
|
|
3394
|
+
},
|
|
3395
|
+
{
|
|
3396
|
+
pattern: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
|
|
3397
|
+
name: "JWT token"
|
|
3398
|
+
},
|
|
3399
|
+
{
|
|
3400
|
+
pattern: /(?:token|bearer)\s*[:=]\s*["']([A-Za-z0-9_-]{20,})["']/gi,
|
|
3401
|
+
name: "Authentication token"
|
|
3402
|
+
},
|
|
3403
|
+
{
|
|
3404
|
+
pattern: /gh[pousr]_[A-Za-z0-9_]{36,}/g,
|
|
3405
|
+
name: "GitHub token"
|
|
3406
|
+
},
|
|
3407
|
+
{
|
|
3408
|
+
pattern: /xox[baprs]-[A-Za-z0-9-]+/g,
|
|
3409
|
+
name: "Slack token"
|
|
3410
|
+
},
|
|
3411
|
+
{
|
|
3412
|
+
pattern: /(?:mongodb|postgres|mysql|redis):\/\/[^"'\s]+:[^"'\s]+@/gi,
|
|
3413
|
+
name: "Database connection string with credentials"
|
|
3414
|
+
}
|
|
3415
|
+
];
|
|
3416
|
+
const PLACEHOLDER_EXACT = new Set([
|
|
3417
|
+
"changeme",
|
|
3418
|
+
"password",
|
|
3419
|
+
"secret",
|
|
3420
|
+
"xxx",
|
|
3421
|
+
"todo",
|
|
3422
|
+
"replace_me"
|
|
3423
|
+
]);
|
|
3424
|
+
const isPlaceholderValue = (matchedText) => {
|
|
3425
|
+
if (/env\(/i.test(matchedText)) return true;
|
|
3426
|
+
if (matchedText.includes("process.env")) return true;
|
|
3427
|
+
if (matchedText.includes("os.environ")) return true;
|
|
3428
|
+
if (matchedText.includes("${")) return true;
|
|
3429
|
+
if (matchedText.includes("<") && matchedText.includes(">")) return true;
|
|
3430
|
+
if (/^your_/i.test(matchedText)) return true;
|
|
3431
|
+
if (PLACEHOLDER_EXACT.has(matchedText.toLowerCase())) return true;
|
|
3432
|
+
return false;
|
|
3433
|
+
};
|
|
3434
|
+
const scanSecrets = async (context) => {
|
|
3435
|
+
const files = getSourceFilesWithExtras(context, [
|
|
3436
|
+
".env",
|
|
3437
|
+
".yaml",
|
|
3438
|
+
".yml",
|
|
3439
|
+
".json",
|
|
3440
|
+
".toml"
|
|
3441
|
+
]);
|
|
3442
|
+
const diagnostics = [];
|
|
3443
|
+
for (const filePath of files) {
|
|
3444
|
+
let content;
|
|
3445
|
+
try {
|
|
3446
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
3447
|
+
} catch {
|
|
3448
|
+
continue;
|
|
3449
|
+
}
|
|
3450
|
+
const relativePath = path.relative(context.rootDirectory, filePath);
|
|
3451
|
+
for (const { pattern, name } of SECRET_PATTERNS) {
|
|
3452
|
+
const regex = new RegExp(pattern.source, pattern.flags);
|
|
3453
|
+
let match;
|
|
3454
|
+
while ((match = regex.exec(content)) !== null) {
|
|
3455
|
+
if (isPlaceholderValue(match[1] ?? match[0])) continue;
|
|
3456
|
+
const line = content.slice(0, match.index).split("\n").length;
|
|
3457
|
+
diagnostics.push({
|
|
3458
|
+
filePath: relativePath,
|
|
3459
|
+
engine: "security",
|
|
3460
|
+
rule: "security/hardcoded-secret",
|
|
3461
|
+
severity: "error",
|
|
3462
|
+
message: `Possible ${name} detected in source code`,
|
|
3463
|
+
help: "Move secrets to environment variables or a secrets manager",
|
|
3464
|
+
line,
|
|
3465
|
+
column: 0,
|
|
3466
|
+
category: "Security",
|
|
3467
|
+
fixable: false
|
|
3468
|
+
});
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
return diagnostics;
|
|
3473
|
+
};
|
|
3474
|
+
|
|
3475
|
+
//#endregion
|
|
3476
|
+
//#region src/engines/security/index.ts
|
|
3477
|
+
const securityEngine = {
|
|
3478
|
+
name: "security",
|
|
3479
|
+
async run(context) {
|
|
3480
|
+
const diagnostics = [];
|
|
3481
|
+
const promises = [scanSecrets(context), detectRiskyConstructs(context)];
|
|
3482
|
+
if (context.config.security.audit) promises.push(runDependencyAudit(context));
|
|
3483
|
+
const results = await Promise.allSettled(promises);
|
|
3484
|
+
for (const result of results) if (result.status === "fulfilled") diagnostics.push(...result.value);
|
|
3485
|
+
return {
|
|
3486
|
+
engine: "security",
|
|
3487
|
+
diagnostics,
|
|
3488
|
+
elapsed: 0,
|
|
3489
|
+
skipped: false
|
|
3490
|
+
};
|
|
3491
|
+
}
|
|
3492
|
+
};
|
|
3493
|
+
|
|
3494
|
+
//#endregion
|
|
3495
|
+
//#region src/engines/orchestrator.ts
|
|
3496
|
+
const ALL_ENGINES = [
|
|
3497
|
+
formatEngine,
|
|
3498
|
+
lintEngine,
|
|
3499
|
+
codeQualityEngine,
|
|
3500
|
+
aiSlopEngine,
|
|
3501
|
+
architectureEngine,
|
|
3502
|
+
securityEngine
|
|
3503
|
+
];
|
|
3504
|
+
const runEngines = async (context, enabledEngines, onStart, onComplete) => {
|
|
3505
|
+
const engines = ALL_ENGINES.filter((e) => enabledEngines[e.name] !== false);
|
|
3506
|
+
return (await Promise.allSettled(engines.map(async (engine) => {
|
|
3507
|
+
onStart?.(engine.name);
|
|
3508
|
+
const start = performance.now();
|
|
3509
|
+
try {
|
|
3510
|
+
const result = await engine.run(context);
|
|
3511
|
+
result.elapsed = performance.now() - start;
|
|
3512
|
+
onComplete?.(result);
|
|
3513
|
+
return result;
|
|
3514
|
+
} catch (error) {
|
|
3515
|
+
const result = {
|
|
3516
|
+
engine: engine.name,
|
|
3517
|
+
diagnostics: [],
|
|
3518
|
+
elapsed: performance.now() - start,
|
|
3519
|
+
skipped: true,
|
|
3520
|
+
skipReason: error instanceof Error ? error.message : String(error)
|
|
3521
|
+
};
|
|
3522
|
+
onComplete?.(result);
|
|
3523
|
+
return result;
|
|
3524
|
+
}
|
|
3525
|
+
}))).map((r, i) => r.status === "fulfilled" ? r.value : {
|
|
3526
|
+
engine: engines[i].name,
|
|
3527
|
+
diagnostics: [],
|
|
3528
|
+
elapsed: 0,
|
|
3529
|
+
skipped: true,
|
|
3530
|
+
skipReason: r.reason instanceof Error ? r.reason.message : String(r.reason)
|
|
3531
|
+
});
|
|
3532
|
+
};
|
|
3533
|
+
|
|
3534
|
+
//#endregion
|
|
3535
|
+
//#region src/output/scan-progress.ts
|
|
3536
|
+
const SPINNER_FRAMES = [
|
|
3537
|
+
"⠋",
|
|
3538
|
+
"⠙",
|
|
3539
|
+
"⠹",
|
|
3540
|
+
"⠸",
|
|
3541
|
+
"⠼",
|
|
3542
|
+
"⠴",
|
|
3543
|
+
"⠦",
|
|
3544
|
+
"⠧",
|
|
3545
|
+
"⠇",
|
|
3546
|
+
"⠏"
|
|
3547
|
+
];
|
|
3548
|
+
const shouldRenderLiveScanProgress = () => Boolean(process.stderr.isTTY) && process.env.CI !== "true" && process.env.CI !== "1";
|
|
3549
|
+
const formatElapsed = (elapsedMs) => elapsedMs < 1e3 ? `${Math.round(elapsedMs)}ms` : `${(elapsedMs / 1e3).toFixed(1)}s`;
|
|
3550
|
+
const truncateText = (text, maxLength = 52) => text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
|
|
3551
|
+
const getIssueSummary = (result) => {
|
|
3552
|
+
const errors = result.diagnostics.filter((d) => d.severity === "error").length;
|
|
3553
|
+
const warnings = result.diagnostics.filter((d) => d.severity === "warning").length;
|
|
3554
|
+
if (errors === 0 && warnings === 0) return `Done (0 issues, ${formatElapsed(result.elapsed)})`;
|
|
3555
|
+
const parts = [];
|
|
3556
|
+
if (errors > 0) parts.push(`${errors} error${errors === 1 ? "" : "s"}`);
|
|
3557
|
+
if (warnings > 0) parts.push(`${warnings} warning${warnings === 1 ? "" : "s"}`);
|
|
3558
|
+
return `Done (${parts.join(", ")}, ${formatElapsed(result.elapsed)})`;
|
|
3559
|
+
};
|
|
3560
|
+
const getStatusParts = (state, frameIndex) => {
|
|
3561
|
+
if (state.status === "running") return {
|
|
3562
|
+
icon: highlighter.info(SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length]),
|
|
3563
|
+
detail: highlighter.info("Running")
|
|
3564
|
+
};
|
|
3565
|
+
if (state.status === "done") {
|
|
3566
|
+
const result = state.result;
|
|
3567
|
+
if (!result) return {
|
|
3568
|
+
icon: highlighter.success("✓"),
|
|
3569
|
+
detail: highlighter.dim("Done")
|
|
3570
|
+
};
|
|
3571
|
+
const hasErrors = result.diagnostics.some((d) => d.severity === "error");
|
|
3572
|
+
const hasWarnings = result.diagnostics.some((d) => d.severity === "warning");
|
|
3573
|
+
let icon = highlighter.success("✓");
|
|
3574
|
+
if (hasErrors) icon = highlighter.error("✗");
|
|
3575
|
+
else if (hasWarnings) icon = highlighter.warn("!");
|
|
3576
|
+
return {
|
|
3577
|
+
icon,
|
|
3578
|
+
detail: highlighter.dim(getIssueSummary(result))
|
|
3579
|
+
};
|
|
3580
|
+
}
|
|
3581
|
+
if (state.status === "skipped") {
|
|
3582
|
+
const reason = state.result?.skipReason?.split("\n").find((line) => line.trim().length > 0) ?? "Skipped";
|
|
3583
|
+
return {
|
|
3584
|
+
icon: highlighter.warn("!"),
|
|
3585
|
+
detail: highlighter.dim(`Skipped (${truncateText(reason)})`)
|
|
3586
|
+
};
|
|
3587
|
+
}
|
|
3588
|
+
return {
|
|
3589
|
+
icon: highlighter.dim("○"),
|
|
3590
|
+
detail: highlighter.dim("Waiting")
|
|
3591
|
+
};
|
|
3592
|
+
};
|
|
3593
|
+
const renderScanProgressBlock = (states, frameIndex) => {
|
|
3594
|
+
if (states.length === 0) return ` ${highlighter.bold("Engines 0/0")} ${highlighter.dim("nothing to run")}\n`;
|
|
3595
|
+
const completedCount = states.filter((state) => state.status === "done" || state.status === "skipped").length;
|
|
3596
|
+
const runningCount = states.filter((state) => state.status === "running").length;
|
|
3597
|
+
const labelWidth = Math.max(...states.map((state) => getEngineLabel(state.engine).length));
|
|
3598
|
+
const headingStatus = completedCount === states.length ? highlighter.dim("complete") : runningCount > 0 ? highlighter.dim(`${runningCount} running`) : highlighter.dim("starting");
|
|
3599
|
+
return `${[` ${highlighter.bold(`Engines ${completedCount}/${states.length}`)} ${headingStatus}`, ...states.map((state) => {
|
|
3600
|
+
const label = getEngineLabel(state.engine).padEnd(labelWidth, " ");
|
|
3601
|
+
const { icon, detail } = getStatusParts(state, frameIndex);
|
|
3602
|
+
return ` ${icon} ${label} ${detail}`;
|
|
3603
|
+
})].join("\n")}\n`;
|
|
3604
|
+
};
|
|
3605
|
+
const clearRenderedLines = (lineCount) => {
|
|
3606
|
+
if (lineCount === 0) return;
|
|
3607
|
+
process.stderr.write(`\u001B[${lineCount}F`);
|
|
3608
|
+
for (let index = 0; index < lineCount; index += 1) {
|
|
3609
|
+
process.stderr.write("\x1B[2K");
|
|
3610
|
+
if (index < lineCount - 1) process.stderr.write("\x1B[1E");
|
|
3611
|
+
}
|
|
3612
|
+
if (lineCount > 1) process.stderr.write(`\u001B[${lineCount - 1}F`);
|
|
3613
|
+
};
|
|
3614
|
+
var ScanProgressRenderer = class {
|
|
3615
|
+
states;
|
|
3616
|
+
previousLineCount = 0;
|
|
3617
|
+
frameIndex = 0;
|
|
3618
|
+
timer;
|
|
3619
|
+
constructor(engines) {
|
|
3620
|
+
this.states = engines.map((engine) => ({
|
|
3621
|
+
engine,
|
|
3622
|
+
status: "pending"
|
|
3623
|
+
}));
|
|
3624
|
+
}
|
|
3625
|
+
start() {
|
|
3626
|
+
if (!shouldRenderLiveScanProgress()) return;
|
|
3627
|
+
this.render();
|
|
3628
|
+
this.timer = setInterval(() => {
|
|
3629
|
+
this.frameIndex += 1;
|
|
3630
|
+
this.render();
|
|
3631
|
+
}, 100);
|
|
3632
|
+
this.timer.unref();
|
|
3633
|
+
}
|
|
3634
|
+
markStarted(engine) {
|
|
3635
|
+
const state = this.states.find((entry) => entry.engine === engine);
|
|
3636
|
+
if (!state) return;
|
|
3637
|
+
state.status = "running";
|
|
3638
|
+
this.render();
|
|
3639
|
+
}
|
|
3640
|
+
markComplete(result) {
|
|
3641
|
+
const state = this.states.find((entry) => entry.engine === result.engine);
|
|
3642
|
+
if (!state) return;
|
|
3643
|
+
state.status = result.skipped ? "skipped" : "done";
|
|
3644
|
+
state.result = result;
|
|
3645
|
+
this.render();
|
|
3646
|
+
}
|
|
3647
|
+
stop() {
|
|
3648
|
+
if (this.timer) {
|
|
3649
|
+
clearInterval(this.timer);
|
|
3650
|
+
this.timer = void 0;
|
|
3651
|
+
}
|
|
3652
|
+
if (!shouldRenderLiveScanProgress()) return;
|
|
3653
|
+
this.render();
|
|
3654
|
+
}
|
|
3655
|
+
render() {
|
|
3656
|
+
if (!shouldRenderLiveScanProgress()) return;
|
|
3657
|
+
if (this.previousLineCount > 0) clearRenderedLines(this.previousLineCount);
|
|
3658
|
+
const output = renderScanProgressBlock(this.states, this.frameIndex);
|
|
3659
|
+
process.stderr.write(output);
|
|
3660
|
+
this.previousLineCount = output.split("\n").length - 1;
|
|
3661
|
+
}
|
|
3662
|
+
};
|
|
3663
|
+
|
|
3664
|
+
//#endregion
|
|
3665
|
+
//#region src/scoring/index.ts
|
|
3666
|
+
const PERFECT_SCORE$1 = 100;
|
|
3667
|
+
const calculateScore = (diagnostics, weights, thresholds) => {
|
|
3668
|
+
if (diagnostics.length === 0) return {
|
|
3669
|
+
score: PERFECT_SCORE$1,
|
|
3670
|
+
label: "Healthy"
|
|
3671
|
+
};
|
|
3672
|
+
let deductions = 0;
|
|
3673
|
+
for (const d of diagnostics) {
|
|
3674
|
+
const engineWeight = weights[d.engine] ?? 1;
|
|
3675
|
+
const severityPenalty = d.severity === "error" ? 3 : d.severity === "warning" ? 1 : .25;
|
|
3676
|
+
deductions += severityPenalty * engineWeight;
|
|
3677
|
+
}
|
|
3678
|
+
const score = Math.max(0, Math.round(PERFECT_SCORE$1 - PERFECT_SCORE$1 * Math.log1p(deductions) / Math.log1p(PERFECT_SCORE$1 + deductions)));
|
|
3679
|
+
return {
|
|
3680
|
+
score,
|
|
3681
|
+
label: score >= thresholds.good ? "Healthy" : score >= thresholds.ok ? "Needs Work" : "Critical"
|
|
3682
|
+
};
|
|
3683
|
+
};
|
|
3684
|
+
const getScoreColor = (score, thresholds) => {
|
|
3685
|
+
if (score >= thresholds.good) return "success";
|
|
3686
|
+
if (score >= thresholds.ok) return "warn";
|
|
3687
|
+
return "error";
|
|
3688
|
+
};
|
|
3689
|
+
|
|
3690
|
+
//#endregion
|
|
3691
|
+
//#region src/output/terminal.ts
|
|
3692
|
+
const PERFECT_SCORE = 100;
|
|
3693
|
+
const groupBy = (items, key) => {
|
|
3694
|
+
const map = /* @__PURE__ */ new Map();
|
|
3695
|
+
for (const item of items) {
|
|
3696
|
+
const k = key(item);
|
|
3697
|
+
const group = map.get(k) ?? [];
|
|
3698
|
+
group.push(item);
|
|
3699
|
+
map.set(k, group);
|
|
3700
|
+
}
|
|
3701
|
+
return map;
|
|
3702
|
+
};
|
|
3703
|
+
const colorBySeverity = (text, severity) => severity === "error" ? highlighter.error(text) : highlighter.warn(text);
|
|
3704
|
+
const colorByScore = (text, score, thresholds) => {
|
|
3705
|
+
return highlighter[getScoreColor(score, thresholds)](text);
|
|
3706
|
+
};
|
|
3707
|
+
const toElapsedLabel = (elapsedMs) => elapsedMs < 1e3 ? `${Math.round(elapsedMs)}ms` : `${(elapsedMs / 1e3).toFixed(1)}s`;
|
|
3708
|
+
const toSeverityLabel = (severity) => {
|
|
3709
|
+
if (severity === "error") return "ERROR";
|
|
3710
|
+
if (severity === "warning") return "WARN";
|
|
3711
|
+
return "INFO";
|
|
3712
|
+
};
|
|
3713
|
+
const toLocationLabel = (diagnostic) => {
|
|
3714
|
+
const line = diagnostic.line > 0 ? `:${diagnostic.line}` : "";
|
|
3715
|
+
const column = diagnostic.column > 0 ? `:${diagnostic.column}` : "";
|
|
3716
|
+
return `${diagnostic.filePath}${line}${column}`;
|
|
3717
|
+
};
|
|
3718
|
+
const renderDiagnostics = (diagnostics, verbose) => {
|
|
3719
|
+
const lines = [];
|
|
3720
|
+
const byEngine = groupBy(diagnostics, (d) => d.engine);
|
|
3721
|
+
for (const [engine, engineDiags] of byEngine) {
|
|
3722
|
+
const label = getEngineLabel(engine);
|
|
3723
|
+
lines.push(` ${highlighter.bold(`➤ ${label}`)}`);
|
|
3724
|
+
const sorted = [...groupBy(engineDiags, (d) => `${d.rule}:${d.message}`).entries()].sort(([, a], [, b]) => {
|
|
3725
|
+
return (a[0].severity === "error" ? 0 : a[0].severity === "warning" ? 1 : 2) - (b[0].severity === "error" ? 0 : b[0].severity === "warning" ? 1 : 2);
|
|
3726
|
+
});
|
|
3727
|
+
for (const [, ruleDiags] of sorted) {
|
|
3728
|
+
const first = ruleDiags[0];
|
|
3729
|
+
const level = toSeverityLabel(first.severity);
|
|
3730
|
+
const count = ruleDiags.length > 1 ? ` (${ruleDiags.length})` : "";
|
|
3731
|
+
const status = colorBySeverity(level, first.severity);
|
|
3732
|
+
lines.push(` [${status}] ${first.message}${count}`);
|
|
3733
|
+
const locations = verbose ? ruleDiags : ruleDiags.slice(0, 3);
|
|
3734
|
+
for (const diagnostic of locations) lines.push(highlighter.dim(` ${toLocationLabel(diagnostic)}`));
|
|
3735
|
+
if (!verbose && ruleDiags.length > locations.length) lines.push(highlighter.dim(` +${ruleDiags.length - locations.length} more location(s), use -d for full list`));
|
|
3736
|
+
if (first.help) lines.push(highlighter.dim(` ${first.help}`));
|
|
3737
|
+
lines.push("");
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
return `${lines.join("\n")}\n`;
|
|
3741
|
+
};
|
|
3742
|
+
const renderSummary = (diagnostics, scoreResult, elapsedMs, fileCount, thresholds) => {
|
|
3743
|
+
const errorCount = diagnostics.filter((d) => d.severity === "error").length;
|
|
3744
|
+
const warningCount = diagnostics.filter((d) => d.severity === "warning").length;
|
|
3745
|
+
const fixableCount = diagnostics.filter((d) => d.fixable).length;
|
|
3746
|
+
const elapsed = toElapsedLabel(elapsedMs);
|
|
3747
|
+
return `${[
|
|
3748
|
+
highlighter.dim("------------------------------------------------------------"),
|
|
3749
|
+
highlighter.bold("Summary"),
|
|
3750
|
+
` Score: ${colorByScore(`${scoreResult.score}/${PERFECT_SCORE}`, scoreResult.score, thresholds)} ${colorByScore(`(${scoreResult.label})`, scoreResult.score, thresholds)}`,
|
|
3751
|
+
` Issues: ${highlighter.error(`${errorCount} error${errorCount === 1 ? "" : "s"}`)}, ${highlighter.warn(`${warningCount} warning${warningCount === 1 ? "" : "s"}`)}`,
|
|
3752
|
+
` Auto-fixable: ${highlighter.info(String(fixableCount))}`,
|
|
3753
|
+
` Files: ${highlighter.info(String(fileCount))}`,
|
|
3754
|
+
` Time: ${highlighter.info(elapsed)}`,
|
|
3755
|
+
highlighter.dim("------------------------------------------------------------")
|
|
3756
|
+
].join("\n")}\n`;
|
|
3757
|
+
};
|
|
3758
|
+
const printEngineStatus = (result) => {
|
|
3759
|
+
const label = getEngineLabel(result.engine);
|
|
3760
|
+
const elapsed = toElapsedLabel(result.elapsed);
|
|
3761
|
+
if (result.skipped) logger.warn(` ! ${label}: skipped${result.skipReason ? ` (${result.skipReason})` : ""}`);
|
|
3762
|
+
else if (result.diagnostics.length === 0) logger.success(` ✓ ${label}: done (0 issues, ${elapsed})`);
|
|
3763
|
+
else {
|
|
3764
|
+
const errors = result.diagnostics.filter((d) => d.severity === "error").length;
|
|
3765
|
+
const warnings = result.diagnostics.filter((d) => d.severity === "warning").length;
|
|
3766
|
+
const parts = [];
|
|
3767
|
+
if (errors > 0) parts.push(`${errors} error${errors === 1 ? "" : "s"}`);
|
|
3768
|
+
if (warnings > 0) parts.push(`${warnings} warning${warnings === 1 ? "" : "s"}`);
|
|
3769
|
+
const statusText = `${parts.join(", ")}, ${elapsed}`;
|
|
3770
|
+
if (errors > 0) logger.error(` ✗ ${label}: done (${statusText})`);
|
|
3771
|
+
else logger.warn(` ! ${label}: done (${statusText})`);
|
|
3772
|
+
}
|
|
3773
|
+
};
|
|
3774
|
+
|
|
3775
|
+
//#endregion
|
|
3776
|
+
//#region src/utils/git.ts
|
|
3777
|
+
const MAX_BUFFER = 50 * 1024 * 1024;
|
|
3778
|
+
const getChangedFiles = (cwd, base) => {
|
|
3779
|
+
const result = spawnSync("git", [
|
|
3780
|
+
"diff",
|
|
3781
|
+
"--name-only",
|
|
3782
|
+
"--diff-filter=ACMR",
|
|
3783
|
+
base ?? "HEAD"
|
|
3784
|
+
], {
|
|
3785
|
+
cwd,
|
|
3786
|
+
encoding: "utf-8",
|
|
3787
|
+
maxBuffer: MAX_BUFFER
|
|
3788
|
+
});
|
|
3789
|
+
if (result.error || result.status !== 0) return [];
|
|
3790
|
+
return result.stdout.split("\n").filter((f) => f.length > 0).map((f) => path.resolve(cwd, f));
|
|
3791
|
+
};
|
|
3792
|
+
const getStagedFiles = (cwd) => {
|
|
3793
|
+
const result = spawnSync("git", [
|
|
3794
|
+
"diff",
|
|
3795
|
+
"--cached",
|
|
3796
|
+
"--name-only",
|
|
3797
|
+
"--diff-filter=ACMR"
|
|
3798
|
+
], {
|
|
3799
|
+
cwd,
|
|
3800
|
+
encoding: "utf-8",
|
|
3801
|
+
maxBuffer: MAX_BUFFER
|
|
3802
|
+
});
|
|
3803
|
+
if (result.error || result.status !== 0) return [];
|
|
3804
|
+
return result.stdout.split("\n").filter((f) => f.length > 0).map((f) => path.resolve(cwd, f));
|
|
3805
|
+
};
|
|
3806
|
+
|
|
3807
|
+
//#endregion
|
|
3808
|
+
//#region src/commands/scan.ts
|
|
3809
|
+
const shouldUseSpinner = () => Boolean(process.stderr.isTTY) && process.env.CI !== "true" && process.env.CI !== "1";
|
|
3810
|
+
const ALL_ENGINE_NAMES = Object.keys(ENGINE_INFO);
|
|
3811
|
+
const scanCommand = async (directory, config, options) => {
|
|
3812
|
+
const startTime = performance.now();
|
|
3813
|
+
const resolvedDir = path.resolve(directory);
|
|
3814
|
+
const showHeader = options.showHeader !== false;
|
|
3815
|
+
const useLiveProgress = !options.json && shouldUseSpinner();
|
|
3816
|
+
if (!options.json && showHeader) printCommandHeader("Scan");
|
|
3817
|
+
const discoverSpinner = options.json || !shouldUseSpinner() || !showHeader ? null : spinner("Discovering project...").start();
|
|
3818
|
+
const projectInfo = await discoverProject(resolvedDir);
|
|
3819
|
+
const projectSummary = formatProjectSummary(projectInfo);
|
|
3820
|
+
if (discoverSpinner) discoverSpinner.succeed(projectSummary);
|
|
3821
|
+
else if (!options.json) logger.success(` ✓ ${projectSummary}`);
|
|
3822
|
+
if (!options.json) printProjectMetadata(projectInfo);
|
|
3823
|
+
let files;
|
|
3824
|
+
if (options.staged) {
|
|
3825
|
+
files = filterProjectFiles(resolvedDir, getStagedFiles(resolvedDir));
|
|
3826
|
+
if (!options.json) {
|
|
3827
|
+
logger.dim(` Scope: ${files.length} staged file(s)`);
|
|
3828
|
+
logger.break();
|
|
3829
|
+
}
|
|
3830
|
+
} else if (options.changes) {
|
|
3831
|
+
files = filterProjectFiles(resolvedDir, getChangedFiles(resolvedDir));
|
|
3832
|
+
if (!options.json) {
|
|
3833
|
+
logger.dim(` Scope: ${files.length} changed file(s)`);
|
|
3834
|
+
logger.break();
|
|
3835
|
+
}
|
|
3836
|
+
}
|
|
3837
|
+
const configDir = findConfigDir(resolvedDir);
|
|
3838
|
+
const rulesPath = configDir ? path.join(configDir, RULES_FILE) : void 0;
|
|
3839
|
+
const engineConfig = {
|
|
3840
|
+
quality: config.quality,
|
|
3841
|
+
security: config.security,
|
|
3842
|
+
architectureRulesPath: config.engines.architecture ? rulesPath : void 0
|
|
3843
|
+
};
|
|
3844
|
+
const progressRenderer = useLiveProgress ? new ScanProgressRenderer(ALL_ENGINE_NAMES.filter((engine) => config.engines[engine] !== false)) : null;
|
|
3845
|
+
progressRenderer?.start();
|
|
3846
|
+
const results = await runEngines({
|
|
3847
|
+
rootDirectory: resolvedDir,
|
|
3848
|
+
languages: projectInfo.languages,
|
|
3849
|
+
frameworks: projectInfo.frameworks,
|
|
3850
|
+
files,
|
|
3851
|
+
installedTools: projectInfo.installedTools,
|
|
3852
|
+
config: engineConfig
|
|
3853
|
+
}, config.engines, (engine) => {
|
|
3854
|
+
progressRenderer?.markStarted(engine);
|
|
3855
|
+
}, (result) => {
|
|
3856
|
+
progressRenderer?.markComplete(result);
|
|
3857
|
+
if (!options.json && !progressRenderer) printEngineStatus(result);
|
|
3858
|
+
});
|
|
3859
|
+
progressRenderer?.stop();
|
|
3860
|
+
const allDiagnostics = results.flatMap((r) => r.diagnostics);
|
|
3861
|
+
const elapsedMs = performance.now() - startTime;
|
|
3862
|
+
const scoreResult = calculateScore(allDiagnostics, config.scoring.weights, config.scoring.thresholds);
|
|
3863
|
+
const exitCode = scoreResult.score < config.ci.failBelow ? 1 : 0;
|
|
3864
|
+
if (options.json) {
|
|
3865
|
+
const { buildJsonOutput } = await import("./json-DkpW9UQj.js");
|
|
3866
|
+
const jsonOut = buildJsonOutput(results, scoreResult, projectInfo.sourceFileCount, elapsedMs);
|
|
3867
|
+
console.log(JSON.stringify(jsonOut, null, 2));
|
|
3868
|
+
return { exitCode };
|
|
3869
|
+
}
|
|
3870
|
+
await printMaybePaged([
|
|
3871
|
+
"",
|
|
3872
|
+
allDiagnostics.length === 0 ? `${highlighter.success(" ✓ No issues found.")}\n` : renderDiagnostics(allDiagnostics, options.verbose),
|
|
3873
|
+
renderSummary(allDiagnostics, scoreResult, elapsedMs, projectInfo.sourceFileCount, config.scoring.thresholds),
|
|
3874
|
+
""
|
|
3875
|
+
].join("\n"));
|
|
3876
|
+
return { exitCode };
|
|
3877
|
+
};
|
|
3878
|
+
|
|
3879
|
+
//#endregion
|
|
3880
|
+
export { calculateScore, discoverProject, doctorCommand, fixCommand, initCommand, loadConfig, scanCommand };
|