@oxog/codeguardian 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/dist/cli.cjs +3498 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +3472 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +3158 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +169 -0
- package/dist/index.d.ts +169 -0
- package/dist/index.js +3130 -0
- package/dist/index.js.map +1 -0
- package/dist/plugins/index.cjs +1329 -0
- package/dist/plugins/index.cjs.map +1 -0
- package/dist/plugins/index.d.cts +138 -0
- package/dist/plugins/index.d.ts +138 -0
- package/dist/plugins/index.js +1316 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/types-CQZEdzEa.d.cts +487 -0
- package/dist/types-CQZEdzEa.d.ts +487 -0
- package/package.json +88 -0
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,3498 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var path8 = require('path');
|
|
5
|
+
var ts2 = require('typescript');
|
|
6
|
+
var fs2 = require('fs');
|
|
7
|
+
var child_process = require('child_process');
|
|
8
|
+
|
|
9
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
10
|
+
|
|
11
|
+
function _interopNamespace(e) {
|
|
12
|
+
if (e && e.__esModule) return e;
|
|
13
|
+
var n = Object.create(null);
|
|
14
|
+
if (e) {
|
|
15
|
+
Object.keys(e).forEach(function (k) {
|
|
16
|
+
if (k !== 'default') {
|
|
17
|
+
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
18
|
+
Object.defineProperty(n, k, d.get ? d : {
|
|
19
|
+
enumerable: true,
|
|
20
|
+
get: function () { return e[k]; }
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
n.default = e;
|
|
26
|
+
return Object.freeze(n);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
var path8__namespace = /*#__PURE__*/_interopNamespace(path8);
|
|
30
|
+
var ts2__default = /*#__PURE__*/_interopDefault(ts2);
|
|
31
|
+
var fs2__namespace = /*#__PURE__*/_interopNamespace(fs2);
|
|
32
|
+
|
|
33
|
+
// src/utils/args.ts
|
|
34
|
+
function parseArgs(argv) {
|
|
35
|
+
const result = {
|
|
36
|
+
command: "",
|
|
37
|
+
flags: {},
|
|
38
|
+
positional: []
|
|
39
|
+
};
|
|
40
|
+
let i = 0;
|
|
41
|
+
while (i < argv.length) {
|
|
42
|
+
const arg = argv[i];
|
|
43
|
+
if (!arg.startsWith("-")) {
|
|
44
|
+
result.command = arg;
|
|
45
|
+
i++;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
while (i < argv.length) {
|
|
51
|
+
const arg = argv[i];
|
|
52
|
+
if (arg.startsWith("--")) {
|
|
53
|
+
const key = arg.slice(2);
|
|
54
|
+
const eqIndex = key.indexOf("=");
|
|
55
|
+
if (eqIndex !== -1) {
|
|
56
|
+
const name = key.slice(0, eqIndex);
|
|
57
|
+
const value = key.slice(eqIndex + 1);
|
|
58
|
+
if (name) {
|
|
59
|
+
result.flags[name] = value;
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
const next = argv[i + 1];
|
|
63
|
+
if (next !== void 0 && !next.startsWith("-")) {
|
|
64
|
+
result.flags[key] = next;
|
|
65
|
+
i++;
|
|
66
|
+
} else {
|
|
67
|
+
result.flags[key] = true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} else if (arg.startsWith("-")) {
|
|
71
|
+
const key = arg.slice(1);
|
|
72
|
+
const next = argv[i + 1];
|
|
73
|
+
if (next !== void 0 && !next.startsWith("-")) {
|
|
74
|
+
result.flags[key] = next;
|
|
75
|
+
i++;
|
|
76
|
+
} else {
|
|
77
|
+
result.flags[key] = true;
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
result.positional.push(arg);
|
|
81
|
+
}
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/utils/color.ts
|
|
88
|
+
var isColorSupported = () => {
|
|
89
|
+
if (typeof process !== "undefined") {
|
|
90
|
+
if (process.env["NO_COLOR"] !== void 0) return false;
|
|
91
|
+
if (process.env["FORCE_COLOR"] !== void 0) return true;
|
|
92
|
+
if (process.stdout && "isTTY" in process.stdout) return !!process.stdout.isTTY;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
};
|
|
96
|
+
var enabled = isColorSupported();
|
|
97
|
+
var wrap = (open, close) => {
|
|
98
|
+
if (!enabled) return (str) => str;
|
|
99
|
+
return (str) => `\x1B[${open}m${str}\x1B[${close}m`;
|
|
100
|
+
};
|
|
101
|
+
var color = {
|
|
102
|
+
/** Check if colors are enabled */
|
|
103
|
+
enabled,
|
|
104
|
+
// Modifiers
|
|
105
|
+
bold: wrap("1", "22"),
|
|
106
|
+
dim: wrap("2", "22"),
|
|
107
|
+
italic: wrap("3", "23"),
|
|
108
|
+
underline: wrap("4", "24"),
|
|
109
|
+
// Colors
|
|
110
|
+
red: wrap("31", "39"),
|
|
111
|
+
green: wrap("32", "39"),
|
|
112
|
+
yellow: wrap("33", "39"),
|
|
113
|
+
blue: wrap("34", "39"),
|
|
114
|
+
magenta: wrap("35", "39"),
|
|
115
|
+
cyan: wrap("36", "39"),
|
|
116
|
+
white: wrap("37", "39"),
|
|
117
|
+
gray: wrap("90", "39"),
|
|
118
|
+
// Background
|
|
119
|
+
bgRed: wrap("41", "49"),
|
|
120
|
+
bgGreen: wrap("42", "49"),
|
|
121
|
+
bgYellow: wrap("43", "49"),
|
|
122
|
+
bgBlue: wrap("44", "49")
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// src/errors.ts
|
|
126
|
+
var CodeGuardianError = class extends Error {
|
|
127
|
+
code;
|
|
128
|
+
context;
|
|
129
|
+
constructor(message, code, context) {
|
|
130
|
+
super(message);
|
|
131
|
+
this.name = "CodeGuardianError";
|
|
132
|
+
this.code = code;
|
|
133
|
+
this.context = context;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
var ConfigError = class extends CodeGuardianError {
|
|
137
|
+
constructor(message, context) {
|
|
138
|
+
super(message, "CONFIG_ERROR", context);
|
|
139
|
+
this.name = "ConfigError";
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
var ParseError = class extends CodeGuardianError {
|
|
143
|
+
constructor(message, context) {
|
|
144
|
+
super(message, "PARSE_ERROR", context);
|
|
145
|
+
this.name = "ParseError";
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
var PluginError = class extends CodeGuardianError {
|
|
149
|
+
constructor(message, context) {
|
|
150
|
+
super(message, "PLUGIN_ERROR", context);
|
|
151
|
+
this.name = "PluginError";
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
var GraphError = class extends CodeGuardianError {
|
|
155
|
+
constructor(message, context) {
|
|
156
|
+
super(message, "GRAPH_ERROR", context);
|
|
157
|
+
this.name = "GraphError";
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
var GitError = class extends CodeGuardianError {
|
|
161
|
+
constructor(message, context) {
|
|
162
|
+
super(message, "GIT_ERROR", context);
|
|
163
|
+
this.name = "GitError";
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// src/kernel.ts
|
|
168
|
+
function createKernel() {
|
|
169
|
+
const plugins = /* @__PURE__ */ new Map();
|
|
170
|
+
const rules = /* @__PURE__ */ new Map();
|
|
171
|
+
function createKernelAdapter(config) {
|
|
172
|
+
return {
|
|
173
|
+
registerRule(rule) {
|
|
174
|
+
if (rules.has(rule.name)) {
|
|
175
|
+
throw new PluginError(`Rule "${rule.name}" is already registered`, {
|
|
176
|
+
rule: rule.name
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
rules.set(rule.name, rule);
|
|
180
|
+
},
|
|
181
|
+
unregisterRule(name) {
|
|
182
|
+
rules.delete(name);
|
|
183
|
+
},
|
|
184
|
+
getRules() {
|
|
185
|
+
return Array.from(rules.values());
|
|
186
|
+
},
|
|
187
|
+
getConfig() {
|
|
188
|
+
return config;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
/**
|
|
194
|
+
* Install a plugin into the kernel.
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* ```typescript
|
|
198
|
+
* kernel.installPlugin(architecturePlugin({ layers: [...] }));
|
|
199
|
+
* ```
|
|
200
|
+
*/
|
|
201
|
+
installPlugin(plugin, config) {
|
|
202
|
+
if (plugins.has(plugin.name)) {
|
|
203
|
+
throw new PluginError(`Plugin "${plugin.name}" is already installed`, {
|
|
204
|
+
plugin: plugin.name
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (plugin.dependencies) {
|
|
208
|
+
for (const dep of plugin.dependencies) {
|
|
209
|
+
if (!plugins.has(dep)) {
|
|
210
|
+
throw new PluginError(
|
|
211
|
+
`Plugin "${plugin.name}" depends on "${dep}" which is not installed`,
|
|
212
|
+
{ plugin: plugin.name, dependency: dep }
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const adapter = createKernelAdapter(config ?? {});
|
|
218
|
+
try {
|
|
219
|
+
plugin.install(adapter);
|
|
220
|
+
plugins.set(plugin.name, plugin);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
if (plugin.onError && err instanceof Error) {
|
|
223
|
+
plugin.onError(err);
|
|
224
|
+
}
|
|
225
|
+
throw new PluginError(
|
|
226
|
+
`Failed to install plugin "${plugin.name}": ${err instanceof Error ? err.message : String(err)}`,
|
|
227
|
+
{ plugin: plugin.name }
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
/**
|
|
232
|
+
* Uninstall a plugin from the kernel.
|
|
233
|
+
*/
|
|
234
|
+
async uninstallPlugin(name) {
|
|
235
|
+
const plugin = plugins.get(name);
|
|
236
|
+
if (!plugin) return;
|
|
237
|
+
const prefix = name + "/";
|
|
238
|
+
for (const [ruleName] of rules) {
|
|
239
|
+
if (ruleName.startsWith(prefix)) {
|
|
240
|
+
rules.delete(ruleName);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (plugin.onDestroy) {
|
|
244
|
+
await plugin.onDestroy();
|
|
245
|
+
}
|
|
246
|
+
plugins.delete(name);
|
|
247
|
+
},
|
|
248
|
+
/**
|
|
249
|
+
* Initialize all plugins with the built graph.
|
|
250
|
+
*/
|
|
251
|
+
async initPlugins(graph) {
|
|
252
|
+
for (const plugin of plugins.values()) {
|
|
253
|
+
if (plugin.onInit) {
|
|
254
|
+
try {
|
|
255
|
+
await plugin.onInit(graph);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
if (plugin.onError && err instanceof Error) {
|
|
258
|
+
plugin.onError(err);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
/**
|
|
265
|
+
* Get all registered rules.
|
|
266
|
+
*/
|
|
267
|
+
getRules() {
|
|
268
|
+
return Array.from(rules.values());
|
|
269
|
+
},
|
|
270
|
+
/**
|
|
271
|
+
* Get all installed plugin names.
|
|
272
|
+
*/
|
|
273
|
+
getPluginNames() {
|
|
274
|
+
return Array.from(plugins.keys());
|
|
275
|
+
},
|
|
276
|
+
/**
|
|
277
|
+
* Check if a plugin is installed.
|
|
278
|
+
*/
|
|
279
|
+
hasPlugin(name) {
|
|
280
|
+
return plugins.has(name);
|
|
281
|
+
},
|
|
282
|
+
/**
|
|
283
|
+
* Get the number of registered rules.
|
|
284
|
+
*/
|
|
285
|
+
getRuleCount() {
|
|
286
|
+
return rules.size;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function readFileSync2(filePath) {
|
|
291
|
+
return fs2__namespace.readFileSync(filePath, "utf-8");
|
|
292
|
+
}
|
|
293
|
+
function writeFileSync2(filePath, content) {
|
|
294
|
+
const dir = path8__namespace.dirname(filePath);
|
|
295
|
+
if (!fs2__namespace.existsSync(dir)) {
|
|
296
|
+
fs2__namespace.mkdirSync(dir, { recursive: true });
|
|
297
|
+
}
|
|
298
|
+
fs2__namespace.writeFileSync(filePath, content, "utf-8");
|
|
299
|
+
}
|
|
300
|
+
function fileExists(filePath) {
|
|
301
|
+
return fs2__namespace.existsSync(filePath);
|
|
302
|
+
}
|
|
303
|
+
function readJsonSync(filePath) {
|
|
304
|
+
const content = readFileSync2(filePath);
|
|
305
|
+
return JSON.parse(content);
|
|
306
|
+
}
|
|
307
|
+
function writeJsonSync(filePath, data) {
|
|
308
|
+
writeFileSync2(filePath, JSON.stringify(data, null, 2));
|
|
309
|
+
}
|
|
310
|
+
function normalizePath(filePath) {
|
|
311
|
+
return filePath.replace(/\\/g, "/");
|
|
312
|
+
}
|
|
313
|
+
function relativePath(rootDir, filePath) {
|
|
314
|
+
return normalizePath(path8__namespace.relative(rootDir, filePath));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/ast/parser.ts
|
|
318
|
+
function createTSProgram(rootDir, tsconfigPath) {
|
|
319
|
+
const configPath = path8__namespace.resolve(rootDir, tsconfigPath);
|
|
320
|
+
const configFile = ts2__default.default.readConfigFile(configPath, (p) => readFileSync2(p));
|
|
321
|
+
if (configFile.error) {
|
|
322
|
+
throw new ParseError(`Failed to read tsconfig: ${configFile.error.messageText}`, {
|
|
323
|
+
file: configPath
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
const parsed = ts2__default.default.parseJsonConfigFileContent(
|
|
327
|
+
configFile.config,
|
|
328
|
+
ts2__default.default.sys,
|
|
329
|
+
rootDir
|
|
330
|
+
);
|
|
331
|
+
if (parsed.errors.length > 0) {
|
|
332
|
+
const messages = parsed.errors.map((e) => typeof e.messageText === "string" ? e.messageText : e.messageText.messageText).join(", ");
|
|
333
|
+
throw new ParseError(`tsconfig parse errors: ${messages}`, { file: configPath });
|
|
334
|
+
}
|
|
335
|
+
return ts2__default.default.createProgram({
|
|
336
|
+
rootNames: parsed.fileNames,
|
|
337
|
+
options: parsed.options
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
function parseFile(filePath, content) {
|
|
341
|
+
const source = readFileSync2(filePath);
|
|
342
|
+
const sourceFile = ts2__default.default.createSourceFile(
|
|
343
|
+
filePath,
|
|
344
|
+
source,
|
|
345
|
+
ts2__default.default.ScriptTarget.ES2022,
|
|
346
|
+
true,
|
|
347
|
+
ts2__default.default.ScriptKind.TS
|
|
348
|
+
);
|
|
349
|
+
return sourceFile;
|
|
350
|
+
}
|
|
351
|
+
function getSourceFiles(program) {
|
|
352
|
+
return program.getSourceFiles().filter((sf) => !sf.isDeclarationFile && !sf.fileName.includes("node_modules")).map((sf) => sf.fileName);
|
|
353
|
+
}
|
|
354
|
+
function isCallTo(node, name) {
|
|
355
|
+
const expr = node.expression;
|
|
356
|
+
if (ts2__default.default.isIdentifier(expr)) {
|
|
357
|
+
return expr.text === name;
|
|
358
|
+
}
|
|
359
|
+
if (ts2__default.default.isPropertyAccessExpression(expr)) {
|
|
360
|
+
return expr.name.text === name;
|
|
361
|
+
}
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
function isConsoleCall(node, method) {
|
|
365
|
+
const expr = node.expression;
|
|
366
|
+
if (ts2__default.default.isPropertyAccessExpression(expr)) {
|
|
367
|
+
if (ts2__default.default.isIdentifier(expr.expression) && expr.expression.text === "console") {
|
|
368
|
+
if (method) {
|
|
369
|
+
return expr.name.text === method;
|
|
370
|
+
}
|
|
371
|
+
return true;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
function getTypeString(node, checker) {
|
|
377
|
+
try {
|
|
378
|
+
const type = checker.getTypeAtLocation(node);
|
|
379
|
+
return checker.typeToString(type);
|
|
380
|
+
} catch {
|
|
381
|
+
return "unknown";
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function hasStringConcat(node) {
|
|
385
|
+
if (ts2__default.default.isTemplateExpression(node)) {
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
if (ts2__default.default.isBinaryExpression(node) && node.operatorToken.kind === ts2__default.default.SyntaxKind.PlusToken) {
|
|
389
|
+
if (ts2__default.default.isStringLiteral(node.left) || ts2__default.default.isStringLiteral(node.right)) {
|
|
390
|
+
return true;
|
|
391
|
+
}
|
|
392
|
+
if (ts2__default.default.isTemplateExpression(node.left) || ts2__default.default.isTemplateExpression(node.right)) {
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
let found = false;
|
|
397
|
+
ts2__default.default.forEachChild(node, (child) => {
|
|
398
|
+
if (!found && hasStringConcat(child)) {
|
|
399
|
+
found = true;
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
return found;
|
|
403
|
+
}
|
|
404
|
+
function extractImports(sourceFile) {
|
|
405
|
+
const imports = [];
|
|
406
|
+
for (const stmt of sourceFile.statements) {
|
|
407
|
+
if (ts2__default.default.isImportDeclaration(stmt)) {
|
|
408
|
+
const moduleSpecifier = stmt.moduleSpecifier;
|
|
409
|
+
if (!ts2__default.default.isStringLiteral(moduleSpecifier)) continue;
|
|
410
|
+
const source = moduleSpecifier.text;
|
|
411
|
+
const isTypeOnly = stmt.importClause?.isTypeOnly ?? false;
|
|
412
|
+
const specifiers = [];
|
|
413
|
+
if (stmt.importClause) {
|
|
414
|
+
if (stmt.importClause.name) {
|
|
415
|
+
specifiers.push(stmt.importClause.name.text);
|
|
416
|
+
}
|
|
417
|
+
const namedBindings = stmt.importClause.namedBindings;
|
|
418
|
+
if (namedBindings) {
|
|
419
|
+
if (ts2__default.default.isNamedImports(namedBindings)) {
|
|
420
|
+
for (const element of namedBindings.elements) {
|
|
421
|
+
specifiers.push(element.name.text);
|
|
422
|
+
}
|
|
423
|
+
} else if (ts2__default.default.isNamespaceImport(namedBindings)) {
|
|
424
|
+
specifiers.push(namedBindings.name.text);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
imports.push({ source, specifiers, isTypeOnly });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return imports;
|
|
432
|
+
}
|
|
433
|
+
function extractExports(sourceFile) {
|
|
434
|
+
const exports$1 = [];
|
|
435
|
+
for (const stmt of sourceFile.statements) {
|
|
436
|
+
if (hasExportModifier(stmt)) {
|
|
437
|
+
if (ts2__default.default.isFunctionDeclaration(stmt) && stmt.name) {
|
|
438
|
+
exports$1.push(stmt.name.text);
|
|
439
|
+
} else if (ts2__default.default.isClassDeclaration(stmt) && stmt.name) {
|
|
440
|
+
exports$1.push(stmt.name.text);
|
|
441
|
+
} else if (ts2__default.default.isInterfaceDeclaration(stmt)) {
|
|
442
|
+
exports$1.push(stmt.name.text);
|
|
443
|
+
} else if (ts2__default.default.isTypeAliasDeclaration(stmt)) {
|
|
444
|
+
exports$1.push(stmt.name.text);
|
|
445
|
+
} else if (ts2__default.default.isEnumDeclaration(stmt)) {
|
|
446
|
+
exports$1.push(stmt.name.text);
|
|
447
|
+
} else if (ts2__default.default.isVariableStatement(stmt)) {
|
|
448
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
449
|
+
if (ts2__default.default.isIdentifier(decl.name)) {
|
|
450
|
+
exports$1.push(decl.name.text);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (ts2__default.default.isExportDeclaration(stmt) && stmt.exportClause && ts2__default.default.isNamedExports(stmt.exportClause)) {
|
|
456
|
+
for (const element of stmt.exportClause.elements) {
|
|
457
|
+
exports$1.push(element.name.text);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (ts2__default.default.isExportAssignment(stmt)) {
|
|
461
|
+
exports$1.push("default");
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return exports$1;
|
|
465
|
+
}
|
|
466
|
+
function hasExportModifier(node) {
|
|
467
|
+
const modifiers = ts2__default.default.canHaveModifiers(node) ? ts2__default.default.getModifiers(node) : void 0;
|
|
468
|
+
return modifiers?.some((m) => m.kind === ts2__default.default.SyntaxKind.ExportKeyword) ?? false;
|
|
469
|
+
}
|
|
470
|
+
function extractFunctions(sourceFile, filePath) {
|
|
471
|
+
const functions = [];
|
|
472
|
+
const visit = (node) => {
|
|
473
|
+
if (ts2__default.default.isFunctionDeclaration(node) && node.name) {
|
|
474
|
+
functions.push(buildFunctionInfo(node, node.name.text, sourceFile));
|
|
475
|
+
} else if (ts2__default.default.isMethodDeclaration(node) && ts2__default.default.isIdentifier(node.name)) {
|
|
476
|
+
functions.push(buildFunctionInfo(node, node.name.text, sourceFile));
|
|
477
|
+
} else if (ts2__default.default.isArrowFunction(node)) {
|
|
478
|
+
const parent = node.parent;
|
|
479
|
+
if (ts2__default.default.isVariableDeclaration(parent) && ts2__default.default.isIdentifier(parent.name)) {
|
|
480
|
+
functions.push(buildFunctionInfo(node, parent.name.text, sourceFile));
|
|
481
|
+
}
|
|
482
|
+
} else if (ts2__default.default.isFunctionExpression(node)) {
|
|
483
|
+
const parent = node.parent;
|
|
484
|
+
if (ts2__default.default.isVariableDeclaration(parent) && ts2__default.default.isIdentifier(parent.name)) {
|
|
485
|
+
functions.push(buildFunctionInfo(node, parent.name.text, sourceFile));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
ts2__default.default.forEachChild(node, visit);
|
|
489
|
+
};
|
|
490
|
+
ts2__default.default.forEachChild(sourceFile, visit);
|
|
491
|
+
return functions;
|
|
492
|
+
}
|
|
493
|
+
function buildFunctionInfo(node, name, sourceFile, _filePath) {
|
|
494
|
+
const startLine = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
|
495
|
+
const endLine = sourceFile.getLineAndCharacterOfPosition(node.getEnd()).line + 1;
|
|
496
|
+
const params = node.parameters.map((p) => ({
|
|
497
|
+
name: ts2__default.default.isIdentifier(p.name) ? p.name.text : p.name.getText(sourceFile),
|
|
498
|
+
type: p.type ? p.type.getText(sourceFile) : "any",
|
|
499
|
+
optional: !!p.questionToken || !!p.initializer
|
|
500
|
+
}));
|
|
501
|
+
const returnType = node.type ? node.type.getText(sourceFile) : "void";
|
|
502
|
+
const modifiers = ts2__default.default.canHaveModifiers(node) ? ts2__default.default.getModifiers(node) : void 0;
|
|
503
|
+
const isAsync = modifiers?.some((m) => m.kind === ts2__default.default.SyntaxKind.AsyncKeyword) ?? false;
|
|
504
|
+
return {
|
|
505
|
+
name,
|
|
506
|
+
startLine,
|
|
507
|
+
endLine,
|
|
508
|
+
params,
|
|
509
|
+
returnType,
|
|
510
|
+
isAsync,
|
|
511
|
+
node
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
function walkAST(node, visitors) {
|
|
515
|
+
const kindName = ts2__default.default.SyntaxKind[node.kind];
|
|
516
|
+
let visitor = kindName !== void 0 ? visitors[kindName] : void 0;
|
|
517
|
+
if (!visitor) {
|
|
518
|
+
for (const key of Object.keys(visitors)) {
|
|
519
|
+
if (ts2__default.default.SyntaxKind[key] === node.kind) {
|
|
520
|
+
visitor = visitors[key];
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (visitor) {
|
|
526
|
+
visitor(node);
|
|
527
|
+
}
|
|
528
|
+
ts2__default.default.forEachChild(node, (child) => walkAST(child, visitors));
|
|
529
|
+
}
|
|
530
|
+
function calculateComplexity(node) {
|
|
531
|
+
let complexity = 1;
|
|
532
|
+
const visit = (child) => {
|
|
533
|
+
switch (child.kind) {
|
|
534
|
+
case ts2__default.default.SyntaxKind.IfStatement:
|
|
535
|
+
case ts2__default.default.SyntaxKind.ForStatement:
|
|
536
|
+
case ts2__default.default.SyntaxKind.ForInStatement:
|
|
537
|
+
case ts2__default.default.SyntaxKind.ForOfStatement:
|
|
538
|
+
case ts2__default.default.SyntaxKind.WhileStatement:
|
|
539
|
+
case ts2__default.default.SyntaxKind.DoStatement:
|
|
540
|
+
case ts2__default.default.SyntaxKind.CatchClause:
|
|
541
|
+
case ts2__default.default.SyntaxKind.ConditionalExpression:
|
|
542
|
+
complexity++;
|
|
543
|
+
break;
|
|
544
|
+
case ts2__default.default.SyntaxKind.CaseClause:
|
|
545
|
+
complexity++;
|
|
546
|
+
break;
|
|
547
|
+
case ts2__default.default.SyntaxKind.BinaryExpression: {
|
|
548
|
+
const binExpr = child;
|
|
549
|
+
if (binExpr.operatorToken.kind === ts2__default.default.SyntaxKind.AmpersandAmpersandToken || binExpr.operatorToken.kind === ts2__default.default.SyntaxKind.BarBarToken || binExpr.operatorToken.kind === ts2__default.default.SyntaxKind.QuestionQuestionToken) {
|
|
550
|
+
complexity++;
|
|
551
|
+
}
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
ts2__default.default.forEachChild(child, visit);
|
|
556
|
+
};
|
|
557
|
+
ts2__default.default.forEachChild(node, visit);
|
|
558
|
+
return complexity;
|
|
559
|
+
}
|
|
560
|
+
function countLOC(sourceFile) {
|
|
561
|
+
const text = sourceFile.getFullText();
|
|
562
|
+
const lines = text.split("\n");
|
|
563
|
+
let count = 0;
|
|
564
|
+
for (const line of lines) {
|
|
565
|
+
const trimmed = line.trim();
|
|
566
|
+
if (trimmed.length > 0 && !trimmed.startsWith("//") && !trimmed.startsWith("/*") && !trimmed.startsWith("*")) {
|
|
567
|
+
count++;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return count;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// src/utils/glob.ts
|
|
574
|
+
function globToRegex(pattern) {
|
|
575
|
+
let result = "";
|
|
576
|
+
let i = 0;
|
|
577
|
+
const len = pattern.length;
|
|
578
|
+
while (i < len) {
|
|
579
|
+
const c = pattern[i];
|
|
580
|
+
if (c === "*") {
|
|
581
|
+
if (pattern[i + 1] === "*") {
|
|
582
|
+
if (pattern[i + 2] === "/") {
|
|
583
|
+
result += "(?:.+/)?";
|
|
584
|
+
i += 3;
|
|
585
|
+
} else {
|
|
586
|
+
result += ".*";
|
|
587
|
+
i += 2;
|
|
588
|
+
}
|
|
589
|
+
} else {
|
|
590
|
+
result += "[^/]*";
|
|
591
|
+
i++;
|
|
592
|
+
}
|
|
593
|
+
} else if (c === "?") {
|
|
594
|
+
result += "[^/]";
|
|
595
|
+
i++;
|
|
596
|
+
} else if (c === "." || c === "(" || c === ")" || c === "{" || c === "}" || c === "[" || c === "]" || c === "+" || c === "^" || c === "$" || c === "|" || c === "\\") {
|
|
597
|
+
result += "\\" + c;
|
|
598
|
+
i++;
|
|
599
|
+
} else {
|
|
600
|
+
result += c;
|
|
601
|
+
i++;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return new RegExp(`^${result}$`);
|
|
605
|
+
}
|
|
606
|
+
function globMatch(filePath, pattern) {
|
|
607
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
608
|
+
const normalizedPattern = pattern.replace(/\\/g, "/");
|
|
609
|
+
const regex = globToRegex(normalizedPattern);
|
|
610
|
+
return regex.test(normalized);
|
|
611
|
+
}
|
|
612
|
+
function globMatchAny(filePath, patterns) {
|
|
613
|
+
return patterns.some((p) => globMatch(filePath, p));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// src/graph/builder.ts
|
|
617
|
+
function buildGraph(rootDir, tsconfigPath, include, exclude, layers = ["controller", "service", "repository", "util"]) {
|
|
618
|
+
const program = createTSProgram(rootDir, tsconfigPath);
|
|
619
|
+
program.getTypeChecker();
|
|
620
|
+
const sourceFilePaths = getSourceFiles(program);
|
|
621
|
+
const graph = {
|
|
622
|
+
files: /* @__PURE__ */ new Map(),
|
|
623
|
+
symbols: /* @__PURE__ */ new Map(),
|
|
624
|
+
edges: [],
|
|
625
|
+
layers: buildLayerDefinitions(layers),
|
|
626
|
+
patterns: [],
|
|
627
|
+
dependencies: { adjacency: /* @__PURE__ */ new Map() }
|
|
628
|
+
};
|
|
629
|
+
const filteredPaths = sourceFilePaths.filter((fp) => {
|
|
630
|
+
const rel = relativePath(rootDir, fp);
|
|
631
|
+
const included = include.length === 0 || globMatchAny(rel, include);
|
|
632
|
+
const excluded = exclude.length > 0 && globMatchAny(rel, exclude);
|
|
633
|
+
return included && !excluded;
|
|
634
|
+
});
|
|
635
|
+
for (const filePath of filteredPaths) {
|
|
636
|
+
const sourceFile = program.getSourceFile(filePath);
|
|
637
|
+
if (!sourceFile) continue;
|
|
638
|
+
const relPath = relativePath(rootDir, filePath);
|
|
639
|
+
processFile(graph, sourceFile, relPath);
|
|
640
|
+
}
|
|
641
|
+
buildDependencyGraph(graph);
|
|
642
|
+
return graph;
|
|
643
|
+
}
|
|
644
|
+
function processFile(graph, sourceFile, relPath, rootDir, _checker) {
|
|
645
|
+
const imports = extractImports(sourceFile);
|
|
646
|
+
const exports$1 = extractExports(sourceFile);
|
|
647
|
+
const rawFunctions = extractFunctions(sourceFile);
|
|
648
|
+
const role = detectFileRole(relPath);
|
|
649
|
+
const layer = detectLayer(relPath);
|
|
650
|
+
const loc = countLOC(sourceFile);
|
|
651
|
+
const functions = rawFunctions.map((fn) => ({
|
|
652
|
+
name: fn.name,
|
|
653
|
+
file: relPath,
|
|
654
|
+
startLine: fn.startLine,
|
|
655
|
+
endLine: fn.endLine,
|
|
656
|
+
params: fn.params,
|
|
657
|
+
returnType: fn.returnType,
|
|
658
|
+
complexity: calculateComplexity(fn.node),
|
|
659
|
+
isAsync: fn.isAsync,
|
|
660
|
+
hasSideEffects: false,
|
|
661
|
+
issues: []
|
|
662
|
+
}));
|
|
663
|
+
const fileComplexity = functions.reduce((sum, fn) => sum + fn.complexity, 0);
|
|
664
|
+
const fileNode = {
|
|
665
|
+
path: relPath,
|
|
666
|
+
role,
|
|
667
|
+
layer,
|
|
668
|
+
exports: exports$1,
|
|
669
|
+
imports,
|
|
670
|
+
complexity: fileComplexity,
|
|
671
|
+
loc,
|
|
672
|
+
functions
|
|
673
|
+
};
|
|
674
|
+
graph.files.set(relPath, fileNode);
|
|
675
|
+
for (const exportName of exports$1) {
|
|
676
|
+
const symbolNode = {
|
|
677
|
+
name: exportName,
|
|
678
|
+
kind: "variable",
|
|
679
|
+
file: relPath,
|
|
680
|
+
usedBy: [],
|
|
681
|
+
dependsOn: [],
|
|
682
|
+
isPublicAPI: false
|
|
683
|
+
};
|
|
684
|
+
graph.symbols.set(`${relPath}:${exportName}`, symbolNode);
|
|
685
|
+
}
|
|
686
|
+
for (const imp of imports) {
|
|
687
|
+
const resolvedPath = resolveImportPath(relPath, imp.source);
|
|
688
|
+
if (resolvedPath) {
|
|
689
|
+
graph.edges.push({
|
|
690
|
+
from: relPath,
|
|
691
|
+
to: resolvedPath,
|
|
692
|
+
specifiers: imp.specifiers,
|
|
693
|
+
isTypeOnly: imp.isTypeOnly
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
function detectFileRole(filePath) {
|
|
699
|
+
const lower = filePath.toLowerCase();
|
|
700
|
+
if (/\.(test|spec)\.(ts|tsx|js|jsx)$/.test(lower) || lower.includes("/tests/") || lower.includes("/__tests__/")) {
|
|
701
|
+
return "test";
|
|
702
|
+
}
|
|
703
|
+
if (lower.includes("/controller") || lower.includes(".controller.")) return "controller";
|
|
704
|
+
if (lower.includes("/service") || lower.includes(".service.")) return "service";
|
|
705
|
+
if (lower.includes("/repositor") || lower.includes(".repository.") || lower.includes(".repo.")) return "repository";
|
|
706
|
+
if (lower.includes("/util") || lower.includes("/helper") || lower.includes(".util.") || lower.includes(".helper.")) return "util";
|
|
707
|
+
if (lower.includes("/type") || lower.endsWith(".d.ts") || lower.includes(".types.")) return "type";
|
|
708
|
+
if (lower.includes("/config") || lower.includes(".config.")) return "config";
|
|
709
|
+
return "unknown";
|
|
710
|
+
}
|
|
711
|
+
function detectLayer(filePath) {
|
|
712
|
+
const lower = filePath.toLowerCase();
|
|
713
|
+
if (lower.includes("/controller")) return "controller";
|
|
714
|
+
if (lower.includes("/service")) return "service";
|
|
715
|
+
if (lower.includes("/repositor")) return "repository";
|
|
716
|
+
if (lower.includes("/util") || lower.includes("/helper")) return "util";
|
|
717
|
+
if (lower.includes("/middleware")) return "middleware";
|
|
718
|
+
if (lower.includes("/model")) return "model";
|
|
719
|
+
return "unknown";
|
|
720
|
+
}
|
|
721
|
+
function buildLayerDefinitions(layers) {
|
|
722
|
+
return layers.map((name, index) => ({
|
|
723
|
+
name,
|
|
724
|
+
order: index,
|
|
725
|
+
patterns: [`**/${name}s/**`, `**/${name}/**`, `**/*.${name}.*`]
|
|
726
|
+
}));
|
|
727
|
+
}
|
|
728
|
+
function resolveImportPath(fromFile, importSource, _rootDir) {
|
|
729
|
+
if (!importSource.startsWith(".") && !importSource.startsWith("/")) {
|
|
730
|
+
return void 0;
|
|
731
|
+
}
|
|
732
|
+
const fromDir = path8__namespace.dirname(fromFile);
|
|
733
|
+
let resolved = path8__namespace.posix.join(fromDir, importSource);
|
|
734
|
+
if (!resolved.endsWith(".ts") && !resolved.endsWith(".tsx") && !resolved.endsWith(".js")) {
|
|
735
|
+
resolved = resolved + ".ts";
|
|
736
|
+
}
|
|
737
|
+
return resolved.replace(/\\/g, "/");
|
|
738
|
+
}
|
|
739
|
+
function buildDependencyGraph(graph) {
|
|
740
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
741
|
+
for (const filePath of graph.files.keys()) {
|
|
742
|
+
adjacency.set(filePath, /* @__PURE__ */ new Set());
|
|
743
|
+
}
|
|
744
|
+
for (const edge of graph.edges) {
|
|
745
|
+
const deps = adjacency.get(edge.from);
|
|
746
|
+
if (deps) {
|
|
747
|
+
deps.add(edge.to);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
for (const edge of graph.edges) {
|
|
751
|
+
for (const spec of edge.specifiers) {
|
|
752
|
+
const symbolKey = `${edge.to}:${spec}`;
|
|
753
|
+
const symbol = graph.symbols.get(symbolKey);
|
|
754
|
+
if (symbol) {
|
|
755
|
+
symbol.usedBy.push(edge.from);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
graph.dependencies = { adjacency };
|
|
760
|
+
}
|
|
761
|
+
var GRAPH_VERSION = "1.0.0";
|
|
762
|
+
function serializeGraph(graph) {
|
|
763
|
+
return {
|
|
764
|
+
version: GRAPH_VERSION,
|
|
765
|
+
timestamp: Date.now(),
|
|
766
|
+
files: Array.from(graph.files.entries()),
|
|
767
|
+
symbols: Array.from(graph.symbols.entries()),
|
|
768
|
+
edges: graph.edges,
|
|
769
|
+
layers: graph.layers,
|
|
770
|
+
patterns: graph.patterns,
|
|
771
|
+
adjacency: Array.from(graph.dependencies.adjacency.entries()).map(
|
|
772
|
+
([key, value]) => [key, Array.from(value)]
|
|
773
|
+
)
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
function deserializeGraph(data) {
|
|
777
|
+
if (data.version !== GRAPH_VERSION) {
|
|
778
|
+
throw new GraphError(`Graph cache version mismatch: expected ${GRAPH_VERSION}, got ${data.version}`);
|
|
779
|
+
}
|
|
780
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
781
|
+
for (const [key, values] of data.adjacency) {
|
|
782
|
+
adjacency.set(key, new Set(values));
|
|
783
|
+
}
|
|
784
|
+
return {
|
|
785
|
+
files: new Map(data.files),
|
|
786
|
+
symbols: new Map(data.symbols),
|
|
787
|
+
edges: data.edges,
|
|
788
|
+
layers: data.layers,
|
|
789
|
+
patterns: data.patterns,
|
|
790
|
+
dependencies: { adjacency }
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
function saveGraphCache(rootDir, graph) {
|
|
794
|
+
const cachePath = path8__namespace.join(rootDir, ".codeguardian", "graph.json");
|
|
795
|
+
const serialized = serializeGraph(graph);
|
|
796
|
+
writeJsonSync(cachePath, serialized);
|
|
797
|
+
}
|
|
798
|
+
function loadGraphCache(rootDir) {
|
|
799
|
+
const cachePath = path8__namespace.join(rootDir, ".codeguardian", "graph.json");
|
|
800
|
+
if (!fileExists(cachePath)) {
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
try {
|
|
804
|
+
const data = readJsonSync(cachePath);
|
|
805
|
+
return deserializeGraph(data);
|
|
806
|
+
} catch {
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
function updateGraphIncremental(graph, changedFiles, rootDir, program) {
|
|
811
|
+
const checker = program.getTypeChecker();
|
|
812
|
+
const affectedFiles = /* @__PURE__ */ new Set();
|
|
813
|
+
for (const filePath of changedFiles) {
|
|
814
|
+
const relPath = filePath.startsWith(rootDir) ? relativePath(rootDir, filePath) : filePath;
|
|
815
|
+
const dependents = findDependents(graph, relPath);
|
|
816
|
+
for (const dep of dependents) {
|
|
817
|
+
affectedFiles.add(dep);
|
|
818
|
+
}
|
|
819
|
+
removeFileFromGraph(graph, relPath);
|
|
820
|
+
const absolutePath = path8__namespace.resolve(rootDir, relPath);
|
|
821
|
+
try {
|
|
822
|
+
const sourceFile = program.getSourceFile(absolutePath) ?? parseFile(absolutePath);
|
|
823
|
+
processFile(graph, sourceFile, relPath, rootDir, checker);
|
|
824
|
+
} catch {
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
affectedFiles.add(relPath);
|
|
828
|
+
}
|
|
829
|
+
rebuildAdjacency(graph);
|
|
830
|
+
return {
|
|
831
|
+
changedFiles,
|
|
832
|
+
affectedFiles: Array.from(affectedFiles),
|
|
833
|
+
graph
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
function removeFileFromGraph(graph, filePath) {
|
|
837
|
+
graph.files.delete(filePath);
|
|
838
|
+
const symbolKeysToDelete = [];
|
|
839
|
+
for (const [key, symbol] of graph.symbols) {
|
|
840
|
+
if (symbol.file === filePath) {
|
|
841
|
+
symbolKeysToDelete.push(key);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
for (const key of symbolKeysToDelete) {
|
|
845
|
+
graph.symbols.delete(key);
|
|
846
|
+
}
|
|
847
|
+
graph.edges = graph.edges.filter(
|
|
848
|
+
(edge) => edge.from !== filePath && edge.to !== filePath
|
|
849
|
+
);
|
|
850
|
+
graph.dependencies.adjacency.delete(filePath);
|
|
851
|
+
for (const deps of graph.dependencies.adjacency.values()) {
|
|
852
|
+
deps.delete(filePath);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
function findDependents(graph, filePath) {
|
|
856
|
+
const dependents = [];
|
|
857
|
+
for (const [file, deps] of graph.dependencies.adjacency) {
|
|
858
|
+
if (deps.has(filePath)) {
|
|
859
|
+
dependents.push(file);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return dependents;
|
|
863
|
+
}
|
|
864
|
+
function rebuildAdjacency(graph) {
|
|
865
|
+
const adjacency = /* @__PURE__ */ new Map();
|
|
866
|
+
for (const filePath of graph.files.keys()) {
|
|
867
|
+
adjacency.set(filePath, /* @__PURE__ */ new Set());
|
|
868
|
+
}
|
|
869
|
+
for (const edge of graph.edges) {
|
|
870
|
+
const deps = adjacency.get(edge.from);
|
|
871
|
+
if (deps) {
|
|
872
|
+
deps.add(edge.to);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
graph.dependencies.adjacency = adjacency;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// src/graph/query.ts
|
|
879
|
+
function getFile(graph, filePath) {
|
|
880
|
+
return graph.files.get(filePath);
|
|
881
|
+
}
|
|
882
|
+
function getSymbol(graph, name) {
|
|
883
|
+
for (const [, symbol] of graph.symbols) {
|
|
884
|
+
if (symbol.name === name) {
|
|
885
|
+
return symbol;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return void 0;
|
|
889
|
+
}
|
|
890
|
+
function getDependencies(graph, filePath) {
|
|
891
|
+
const deps = graph.dependencies.adjacency.get(filePath);
|
|
892
|
+
return deps ? Array.from(deps) : [];
|
|
893
|
+
}
|
|
894
|
+
function getDependents(graph, filePath) {
|
|
895
|
+
const dependents = [];
|
|
896
|
+
for (const [file, deps] of graph.dependencies.adjacency) {
|
|
897
|
+
if (deps.has(filePath)) {
|
|
898
|
+
dependents.push(file);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return dependents;
|
|
902
|
+
}
|
|
903
|
+
function findCircularDeps(graph) {
|
|
904
|
+
const cycles = [];
|
|
905
|
+
const visited = /* @__PURE__ */ new Set();
|
|
906
|
+
const inStack = /* @__PURE__ */ new Set();
|
|
907
|
+
const stack = [];
|
|
908
|
+
const dfs = (node) => {
|
|
909
|
+
if (inStack.has(node)) {
|
|
910
|
+
const cycleStart = stack.indexOf(node);
|
|
911
|
+
if (cycleStart !== -1) {
|
|
912
|
+
const cycle = [...stack.slice(cycleStart), node];
|
|
913
|
+
cycles.push(cycle);
|
|
914
|
+
}
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
if (visited.has(node)) return;
|
|
918
|
+
visited.add(node);
|
|
919
|
+
inStack.add(node);
|
|
920
|
+
stack.push(node);
|
|
921
|
+
const deps = graph.dependencies.adjacency.get(node);
|
|
922
|
+
if (deps) {
|
|
923
|
+
for (const dep of deps) {
|
|
924
|
+
dfs(dep);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
stack.pop();
|
|
928
|
+
inStack.delete(node);
|
|
929
|
+
};
|
|
930
|
+
for (const node of graph.dependencies.adjacency.keys()) {
|
|
931
|
+
dfs(node);
|
|
932
|
+
}
|
|
933
|
+
return cycles;
|
|
934
|
+
}
|
|
935
|
+
function getGraphStats(graph) {
|
|
936
|
+
let totalFunctions = 0;
|
|
937
|
+
let totalLOC = 0;
|
|
938
|
+
let totalComplexity = 0;
|
|
939
|
+
const filesByRole = {};
|
|
940
|
+
const filesByLayer = {};
|
|
941
|
+
for (const file of graph.files.values()) {
|
|
942
|
+
totalFunctions += file.functions.length;
|
|
943
|
+
totalLOC += file.loc;
|
|
944
|
+
totalComplexity += file.complexity;
|
|
945
|
+
filesByRole[file.role] = (filesByRole[file.role] ?? 0) + 1;
|
|
946
|
+
filesByLayer[file.layer] = (filesByLayer[file.layer] ?? 0) + 1;
|
|
947
|
+
}
|
|
948
|
+
const fileCount = graph.files.size;
|
|
949
|
+
return {
|
|
950
|
+
totalFiles: fileCount,
|
|
951
|
+
totalSymbols: graph.symbols.size,
|
|
952
|
+
totalEdges: graph.edges.length,
|
|
953
|
+
totalFunctions,
|
|
954
|
+
totalLOC,
|
|
955
|
+
avgComplexity: fileCount > 0 ? totalComplexity / fileCount : 0,
|
|
956
|
+
filesByRole,
|
|
957
|
+
filesByLayer
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// src/rules/context.ts
|
|
962
|
+
function createRuleContext(file, ast, graph, program, checker, pluginConfig = {}) {
|
|
963
|
+
return {
|
|
964
|
+
file,
|
|
965
|
+
ast,
|
|
966
|
+
graph,
|
|
967
|
+
program,
|
|
968
|
+
checker,
|
|
969
|
+
walk(node, visitors) {
|
|
970
|
+
walkAST(node, visitors);
|
|
971
|
+
},
|
|
972
|
+
isCallTo(node, name) {
|
|
973
|
+
return isCallTo(node, name);
|
|
974
|
+
},
|
|
975
|
+
isConsoleCall(node, method) {
|
|
976
|
+
return isConsoleCall(node, method);
|
|
977
|
+
},
|
|
978
|
+
getTypeString(node) {
|
|
979
|
+
return getTypeString(node, checker);
|
|
980
|
+
},
|
|
981
|
+
hasStringConcat(node) {
|
|
982
|
+
return hasStringConcat(node);
|
|
983
|
+
},
|
|
984
|
+
getImports() {
|
|
985
|
+
return extractImports(ast);
|
|
986
|
+
},
|
|
987
|
+
isExternallyUsed(symbolName) {
|
|
988
|
+
const symbolKey = `${file.path}:${symbolName}`;
|
|
989
|
+
const symbol = graph.symbols.get(symbolKey);
|
|
990
|
+
if (!symbol) return false;
|
|
991
|
+
return symbol.usedBy.length > 0;
|
|
992
|
+
},
|
|
993
|
+
config: pluginConfig
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// src/rules/suppression.ts
|
|
998
|
+
function parseSuppressions(sourceFile) {
|
|
999
|
+
const directives = [];
|
|
1000
|
+
const text = sourceFile.getFullText();
|
|
1001
|
+
const lines = text.split("\n");
|
|
1002
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1003
|
+
const line = lines[i].trim();
|
|
1004
|
+
const match = line.match(
|
|
1005
|
+
/\/\/\s*codeguardian-(disable-next-line|disable|enable)\s+(.*)/
|
|
1006
|
+
);
|
|
1007
|
+
if (!match) continue;
|
|
1008
|
+
const type = match[1];
|
|
1009
|
+
const rest = match[2].trim();
|
|
1010
|
+
const reasonSplit = rest.split("--");
|
|
1011
|
+
const rulesPart = reasonSplit[0].trim();
|
|
1012
|
+
const reason = reasonSplit[1]?.trim();
|
|
1013
|
+
const rules = rulesPart.split(/\s+/).filter((r) => r.length > 0);
|
|
1014
|
+
if (rules.length > 0) {
|
|
1015
|
+
directives.push({
|
|
1016
|
+
type,
|
|
1017
|
+
rules,
|
|
1018
|
+
reason,
|
|
1019
|
+
line: i + 1
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return directives;
|
|
1024
|
+
}
|
|
1025
|
+
function isRuleSuppressed(directives, ruleName, line) {
|
|
1026
|
+
let disabled = false;
|
|
1027
|
+
for (const directive of directives) {
|
|
1028
|
+
if (!directive.rules.includes(ruleName)) continue;
|
|
1029
|
+
if (directive.type === "disable-next-line") {
|
|
1030
|
+
if (directive.line + 1 === line) {
|
|
1031
|
+
return true;
|
|
1032
|
+
}
|
|
1033
|
+
} else if (directive.type === "disable") {
|
|
1034
|
+
if (directive.line <= line) {
|
|
1035
|
+
disabled = true;
|
|
1036
|
+
}
|
|
1037
|
+
} else if (directive.type === "enable") {
|
|
1038
|
+
if (directive.line <= line) {
|
|
1039
|
+
disabled = false;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return disabled;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// src/rules/engine.ts
|
|
1047
|
+
async function executeRules(graph, rules, targetFiles, program, config, ignoredRules, ignoredFiles, blockOn = ["critical", "error"], rootDir) {
|
|
1048
|
+
const startTime = Date.now();
|
|
1049
|
+
const parseStart = Date.now();
|
|
1050
|
+
const allFindings = [];
|
|
1051
|
+
let rulesExecuted = 0;
|
|
1052
|
+
const checker = program.getTypeChecker();
|
|
1053
|
+
const parseTime = Date.now() - parseStart;
|
|
1054
|
+
const analysisStart = Date.now();
|
|
1055
|
+
const activeRules = rules.filter((r) => !ignoredRules.includes(r.name));
|
|
1056
|
+
for (const filePath of targetFiles) {
|
|
1057
|
+
if (ignoredFiles.includes(filePath)) continue;
|
|
1058
|
+
const fileNode = graph.files.get(filePath);
|
|
1059
|
+
if (!fileNode) continue;
|
|
1060
|
+
const absPath = rootDir ? path8__namespace.resolve(rootDir, filePath) : filePath;
|
|
1061
|
+
const sourceFile = program.getSourceFile(absPath);
|
|
1062
|
+
if (!sourceFile) continue;
|
|
1063
|
+
const suppressions = parseSuppressions(sourceFile);
|
|
1064
|
+
for (const rule of activeRules) {
|
|
1065
|
+
const pluginName = rule.name.split("/")[0] ?? "";
|
|
1066
|
+
const pluginConfig = config[pluginName] ?? {};
|
|
1067
|
+
const context = createRuleContext(
|
|
1068
|
+
fileNode,
|
|
1069
|
+
sourceFile,
|
|
1070
|
+
graph,
|
|
1071
|
+
program,
|
|
1072
|
+
checker,
|
|
1073
|
+
pluginConfig
|
|
1074
|
+
);
|
|
1075
|
+
try {
|
|
1076
|
+
const findings = await rule.check(context);
|
|
1077
|
+
rulesExecuted++;
|
|
1078
|
+
for (const finding of findings) {
|
|
1079
|
+
if (isRuleSuppressed(suppressions, rule.name, finding.line)) {
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
allFindings.push({
|
|
1083
|
+
...finding,
|
|
1084
|
+
rule: finding.rule ?? rule.name,
|
|
1085
|
+
severity: finding.severity ?? rule.severity
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
} catch (err) {
|
|
1089
|
+
allFindings.push({
|
|
1090
|
+
message: `Rule ${rule.name} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1091
|
+
file: filePath,
|
|
1092
|
+
line: 0,
|
|
1093
|
+
column: 0,
|
|
1094
|
+
rule: rule.name,
|
|
1095
|
+
severity: "warning"
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
const analysisTime = Date.now() - analysisStart;
|
|
1101
|
+
const duration = Date.now() - startTime;
|
|
1102
|
+
const bySeverity = {
|
|
1103
|
+
critical: [],
|
|
1104
|
+
error: [],
|
|
1105
|
+
warning: [],
|
|
1106
|
+
info: []
|
|
1107
|
+
};
|
|
1108
|
+
const byFile = {};
|
|
1109
|
+
for (const finding of allFindings) {
|
|
1110
|
+
const sev = finding.severity ?? "info";
|
|
1111
|
+
bySeverity[sev].push(finding);
|
|
1112
|
+
if (!byFile[finding.file]) {
|
|
1113
|
+
byFile[finding.file] = [];
|
|
1114
|
+
}
|
|
1115
|
+
byFile[finding.file].push(finding);
|
|
1116
|
+
}
|
|
1117
|
+
const blocked = allFindings.some(
|
|
1118
|
+
(f) => f.severity && blockOn.includes(f.severity)
|
|
1119
|
+
);
|
|
1120
|
+
const stats = {
|
|
1121
|
+
filesAnalyzed: targetFiles.length,
|
|
1122
|
+
rulesExecuted,
|
|
1123
|
+
duration,
|
|
1124
|
+
parseTime,
|
|
1125
|
+
analysisTime
|
|
1126
|
+
};
|
|
1127
|
+
return {
|
|
1128
|
+
findings: allFindings,
|
|
1129
|
+
stats,
|
|
1130
|
+
blocked,
|
|
1131
|
+
bySeverity,
|
|
1132
|
+
byFile
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// src/config/defaults.ts
|
|
1137
|
+
var DEFAULT_CONFIG = {
|
|
1138
|
+
rootDir: ".",
|
|
1139
|
+
tsconfig: "./tsconfig.json",
|
|
1140
|
+
include: ["src/**/*.ts"],
|
|
1141
|
+
exclude: ["**/*.test.ts", "**/*.spec.ts", "**/node_modules/**", "**/dist/**"],
|
|
1142
|
+
severity: {
|
|
1143
|
+
blockOn: ["critical", "error"],
|
|
1144
|
+
warnOn: ["warning"],
|
|
1145
|
+
ignoreBelow: "info"
|
|
1146
|
+
},
|
|
1147
|
+
plugins: {
|
|
1148
|
+
architecture: {
|
|
1149
|
+
enabled: true,
|
|
1150
|
+
layers: ["controller", "service", "repository", "util"],
|
|
1151
|
+
enforceDirection: true,
|
|
1152
|
+
maxFileLines: 300,
|
|
1153
|
+
maxFunctionLines: 50,
|
|
1154
|
+
maxFunctionComplexity: 15
|
|
1155
|
+
},
|
|
1156
|
+
security: {
|
|
1157
|
+
enabled: true,
|
|
1158
|
+
checkInjection: true,
|
|
1159
|
+
checkAuth: true,
|
|
1160
|
+
checkSecrets: true,
|
|
1161
|
+
checkXSS: true,
|
|
1162
|
+
checkCSRF: true
|
|
1163
|
+
},
|
|
1164
|
+
performance: {
|
|
1165
|
+
enabled: true,
|
|
1166
|
+
checkN1Queries: true,
|
|
1167
|
+
checkMemoryLeaks: true,
|
|
1168
|
+
checkAsyncPatterns: true,
|
|
1169
|
+
checkBundleSize: false
|
|
1170
|
+
},
|
|
1171
|
+
quality: {
|
|
1172
|
+
enabled: true,
|
|
1173
|
+
checkDeadCode: true,
|
|
1174
|
+
checkNaming: true,
|
|
1175
|
+
checkComplexity: true,
|
|
1176
|
+
maxCyclomaticComplexity: 15
|
|
1177
|
+
}
|
|
1178
|
+
},
|
|
1179
|
+
ignore: {
|
|
1180
|
+
rules: [],
|
|
1181
|
+
files: [],
|
|
1182
|
+
lines: {}
|
|
1183
|
+
}
|
|
1184
|
+
};
|
|
1185
|
+
function getDefaultConfigJSON() {
|
|
1186
|
+
const { rootDir: _r, tsconfig: _t, ...rest } = DEFAULT_CONFIG;
|
|
1187
|
+
return JSON.stringify(rest, null, 2);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// src/types.ts
|
|
1191
|
+
var SEVERITY_ORDER = [
|
|
1192
|
+
"critical",
|
|
1193
|
+
"error",
|
|
1194
|
+
"warning",
|
|
1195
|
+
"info"
|
|
1196
|
+
];
|
|
1197
|
+
|
|
1198
|
+
// src/config/validator.ts
|
|
1199
|
+
var VALID_SEVERITIES = new Set(SEVERITY_ORDER);
|
|
1200
|
+
function validateConfig(config) {
|
|
1201
|
+
if (config.severity) {
|
|
1202
|
+
if (config.severity.blockOn) {
|
|
1203
|
+
for (const s of config.severity.blockOn) {
|
|
1204
|
+
if (!VALID_SEVERITIES.has(s)) {
|
|
1205
|
+
throw new ConfigError(`Invalid severity in blockOn: "${s}"`, {
|
|
1206
|
+
field: "severity.blockOn",
|
|
1207
|
+
validValues: [...SEVERITY_ORDER]
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
if (config.severity.warnOn) {
|
|
1213
|
+
for (const s of config.severity.warnOn) {
|
|
1214
|
+
if (!VALID_SEVERITIES.has(s)) {
|
|
1215
|
+
throw new ConfigError(`Invalid severity in warnOn: "${s}"`, {
|
|
1216
|
+
field: "severity.warnOn",
|
|
1217
|
+
validValues: [...SEVERITY_ORDER]
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
if (config.severity.ignoreBelow !== void 0) {
|
|
1223
|
+
if (!VALID_SEVERITIES.has(config.severity.ignoreBelow)) {
|
|
1224
|
+
throw new ConfigError(`Invalid severity in ignoreBelow: "${config.severity.ignoreBelow}"`, {
|
|
1225
|
+
field: "severity.ignoreBelow",
|
|
1226
|
+
validValues: [...SEVERITY_ORDER]
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
if (config.include) {
|
|
1232
|
+
if (!Array.isArray(config.include)) {
|
|
1233
|
+
throw new ConfigError('"include" must be an array of glob patterns', {
|
|
1234
|
+
field: "include"
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
if (config.exclude) {
|
|
1239
|
+
if (!Array.isArray(config.exclude)) {
|
|
1240
|
+
throw new ConfigError('"exclude" must be an array of glob patterns', {
|
|
1241
|
+
field: "exclude"
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
if (config.plugins) {
|
|
1246
|
+
if (config.plugins.architecture) {
|
|
1247
|
+
const arch = config.plugins.architecture;
|
|
1248
|
+
if (arch.maxFileLines !== void 0 && (typeof arch.maxFileLines !== "number" || arch.maxFileLines < 1)) {
|
|
1249
|
+
throw new ConfigError("maxFileLines must be a positive number", {
|
|
1250
|
+
field: "plugins.architecture.maxFileLines"
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
if (arch.maxFunctionLines !== void 0 && (typeof arch.maxFunctionLines !== "number" || arch.maxFunctionLines < 1)) {
|
|
1254
|
+
throw new ConfigError("maxFunctionLines must be a positive number", {
|
|
1255
|
+
field: "plugins.architecture.maxFunctionLines"
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
if (arch.maxFunctionComplexity !== void 0 && (typeof arch.maxFunctionComplexity !== "number" || arch.maxFunctionComplexity < 1)) {
|
|
1259
|
+
throw new ConfigError("maxFunctionComplexity must be a positive number", {
|
|
1260
|
+
field: "plugins.architecture.maxFunctionComplexity"
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if (config.plugins.quality) {
|
|
1265
|
+
const q = config.plugins.quality;
|
|
1266
|
+
if (q.maxCyclomaticComplexity !== void 0 && (typeof q.maxCyclomaticComplexity !== "number" || q.maxCyclomaticComplexity < 1)) {
|
|
1267
|
+
throw new ConfigError("maxCyclomaticComplexity must be a positive number", {
|
|
1268
|
+
field: "plugins.quality.maxCyclomaticComplexity"
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// src/config/loader.ts
|
|
1276
|
+
function loadConfig(rootDir, configPathOrInline) {
|
|
1277
|
+
let fileConfig = {};
|
|
1278
|
+
if (typeof configPathOrInline === "object") {
|
|
1279
|
+
fileConfig = configPathOrInline;
|
|
1280
|
+
} else if (typeof configPathOrInline === "string") {
|
|
1281
|
+
const fullPath = path8__namespace.resolve(rootDir, configPathOrInline);
|
|
1282
|
+
if (fileExists(fullPath)) {
|
|
1283
|
+
fileConfig = readJsonSync(fullPath);
|
|
1284
|
+
} else {
|
|
1285
|
+
throw new ConfigError(`Config file not found: ${configPathOrInline}`, {
|
|
1286
|
+
path: fullPath
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
} else {
|
|
1290
|
+
const codeguardianPath = path8__namespace.resolve(rootDir, ".codeguardian.json");
|
|
1291
|
+
const packageJsonPath = path8__namespace.resolve(rootDir, "package.json");
|
|
1292
|
+
if (fileExists(codeguardianPath)) {
|
|
1293
|
+
fileConfig = readJsonSync(codeguardianPath);
|
|
1294
|
+
} else if (fileExists(packageJsonPath)) {
|
|
1295
|
+
const pkg = readJsonSync(packageJsonPath);
|
|
1296
|
+
if (pkg["codeguardian"] && typeof pkg["codeguardian"] === "object") {
|
|
1297
|
+
fileConfig = pkg["codeguardian"];
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
const config = mergeConfig(DEFAULT_CONFIG, fileConfig, rootDir);
|
|
1302
|
+
validateConfig(config);
|
|
1303
|
+
return config;
|
|
1304
|
+
}
|
|
1305
|
+
function mergeConfig(defaults, overrides, rootDir) {
|
|
1306
|
+
return {
|
|
1307
|
+
rootDir,
|
|
1308
|
+
tsconfig: overrides.tsconfig ?? defaults.tsconfig,
|
|
1309
|
+
include: overrides.include ?? defaults.include,
|
|
1310
|
+
exclude: overrides.exclude ?? defaults.exclude,
|
|
1311
|
+
severity: {
|
|
1312
|
+
...defaults.severity,
|
|
1313
|
+
...overrides.severity
|
|
1314
|
+
},
|
|
1315
|
+
plugins: {
|
|
1316
|
+
architecture: {
|
|
1317
|
+
...defaults.plugins.architecture,
|
|
1318
|
+
...overrides.plugins?.architecture
|
|
1319
|
+
},
|
|
1320
|
+
security: {
|
|
1321
|
+
...defaults.plugins.security,
|
|
1322
|
+
...overrides.plugins?.security
|
|
1323
|
+
},
|
|
1324
|
+
performance: {
|
|
1325
|
+
...defaults.plugins.performance,
|
|
1326
|
+
...overrides.plugins?.performance
|
|
1327
|
+
},
|
|
1328
|
+
quality: {
|
|
1329
|
+
...defaults.plugins.quality,
|
|
1330
|
+
...overrides.plugins?.quality
|
|
1331
|
+
},
|
|
1332
|
+
naming: overrides.plugins?.naming,
|
|
1333
|
+
api: overrides.plugins?.api,
|
|
1334
|
+
testGuard: overrides.plugins?.testGuard,
|
|
1335
|
+
depAudit: overrides.plugins?.depAudit
|
|
1336
|
+
},
|
|
1337
|
+
ignore: {
|
|
1338
|
+
rules: overrides.ignore?.rules ?? defaults.ignore.rules,
|
|
1339
|
+
files: overrides.ignore?.files ?? defaults.ignore.files,
|
|
1340
|
+
lines: overrides.ignore?.lines ?? defaults.ignore.lines
|
|
1341
|
+
}
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
function getStagedFiles(rootDir) {
|
|
1345
|
+
try {
|
|
1346
|
+
const output = child_process.execSync("git diff --cached --name-only", {
|
|
1347
|
+
cwd: rootDir,
|
|
1348
|
+
encoding: "utf-8",
|
|
1349
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1350
|
+
});
|
|
1351
|
+
return output.trim().split("\n").filter((line) => line.trim().length > 0);
|
|
1352
|
+
} catch (err) {
|
|
1353
|
+
throw new GitError("Failed to get staged files. Is this a git repository?", {
|
|
1354
|
+
rootDir,
|
|
1355
|
+
error: String(err)
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
function isGitRepo(rootDir) {
|
|
1360
|
+
try {
|
|
1361
|
+
child_process.execSync("git rev-parse --is-inside-work-tree", {
|
|
1362
|
+
cwd: rootDir,
|
|
1363
|
+
encoding: "utf-8",
|
|
1364
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1365
|
+
});
|
|
1366
|
+
return true;
|
|
1367
|
+
} catch {
|
|
1368
|
+
return false;
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// src/reporter/terminal.ts
|
|
1373
|
+
function formatTerminal(result, verbose = false) {
|
|
1374
|
+
const lines = [];
|
|
1375
|
+
lines.push("");
|
|
1376
|
+
lines.push(color.bold(" @oxog/codeguardian"));
|
|
1377
|
+
lines.push(color.dim(` Analyzed ${result.stats.filesAnalyzed} files in ${result.stats.duration}ms`));
|
|
1378
|
+
lines.push("");
|
|
1379
|
+
const findings = verbose ? result.findings : result.findings.filter((f) => f.severity !== "info");
|
|
1380
|
+
if (findings.length === 0) {
|
|
1381
|
+
lines.push(color.green(" \u2713 No issues found"));
|
|
1382
|
+
lines.push("");
|
|
1383
|
+
return lines.join("\n");
|
|
1384
|
+
}
|
|
1385
|
+
const sorted = [...findings].sort((a, b) => {
|
|
1386
|
+
const order = { critical: 0, error: 1, warning: 2, info: 3 };
|
|
1387
|
+
return (order[a.severity ?? "info"] ?? 3) - (order[b.severity ?? "info"] ?? 3);
|
|
1388
|
+
});
|
|
1389
|
+
for (const finding of sorted) {
|
|
1390
|
+
lines.push(formatFinding(finding));
|
|
1391
|
+
}
|
|
1392
|
+
lines.push("");
|
|
1393
|
+
lines.push(formatSummaryLine(result));
|
|
1394
|
+
lines.push("");
|
|
1395
|
+
return lines.join("\n");
|
|
1396
|
+
}
|
|
1397
|
+
function formatFinding(finding) {
|
|
1398
|
+
const lines = [];
|
|
1399
|
+
const sev = finding.severity ?? "info";
|
|
1400
|
+
const icon = getIcon(sev);
|
|
1401
|
+
const label = getSeverityLabel(sev);
|
|
1402
|
+
const location = `${finding.file}:${finding.line}`;
|
|
1403
|
+
lines.push(`${icon} ${label} ${color.white(location)}`);
|
|
1404
|
+
lines.push(` ${color.dim(`[${finding.rule ?? "unknown"}]`)} ${finding.message}`);
|
|
1405
|
+
if (finding.fix?.suggestion) {
|
|
1406
|
+
lines.push(` ${color.dim("\u2192")} ${color.dim(finding.fix.suggestion)}`);
|
|
1407
|
+
}
|
|
1408
|
+
lines.push("");
|
|
1409
|
+
return lines.join("\n");
|
|
1410
|
+
}
|
|
1411
|
+
function getIcon(severity) {
|
|
1412
|
+
switch (severity) {
|
|
1413
|
+
case "critical":
|
|
1414
|
+
case "error":
|
|
1415
|
+
return color.red("\u2717");
|
|
1416
|
+
case "warning":
|
|
1417
|
+
return color.yellow("\u26A0");
|
|
1418
|
+
case "info":
|
|
1419
|
+
return color.blue("\u2139");
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
function getSeverityLabel(severity) {
|
|
1423
|
+
switch (severity) {
|
|
1424
|
+
case "critical":
|
|
1425
|
+
return color.red(color.bold("CRITICAL"));
|
|
1426
|
+
case "error":
|
|
1427
|
+
return color.red(color.bold("ERROR "));
|
|
1428
|
+
case "warning":
|
|
1429
|
+
return color.yellow("WARNING ");
|
|
1430
|
+
case "info":
|
|
1431
|
+
return color.blue("INFO ");
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
function formatSummaryLine(result) {
|
|
1435
|
+
const criticalCount = result.bySeverity.critical.length;
|
|
1436
|
+
const errorCount = result.bySeverity.error.length;
|
|
1437
|
+
const warningCount = result.bySeverity.warning.length;
|
|
1438
|
+
const infoCount = result.bySeverity.info.length;
|
|
1439
|
+
const blockCount = criticalCount + errorCount;
|
|
1440
|
+
const parts = [];
|
|
1441
|
+
if (blockCount > 0) {
|
|
1442
|
+
parts.push(color.red(` ${blockCount} critical/error (commit blocked)`));
|
|
1443
|
+
}
|
|
1444
|
+
if (warningCount > 0) {
|
|
1445
|
+
parts.push(color.yellow(`${warningCount} warnings`));
|
|
1446
|
+
}
|
|
1447
|
+
if (infoCount > 0) {
|
|
1448
|
+
parts.push(color.dim(`${infoCount} info`));
|
|
1449
|
+
}
|
|
1450
|
+
const line = "\u2500".repeat(50);
|
|
1451
|
+
if (result.blocked) {
|
|
1452
|
+
return `${color.dim(line)}
|
|
1453
|
+
${parts.join(" \u2502 ")}
|
|
1454
|
+
${color.dim(line)}`;
|
|
1455
|
+
}
|
|
1456
|
+
if (parts.length === 0) {
|
|
1457
|
+
return `${color.dim(line)}
|
|
1458
|
+
${color.green(" \u2713 All clear")}
|
|
1459
|
+
${color.dim(line)}`;
|
|
1460
|
+
}
|
|
1461
|
+
return `${color.dim(line)}
|
|
1462
|
+
${parts.join(" \u2502 ")}
|
|
1463
|
+
${color.dim(line)}`;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// src/reporter/json.ts
|
|
1467
|
+
function formatJSON(result) {
|
|
1468
|
+
return JSON.stringify(
|
|
1469
|
+
{
|
|
1470
|
+
blocked: result.blocked,
|
|
1471
|
+
stats: result.stats,
|
|
1472
|
+
findings: result.findings.map((f) => ({
|
|
1473
|
+
rule: f.rule,
|
|
1474
|
+
severity: f.severity,
|
|
1475
|
+
message: f.message,
|
|
1476
|
+
file: f.file,
|
|
1477
|
+
line: f.line,
|
|
1478
|
+
column: f.column,
|
|
1479
|
+
fix: f.fix
|
|
1480
|
+
})),
|
|
1481
|
+
summary: {
|
|
1482
|
+
critical: result.bySeverity.critical.length,
|
|
1483
|
+
error: result.bySeverity.error.length,
|
|
1484
|
+
warning: result.bySeverity.warning.length,
|
|
1485
|
+
info: result.bySeverity.info.length,
|
|
1486
|
+
total: result.findings.length
|
|
1487
|
+
}
|
|
1488
|
+
},
|
|
1489
|
+
null,
|
|
1490
|
+
2
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
// src/reporter/sarif.ts
|
|
1495
|
+
function toSarifLevel(severity) {
|
|
1496
|
+
switch (severity) {
|
|
1497
|
+
case "critical":
|
|
1498
|
+
case "error":
|
|
1499
|
+
return "error";
|
|
1500
|
+
case "warning":
|
|
1501
|
+
return "warning";
|
|
1502
|
+
case "info":
|
|
1503
|
+
return "note";
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
function formatSARIF(result) {
|
|
1507
|
+
const rules = /* @__PURE__ */ new Map();
|
|
1508
|
+
for (const finding of result.findings) {
|
|
1509
|
+
if (finding.rule && !rules.has(finding.rule)) {
|
|
1510
|
+
rules.set(finding.rule, {
|
|
1511
|
+
name: finding.rule,
|
|
1512
|
+
description: finding.message,
|
|
1513
|
+
severity: finding.severity ?? "info"
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
const sarif = {
|
|
1518
|
+
$schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
|
|
1519
|
+
version: "2.1.0",
|
|
1520
|
+
runs: [
|
|
1521
|
+
{
|
|
1522
|
+
tool: {
|
|
1523
|
+
driver: {
|
|
1524
|
+
name: "@oxog/codeguardian",
|
|
1525
|
+
version: "1.0.0",
|
|
1526
|
+
informationUri: "https://codeguardian.oxog.dev",
|
|
1527
|
+
rules: Array.from(rules.values()).map((r) => ({
|
|
1528
|
+
id: r.name,
|
|
1529
|
+
shortDescription: { text: r.description },
|
|
1530
|
+
defaultConfiguration: {
|
|
1531
|
+
level: toSarifLevel(r.severity)
|
|
1532
|
+
}
|
|
1533
|
+
}))
|
|
1534
|
+
}
|
|
1535
|
+
},
|
|
1536
|
+
results: result.findings.map((f) => ({
|
|
1537
|
+
ruleId: f.rule ?? "unknown",
|
|
1538
|
+
level: toSarifLevel(f.severity ?? "info"),
|
|
1539
|
+
message: { text: f.message },
|
|
1540
|
+
locations: [
|
|
1541
|
+
{
|
|
1542
|
+
physicalLocation: {
|
|
1543
|
+
artifactLocation: { uri: f.file },
|
|
1544
|
+
region: {
|
|
1545
|
+
startLine: f.line,
|
|
1546
|
+
startColumn: f.column
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
],
|
|
1551
|
+
...f.fix ? {
|
|
1552
|
+
fixes: [
|
|
1553
|
+
{
|
|
1554
|
+
description: { text: f.fix.suggestion }
|
|
1555
|
+
}
|
|
1556
|
+
]
|
|
1557
|
+
} : {}
|
|
1558
|
+
}))
|
|
1559
|
+
}
|
|
1560
|
+
]
|
|
1561
|
+
};
|
|
1562
|
+
return JSON.stringify(sarif, null, 2);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// src/discovery/conventions.ts
|
|
1566
|
+
function discoverConventions(graph) {
|
|
1567
|
+
const patterns = [];
|
|
1568
|
+
patterns.push(...detectFileNamingPatterns(graph));
|
|
1569
|
+
patterns.push(...detectExportPatterns(graph));
|
|
1570
|
+
patterns.push(...detectImportDirectionPatterns(graph));
|
|
1571
|
+
patterns.push(...detectNamingConventions(graph));
|
|
1572
|
+
return patterns;
|
|
1573
|
+
}
|
|
1574
|
+
function detectFileNamingPatterns(graph) {
|
|
1575
|
+
const patterns = [];
|
|
1576
|
+
const dirFiles = /* @__PURE__ */ new Map();
|
|
1577
|
+
for (const [filePath] of graph.files) {
|
|
1578
|
+
const parts = filePath.split("/");
|
|
1579
|
+
const dir = parts.slice(0, -1).join("/");
|
|
1580
|
+
const file = parts[parts.length - 1] ?? "";
|
|
1581
|
+
if (!dirFiles.has(dir)) {
|
|
1582
|
+
dirFiles.set(dir, []);
|
|
1583
|
+
}
|
|
1584
|
+
dirFiles.get(dir).push(file);
|
|
1585
|
+
}
|
|
1586
|
+
for (const [dir, files] of dirFiles) {
|
|
1587
|
+
if (files.length < 2) continue;
|
|
1588
|
+
const suffixes = /* @__PURE__ */ new Map();
|
|
1589
|
+
for (const file of files) {
|
|
1590
|
+
const parts = file.split(".");
|
|
1591
|
+
if (parts.length >= 3) {
|
|
1592
|
+
const suffix = parts.slice(-2).join(".");
|
|
1593
|
+
if (!suffixes.has(suffix)) {
|
|
1594
|
+
suffixes.set(suffix, []);
|
|
1595
|
+
}
|
|
1596
|
+
suffixes.get(suffix).push(file);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
for (const [suffix, matchingFiles] of suffixes) {
|
|
1600
|
+
const ratio = matchingFiles.length / files.length;
|
|
1601
|
+
if (ratio >= 0.5 && matchingFiles.length >= 2) {
|
|
1602
|
+
patterns.push({
|
|
1603
|
+
type: "file-naming",
|
|
1604
|
+
description: `Files in ${dir}/ follow *.${suffix} naming pattern`,
|
|
1605
|
+
files: matchingFiles.map((f) => `${dir}/${f}`),
|
|
1606
|
+
confidence: Math.round(ratio * 100)
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
return patterns;
|
|
1612
|
+
}
|
|
1613
|
+
function detectExportPatterns(graph) {
|
|
1614
|
+
const patterns = [];
|
|
1615
|
+
const roleExports = /* @__PURE__ */ new Map();
|
|
1616
|
+
for (const [filePath, fileNode] of graph.files) {
|
|
1617
|
+
if (fileNode.role === "unknown" || fileNode.role === "test") continue;
|
|
1618
|
+
if (!roleExports.has(fileNode.role)) {
|
|
1619
|
+
roleExports.set(fileNode.role, { classes: 0, functions: 0, files: [] });
|
|
1620
|
+
}
|
|
1621
|
+
const entry = roleExports.get(fileNode.role);
|
|
1622
|
+
entry.files.push(filePath);
|
|
1623
|
+
for (const exp of fileNode.exports) {
|
|
1624
|
+
const symbol = graph.symbols.get(`${filePath}:${exp}`);
|
|
1625
|
+
if (symbol) {
|
|
1626
|
+
if (symbol.kind === "class") entry.classes++;
|
|
1627
|
+
if (symbol.kind === "function") entry.functions++;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
for (const [role, data] of roleExports) {
|
|
1632
|
+
if (data.files.length < 2) continue;
|
|
1633
|
+
if (data.classes > data.functions && data.classes >= 2) {
|
|
1634
|
+
patterns.push({
|
|
1635
|
+
type: "export-pattern",
|
|
1636
|
+
description: `${role} files primarily export classes`,
|
|
1637
|
+
files: data.files,
|
|
1638
|
+
confidence: Math.round(data.classes / (data.classes + data.functions) * 100)
|
|
1639
|
+
});
|
|
1640
|
+
} else if (data.functions > data.classes && data.functions >= 2) {
|
|
1641
|
+
patterns.push({
|
|
1642
|
+
type: "export-pattern",
|
|
1643
|
+
description: `${role} files primarily export functions`,
|
|
1644
|
+
files: data.files,
|
|
1645
|
+
confidence: Math.round(data.functions / (data.classes + data.functions) * 100)
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
return patterns;
|
|
1650
|
+
}
|
|
1651
|
+
function detectImportDirectionPatterns(graph) {
|
|
1652
|
+
const patterns = [];
|
|
1653
|
+
const layerImports = /* @__PURE__ */ new Map();
|
|
1654
|
+
for (const edge of graph.edges) {
|
|
1655
|
+
const fromFile = graph.files.get(edge.from);
|
|
1656
|
+
const toFile = graph.files.get(edge.to);
|
|
1657
|
+
if (!fromFile || !toFile) continue;
|
|
1658
|
+
const fromLayer = fromFile.layer;
|
|
1659
|
+
const toLayer = toFile.layer;
|
|
1660
|
+
if (fromLayer === "unknown" || toLayer === "unknown") continue;
|
|
1661
|
+
if (!layerImports.has(fromLayer)) {
|
|
1662
|
+
layerImports.set(fromLayer, /* @__PURE__ */ new Map());
|
|
1663
|
+
}
|
|
1664
|
+
const targets = layerImports.get(fromLayer);
|
|
1665
|
+
targets.set(toLayer, (targets.get(toLayer) ?? 0) + 1);
|
|
1666
|
+
}
|
|
1667
|
+
for (const [fromLayer, targets] of layerImports) {
|
|
1668
|
+
for (const [toLayer, count] of targets) {
|
|
1669
|
+
if (count >= 2) {
|
|
1670
|
+
patterns.push({
|
|
1671
|
+
type: "import-direction",
|
|
1672
|
+
description: `${fromLayer} \u2192 ${toLayer} (${count} imports)`,
|
|
1673
|
+
files: [],
|
|
1674
|
+
confidence: Math.min(100, count * 20)
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
return patterns;
|
|
1680
|
+
}
|
|
1681
|
+
function detectNamingConventions(graph) {
|
|
1682
|
+
const patterns = [];
|
|
1683
|
+
let camelCase = 0;
|
|
1684
|
+
let pascalCase = 0;
|
|
1685
|
+
let snakeCase = 0;
|
|
1686
|
+
for (const file of graph.files.values()) {
|
|
1687
|
+
for (const fn of file.functions) {
|
|
1688
|
+
if (/^[a-z][a-zA-Z0-9]*$/.test(fn.name)) camelCase++;
|
|
1689
|
+
else if (/^[A-Z][a-zA-Z0-9]*$/.test(fn.name)) pascalCase++;
|
|
1690
|
+
else if (/^[a-z][a-z0-9_]*$/.test(fn.name)) snakeCase++;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
const total = camelCase + pascalCase + snakeCase;
|
|
1694
|
+
if (total >= 5) {
|
|
1695
|
+
if (camelCase / total >= 0.7) {
|
|
1696
|
+
patterns.push({
|
|
1697
|
+
type: "naming-convention",
|
|
1698
|
+
description: "Functions use camelCase naming",
|
|
1699
|
+
files: [],
|
|
1700
|
+
confidence: Math.round(camelCase / total * 100)
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
return patterns;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
// src/plugins/core/architecture.ts
|
|
1708
|
+
function architecturePlugin(config = {}) {
|
|
1709
|
+
const fullConfig = {
|
|
1710
|
+
layers: ["controller", "service", "repository", "util"],
|
|
1711
|
+
enforceDirection: true,
|
|
1712
|
+
maxFileLines: 300,
|
|
1713
|
+
maxFunctionLines: 50,
|
|
1714
|
+
...config
|
|
1715
|
+
};
|
|
1716
|
+
return {
|
|
1717
|
+
name: "architecture",
|
|
1718
|
+
version: "1.0.0",
|
|
1719
|
+
install(kernel) {
|
|
1720
|
+
kernel.registerRule({
|
|
1721
|
+
name: "architecture/layer-violation",
|
|
1722
|
+
severity: "error",
|
|
1723
|
+
description: "Detects when a lower layer imports from a higher layer",
|
|
1724
|
+
category: "architecture",
|
|
1725
|
+
check(context) {
|
|
1726
|
+
const findings = [];
|
|
1727
|
+
const layers = fullConfig.layers;
|
|
1728
|
+
if (!fullConfig.enforceDirection || layers.length === 0) return findings;
|
|
1729
|
+
const fileLayer = context.file.layer;
|
|
1730
|
+
const fileLayerIndex = layers.indexOf(fileLayer);
|
|
1731
|
+
if (fileLayerIndex === -1) return findings;
|
|
1732
|
+
for (const imp of context.file.imports) {
|
|
1733
|
+
const targetFile = context.graph.files.get(resolveImport(context.file.path, imp.source));
|
|
1734
|
+
if (!targetFile) continue;
|
|
1735
|
+
const targetLayerIndex = layers.indexOf(targetFile.layer);
|
|
1736
|
+
if (targetLayerIndex === -1) continue;
|
|
1737
|
+
if (fileLayerIndex <= targetLayerIndex) ; else {
|
|
1738
|
+
findings.push({
|
|
1739
|
+
message: `${fileLayer} layer importing from ${targetFile.layer} layer (violates layer direction)`,
|
|
1740
|
+
file: context.file.path,
|
|
1741
|
+
line: 1,
|
|
1742
|
+
column: 1,
|
|
1743
|
+
fix: {
|
|
1744
|
+
suggestion: `${capitalize(fileLayer)}s should not depend on ${targetFile.layer}s. Invert the dependency.`
|
|
1745
|
+
}
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
return findings;
|
|
1750
|
+
}
|
|
1751
|
+
});
|
|
1752
|
+
kernel.registerRule({
|
|
1753
|
+
name: "architecture/circular-dependency",
|
|
1754
|
+
severity: "error",
|
|
1755
|
+
description: "Detects circular import chains",
|
|
1756
|
+
category: "architecture",
|
|
1757
|
+
check(context) {
|
|
1758
|
+
const findings = [];
|
|
1759
|
+
const cycles = findCircularDeps(context.graph);
|
|
1760
|
+
for (const cycle of cycles) {
|
|
1761
|
+
if (cycle.includes(context.file.path)) {
|
|
1762
|
+
findings.push({
|
|
1763
|
+
message: `Circular dependency detected: ${cycle.join(" \u2192 ")}`,
|
|
1764
|
+
file: context.file.path,
|
|
1765
|
+
line: 1,
|
|
1766
|
+
column: 1,
|
|
1767
|
+
fix: {
|
|
1768
|
+
suggestion: "Break the cycle by extracting shared types or using dependency injection."
|
|
1769
|
+
}
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
return findings;
|
|
1774
|
+
}
|
|
1775
|
+
});
|
|
1776
|
+
kernel.registerRule({
|
|
1777
|
+
name: "architecture/file-role-mismatch",
|
|
1778
|
+
severity: "warning",
|
|
1779
|
+
description: "Detects when file content does not match directory role",
|
|
1780
|
+
category: "architecture",
|
|
1781
|
+
check(context) {
|
|
1782
|
+
const findings = [];
|
|
1783
|
+
const path10 = context.file.path.toLowerCase();
|
|
1784
|
+
const role = context.file.role;
|
|
1785
|
+
if (path10.includes("/service") && role !== "service" && role !== "unknown") {
|
|
1786
|
+
findings.push({
|
|
1787
|
+
message: `File is in services directory but detected role is "${role}"`,
|
|
1788
|
+
file: context.file.path,
|
|
1789
|
+
line: 1,
|
|
1790
|
+
column: 1,
|
|
1791
|
+
fix: {
|
|
1792
|
+
suggestion: "Move this file to the appropriate directory or rename it."
|
|
1793
|
+
}
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
if (path10.includes("/controller") && role !== "controller" && role !== "unknown") {
|
|
1797
|
+
findings.push({
|
|
1798
|
+
message: `File is in controllers directory but detected role is "${role}"`,
|
|
1799
|
+
file: context.file.path,
|
|
1800
|
+
line: 1,
|
|
1801
|
+
column: 1,
|
|
1802
|
+
fix: {
|
|
1803
|
+
suggestion: "Move this file to the appropriate directory or rename it."
|
|
1804
|
+
}
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
return findings;
|
|
1808
|
+
}
|
|
1809
|
+
});
|
|
1810
|
+
kernel.registerRule({
|
|
1811
|
+
name: "architecture/god-file",
|
|
1812
|
+
severity: "warning",
|
|
1813
|
+
description: "Detects files exceeding maximum line count",
|
|
1814
|
+
category: "architecture",
|
|
1815
|
+
check(context) {
|
|
1816
|
+
const maxLines = fullConfig.maxFileLines ?? 300;
|
|
1817
|
+
if (context.file.loc > maxLines) {
|
|
1818
|
+
return [
|
|
1819
|
+
{
|
|
1820
|
+
message: `File has ${context.file.loc} lines (max: ${maxLines})`,
|
|
1821
|
+
file: context.file.path,
|
|
1822
|
+
line: 1,
|
|
1823
|
+
column: 1,
|
|
1824
|
+
fix: {
|
|
1825
|
+
suggestion: "Split this file into smaller, focused modules."
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
];
|
|
1829
|
+
}
|
|
1830
|
+
return [];
|
|
1831
|
+
}
|
|
1832
|
+
});
|
|
1833
|
+
kernel.registerRule({
|
|
1834
|
+
name: "architecture/god-function",
|
|
1835
|
+
severity: "warning",
|
|
1836
|
+
description: "Detects functions exceeding maximum line count",
|
|
1837
|
+
category: "architecture",
|
|
1838
|
+
check(context) {
|
|
1839
|
+
const findings = [];
|
|
1840
|
+
const maxLines = fullConfig.maxFunctionLines ?? 50;
|
|
1841
|
+
for (const fn of context.file.functions) {
|
|
1842
|
+
const fnLines = fn.endLine - fn.startLine + 1;
|
|
1843
|
+
if (fnLines > maxLines) {
|
|
1844
|
+
findings.push({
|
|
1845
|
+
message: `Function "${fn.name}" has ${fnLines} lines (max: ${maxLines})`,
|
|
1846
|
+
file: context.file.path,
|
|
1847
|
+
line: fn.startLine,
|
|
1848
|
+
column: 1,
|
|
1849
|
+
fix: {
|
|
1850
|
+
suggestion: "Extract logic into smaller helper functions."
|
|
1851
|
+
}
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
return findings;
|
|
1856
|
+
}
|
|
1857
|
+
});
|
|
1858
|
+
kernel.registerRule({
|
|
1859
|
+
name: "architecture/barrel-explosion",
|
|
1860
|
+
severity: "info",
|
|
1861
|
+
description: "Detects barrel files (index.ts) that re-export everything",
|
|
1862
|
+
category: "architecture",
|
|
1863
|
+
check(context) {
|
|
1864
|
+
const findings = [];
|
|
1865
|
+
const fileName = context.file.path.split("/").pop() ?? "";
|
|
1866
|
+
if (fileName === "index.ts" || fileName === "index.tsx") {
|
|
1867
|
+
const exportCount = context.file.exports.length;
|
|
1868
|
+
if (exportCount > 10) {
|
|
1869
|
+
findings.push({
|
|
1870
|
+
message: `Barrel file re-exports ${exportCount} symbols (may cause bundle size issues)`,
|
|
1871
|
+
file: context.file.path,
|
|
1872
|
+
line: 1,
|
|
1873
|
+
column: 1,
|
|
1874
|
+
fix: {
|
|
1875
|
+
suggestion: "Consider using direct imports instead of barrel files for tree-shaking."
|
|
1876
|
+
}
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
return findings;
|
|
1881
|
+
}
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
function resolveImport(fromFile, source) {
|
|
1887
|
+
if (!source.startsWith(".")) return source;
|
|
1888
|
+
const fromDir = fromFile.split("/").slice(0, -1).join("/");
|
|
1889
|
+
const parts = [...fromDir.split("/"), ...source.split("/")];
|
|
1890
|
+
const resolved = [];
|
|
1891
|
+
for (const part of parts) {
|
|
1892
|
+
if (part === "..") resolved.pop();
|
|
1893
|
+
else if (part !== ".") resolved.push(part);
|
|
1894
|
+
}
|
|
1895
|
+
let result = resolved.join("/");
|
|
1896
|
+
if (!result.endsWith(".ts") && !result.endsWith(".tsx")) {
|
|
1897
|
+
result += ".ts";
|
|
1898
|
+
}
|
|
1899
|
+
return result;
|
|
1900
|
+
}
|
|
1901
|
+
function capitalize(str) {
|
|
1902
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
1903
|
+
}
|
|
1904
|
+
var DB_METHODS = ["query", "execute", "raw", "prepare", "exec"];
|
|
1905
|
+
var SECRET_PATTERNS = [
|
|
1906
|
+
/^(sk_|pk_|api_|token_|secret_|password|auth_)/i,
|
|
1907
|
+
/^(AKIA[0-9A-Z]{16})/,
|
|
1908
|
+
// AWS access key
|
|
1909
|
+
/^eyJ[A-Za-z0-9-_]+\.eyJ/,
|
|
1910
|
+
// JWT token
|
|
1911
|
+
/^(ghp_|gho_|ghu_|ghs_|ghr_)/,
|
|
1912
|
+
// GitHub tokens
|
|
1913
|
+
/(mongodb(\+srv)?:\/\/|postgres(ql)?:\/\/|mysql:\/\/|redis:\/\/)/i
|
|
1914
|
+
// Connection strings
|
|
1915
|
+
];
|
|
1916
|
+
function securityPlugin(config = {}) {
|
|
1917
|
+
const fullConfig = {
|
|
1918
|
+
checkInjection: true,
|
|
1919
|
+
checkAuth: true,
|
|
1920
|
+
checkSecrets: true,
|
|
1921
|
+
checkXSS: true,
|
|
1922
|
+
...config
|
|
1923
|
+
};
|
|
1924
|
+
return {
|
|
1925
|
+
name: "security",
|
|
1926
|
+
version: "1.0.0",
|
|
1927
|
+
install(kernel) {
|
|
1928
|
+
kernel.registerRule({
|
|
1929
|
+
name: "security/sql-injection",
|
|
1930
|
+
severity: "critical",
|
|
1931
|
+
description: "Detects string concatenation in SQL queries",
|
|
1932
|
+
category: "security",
|
|
1933
|
+
check(context) {
|
|
1934
|
+
if (!fullConfig.checkInjection) return [];
|
|
1935
|
+
const findings = [];
|
|
1936
|
+
context.walk(context.ast, {
|
|
1937
|
+
CallExpression(node) {
|
|
1938
|
+
const call = node;
|
|
1939
|
+
const expr = call.expression;
|
|
1940
|
+
let methodName = "";
|
|
1941
|
+
if (ts2__default.default.isPropertyAccessExpression(expr)) {
|
|
1942
|
+
methodName = expr.name.text;
|
|
1943
|
+
} else if (ts2__default.default.isIdentifier(expr)) {
|
|
1944
|
+
methodName = expr.text;
|
|
1945
|
+
}
|
|
1946
|
+
if (DB_METHODS.includes(methodName)) {
|
|
1947
|
+
for (const arg of call.arguments) {
|
|
1948
|
+
if (ts2__default.default.isTemplateExpression(arg) || context.hasStringConcat(arg)) {
|
|
1949
|
+
const pos = context.ast.getLineAndCharacterOfPosition(call.getStart(context.ast));
|
|
1950
|
+
findings.push({
|
|
1951
|
+
message: "Raw string concatenation in SQL query \u2014 potential SQL injection",
|
|
1952
|
+
file: context.file.path,
|
|
1953
|
+
line: pos.line + 1,
|
|
1954
|
+
column: pos.character + 1,
|
|
1955
|
+
fix: {
|
|
1956
|
+
suggestion: "Use parameterized queries instead of string templates."
|
|
1957
|
+
}
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
});
|
|
1964
|
+
return findings;
|
|
1965
|
+
}
|
|
1966
|
+
});
|
|
1967
|
+
kernel.registerRule({
|
|
1968
|
+
name: "security/hardcoded-secret",
|
|
1969
|
+
severity: "critical",
|
|
1970
|
+
description: "Detects hardcoded API keys, tokens, and passwords",
|
|
1971
|
+
category: "security",
|
|
1972
|
+
check(context) {
|
|
1973
|
+
if (!fullConfig.checkSecrets) return [];
|
|
1974
|
+
if (context.file.role === "test") return [];
|
|
1975
|
+
const findings = [];
|
|
1976
|
+
context.walk(context.ast, {
|
|
1977
|
+
StringLiteral(node) {
|
|
1978
|
+
const str = node;
|
|
1979
|
+
const value = str.text;
|
|
1980
|
+
if (value.length < 8) return;
|
|
1981
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
1982
|
+
if (pattern.test(value)) {
|
|
1983
|
+
const pos = context.ast.getLineAndCharacterOfPosition(str.getStart(context.ast));
|
|
1984
|
+
findings.push({
|
|
1985
|
+
message: "Possible hardcoded secret detected",
|
|
1986
|
+
file: context.file.path,
|
|
1987
|
+
line: pos.line + 1,
|
|
1988
|
+
column: pos.character + 1,
|
|
1989
|
+
fix: {
|
|
1990
|
+
suggestion: "Move secrets to environment variables or a secure vault."
|
|
1991
|
+
}
|
|
1992
|
+
});
|
|
1993
|
+
break;
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
},
|
|
1997
|
+
NoSubstitutionTemplateLiteral(node) {
|
|
1998
|
+
const tmpl = node;
|
|
1999
|
+
const value = tmpl.text;
|
|
2000
|
+
if (value.length < 8) return;
|
|
2001
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
2002
|
+
if (pattern.test(value)) {
|
|
2003
|
+
const pos = context.ast.getLineAndCharacterOfPosition(tmpl.getStart(context.ast));
|
|
2004
|
+
findings.push({
|
|
2005
|
+
message: "Possible hardcoded secret detected",
|
|
2006
|
+
file: context.file.path,
|
|
2007
|
+
line: pos.line + 1,
|
|
2008
|
+
column: pos.character + 1,
|
|
2009
|
+
fix: {
|
|
2010
|
+
suggestion: "Move secrets to environment variables or a secure vault."
|
|
2011
|
+
}
|
|
2012
|
+
});
|
|
2013
|
+
break;
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
});
|
|
2018
|
+
return findings;
|
|
2019
|
+
}
|
|
2020
|
+
});
|
|
2021
|
+
kernel.registerRule({
|
|
2022
|
+
name: "security/eval-usage",
|
|
2023
|
+
severity: "critical",
|
|
2024
|
+
description: "Detects eval(), Function(), and similar unsafe patterns",
|
|
2025
|
+
category: "security",
|
|
2026
|
+
check(context) {
|
|
2027
|
+
const findings = [];
|
|
2028
|
+
context.walk(context.ast, {
|
|
2029
|
+
CallExpression(node) {
|
|
2030
|
+
const call = node;
|
|
2031
|
+
let name = "";
|
|
2032
|
+
if (ts2__default.default.isIdentifier(call.expression)) {
|
|
2033
|
+
name = call.expression.text;
|
|
2034
|
+
}
|
|
2035
|
+
if (name === "eval" || name === "Function") {
|
|
2036
|
+
const pos = context.ast.getLineAndCharacterOfPosition(call.getStart(context.ast));
|
|
2037
|
+
findings.push({
|
|
2038
|
+
message: `Unsafe ${name}() call \u2014 potential code injection`,
|
|
2039
|
+
file: context.file.path,
|
|
2040
|
+
line: pos.line + 1,
|
|
2041
|
+
column: pos.character + 1,
|
|
2042
|
+
fix: {
|
|
2043
|
+
suggestion: `Avoid ${name}(). Use safe alternatives like JSON.parse() or structured data.`
|
|
2044
|
+
}
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
2047
|
+
if ((name === "setTimeout" || name === "setInterval") && call.arguments.length > 0) {
|
|
2048
|
+
const firstArg = call.arguments[0];
|
|
2049
|
+
if (ts2__default.default.isStringLiteral(firstArg)) {
|
|
2050
|
+
const pos = context.ast.getLineAndCharacterOfPosition(call.getStart(context.ast));
|
|
2051
|
+
findings.push({
|
|
2052
|
+
message: `${name}() with string argument is equivalent to eval()`,
|
|
2053
|
+
file: context.file.path,
|
|
2054
|
+
line: pos.line + 1,
|
|
2055
|
+
column: pos.character + 1,
|
|
2056
|
+
fix: {
|
|
2057
|
+
suggestion: "Pass a function reference instead of a string."
|
|
2058
|
+
}
|
|
2059
|
+
});
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
},
|
|
2063
|
+
NewExpression(node) {
|
|
2064
|
+
const newExpr = node;
|
|
2065
|
+
if (ts2__default.default.isIdentifier(newExpr.expression) && newExpr.expression.text === "Function") {
|
|
2066
|
+
const pos = context.ast.getLineAndCharacterOfPosition(newExpr.getStart(context.ast));
|
|
2067
|
+
findings.push({
|
|
2068
|
+
message: "new Function() is equivalent to eval() \u2014 potential code injection",
|
|
2069
|
+
file: context.file.path,
|
|
2070
|
+
line: pos.line + 1,
|
|
2071
|
+
column: pos.character + 1,
|
|
2072
|
+
fix: {
|
|
2073
|
+
suggestion: "Avoid new Function(). Use safe alternatives."
|
|
2074
|
+
}
|
|
2075
|
+
});
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
});
|
|
2079
|
+
return findings;
|
|
2080
|
+
}
|
|
2081
|
+
});
|
|
2082
|
+
kernel.registerRule({
|
|
2083
|
+
name: "security/prototype-pollution",
|
|
2084
|
+
severity: "error",
|
|
2085
|
+
description: "Detects potential prototype pollution",
|
|
2086
|
+
category: "security",
|
|
2087
|
+
check(context) {
|
|
2088
|
+
const findings = [];
|
|
2089
|
+
context.walk(context.ast, {
|
|
2090
|
+
PropertyAccessExpression(node) {
|
|
2091
|
+
const propAccess = node;
|
|
2092
|
+
if (propAccess.name.text === "__proto__" || propAccess.name.text === "prototype") {
|
|
2093
|
+
const parent = propAccess.parent;
|
|
2094
|
+
if (ts2__default.default.isBinaryExpression(parent) && parent.operatorToken.kind === ts2__default.default.SyntaxKind.EqualsToken) {
|
|
2095
|
+
const pos = context.ast.getLineAndCharacterOfPosition(propAccess.getStart(context.ast));
|
|
2096
|
+
findings.push({
|
|
2097
|
+
message: "Direct prototype assignment \u2014 potential prototype pollution",
|
|
2098
|
+
file: context.file.path,
|
|
2099
|
+
line: pos.line + 1,
|
|
2100
|
+
column: pos.character + 1,
|
|
2101
|
+
fix: {
|
|
2102
|
+
suggestion: "Use Object.create(null) or Map for dynamic key-value stores."
|
|
2103
|
+
}
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
});
|
|
2109
|
+
return findings;
|
|
2110
|
+
}
|
|
2111
|
+
});
|
|
2112
|
+
kernel.registerRule({
|
|
2113
|
+
name: "security/xss-risk",
|
|
2114
|
+
severity: "error",
|
|
2115
|
+
description: "Detects innerHTML and similar XSS-prone patterns",
|
|
2116
|
+
category: "security",
|
|
2117
|
+
check(context) {
|
|
2118
|
+
if (!fullConfig.checkXSS) return [];
|
|
2119
|
+
const findings = [];
|
|
2120
|
+
const xssProps = ["innerHTML", "outerHTML", "dangerouslySetInnerHTML"];
|
|
2121
|
+
context.walk(context.ast, {
|
|
2122
|
+
PropertyAccessExpression(node) {
|
|
2123
|
+
const propAccess = node;
|
|
2124
|
+
if (xssProps.includes(propAccess.name.text)) {
|
|
2125
|
+
const pos = context.ast.getLineAndCharacterOfPosition(propAccess.getStart(context.ast));
|
|
2126
|
+
findings.push({
|
|
2127
|
+
message: `Use of ${propAccess.name.text} \u2014 potential XSS vulnerability`,
|
|
2128
|
+
file: context.file.path,
|
|
2129
|
+
line: pos.line + 1,
|
|
2130
|
+
column: pos.character + 1,
|
|
2131
|
+
fix: {
|
|
2132
|
+
suggestion: "Sanitize HTML content before insertion or use safe alternatives."
|
|
2133
|
+
}
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
},
|
|
2137
|
+
CallExpression(node) {
|
|
2138
|
+
const call = node;
|
|
2139
|
+
if (ts2__default.default.isPropertyAccessExpression(call.expression)) {
|
|
2140
|
+
const name = call.expression.name.text;
|
|
2141
|
+
if (name === "write" || name === "writeln") {
|
|
2142
|
+
if (ts2__default.default.isIdentifier(call.expression.expression) && call.expression.expression.text === "document") {
|
|
2143
|
+
const pos = context.ast.getLineAndCharacterOfPosition(call.getStart(context.ast));
|
|
2144
|
+
findings.push({
|
|
2145
|
+
message: "document.write() is an XSS risk",
|
|
2146
|
+
file: context.file.path,
|
|
2147
|
+
line: pos.line + 1,
|
|
2148
|
+
column: pos.character + 1,
|
|
2149
|
+
fix: {
|
|
2150
|
+
suggestion: "Use DOM manipulation methods instead of document.write()."
|
|
2151
|
+
}
|
|
2152
|
+
});
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
});
|
|
2158
|
+
return findings;
|
|
2159
|
+
}
|
|
2160
|
+
});
|
|
2161
|
+
kernel.registerRule({
|
|
2162
|
+
name: "security/missing-auth-check",
|
|
2163
|
+
severity: "warning",
|
|
2164
|
+
description: "Detects route handlers without auth checks",
|
|
2165
|
+
category: "security",
|
|
2166
|
+
check(context) {
|
|
2167
|
+
if (!fullConfig.checkAuth) return [];
|
|
2168
|
+
if (context.file.role !== "controller") return [];
|
|
2169
|
+
const findings = [];
|
|
2170
|
+
const fileText = context.ast.getFullText();
|
|
2171
|
+
const hasAuthReference = fileText.includes("auth") || fileText.includes("Auth") || fileText.includes("authenticate") || fileText.includes("authorize") || fileText.includes("guard") || fileText.includes("middleware") || fileText.includes("jwt") || fileText.includes("token");
|
|
2172
|
+
if (!hasAuthReference && context.file.functions.length > 0) {
|
|
2173
|
+
findings.push({
|
|
2174
|
+
message: "Controller has no authentication/authorization references",
|
|
2175
|
+
file: context.file.path,
|
|
2176
|
+
line: 1,
|
|
2177
|
+
column: 1,
|
|
2178
|
+
fix: {
|
|
2179
|
+
suggestion: "Add authentication middleware or auth checks to route handlers."
|
|
2180
|
+
}
|
|
2181
|
+
});
|
|
2182
|
+
}
|
|
2183
|
+
return findings;
|
|
2184
|
+
}
|
|
2185
|
+
});
|
|
2186
|
+
kernel.registerRule({
|
|
2187
|
+
name: "security/insecure-random",
|
|
2188
|
+
severity: "warning",
|
|
2189
|
+
description: "Detects Math.random() in security-sensitive contexts",
|
|
2190
|
+
category: "security",
|
|
2191
|
+
check(context) {
|
|
2192
|
+
const findings = [];
|
|
2193
|
+
context.walk(context.ast, {
|
|
2194
|
+
CallExpression(node) {
|
|
2195
|
+
const call = node;
|
|
2196
|
+
if (ts2__default.default.isPropertyAccessExpression(call.expression)) {
|
|
2197
|
+
if (ts2__default.default.isIdentifier(call.expression.expression) && call.expression.expression.text === "Math" && call.expression.name.text === "random") {
|
|
2198
|
+
const fileText = context.ast.getFullText();
|
|
2199
|
+
const isSecurityContext = fileText.includes("token") || fileText.includes("secret") || fileText.includes("password") || fileText.includes("hash") || fileText.includes("crypto") || fileText.includes("session");
|
|
2200
|
+
if (isSecurityContext) {
|
|
2201
|
+
const pos = context.ast.getLineAndCharacterOfPosition(call.getStart(context.ast));
|
|
2202
|
+
findings.push({
|
|
2203
|
+
message: "Math.random() is not cryptographically secure",
|
|
2204
|
+
file: context.file.path,
|
|
2205
|
+
line: pos.line + 1,
|
|
2206
|
+
column: pos.character + 1,
|
|
2207
|
+
fix: {
|
|
2208
|
+
suggestion: "Use crypto.randomBytes() or crypto.randomUUID() for security-sensitive values."
|
|
2209
|
+
}
|
|
2210
|
+
});
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
});
|
|
2216
|
+
return findings;
|
|
2217
|
+
}
|
|
2218
|
+
});
|
|
2219
|
+
kernel.registerRule({
|
|
2220
|
+
name: "security/path-traversal",
|
|
2221
|
+
severity: "error",
|
|
2222
|
+
description: "Detects file operations with potential path traversal",
|
|
2223
|
+
category: "security",
|
|
2224
|
+
check(context) {
|
|
2225
|
+
const findings = [];
|
|
2226
|
+
const fsOps = ["readFile", "readFileSync", "writeFile", "writeFileSync", "createReadStream", "createWriteStream", "access", "open"];
|
|
2227
|
+
context.walk(context.ast, {
|
|
2228
|
+
CallExpression(node) {
|
|
2229
|
+
const call = node;
|
|
2230
|
+
let methodName = "";
|
|
2231
|
+
if (ts2__default.default.isPropertyAccessExpression(call.expression)) {
|
|
2232
|
+
methodName = call.expression.name.text;
|
|
2233
|
+
} else if (ts2__default.default.isIdentifier(call.expression)) {
|
|
2234
|
+
methodName = call.expression.text;
|
|
2235
|
+
}
|
|
2236
|
+
if (fsOps.includes(methodName) && call.arguments.length > 0) {
|
|
2237
|
+
const firstArg = call.arguments[0];
|
|
2238
|
+
if (ts2__default.default.isTemplateExpression(firstArg) || context.hasStringConcat(firstArg)) {
|
|
2239
|
+
const pos = context.ast.getLineAndCharacterOfPosition(call.getStart(context.ast));
|
|
2240
|
+
findings.push({
|
|
2241
|
+
message: `File operation "${methodName}" with dynamic path \u2014 potential path traversal`,
|
|
2242
|
+
file: context.file.path,
|
|
2243
|
+
line: pos.line + 1,
|
|
2244
|
+
column: pos.character + 1,
|
|
2245
|
+
fix: {
|
|
2246
|
+
suggestion: "Validate and sanitize file paths. Use path.resolve() and check against a whitelist."
|
|
2247
|
+
}
|
|
2248
|
+
});
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
});
|
|
2253
|
+
return findings;
|
|
2254
|
+
}
|
|
2255
|
+
});
|
|
2256
|
+
}
|
|
2257
|
+
};
|
|
2258
|
+
}
|
|
2259
|
+
var DB_CALL_PATTERNS = ["find", "findOne", "findAll", "findById", "query", "execute", "fetch", "get", "select"];
|
|
2260
|
+
var SYNC_FS_METHODS = ["readFileSync", "writeFileSync", "appendFileSync", "mkdirSync", "readdirSync", "statSync", "existsSync", "unlinkSync", "copyFileSync"];
|
|
2261
|
+
function performancePlugin(config = {}) {
|
|
2262
|
+
const fullConfig = {
|
|
2263
|
+
checkN1Queries: true,
|
|
2264
|
+
checkMemoryLeaks: true,
|
|
2265
|
+
checkAsyncPatterns: true,
|
|
2266
|
+
checkBundleSize: false,
|
|
2267
|
+
...config
|
|
2268
|
+
};
|
|
2269
|
+
return {
|
|
2270
|
+
name: "performance",
|
|
2271
|
+
version: "1.0.0",
|
|
2272
|
+
install(kernel) {
|
|
2273
|
+
kernel.registerRule({
|
|
2274
|
+
name: "performance/n1-query",
|
|
2275
|
+
severity: "warning",
|
|
2276
|
+
description: "Detects database calls inside loops (potential N+1 query)",
|
|
2277
|
+
category: "performance",
|
|
2278
|
+
check(context) {
|
|
2279
|
+
if (!fullConfig.checkN1Queries) return [];
|
|
2280
|
+
const findings = [];
|
|
2281
|
+
const checkForDbCallsInLoop = (loopNode) => {
|
|
2282
|
+
ts2__default.default.forEachChild(loopNode, function visitChild(child) {
|
|
2283
|
+
if (ts2__default.default.isCallExpression(child)) {
|
|
2284
|
+
let methodName = "";
|
|
2285
|
+
if (ts2__default.default.isPropertyAccessExpression(child.expression)) {
|
|
2286
|
+
methodName = child.expression.name.text;
|
|
2287
|
+
}
|
|
2288
|
+
if (DB_CALL_PATTERNS.includes(methodName)) {
|
|
2289
|
+
const pos = context.ast.getLineAndCharacterOfPosition(child.getStart(context.ast));
|
|
2290
|
+
findings.push({
|
|
2291
|
+
message: `Potential N+1 query: "${methodName}" called inside a loop`,
|
|
2292
|
+
file: context.file.path,
|
|
2293
|
+
line: pos.line + 1,
|
|
2294
|
+
column: pos.character + 1,
|
|
2295
|
+
fix: {
|
|
2296
|
+
suggestion: "Batch queries using Promise.all() or a single query with IN clause."
|
|
2297
|
+
}
|
|
2298
|
+
});
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
ts2__default.default.forEachChild(child, visitChild);
|
|
2302
|
+
});
|
|
2303
|
+
};
|
|
2304
|
+
context.walk(context.ast, {
|
|
2305
|
+
ForStatement(node) {
|
|
2306
|
+
checkForDbCallsInLoop(node);
|
|
2307
|
+
},
|
|
2308
|
+
ForInStatement(node) {
|
|
2309
|
+
checkForDbCallsInLoop(node);
|
|
2310
|
+
},
|
|
2311
|
+
ForOfStatement(node) {
|
|
2312
|
+
checkForDbCallsInLoop(node);
|
|
2313
|
+
},
|
|
2314
|
+
WhileStatement(node) {
|
|
2315
|
+
checkForDbCallsInLoop(node);
|
|
2316
|
+
},
|
|
2317
|
+
DoStatement(node) {
|
|
2318
|
+
checkForDbCallsInLoop(node);
|
|
2319
|
+
}
|
|
2320
|
+
});
|
|
2321
|
+
context.walk(context.ast, {
|
|
2322
|
+
CallExpression(node) {
|
|
2323
|
+
const call = node;
|
|
2324
|
+
if (ts2__default.default.isPropertyAccessExpression(call.expression)) {
|
|
2325
|
+
const name = call.expression.name.text;
|
|
2326
|
+
if (name === "forEach" || name === "map") {
|
|
2327
|
+
if (call.arguments.length > 0) {
|
|
2328
|
+
checkForDbCallsInLoop(call.arguments[0]);
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
});
|
|
2334
|
+
return findings;
|
|
2335
|
+
}
|
|
2336
|
+
});
|
|
2337
|
+
kernel.registerRule({
|
|
2338
|
+
name: "performance/sync-in-async",
|
|
2339
|
+
severity: "warning",
|
|
2340
|
+
description: "Detects synchronous file operations in async functions",
|
|
2341
|
+
category: "performance",
|
|
2342
|
+
check(context) {
|
|
2343
|
+
if (!fullConfig.checkAsyncPatterns) return [];
|
|
2344
|
+
const findings = [];
|
|
2345
|
+
for (const fn of context.file.functions) {
|
|
2346
|
+
if (!fn.isAsync) continue;
|
|
2347
|
+
context.walk(context.ast, {
|
|
2348
|
+
CallExpression(node) {
|
|
2349
|
+
const call = node;
|
|
2350
|
+
const pos = context.ast.getLineAndCharacterOfPosition(call.getStart(context.ast));
|
|
2351
|
+
const callLine = pos.line + 1;
|
|
2352
|
+
if (callLine < fn.startLine || callLine > fn.endLine) return;
|
|
2353
|
+
let methodName = "";
|
|
2354
|
+
if (ts2__default.default.isPropertyAccessExpression(call.expression)) {
|
|
2355
|
+
methodName = call.expression.name.text;
|
|
2356
|
+
} else if (ts2__default.default.isIdentifier(call.expression)) {
|
|
2357
|
+
methodName = call.expression.text;
|
|
2358
|
+
}
|
|
2359
|
+
if (SYNC_FS_METHODS.includes(methodName)) {
|
|
2360
|
+
findings.push({
|
|
2361
|
+
message: `Synchronous "${methodName}" in async function "${fn.name}"`,
|
|
2362
|
+
file: context.file.path,
|
|
2363
|
+
line: callLine,
|
|
2364
|
+
column: pos.character + 1,
|
|
2365
|
+
fix: {
|
|
2366
|
+
suggestion: `Use the async version instead (e.g., fs.promises.${methodName.replace("Sync", "")}).`
|
|
2367
|
+
}
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2373
|
+
return findings;
|
|
2374
|
+
}
|
|
2375
|
+
});
|
|
2376
|
+
kernel.registerRule({
|
|
2377
|
+
name: "performance/memory-leak-risk",
|
|
2378
|
+
severity: "warning",
|
|
2379
|
+
description: "Detects addEventListener without removeEventListener, setInterval without clearInterval",
|
|
2380
|
+
category: "performance",
|
|
2381
|
+
check(context) {
|
|
2382
|
+
if (!fullConfig.checkMemoryLeaks) return [];
|
|
2383
|
+
const findings = [];
|
|
2384
|
+
const fileText = context.ast.getFullText();
|
|
2385
|
+
if (fileText.includes("addEventListener") && !fileText.includes("removeEventListener")) {
|
|
2386
|
+
context.walk(context.ast, {
|
|
2387
|
+
CallExpression(node) {
|
|
2388
|
+
const call = node;
|
|
2389
|
+
if (ts2__default.default.isPropertyAccessExpression(call.expression) && call.expression.name.text === "addEventListener") {
|
|
2390
|
+
const pos = context.ast.getLineAndCharacterOfPosition(call.getStart(context.ast));
|
|
2391
|
+
findings.push({
|
|
2392
|
+
message: "addEventListener without corresponding removeEventListener \u2014 potential memory leak",
|
|
2393
|
+
file: context.file.path,
|
|
2394
|
+
line: pos.line + 1,
|
|
2395
|
+
column: pos.character + 1,
|
|
2396
|
+
fix: {
|
|
2397
|
+
suggestion: "Add a cleanup function that calls removeEventListener."
|
|
2398
|
+
}
|
|
2399
|
+
});
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
});
|
|
2403
|
+
}
|
|
2404
|
+
if (fileText.includes("setInterval") && !fileText.includes("clearInterval")) {
|
|
2405
|
+
context.walk(context.ast, {
|
|
2406
|
+
CallExpression(node) {
|
|
2407
|
+
const call = node;
|
|
2408
|
+
if (ts2__default.default.isIdentifier(call.expression) && call.expression.text === "setInterval") {
|
|
2409
|
+
const pos = context.ast.getLineAndCharacterOfPosition(call.getStart(context.ast));
|
|
2410
|
+
findings.push({
|
|
2411
|
+
message: "setInterval without clearInterval \u2014 potential memory leak",
|
|
2412
|
+
file: context.file.path,
|
|
2413
|
+
line: pos.line + 1,
|
|
2414
|
+
column: pos.character + 1,
|
|
2415
|
+
fix: {
|
|
2416
|
+
suggestion: "Store the interval ID and clear it when no longer needed."
|
|
2417
|
+
}
|
|
2418
|
+
});
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
return findings;
|
|
2424
|
+
}
|
|
2425
|
+
});
|
|
2426
|
+
kernel.registerRule({
|
|
2427
|
+
name: "performance/unbounded-query",
|
|
2428
|
+
severity: "warning",
|
|
2429
|
+
description: "Detects database queries without LIMIT or pagination",
|
|
2430
|
+
category: "performance",
|
|
2431
|
+
check(context) {
|
|
2432
|
+
const findings = [];
|
|
2433
|
+
context.walk(context.ast, {
|
|
2434
|
+
CallExpression(node) {
|
|
2435
|
+
const call = node;
|
|
2436
|
+
let methodName = "";
|
|
2437
|
+
if (ts2__default.default.isPropertyAccessExpression(call.expression)) {
|
|
2438
|
+
methodName = call.expression.name.text;
|
|
2439
|
+
}
|
|
2440
|
+
if (methodName === "findAll" || methodName === "find") {
|
|
2441
|
+
const argText = call.arguments.map((a) => a.getText(context.ast)).join(" ");
|
|
2442
|
+
if (!argText.includes("limit") && !argText.includes("take") && !argText.includes("LIMIT")) {
|
|
2443
|
+
const pos = context.ast.getLineAndCharacterOfPosition(call.getStart(context.ast));
|
|
2444
|
+
findings.push({
|
|
2445
|
+
message: `"${methodName}" without limit \u2014 could return unbounded results`,
|
|
2446
|
+
file: context.file.path,
|
|
2447
|
+
line: pos.line + 1,
|
|
2448
|
+
column: pos.character + 1,
|
|
2449
|
+
fix: {
|
|
2450
|
+
suggestion: "Add a LIMIT clause or pagination to prevent loading too many records."
|
|
2451
|
+
}
|
|
2452
|
+
});
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
});
|
|
2457
|
+
return findings;
|
|
2458
|
+
}
|
|
2459
|
+
});
|
|
2460
|
+
kernel.registerRule({
|
|
2461
|
+
name: "performance/missing-index-hint",
|
|
2462
|
+
severity: "info",
|
|
2463
|
+
description: "Detects queries that may need database indexes",
|
|
2464
|
+
category: "performance",
|
|
2465
|
+
check(_context) {
|
|
2466
|
+
return [];
|
|
2467
|
+
}
|
|
2468
|
+
});
|
|
2469
|
+
kernel.registerRule({
|
|
2470
|
+
name: "performance/heavy-import",
|
|
2471
|
+
severity: "info",
|
|
2472
|
+
description: "Detects importing entire libraries when a smaller import is available",
|
|
2473
|
+
category: "performance",
|
|
2474
|
+
check(context) {
|
|
2475
|
+
if (!fullConfig.checkBundleSize) return [];
|
|
2476
|
+
const findings = [];
|
|
2477
|
+
const heavyLibs = ["lodash", "moment", "rxjs"];
|
|
2478
|
+
for (const imp of context.file.imports) {
|
|
2479
|
+
for (const lib of heavyLibs) {
|
|
2480
|
+
if (imp.source === lib && imp.specifiers.length <= 2) {
|
|
2481
|
+
findings.push({
|
|
2482
|
+
/* v8 ignore next */
|
|
2483
|
+
message: `Importing from "${lib}" \u2014 consider using "${lib}/${imp.specifiers[0] ?? ""}" for smaller bundle`,
|
|
2484
|
+
file: context.file.path,
|
|
2485
|
+
line: 1,
|
|
2486
|
+
column: 1,
|
|
2487
|
+
fix: {
|
|
2488
|
+
suggestion: `Use deep imports (e.g., "${lib}/functionName") for tree-shaking.`
|
|
2489
|
+
}
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
return findings;
|
|
2495
|
+
}
|
|
2496
|
+
});
|
|
2497
|
+
kernel.registerRule({
|
|
2498
|
+
name: "performance/blocking-operation",
|
|
2499
|
+
severity: "warning",
|
|
2500
|
+
description: "Detects CPU-intensive operations in request handlers",
|
|
2501
|
+
category: "performance",
|
|
2502
|
+
check(context) {
|
|
2503
|
+
if (context.file.role !== "controller") return [];
|
|
2504
|
+
const findings = [];
|
|
2505
|
+
context.walk(context.ast, {
|
|
2506
|
+
CallExpression(node) {
|
|
2507
|
+
const call = node;
|
|
2508
|
+
let methodName = "";
|
|
2509
|
+
if (ts2__default.default.isPropertyAccessExpression(call.expression)) {
|
|
2510
|
+
methodName = call.expression.name.text;
|
|
2511
|
+
} else if (ts2__default.default.isIdentifier(call.expression)) {
|
|
2512
|
+
methodName = call.expression.text;
|
|
2513
|
+
}
|
|
2514
|
+
if (methodName === "parse" && ts2__default.default.isPropertyAccessExpression(call.expression)) {
|
|
2515
|
+
if (ts2__default.default.isIdentifier(call.expression.expression) && call.expression.expression.text === "JSON") {
|
|
2516
|
+
const pos = context.ast.getLineAndCharacterOfPosition(call.getStart(context.ast));
|
|
2517
|
+
findings.push({
|
|
2518
|
+
message: "JSON.parse() in request handler may block event loop for large payloads",
|
|
2519
|
+
file: context.file.path,
|
|
2520
|
+
line: pos.line + 1,
|
|
2521
|
+
column: pos.character + 1,
|
|
2522
|
+
fix: {
|
|
2523
|
+
suggestion: "Consider streaming JSON parsing or limiting request body size."
|
|
2524
|
+
}
|
|
2525
|
+
});
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
});
|
|
2530
|
+
return findings;
|
|
2531
|
+
}
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2534
|
+
};
|
|
2535
|
+
}
|
|
2536
|
+
function qualityPlugin(config = {}) {
|
|
2537
|
+
const fullConfig = {
|
|
2538
|
+
checkDeadCode: true,
|
|
2539
|
+
checkNaming: true,
|
|
2540
|
+
checkComplexity: true,
|
|
2541
|
+
maxCyclomaticComplexity: 15,
|
|
2542
|
+
...config
|
|
2543
|
+
};
|
|
2544
|
+
return {
|
|
2545
|
+
name: "quality",
|
|
2546
|
+
version: "1.0.0",
|
|
2547
|
+
install(kernel) {
|
|
2548
|
+
kernel.registerRule({
|
|
2549
|
+
name: "quality/cyclomatic-complexity",
|
|
2550
|
+
severity: "warning",
|
|
2551
|
+
description: "Detects functions with high cyclomatic complexity",
|
|
2552
|
+
category: "quality",
|
|
2553
|
+
check(context) {
|
|
2554
|
+
if (!fullConfig.checkComplexity) return [];
|
|
2555
|
+
const findings = [];
|
|
2556
|
+
const maxComplexity = fullConfig.maxCyclomaticComplexity ?? 15;
|
|
2557
|
+
for (const fn of context.file.functions) {
|
|
2558
|
+
if (fn.complexity > maxComplexity) {
|
|
2559
|
+
findings.push({
|
|
2560
|
+
message: `Function "${fn.name}" has cyclomatic complexity ${fn.complexity} (max: ${maxComplexity})`,
|
|
2561
|
+
file: context.file.path,
|
|
2562
|
+
line: fn.startLine,
|
|
2563
|
+
column: 1,
|
|
2564
|
+
fix: {
|
|
2565
|
+
suggestion: "Simplify by extracting conditions into named functions or using early returns."
|
|
2566
|
+
}
|
|
2567
|
+
});
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
return findings;
|
|
2571
|
+
}
|
|
2572
|
+
});
|
|
2573
|
+
kernel.registerRule({
|
|
2574
|
+
name: "quality/dead-code",
|
|
2575
|
+
severity: "warning",
|
|
2576
|
+
description: "Detects exported symbols never imported by other files",
|
|
2577
|
+
category: "quality",
|
|
2578
|
+
check(context) {
|
|
2579
|
+
if (!fullConfig.checkDeadCode) return [];
|
|
2580
|
+
if (context.file.role === "test") return [];
|
|
2581
|
+
const findings = [];
|
|
2582
|
+
for (const exportName of context.file.exports) {
|
|
2583
|
+
if (exportName === "default") continue;
|
|
2584
|
+
const fileName = context.file.path.split("/").pop() ?? "";
|
|
2585
|
+
if (fileName === "index.ts" || fileName === "index.tsx") continue;
|
|
2586
|
+
const isUsed = context.isExternallyUsed(exportName);
|
|
2587
|
+
if (!isUsed) {
|
|
2588
|
+
findings.push({
|
|
2589
|
+
message: `Exported symbol "${exportName}" is never imported by other files`,
|
|
2590
|
+
file: context.file.path,
|
|
2591
|
+
line: 1,
|
|
2592
|
+
column: 1,
|
|
2593
|
+
fix: {
|
|
2594
|
+
suggestion: "Remove unused export or add an import if intentional."
|
|
2595
|
+
}
|
|
2596
|
+
});
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
return findings;
|
|
2600
|
+
}
|
|
2601
|
+
});
|
|
2602
|
+
kernel.registerRule({
|
|
2603
|
+
name: "quality/any-type",
|
|
2604
|
+
severity: "warning",
|
|
2605
|
+
description: "Detects usage of `any` type annotation",
|
|
2606
|
+
category: "quality",
|
|
2607
|
+
check(context) {
|
|
2608
|
+
const findings = [];
|
|
2609
|
+
context.walk(context.ast, {
|
|
2610
|
+
// TypeReference for explicit 'any' annotations
|
|
2611
|
+
AnyKeyword(node) {
|
|
2612
|
+
const pos = context.ast.getLineAndCharacterOfPosition(node.getStart(context.ast));
|
|
2613
|
+
findings.push({
|
|
2614
|
+
message: "Usage of `any` type \u2014 use a specific type instead",
|
|
2615
|
+
file: context.file.path,
|
|
2616
|
+
line: pos.line + 1,
|
|
2617
|
+
column: pos.character + 1,
|
|
2618
|
+
fix: {
|
|
2619
|
+
suggestion: "Replace `any` with a specific type or `unknown`."
|
|
2620
|
+
}
|
|
2621
|
+
});
|
|
2622
|
+
}
|
|
2623
|
+
});
|
|
2624
|
+
return findings;
|
|
2625
|
+
}
|
|
2626
|
+
});
|
|
2627
|
+
kernel.registerRule({
|
|
2628
|
+
name: "quality/no-error-handling",
|
|
2629
|
+
severity: "warning",
|
|
2630
|
+
description: "Detects async functions without error handling",
|
|
2631
|
+
category: "quality",
|
|
2632
|
+
check(context) {
|
|
2633
|
+
const findings = [];
|
|
2634
|
+
for (const fn of context.file.functions) {
|
|
2635
|
+
if (!fn.isAsync) continue;
|
|
2636
|
+
const fileText = context.ast.getFullText();
|
|
2637
|
+
const fnText = fileText.slice(
|
|
2638
|
+
getLineOffset(fileText, fn.startLine),
|
|
2639
|
+
getLineOffset(fileText, fn.endLine + 1)
|
|
2640
|
+
);
|
|
2641
|
+
const hasTryCatch = fnText.includes("try") && fnText.includes("catch");
|
|
2642
|
+
const hasDotCatch = fnText.includes(".catch(");
|
|
2643
|
+
const hasThrow = fnText.includes("throw ");
|
|
2644
|
+
if (!hasTryCatch && !hasDotCatch && !hasThrow) {
|
|
2645
|
+
findings.push({
|
|
2646
|
+
message: `Async function "${fn.name}" has no error handling (try-catch or .catch())`,
|
|
2647
|
+
file: context.file.path,
|
|
2648
|
+
line: fn.startLine,
|
|
2649
|
+
column: 1,
|
|
2650
|
+
fix: {
|
|
2651
|
+
suggestion: "Add try-catch around async operations or use .catch() on promises."
|
|
2652
|
+
}
|
|
2653
|
+
});
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
return findings;
|
|
2657
|
+
}
|
|
2658
|
+
});
|
|
2659
|
+
kernel.registerRule({
|
|
2660
|
+
name: "quality/inconsistent-naming",
|
|
2661
|
+
severity: "info",
|
|
2662
|
+
description: "Detects naming that does not follow project conventions",
|
|
2663
|
+
category: "quality",
|
|
2664
|
+
check(context) {
|
|
2665
|
+
if (!fullConfig.checkNaming) return [];
|
|
2666
|
+
const findings = [];
|
|
2667
|
+
for (const fn of context.file.functions) {
|
|
2668
|
+
if (fn.name && !/^[a-z_$]/.test(fn.name) && !/^[A-Z][A-Z_]+$/.test(fn.name)) {
|
|
2669
|
+
if (context.file.role !== "unknown") {
|
|
2670
|
+
findings.push({
|
|
2671
|
+
message: `Function "${fn.name}" does not follow camelCase naming convention`,
|
|
2672
|
+
file: context.file.path,
|
|
2673
|
+
line: fn.startLine,
|
|
2674
|
+
column: 1,
|
|
2675
|
+
fix: {
|
|
2676
|
+
suggestion: "Use camelCase for function names (e.g., getUser, handleRequest)."
|
|
2677
|
+
}
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
}
|
|
2682
|
+
return findings;
|
|
2683
|
+
}
|
|
2684
|
+
});
|
|
2685
|
+
kernel.registerRule({
|
|
2686
|
+
name: "quality/magic-number",
|
|
2687
|
+
severity: "info",
|
|
2688
|
+
description: "Detects numeric literals used directly in logic",
|
|
2689
|
+
category: "quality",
|
|
2690
|
+
check(context) {
|
|
2691
|
+
const findings = [];
|
|
2692
|
+
const allowedNumbers = /* @__PURE__ */ new Set([0, 1, -1, 2, 100, 200, 201, 204, 301, 302, 400, 401, 403, 404, 500]);
|
|
2693
|
+
context.walk(context.ast, {
|
|
2694
|
+
NumericLiteral(node) {
|
|
2695
|
+
const num = node;
|
|
2696
|
+
const value = parseFloat(num.text);
|
|
2697
|
+
if (allowedNumbers.has(value)) return;
|
|
2698
|
+
let parent = num.parent;
|
|
2699
|
+
while (parent) {
|
|
2700
|
+
if (ts2__default.default.isVariableDeclaration(parent) || ts2__default.default.isEnumMember(parent) || ts2__default.default.isPropertyAssignment(parent)) {
|
|
2701
|
+
return;
|
|
2702
|
+
}
|
|
2703
|
+
parent = parent.parent;
|
|
2704
|
+
}
|
|
2705
|
+
const pos = context.ast.getLineAndCharacterOfPosition(num.getStart(context.ast));
|
|
2706
|
+
findings.push({
|
|
2707
|
+
message: `Magic number ${num.text} \u2014 extract to a named constant`,
|
|
2708
|
+
file: context.file.path,
|
|
2709
|
+
line: pos.line + 1,
|
|
2710
|
+
column: pos.character + 1,
|
|
2711
|
+
fix: {
|
|
2712
|
+
suggestion: "Extract numeric literal to a named constant for clarity."
|
|
2713
|
+
}
|
|
2714
|
+
});
|
|
2715
|
+
}
|
|
2716
|
+
});
|
|
2717
|
+
return findings;
|
|
2718
|
+
}
|
|
2719
|
+
});
|
|
2720
|
+
kernel.registerRule({
|
|
2721
|
+
name: "quality/empty-catch",
|
|
2722
|
+
severity: "warning",
|
|
2723
|
+
description: "Detects empty catch blocks",
|
|
2724
|
+
category: "quality",
|
|
2725
|
+
check(context) {
|
|
2726
|
+
const findings = [];
|
|
2727
|
+
context.walk(context.ast, {
|
|
2728
|
+
CatchClause(node) {
|
|
2729
|
+
const catchClause = node;
|
|
2730
|
+
const block = catchClause.block;
|
|
2731
|
+
if (block.statements.length === 0) {
|
|
2732
|
+
const pos = context.ast.getLineAndCharacterOfPosition(catchClause.getStart(context.ast));
|
|
2733
|
+
findings.push({
|
|
2734
|
+
message: "Empty catch block \u2014 errors are silently swallowed",
|
|
2735
|
+
file: context.file.path,
|
|
2736
|
+
line: pos.line + 1,
|
|
2737
|
+
column: pos.character + 1,
|
|
2738
|
+
fix: {
|
|
2739
|
+
suggestion: "Handle the error, log it, or re-throw if appropriate."
|
|
2740
|
+
}
|
|
2741
|
+
});
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
});
|
|
2745
|
+
return findings;
|
|
2746
|
+
}
|
|
2747
|
+
});
|
|
2748
|
+
kernel.registerRule({
|
|
2749
|
+
name: "quality/nested-callbacks",
|
|
2750
|
+
severity: "warning",
|
|
2751
|
+
description: "Detects deeply nested callbacks (> 3 levels)",
|
|
2752
|
+
category: "quality",
|
|
2753
|
+
check(context) {
|
|
2754
|
+
const findings = [];
|
|
2755
|
+
const maxNesting = 3;
|
|
2756
|
+
const checkNesting = (node, depth) => {
|
|
2757
|
+
if (ts2__default.default.isArrowFunction(node) || ts2__default.default.isFunctionExpression(node)) {
|
|
2758
|
+
if (depth > maxNesting) {
|
|
2759
|
+
const pos = context.ast.getLineAndCharacterOfPosition(node.getStart(context.ast));
|
|
2760
|
+
findings.push({
|
|
2761
|
+
message: `Callback nested ${depth} levels deep (max: ${maxNesting})`,
|
|
2762
|
+
file: context.file.path,
|
|
2763
|
+
line: pos.line + 1,
|
|
2764
|
+
column: pos.character + 1,
|
|
2765
|
+
fix: {
|
|
2766
|
+
suggestion: "Use async/await or extract nested callbacks into named functions."
|
|
2767
|
+
}
|
|
2768
|
+
});
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
ts2__default.default.forEachChild(node, (child) => checkNesting(child, depth + 1));
|
|
2772
|
+
} else {
|
|
2773
|
+
ts2__default.default.forEachChild(node, (child) => checkNesting(child, depth));
|
|
2774
|
+
}
|
|
2775
|
+
};
|
|
2776
|
+
ts2__default.default.forEachChild(context.ast, (child) => checkNesting(child, 0));
|
|
2777
|
+
return findings;
|
|
2778
|
+
}
|
|
2779
|
+
});
|
|
2780
|
+
}
|
|
2781
|
+
};
|
|
2782
|
+
}
|
|
2783
|
+
function getLineOffset(text, line) {
|
|
2784
|
+
let offset = 0;
|
|
2785
|
+
let currentLine = 1;
|
|
2786
|
+
for (let i = 0; i < text.length; i++) {
|
|
2787
|
+
if (currentLine === line) return offset;
|
|
2788
|
+
if (text[i] === "\n") {
|
|
2789
|
+
currentLine++;
|
|
2790
|
+
}
|
|
2791
|
+
offset++;
|
|
2792
|
+
}
|
|
2793
|
+
return offset;
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
// src/plugins/optional/naming.ts
|
|
2797
|
+
function namingPlugin(_config = {}) {
|
|
2798
|
+
return {
|
|
2799
|
+
name: "naming-convention",
|
|
2800
|
+
version: "1.0.0",
|
|
2801
|
+
install(kernel) {
|
|
2802
|
+
kernel.registerRule({
|
|
2803
|
+
name: "naming-convention/file-naming",
|
|
2804
|
+
severity: "warning",
|
|
2805
|
+
description: "Enforces consistent file naming patterns based on directory",
|
|
2806
|
+
category: "quality",
|
|
2807
|
+
check(context) {
|
|
2808
|
+
const findings = [];
|
|
2809
|
+
const path10 = context.file.path;
|
|
2810
|
+
const fileName = path10.split("/").pop() ?? "";
|
|
2811
|
+
if (path10.includes("/services/") && !fileName.endsWith(".service.ts") && !fileName.endsWith(".test.ts")) {
|
|
2812
|
+
findings.push({
|
|
2813
|
+
message: `File in services/ should follow *.service.ts naming: "${fileName}"`,
|
|
2814
|
+
file: context.file.path,
|
|
2815
|
+
line: 1,
|
|
2816
|
+
column: 1,
|
|
2817
|
+
fix: { suggestion: "Rename file to follow *.service.ts convention." }
|
|
2818
|
+
});
|
|
2819
|
+
}
|
|
2820
|
+
if (path10.includes("/controllers/") && !fileName.endsWith(".controller.ts") && !fileName.endsWith(".test.ts")) {
|
|
2821
|
+
findings.push({
|
|
2822
|
+
message: `File in controllers/ should follow *.controller.ts naming: "${fileName}"`,
|
|
2823
|
+
file: context.file.path,
|
|
2824
|
+
line: 1,
|
|
2825
|
+
column: 1,
|
|
2826
|
+
fix: { suggestion: "Rename file to follow *.controller.ts convention." }
|
|
2827
|
+
});
|
|
2828
|
+
}
|
|
2829
|
+
if (path10.includes("/repositories/") && !fileName.endsWith(".repository.ts") && !fileName.endsWith(".test.ts")) {
|
|
2830
|
+
findings.push({
|
|
2831
|
+
message: `File in repositories/ should follow *.repository.ts naming: "${fileName}"`,
|
|
2832
|
+
file: context.file.path,
|
|
2833
|
+
line: 1,
|
|
2834
|
+
column: 1,
|
|
2835
|
+
fix: { suggestion: "Rename file to follow *.repository.ts convention." }
|
|
2836
|
+
});
|
|
2837
|
+
}
|
|
2838
|
+
return findings;
|
|
2839
|
+
}
|
|
2840
|
+
});
|
|
2841
|
+
}
|
|
2842
|
+
};
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2845
|
+
// src/plugins/optional/api.ts
|
|
2846
|
+
function apiPlugin(_config = {}) {
|
|
2847
|
+
return {
|
|
2848
|
+
name: "api-consistency",
|
|
2849
|
+
version: "1.0.0",
|
|
2850
|
+
install(kernel) {
|
|
2851
|
+
kernel.registerRule({
|
|
2852
|
+
name: "api-consistency/endpoint-naming",
|
|
2853
|
+
severity: "info",
|
|
2854
|
+
description: "Checks REST API endpoint naming conventions",
|
|
2855
|
+
category: "quality",
|
|
2856
|
+
check(context) {
|
|
2857
|
+
if (context.file.role !== "controller") return [];
|
|
2858
|
+
const findings = [];
|
|
2859
|
+
context.walk(context.ast, {
|
|
2860
|
+
StringLiteral(node) {
|
|
2861
|
+
const str = node;
|
|
2862
|
+
const value = str.text;
|
|
2863
|
+
if (value.startsWith("/") && value.length > 1) {
|
|
2864
|
+
if (/\/[a-z]+[A-Z]/.test(value)) {
|
|
2865
|
+
const pos = context.ast.getLineAndCharacterOfPosition(str.getStart(context.ast));
|
|
2866
|
+
findings.push({
|
|
2867
|
+
message: `API endpoint "${value}" uses camelCase \u2014 prefer kebab-case`,
|
|
2868
|
+
file: context.file.path,
|
|
2869
|
+
line: pos.line + 1,
|
|
2870
|
+
column: pos.character + 1,
|
|
2871
|
+
fix: { suggestion: "Use kebab-case for URL paths (e.g., /user-profiles instead of /userProfiles)." }
|
|
2872
|
+
});
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
});
|
|
2877
|
+
return findings;
|
|
2878
|
+
}
|
|
2879
|
+
});
|
|
2880
|
+
}
|
|
2881
|
+
};
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
// src/plugins/optional/test-guard.ts
|
|
2885
|
+
function testGuardPlugin(_config = {}) {
|
|
2886
|
+
return {
|
|
2887
|
+
name: "test-coverage-guard",
|
|
2888
|
+
version: "1.0.0",
|
|
2889
|
+
install(kernel) {
|
|
2890
|
+
kernel.registerRule({
|
|
2891
|
+
name: "test-coverage-guard/missing-tests",
|
|
2892
|
+
severity: "warning",
|
|
2893
|
+
description: "Ensures source files have corresponding test files",
|
|
2894
|
+
category: "quality",
|
|
2895
|
+
check(context) {
|
|
2896
|
+
if (context.file.role === "test" || context.file.role === "type" || context.file.role === "config") {
|
|
2897
|
+
return [];
|
|
2898
|
+
}
|
|
2899
|
+
const findings = [];
|
|
2900
|
+
const filePath = context.file.path;
|
|
2901
|
+
const testPaths = getExpectedTestPaths(filePath);
|
|
2902
|
+
const hasTest = testPaths.some((tp) => context.graph.files.has(tp));
|
|
2903
|
+
if (!hasTest) {
|
|
2904
|
+
findings.push({
|
|
2905
|
+
message: `No test file found for "${filePath}"`,
|
|
2906
|
+
file: context.file.path,
|
|
2907
|
+
line: 1,
|
|
2908
|
+
column: 1,
|
|
2909
|
+
fix: {
|
|
2910
|
+
/* v8 ignore next */
|
|
2911
|
+
suggestion: `Create a test file (e.g., ${testPaths[0] ?? filePath.replace(".ts", ".test.ts")}).`
|
|
2912
|
+
}
|
|
2913
|
+
});
|
|
2914
|
+
}
|
|
2915
|
+
return findings;
|
|
2916
|
+
}
|
|
2917
|
+
});
|
|
2918
|
+
}
|
|
2919
|
+
};
|
|
2920
|
+
}
|
|
2921
|
+
function getExpectedTestPaths(filePath) {
|
|
2922
|
+
const base = filePath.replace(/\.ts$/, "");
|
|
2923
|
+
return [
|
|
2924
|
+
`${base}.test.ts`,
|
|
2925
|
+
`${base}.spec.ts`,
|
|
2926
|
+
filePath.replace("src/", "tests/").replace(".ts", ".test.ts"),
|
|
2927
|
+
filePath.replace("src/", "tests/unit/").replace(".ts", ".test.ts")
|
|
2928
|
+
];
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
// src/plugins/optional/dep-audit.ts
|
|
2932
|
+
function depAuditPlugin(config = {}) {
|
|
2933
|
+
const fullConfig = {
|
|
2934
|
+
maxDepth: 5,
|
|
2935
|
+
...config
|
|
2936
|
+
};
|
|
2937
|
+
return {
|
|
2938
|
+
name: "dependency-audit",
|
|
2939
|
+
version: "1.0.0",
|
|
2940
|
+
install(kernel) {
|
|
2941
|
+
kernel.registerRule({
|
|
2942
|
+
name: "dependency-audit/deep-imports",
|
|
2943
|
+
severity: "info",
|
|
2944
|
+
description: "Detects deeply nested import chains",
|
|
2945
|
+
category: "architecture",
|
|
2946
|
+
check(context) {
|
|
2947
|
+
const findings = [];
|
|
2948
|
+
const maxDepth = fullConfig.maxDepth ?? 5;
|
|
2949
|
+
const depth = calculateImportDepth(
|
|
2950
|
+
context.file.path,
|
|
2951
|
+
context.graph.dependencies.adjacency,
|
|
2952
|
+
/* @__PURE__ */ new Set()
|
|
2953
|
+
);
|
|
2954
|
+
if (depth > maxDepth) {
|
|
2955
|
+
findings.push({
|
|
2956
|
+
message: `File has import depth of ${depth} (max: ${maxDepth})`,
|
|
2957
|
+
file: context.file.path,
|
|
2958
|
+
line: 1,
|
|
2959
|
+
column: 1,
|
|
2960
|
+
fix: {
|
|
2961
|
+
suggestion: "Consider restructuring imports to reduce dependency depth."
|
|
2962
|
+
}
|
|
2963
|
+
});
|
|
2964
|
+
}
|
|
2965
|
+
return findings;
|
|
2966
|
+
}
|
|
2967
|
+
});
|
|
2968
|
+
}
|
|
2969
|
+
};
|
|
2970
|
+
}
|
|
2971
|
+
function calculateImportDepth(file, adjacency, visited) {
|
|
2972
|
+
if (visited.has(file)) return 0;
|
|
2973
|
+
visited.add(file);
|
|
2974
|
+
const deps = adjacency.get(file);
|
|
2975
|
+
if (!deps || deps.size === 0) return 0;
|
|
2976
|
+
let maxDepth = 0;
|
|
2977
|
+
for (const dep of deps) {
|
|
2978
|
+
const depth = calculateImportDepth(dep, adjacency, visited);
|
|
2979
|
+
maxDepth = Math.max(maxDepth, depth);
|
|
2980
|
+
}
|
|
2981
|
+
return maxDepth + 1;
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
// src/index.ts
|
|
2985
|
+
function createGuardian(config) {
|
|
2986
|
+
const projectConfig = loadConfig(config.rootDir, config.config);
|
|
2987
|
+
const kernel = createKernel();
|
|
2988
|
+
let _graph = null;
|
|
2989
|
+
let _program = null;
|
|
2990
|
+
const pluginCfg = projectConfig.plugins;
|
|
2991
|
+
if (pluginCfg.architecture?.enabled !== false) {
|
|
2992
|
+
kernel.installPlugin(architecturePlugin(pluginCfg.architecture));
|
|
2993
|
+
}
|
|
2994
|
+
if (pluginCfg.security?.enabled !== false) {
|
|
2995
|
+
kernel.installPlugin(securityPlugin(pluginCfg.security));
|
|
2996
|
+
}
|
|
2997
|
+
if (pluginCfg.performance?.enabled !== false) {
|
|
2998
|
+
kernel.installPlugin(performancePlugin(pluginCfg.performance));
|
|
2999
|
+
}
|
|
3000
|
+
if (pluginCfg.quality?.enabled !== false) {
|
|
3001
|
+
kernel.installPlugin(qualityPlugin(pluginCfg.quality));
|
|
3002
|
+
}
|
|
3003
|
+
if (pluginCfg.naming?.enabled) {
|
|
3004
|
+
kernel.installPlugin(namingPlugin(pluginCfg.naming));
|
|
3005
|
+
}
|
|
3006
|
+
if (pluginCfg.api?.enabled) {
|
|
3007
|
+
kernel.installPlugin(apiPlugin(pluginCfg.api));
|
|
3008
|
+
}
|
|
3009
|
+
if (pluginCfg.testGuard?.enabled) {
|
|
3010
|
+
kernel.installPlugin(testGuardPlugin(pluginCfg.testGuard));
|
|
3011
|
+
}
|
|
3012
|
+
if (pluginCfg.depAudit?.enabled) {
|
|
3013
|
+
kernel.installPlugin(depAuditPlugin(pluginCfg.depAudit));
|
|
3014
|
+
}
|
|
3015
|
+
function getProgram() {
|
|
3016
|
+
if (!_program) {
|
|
3017
|
+
_program = createTSProgram(config.rootDir, projectConfig.tsconfig);
|
|
3018
|
+
}
|
|
3019
|
+
return _program;
|
|
3020
|
+
}
|
|
3021
|
+
const guardian = {
|
|
3022
|
+
/** The project configuration. */
|
|
3023
|
+
config: projectConfig,
|
|
3024
|
+
/** The codebase knowledge graph (available after scan). */
|
|
3025
|
+
get graph() {
|
|
3026
|
+
if (!_graph) {
|
|
3027
|
+
throw new Error("Graph not available. Call scan() first.");
|
|
3028
|
+
}
|
|
3029
|
+
return _graph;
|
|
3030
|
+
},
|
|
3031
|
+
/**
|
|
3032
|
+
* Perform a full codebase scan and build the knowledge graph.
|
|
3033
|
+
*
|
|
3034
|
+
* @returns The complete codebase graph
|
|
3035
|
+
*
|
|
3036
|
+
* @example
|
|
3037
|
+
* ```typescript
|
|
3038
|
+
* const graph = await guardian.scan();
|
|
3039
|
+
* console.log(`${graph.files.size} files, ${graph.symbols.size} symbols`);
|
|
3040
|
+
* ```
|
|
3041
|
+
*/
|
|
3042
|
+
async scan() {
|
|
3043
|
+
_graph = buildGraph(
|
|
3044
|
+
config.rootDir,
|
|
3045
|
+
projectConfig.tsconfig,
|
|
3046
|
+
projectConfig.include,
|
|
3047
|
+
projectConfig.exclude,
|
|
3048
|
+
projectConfig.plugins.architecture?.layers
|
|
3049
|
+
);
|
|
3050
|
+
await kernel.initPlugins(_graph);
|
|
3051
|
+
saveGraphCache(config.rootDir, _graph);
|
|
3052
|
+
return _graph;
|
|
3053
|
+
},
|
|
3054
|
+
/**
|
|
3055
|
+
* Perform an incremental scan based on git staged files.
|
|
3056
|
+
*
|
|
3057
|
+
* @returns Incremental result with changed and affected files
|
|
3058
|
+
*
|
|
3059
|
+
* @example
|
|
3060
|
+
* ```typescript
|
|
3061
|
+
* const result = await guardian.scanIncremental();
|
|
3062
|
+
* console.log(`Updated ${result.changedFiles.length} files`);
|
|
3063
|
+
* ```
|
|
3064
|
+
*/
|
|
3065
|
+
async scanIncremental() {
|
|
3066
|
+
if (!_graph) {
|
|
3067
|
+
_graph = loadGraphCache(config.rootDir);
|
|
3068
|
+
}
|
|
3069
|
+
if (!_graph) {
|
|
3070
|
+
await guardian.scan();
|
|
3071
|
+
}
|
|
3072
|
+
const program = getProgram();
|
|
3073
|
+
const stagedFiles = getStagedFiles(config.rootDir);
|
|
3074
|
+
const tsFiles = stagedFiles.filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
|
|
3075
|
+
const result = updateGraphIncremental(_graph, tsFiles, config.rootDir, program);
|
|
3076
|
+
saveGraphCache(config.rootDir, _graph);
|
|
3077
|
+
return result;
|
|
3078
|
+
},
|
|
3079
|
+
/**
|
|
3080
|
+
* Run analysis rules on the codebase.
|
|
3081
|
+
*
|
|
3082
|
+
* @param options - Run options (staged, verbose, plugins, format)
|
|
3083
|
+
* @returns Run result with findings, stats, and blocked status
|
|
3084
|
+
*
|
|
3085
|
+
* @example
|
|
3086
|
+
* ```typescript
|
|
3087
|
+
* const result = await guardian.run({ staged: true });
|
|
3088
|
+
* if (result.blocked) {
|
|
3089
|
+
* console.error('Commit blocked!');
|
|
3090
|
+
* process.exit(1);
|
|
3091
|
+
* }
|
|
3092
|
+
* ```
|
|
3093
|
+
*/
|
|
3094
|
+
async run(options = {}) {
|
|
3095
|
+
if (!_graph) {
|
|
3096
|
+
if (options.staged) {
|
|
3097
|
+
await guardian.scanIncremental();
|
|
3098
|
+
} else {
|
|
3099
|
+
await guardian.scan();
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
const program = getProgram();
|
|
3103
|
+
let targetFiles;
|
|
3104
|
+
if (options.staged) {
|
|
3105
|
+
const stagedFiles = getStagedFiles(config.rootDir);
|
|
3106
|
+
targetFiles = stagedFiles.filter(
|
|
3107
|
+
(f) => (f.endsWith(".ts") || f.endsWith(".tsx")) && _graph.files.has(f)
|
|
3108
|
+
);
|
|
3109
|
+
} else {
|
|
3110
|
+
targetFiles = Array.from(_graph.files.keys());
|
|
3111
|
+
}
|
|
3112
|
+
let rules = kernel.getRules();
|
|
3113
|
+
if (options.plugins && options.plugins.length > 0) {
|
|
3114
|
+
rules = rules.filter((r) => {
|
|
3115
|
+
const pluginName = r.name.split("/")[0];
|
|
3116
|
+
return pluginName && options.plugins.includes(pluginName);
|
|
3117
|
+
});
|
|
3118
|
+
}
|
|
3119
|
+
const pluginConfigMap = {};
|
|
3120
|
+
if (projectConfig.plugins.architecture) {
|
|
3121
|
+
pluginConfigMap["architecture"] = projectConfig.plugins.architecture;
|
|
3122
|
+
}
|
|
3123
|
+
if (projectConfig.plugins.security) {
|
|
3124
|
+
pluginConfigMap["security"] = projectConfig.plugins.security;
|
|
3125
|
+
}
|
|
3126
|
+
if (projectConfig.plugins.performance) {
|
|
3127
|
+
pluginConfigMap["performance"] = projectConfig.plugins.performance;
|
|
3128
|
+
}
|
|
3129
|
+
if (projectConfig.plugins.quality) {
|
|
3130
|
+
pluginConfigMap["quality"] = projectConfig.plugins.quality;
|
|
3131
|
+
}
|
|
3132
|
+
const result = await executeRules(
|
|
3133
|
+
_graph,
|
|
3134
|
+
rules,
|
|
3135
|
+
targetFiles,
|
|
3136
|
+
program,
|
|
3137
|
+
pluginConfigMap,
|
|
3138
|
+
projectConfig.ignore.rules,
|
|
3139
|
+
projectConfig.ignore.files,
|
|
3140
|
+
projectConfig.severity.blockOn,
|
|
3141
|
+
config.rootDir
|
|
3142
|
+
);
|
|
3143
|
+
return result;
|
|
3144
|
+
},
|
|
3145
|
+
/**
|
|
3146
|
+
* Register a plugin with the guardian.
|
|
3147
|
+
*
|
|
3148
|
+
* @param plugin - Plugin to register
|
|
3149
|
+
*
|
|
3150
|
+
* @example
|
|
3151
|
+
* ```typescript
|
|
3152
|
+
* import { definePlugin } from '@oxog/codeguardian';
|
|
3153
|
+
* guardian.use(myPlugin);
|
|
3154
|
+
* ```
|
|
3155
|
+
*/
|
|
3156
|
+
use(plugin) {
|
|
3157
|
+
kernel.installPlugin(plugin);
|
|
3158
|
+
},
|
|
3159
|
+
/**
|
|
3160
|
+
* Auto-discover project conventions.
|
|
3161
|
+
*
|
|
3162
|
+
* @returns Detected patterns
|
|
3163
|
+
*
|
|
3164
|
+
* @example
|
|
3165
|
+
* ```typescript
|
|
3166
|
+
* const conventions = await guardian.discover();
|
|
3167
|
+
* ```
|
|
3168
|
+
*/
|
|
3169
|
+
async discover() {
|
|
3170
|
+
if (!_graph) {
|
|
3171
|
+
await guardian.scan();
|
|
3172
|
+
}
|
|
3173
|
+
return discoverConventions(_graph);
|
|
3174
|
+
},
|
|
3175
|
+
/**
|
|
3176
|
+
* Format run results for output.
|
|
3177
|
+
*
|
|
3178
|
+
* @param result - Run result
|
|
3179
|
+
* @param format - Output format
|
|
3180
|
+
* @param verbose - Include info-level findings
|
|
3181
|
+
* @returns Formatted string
|
|
3182
|
+
*/
|
|
3183
|
+
format(result, format = "terminal", verbose = false) {
|
|
3184
|
+
switch (format) {
|
|
3185
|
+
case "json":
|
|
3186
|
+
return formatJSON(result);
|
|
3187
|
+
case "sarif":
|
|
3188
|
+
return formatSARIF(result);
|
|
3189
|
+
default:
|
|
3190
|
+
return formatTerminal(result, verbose);
|
|
3191
|
+
}
|
|
3192
|
+
},
|
|
3193
|
+
/**
|
|
3194
|
+
* Get graph query helpers.
|
|
3195
|
+
*/
|
|
3196
|
+
query: {
|
|
3197
|
+
getFile: (path10) => _graph ? getFile(_graph, path10) : void 0,
|
|
3198
|
+
getSymbol: (name) => _graph ? getSymbol(_graph, name) : void 0,
|
|
3199
|
+
getDependencies: (path10) => _graph ? getDependencies(_graph, path10) : [],
|
|
3200
|
+
getDependents: (path10) => _graph ? getDependents(_graph, path10) : [],
|
|
3201
|
+
findCircularDeps: () => _graph ? findCircularDeps(_graph) : [],
|
|
3202
|
+
getStats: () => _graph ? getGraphStats(_graph) : null
|
|
3203
|
+
},
|
|
3204
|
+
/**
|
|
3205
|
+
* Get all registered rules.
|
|
3206
|
+
*/
|
|
3207
|
+
getRules() {
|
|
3208
|
+
return kernel.getRules();
|
|
3209
|
+
},
|
|
3210
|
+
/**
|
|
3211
|
+
* Get all installed plugin names.
|
|
3212
|
+
*/
|
|
3213
|
+
getPlugins() {
|
|
3214
|
+
return kernel.getPluginNames();
|
|
3215
|
+
}
|
|
3216
|
+
};
|
|
3217
|
+
return guardian;
|
|
3218
|
+
}
|
|
3219
|
+
var HOOK_CONTENT = `#!/bin/sh
|
|
3220
|
+
# codeguardian pre-commit hook
|
|
3221
|
+
npx codeguardian run --staged
|
|
3222
|
+
`;
|
|
3223
|
+
function installHook(rootDir) {
|
|
3224
|
+
const gitDir = path8__namespace.join(rootDir, ".git");
|
|
3225
|
+
if (!fs2__namespace.existsSync(gitDir)) {
|
|
3226
|
+
throw new GitError("Not a git repository. Run `git init` first.", { rootDir });
|
|
3227
|
+
}
|
|
3228
|
+
const hooksDir = path8__namespace.join(gitDir, "hooks");
|
|
3229
|
+
if (!fs2__namespace.existsSync(hooksDir)) {
|
|
3230
|
+
fs2__namespace.mkdirSync(hooksDir, { recursive: true });
|
|
3231
|
+
}
|
|
3232
|
+
const hookPath = path8__namespace.join(hooksDir, "pre-commit");
|
|
3233
|
+
if (fs2__namespace.existsSync(hookPath)) {
|
|
3234
|
+
const existing = fs2__namespace.readFileSync(hookPath, "utf-8");
|
|
3235
|
+
if (!existing.includes("codeguardian")) {
|
|
3236
|
+
const backupPath = path8__namespace.join(hooksDir, "pre-commit.backup");
|
|
3237
|
+
fs2__namespace.writeFileSync(backupPath, existing, "utf-8");
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
fs2__namespace.writeFileSync(hookPath, HOOK_CONTENT, { mode: 493, encoding: "utf-8" });
|
|
3241
|
+
return true;
|
|
3242
|
+
}
|
|
3243
|
+
function uninstallHook(rootDir) {
|
|
3244
|
+
const hookPath = path8__namespace.join(rootDir, ".git", "hooks", "pre-commit");
|
|
3245
|
+
if (!fs2__namespace.existsSync(hookPath)) {
|
|
3246
|
+
return false;
|
|
3247
|
+
}
|
|
3248
|
+
const content = fs2__namespace.readFileSync(hookPath, "utf-8");
|
|
3249
|
+
if (!content.includes("codeguardian")) {
|
|
3250
|
+
return false;
|
|
3251
|
+
}
|
|
3252
|
+
const backupPath = path8__namespace.join(rootDir, ".git", "hooks", "pre-commit.backup");
|
|
3253
|
+
if (fs2__namespace.existsSync(backupPath)) {
|
|
3254
|
+
const backup = fs2__namespace.readFileSync(backupPath, "utf-8");
|
|
3255
|
+
fs2__namespace.writeFileSync(hookPath, backup, { mode: 493, encoding: "utf-8" });
|
|
3256
|
+
fs2__namespace.unlinkSync(backupPath);
|
|
3257
|
+
} else {
|
|
3258
|
+
fs2__namespace.unlinkSync(hookPath);
|
|
3259
|
+
}
|
|
3260
|
+
return true;
|
|
3261
|
+
}
|
|
3262
|
+
var VERSION = "1.0.0";
|
|
3263
|
+
async function main() {
|
|
3264
|
+
const args = parseArgs(process.argv.slice(2));
|
|
3265
|
+
if (args.flags["version"] || args.flags["v"]) {
|
|
3266
|
+
console.log(`@oxog/codeguardian v${VERSION}`);
|
|
3267
|
+
return;
|
|
3268
|
+
}
|
|
3269
|
+
if (args.flags["help"] || args.flags["h"] || !args.command) {
|
|
3270
|
+
printHelp();
|
|
3271
|
+
return;
|
|
3272
|
+
}
|
|
3273
|
+
const rootDir = process.cwd();
|
|
3274
|
+
switch (args.command) {
|
|
3275
|
+
case "init":
|
|
3276
|
+
await runInit(rootDir);
|
|
3277
|
+
break;
|
|
3278
|
+
case "run":
|
|
3279
|
+
await runAnalysis(rootDir, args.flags);
|
|
3280
|
+
break;
|
|
3281
|
+
case "stats":
|
|
3282
|
+
await runStats(rootDir);
|
|
3283
|
+
break;
|
|
3284
|
+
case "rules":
|
|
3285
|
+
await runRules(rootDir);
|
|
3286
|
+
break;
|
|
3287
|
+
case "conventions":
|
|
3288
|
+
await runConventions(rootDir);
|
|
3289
|
+
break;
|
|
3290
|
+
case "scan":
|
|
3291
|
+
await runScan(rootDir, args.flags);
|
|
3292
|
+
break;
|
|
3293
|
+
case "uninstall":
|
|
3294
|
+
runUninstall(rootDir);
|
|
3295
|
+
break;
|
|
3296
|
+
default:
|
|
3297
|
+
console.error(color.red(`Unknown command: ${args.command}`));
|
|
3298
|
+
printHelp();
|
|
3299
|
+
process.exit(1);
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
function printHelp() {
|
|
3303
|
+
console.log(`
|
|
3304
|
+
${color.bold("@oxog/codeguardian")} v${VERSION}
|
|
3305
|
+
Zero-dependency TypeScript codebase guardian
|
|
3306
|
+
|
|
3307
|
+
${color.bold("Usage:")}
|
|
3308
|
+
codeguardian <command> [options]
|
|
3309
|
+
|
|
3310
|
+
${color.bold("Commands:")}
|
|
3311
|
+
init Initialize codeguardian in a project
|
|
3312
|
+
run Run analysis on all or staged files
|
|
3313
|
+
stats Show codebase graph statistics
|
|
3314
|
+
rules List all registered rules
|
|
3315
|
+
conventions List detected project conventions
|
|
3316
|
+
scan Rebuild the full codebase graph
|
|
3317
|
+
uninstall Remove pre-commit hook
|
|
3318
|
+
|
|
3319
|
+
${color.bold("Run Options:")}
|
|
3320
|
+
--staged Analyze staged files only (pre-commit mode)
|
|
3321
|
+
--verbose Include info-level findings
|
|
3322
|
+
--format Output format: terminal (default), json, sarif
|
|
3323
|
+
--plugin Run specific plugin(s) only
|
|
3324
|
+
|
|
3325
|
+
${color.bold("Global Options:")}
|
|
3326
|
+
--version, -v Show version
|
|
3327
|
+
--help, -h Show help
|
|
3328
|
+
`);
|
|
3329
|
+
}
|
|
3330
|
+
async function runInit(rootDir) {
|
|
3331
|
+
console.log(color.bold("\n @oxog/codeguardian \u2014 Init\n"));
|
|
3332
|
+
if (!isGitRepo(rootDir)) {
|
|
3333
|
+
console.log(color.yellow(" \u26A0 Not a git repository. Pre-commit hook will not be installed."));
|
|
3334
|
+
console.log(color.dim(" Run `git init` first if you want hook integration.\n"));
|
|
3335
|
+
} else {
|
|
3336
|
+
try {
|
|
3337
|
+
installHook(rootDir);
|
|
3338
|
+
console.log(color.green(" \u2713 Pre-commit hook installed"));
|
|
3339
|
+
} catch (err) {
|
|
3340
|
+
console.log(color.yellow(` \u26A0 Could not install hook: ${err instanceof Error ? err.message : String(err)}`));
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
const configPath = path8__namespace.join(rootDir, ".codeguardian.json");
|
|
3344
|
+
try {
|
|
3345
|
+
writeFileSync2(configPath, getDefaultConfigJSON());
|
|
3346
|
+
console.log(color.green(" \u2713 Configuration file created (.codeguardian.json)"));
|
|
3347
|
+
} catch (err) {
|
|
3348
|
+
console.log(color.yellow(` \u26A0 Could not create config: ${err instanceof Error ? err.message : String(err)}`));
|
|
3349
|
+
}
|
|
3350
|
+
try {
|
|
3351
|
+
console.log(color.dim(" Scanning project..."));
|
|
3352
|
+
const guardian = createGuardian({ rootDir });
|
|
3353
|
+
const graph = await guardian.scan();
|
|
3354
|
+
console.log(color.green(` \u2713 Scanned ${graph.files.size} files, ${graph.symbols.size} symbols`));
|
|
3355
|
+
console.log(color.green(" \u2713 Graph cached to .codeguardian/graph.json"));
|
|
3356
|
+
} catch (err) {
|
|
3357
|
+
console.log(color.yellow(` \u26A0 Scan failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
3358
|
+
console.log(color.dim(" You can try running `codeguardian scan --full` later."));
|
|
3359
|
+
}
|
|
3360
|
+
console.log(color.bold("\n Done! codeguardian is ready.\n"));
|
|
3361
|
+
}
|
|
3362
|
+
async function runAnalysis(rootDir, flags) {
|
|
3363
|
+
const staged = flags["staged"] === true;
|
|
3364
|
+
const verbose = flags["verbose"] === true;
|
|
3365
|
+
const format = typeof flags["format"] === "string" ? flags["format"] : "terminal";
|
|
3366
|
+
const pluginFilter = typeof flags["plugin"] === "string" ? [flags["plugin"]] : void 0;
|
|
3367
|
+
try {
|
|
3368
|
+
const guardian = createGuardian({ rootDir });
|
|
3369
|
+
const result = await guardian.run({
|
|
3370
|
+
staged,
|
|
3371
|
+
verbose,
|
|
3372
|
+
plugins: pluginFilter,
|
|
3373
|
+
format
|
|
3374
|
+
});
|
|
3375
|
+
const output = guardian.format(result, format, verbose);
|
|
3376
|
+
console.log(output);
|
|
3377
|
+
if (result.blocked) {
|
|
3378
|
+
process.exit(1);
|
|
3379
|
+
}
|
|
3380
|
+
} catch (err) {
|
|
3381
|
+
console.error(color.red(`
|
|
3382
|
+
Error: ${err instanceof Error ? err.message : String(err)}
|
|
3383
|
+
`));
|
|
3384
|
+
process.exit(1);
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
async function runStats(rootDir) {
|
|
3388
|
+
try {
|
|
3389
|
+
const guardian = createGuardian({ rootDir });
|
|
3390
|
+
await guardian.scan();
|
|
3391
|
+
const stats = guardian.query.getStats();
|
|
3392
|
+
if (!stats) {
|
|
3393
|
+
console.log(color.yellow(" No graph available. Run scan first."));
|
|
3394
|
+
return;
|
|
3395
|
+
}
|
|
3396
|
+
console.log(`
|
|
3397
|
+
${color.bold(" @oxog/codeguardian \u2014 Stats")}
|
|
3398
|
+
|
|
3399
|
+
Files: ${stats.totalFiles}
|
|
3400
|
+
Symbols: ${stats.totalSymbols}
|
|
3401
|
+
Edges: ${stats.totalEdges}
|
|
3402
|
+
Functions: ${stats.totalFunctions}
|
|
3403
|
+
Total LOC: ${stats.totalLOC}
|
|
3404
|
+
Avg Complexity: ${stats.avgComplexity.toFixed(1)}
|
|
3405
|
+
|
|
3406
|
+
${color.bold("By Role:")}
|
|
3407
|
+
${Object.entries(stats.filesByRole).map(([role, count]) => ` ${role}: ${count}`).join("\n")}
|
|
3408
|
+
|
|
3409
|
+
${color.bold("By Layer:")}
|
|
3410
|
+
${Object.entries(stats.filesByLayer).map(([layer, count]) => ` ${layer}: ${count}`).join("\n")}
|
|
3411
|
+
`);
|
|
3412
|
+
} catch (err) {
|
|
3413
|
+
console.error(color.red(` Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
3414
|
+
process.exit(1);
|
|
3415
|
+
}
|
|
3416
|
+
}
|
|
3417
|
+
async function runRules(rootDir) {
|
|
3418
|
+
try {
|
|
3419
|
+
const guardian = createGuardian({ rootDir });
|
|
3420
|
+
const rules = guardian.getRules();
|
|
3421
|
+
console.log(`
|
|
3422
|
+
${color.bold(" @oxog/codeguardian \u2014 Rules")} (${rules.length} total)
|
|
3423
|
+
`);
|
|
3424
|
+
const byCategory = /* @__PURE__ */ new Map();
|
|
3425
|
+
for (const rule of rules) {
|
|
3426
|
+
if (!byCategory.has(rule.category)) {
|
|
3427
|
+
byCategory.set(rule.category, []);
|
|
3428
|
+
}
|
|
3429
|
+
byCategory.get(rule.category).push(rule);
|
|
3430
|
+
}
|
|
3431
|
+
for (const [category, categoryRules] of byCategory) {
|
|
3432
|
+
console.log(` ${color.bold(category.toUpperCase())}`);
|
|
3433
|
+
for (const rule of categoryRules) {
|
|
3434
|
+
const sevColor = rule.severity === "critical" || rule.severity === "error" ? color.red : rule.severity === "warning" ? color.yellow : color.dim;
|
|
3435
|
+
console.log(` ${sevColor(rule.severity.padEnd(8))} ${rule.name}`);
|
|
3436
|
+
console.log(` ${color.dim(rule.description)}`);
|
|
3437
|
+
}
|
|
3438
|
+
console.log("");
|
|
3439
|
+
}
|
|
3440
|
+
} catch (err) {
|
|
3441
|
+
console.error(color.red(` Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
3442
|
+
process.exit(1);
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
async function runConventions(rootDir) {
|
|
3446
|
+
try {
|
|
3447
|
+
const guardian = createGuardian({ rootDir });
|
|
3448
|
+
const conventions = await guardian.discover();
|
|
3449
|
+
console.log(`
|
|
3450
|
+
${color.bold(" @oxog/codeguardian \u2014 Conventions")} (${conventions.length} detected)
|
|
3451
|
+
`);
|
|
3452
|
+
for (const conv of conventions) {
|
|
3453
|
+
console.log(` ${color.bold(conv.type)} (${conv.confidence}% confidence)`);
|
|
3454
|
+
console.log(` ${conv.description}`);
|
|
3455
|
+
if (conv.files.length > 0) {
|
|
3456
|
+
console.log(` Files: ${conv.files.slice(0, 3).join(", ")}${conv.files.length > 3 ? ` +${conv.files.length - 3} more` : ""}`);
|
|
3457
|
+
}
|
|
3458
|
+
console.log("");
|
|
3459
|
+
}
|
|
3460
|
+
} catch (err) {
|
|
3461
|
+
console.error(color.red(` Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
3462
|
+
process.exit(1);
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
async function runScan(rootDir, flags) {
|
|
3466
|
+
const full = flags["full"] === true;
|
|
3467
|
+
console.log(color.dim(`
|
|
3468
|
+
${full ? "Full" : "Incremental"} scan...
|
|
3469
|
+
`));
|
|
3470
|
+
try {
|
|
3471
|
+
const guardian = createGuardian({ rootDir });
|
|
3472
|
+
const graph = await guardian.scan();
|
|
3473
|
+
console.log(color.green(` \u2713 Scanned ${graph.files.size} files, ${graph.symbols.size} symbols`));
|
|
3474
|
+
console.log(color.green(" \u2713 Graph cached to .codeguardian/graph.json\n"));
|
|
3475
|
+
} catch (err) {
|
|
3476
|
+
console.error(color.red(` Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
3477
|
+
process.exit(1);
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
3480
|
+
function runUninstall(rootDir) {
|
|
3481
|
+
try {
|
|
3482
|
+
const removed = uninstallHook(rootDir);
|
|
3483
|
+
if (removed) {
|
|
3484
|
+
console.log(color.green("\n \u2713 Pre-commit hook removed\n"));
|
|
3485
|
+
} else {
|
|
3486
|
+
console.log(color.yellow("\n \u26A0 No codeguardian hook found\n"));
|
|
3487
|
+
}
|
|
3488
|
+
} catch (err) {
|
|
3489
|
+
console.error(color.red(` Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
3490
|
+
process.exit(1);
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
main().catch((err) => {
|
|
3494
|
+
console.error(color.red(`Fatal error: ${err instanceof Error ? err.message : String(err)}`));
|
|
3495
|
+
process.exit(1);
|
|
3496
|
+
});
|
|
3497
|
+
//# sourceMappingURL=cli.cjs.map
|
|
3498
|
+
//# sourceMappingURL=cli.cjs.map
|