project-graph-mcp 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/AGENT_ROLE.md +126 -0
- package/AGENT_ROLE_MINIMAL.md +54 -0
- package/CONFIGURATION.md +188 -0
- package/LICENSE +21 -0
- package/README.md +279 -0
- package/package.json +46 -0
- package/references/symbiote-3x.md +834 -0
- package/rules/express-5.json +76 -0
- package/rules/fastify-5.json +75 -0
- package/rules/nestjs-10.json +88 -0
- package/rules/nextjs-15.json +87 -0
- package/rules/node-22.json +156 -0
- package/rules/react-18.json +87 -0
- package/rules/react-19.json +76 -0
- package/rules/symbiote-2x.json +158 -0
- package/rules/symbiote-3x.json +221 -0
- package/rules/typescript-5.json +69 -0
- package/rules/vue-3.json +79 -0
- package/src/cli-handlers.js +140 -0
- package/src/cli.js +83 -0
- package/src/complexity.js +223 -0
- package/src/custom-rules.js +583 -0
- package/src/dead-code.js +468 -0
- package/src/filters.js +226 -0
- package/src/framework-references.js +177 -0
- package/src/full-analysis.js +159 -0
- package/src/graph-builder.js +269 -0
- package/src/instructions.js +175 -0
- package/src/jsdoc-generator.js +214 -0
- package/src/large-files.js +162 -0
- package/src/mcp-server.js +375 -0
- package/src/outdated-patterns.js +295 -0
- package/src/parser.js +293 -0
- package/src/server.js +28 -0
- package/src/similar-functions.js +278 -0
- package/src/test-annotations.js +301 -0
- package/src/tool-defs.js +444 -0
- package/src/tools.js +240 -0
- package/src/undocumented.js +260 -0
- package/src/workspace.js +70 -0
- package/vendor/acorn.mjs +6145 -0
- package/vendor/walk.mjs +437 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom Rules System
|
|
3
|
+
* Configurable code analysis rules with JSON storage
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from 'fs';
|
|
7
|
+
import { join, relative, dirname, resolve } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { shouldExcludeDir, shouldExcludeFile, parseGitignore } from './filters.js';
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const RULES_DIR = join(__dirname, '..', 'rules');
|
|
13
|
+
|
|
14
|
+
/** @type {string[]} Patterns from .graphignore */
|
|
15
|
+
let graphignorePatterns = [];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse .graphignore file - searches current and parent directories
|
|
19
|
+
* @param {string} startDir
|
|
20
|
+
*/
|
|
21
|
+
function parseGraphignore(startDir) {
|
|
22
|
+
graphignorePatterns = [];
|
|
23
|
+
|
|
24
|
+
// Search up the directory tree for .graphignore
|
|
25
|
+
let dir = startDir;
|
|
26
|
+
while (dir !== dirname(dir)) {
|
|
27
|
+
const ignorePath = join(dir, '.graphignore');
|
|
28
|
+
if (existsSync(ignorePath)) {
|
|
29
|
+
try {
|
|
30
|
+
const content = readFileSync(ignorePath, 'utf-8');
|
|
31
|
+
graphignorePatterns = content
|
|
32
|
+
.split('\n')
|
|
33
|
+
.map(line => line.trim())
|
|
34
|
+
.filter(line => line && !line.startsWith('#'));
|
|
35
|
+
return;
|
|
36
|
+
} catch (e) { }
|
|
37
|
+
}
|
|
38
|
+
dir = dirname(dir);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if file matches .graphignore patterns
|
|
44
|
+
* @param {string} relativePath
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
function isGraphignored(relativePath) {
|
|
48
|
+
const basename = relativePath.split('/').pop();
|
|
49
|
+
|
|
50
|
+
for (const pattern of graphignorePatterns) {
|
|
51
|
+
// Simple glob matching
|
|
52
|
+
if (pattern.endsWith('*')) {
|
|
53
|
+
const prefix = pattern.slice(0, -1);
|
|
54
|
+
if (relativePath.startsWith(prefix) || basename.startsWith(prefix)) return true;
|
|
55
|
+
} else if (pattern.startsWith('*')) {
|
|
56
|
+
const suffix = pattern.slice(1);
|
|
57
|
+
if (relativePath.endsWith(suffix) || basename.endsWith(suffix)) return true;
|
|
58
|
+
} else {
|
|
59
|
+
// Exact match on path or basename
|
|
60
|
+
if (relativePath.includes(pattern) || basename === pattern) return true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @typedef {Object} Rule
|
|
68
|
+
* @property {string} id
|
|
69
|
+
* @property {string} name
|
|
70
|
+
* @property {string} description
|
|
71
|
+
* @property {string} pattern - String or regex pattern to search
|
|
72
|
+
* @property {string} patternType - 'string' | 'regex'
|
|
73
|
+
* @property {string} replacement - Suggested fix
|
|
74
|
+
* @property {string} severity - 'error' | 'warning' | 'info'
|
|
75
|
+
* @property {string} filePattern - Glob pattern for files
|
|
76
|
+
* @property {string[]} [exclude] - Patterns to exclude
|
|
77
|
+
* @property {string} [contextRequired] - HTML tag context required (e.g. '<template>')
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @typedef {Object} RuleSet
|
|
82
|
+
* @property {string} name
|
|
83
|
+
* @property {string} description
|
|
84
|
+
* @property {Rule[]} rules
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @typedef {Object} Violation
|
|
89
|
+
* @property {string} ruleId
|
|
90
|
+
* @property {string} ruleName
|
|
91
|
+
* @property {string} severity
|
|
92
|
+
* @property {string} file
|
|
93
|
+
* @property {number} line
|
|
94
|
+
* @property {string} match
|
|
95
|
+
* @property {string} replacement
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Load rule sets from rules directory
|
|
100
|
+
* @returns {Object<string, RuleSet>}
|
|
101
|
+
*/
|
|
102
|
+
function loadRuleSets() {
|
|
103
|
+
const ruleSets = {};
|
|
104
|
+
|
|
105
|
+
if (!existsSync(RULES_DIR)) return ruleSets;
|
|
106
|
+
|
|
107
|
+
for (const file of readdirSync(RULES_DIR)) {
|
|
108
|
+
if (!file.endsWith('.json')) continue;
|
|
109
|
+
try {
|
|
110
|
+
const content = readFileSync(join(RULES_DIR, file), 'utf-8');
|
|
111
|
+
const ruleSet = JSON.parse(content);
|
|
112
|
+
ruleSets[ruleSet.name] = ruleSet;
|
|
113
|
+
} catch (e) { }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return ruleSets;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Save rule set to file
|
|
121
|
+
* @param {RuleSet} ruleSet
|
|
122
|
+
*/
|
|
123
|
+
function saveRuleSet(ruleSet) {
|
|
124
|
+
const filePath = join(RULES_DIR, `${ruleSet.name}.json`);
|
|
125
|
+
writeFileSync(filePath, JSON.stringify(ruleSet, null, 2));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Find all files matching pattern
|
|
130
|
+
* @param {string} dir
|
|
131
|
+
* @param {string} filePattern
|
|
132
|
+
* @param {string} rootDir
|
|
133
|
+
* @returns {string[]}
|
|
134
|
+
*/
|
|
135
|
+
function findFiles(dir, filePattern, rootDir = dir) {
|
|
136
|
+
if (dir === rootDir) {
|
|
137
|
+
parseGitignore(rootDir);
|
|
138
|
+
parseGraphignore(rootDir);
|
|
139
|
+
}
|
|
140
|
+
const files = [];
|
|
141
|
+
const ext = filePattern.replace('*', '');
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
for (const entry of readdirSync(dir)) {
|
|
145
|
+
const fullPath = join(dir, entry);
|
|
146
|
+
const relativePath = relative(rootDir, fullPath);
|
|
147
|
+
const stat = statSync(fullPath);
|
|
148
|
+
|
|
149
|
+
if (stat.isDirectory()) {
|
|
150
|
+
if (!shouldExcludeDir(entry, relativePath)) {
|
|
151
|
+
files.push(...findFiles(fullPath, filePattern, rootDir));
|
|
152
|
+
}
|
|
153
|
+
} else if (entry.endsWith(ext)) {
|
|
154
|
+
if (!shouldExcludeFile(entry, relativePath) && !isGraphignored(relativePath)) {
|
|
155
|
+
files.push(fullPath);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} catch (e) { }
|
|
160
|
+
|
|
161
|
+
return files;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Check if file matches exclude patterns
|
|
166
|
+
* @param {string} filePath
|
|
167
|
+
* @param {string[]} excludePatterns
|
|
168
|
+
* @returns {boolean}
|
|
169
|
+
*/
|
|
170
|
+
function isExcluded(filePath, excludePatterns = []) {
|
|
171
|
+
for (const pattern of excludePatterns) {
|
|
172
|
+
const ext = pattern.replace('*', '');
|
|
173
|
+
if (filePath.endsWith(ext)) return true;
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Check if a position in a line is inside a string or comment
|
|
180
|
+
* @param {string} line - The line of code
|
|
181
|
+
* @param {number} matchIndex - Position of the match
|
|
182
|
+
* @returns {boolean}
|
|
183
|
+
*/
|
|
184
|
+
function isInStringOrComment(line, matchIndex) {
|
|
185
|
+
// Check if in single-line comment
|
|
186
|
+
const commentIndex = line.indexOf('//');
|
|
187
|
+
if (commentIndex !== -1 && matchIndex > commentIndex) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Check if in string literal (simplified - handles most cases)
|
|
192
|
+
let inString = false;
|
|
193
|
+
let stringChar = null;
|
|
194
|
+
|
|
195
|
+
for (let i = 0; i < matchIndex; i++) {
|
|
196
|
+
const char = line[i];
|
|
197
|
+
const prevChar = i > 0 ? line[i - 1] : '';
|
|
198
|
+
|
|
199
|
+
if (!inString && (char === '"' || char === "'" || char === '`')) {
|
|
200
|
+
inString = true;
|
|
201
|
+
stringChar = char;
|
|
202
|
+
} else if (inString && char === stringChar && prevChar !== '\\') {
|
|
203
|
+
inString = false;
|
|
204
|
+
stringChar = null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return inString;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Check if a line index is within an HTML context block (e.g. <template>...</template>)
|
|
213
|
+
* @param {string[]} lines - All file lines
|
|
214
|
+
* @param {number} lineIndex - Current line index
|
|
215
|
+
* @param {string} contextTag - Tag to check (e.g. '<template>')
|
|
216
|
+
* @returns {boolean}
|
|
217
|
+
*/
|
|
218
|
+
function isWithinContext(lines, lineIndex, contextTag) {
|
|
219
|
+
const openTag = contextTag;
|
|
220
|
+
const tagName = openTag.replace(/[<>]/g, '');
|
|
221
|
+
const closeTag = `</${tagName}>`;
|
|
222
|
+
let depth = 0;
|
|
223
|
+
|
|
224
|
+
for (let i = 0; i <= lineIndex; i++) {
|
|
225
|
+
const line = lines[i];
|
|
226
|
+
// Count all opens/closes on this line
|
|
227
|
+
let pos = 0;
|
|
228
|
+
while (pos < line.length) {
|
|
229
|
+
const openIdx = line.indexOf(openTag, pos);
|
|
230
|
+
const closeIdx = line.indexOf(closeTag, pos);
|
|
231
|
+
|
|
232
|
+
if (openIdx === -1 && closeIdx === -1) break;
|
|
233
|
+
|
|
234
|
+
if (openIdx !== -1 && (closeIdx === -1 || openIdx < closeIdx)) {
|
|
235
|
+
depth++;
|
|
236
|
+
pos = openIdx + openTag.length;
|
|
237
|
+
} else {
|
|
238
|
+
depth--;
|
|
239
|
+
pos = closeIdx + closeTag.length;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return depth > 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Check file against rule
|
|
249
|
+
* @param {string} filePath
|
|
250
|
+
* @param {Rule} rule
|
|
251
|
+
* @returns {Violation[]}
|
|
252
|
+
*/
|
|
253
|
+
function checkFileAgainstRule(filePath, rule, rootDir) {
|
|
254
|
+
if (isExcluded(filePath, rule.exclude)) return [];
|
|
255
|
+
|
|
256
|
+
const violations = [];
|
|
257
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
258
|
+
const lines = content.split('\n');
|
|
259
|
+
const relPath = relative(rootDir, filePath);
|
|
260
|
+
|
|
261
|
+
for (let i = 0; i < lines.length; i++) {
|
|
262
|
+
const line = lines[i];
|
|
263
|
+
let matches = false;
|
|
264
|
+
let matchText = '';
|
|
265
|
+
|
|
266
|
+
if (rule.patternType === 'regex') {
|
|
267
|
+
try {
|
|
268
|
+
const regex = new RegExp(rule.pattern, 'g');
|
|
269
|
+
let match;
|
|
270
|
+
while ((match = regex.exec(line)) !== null) {
|
|
271
|
+
if (!isInStringOrComment(line, match.index)) {
|
|
272
|
+
matches = true;
|
|
273
|
+
matchText = match[0];
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} catch (e) { }
|
|
278
|
+
} else {
|
|
279
|
+
const matchIndex = line.indexOf(rule.pattern);
|
|
280
|
+
if (matchIndex !== -1 && !isInStringOrComment(line, matchIndex)) {
|
|
281
|
+
matches = true;
|
|
282
|
+
matchText = rule.pattern;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Skip if context required but not within that context
|
|
287
|
+
if (matches && rule.contextRequired) {
|
|
288
|
+
if (!isWithinContext(lines, i, rule.contextRequired)) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (matches) {
|
|
294
|
+
violations.push({
|
|
295
|
+
ruleId: rule.id,
|
|
296
|
+
ruleName: rule.name,
|
|
297
|
+
severity: rule.severity,
|
|
298
|
+
file: relPath,
|
|
299
|
+
line: i + 1,
|
|
300
|
+
match: matchText,
|
|
301
|
+
replacement: rule.replacement,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return violations;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Get all available custom rules
|
|
311
|
+
* @returns {Promise<{ruleSets: Object, totalRules: number}>}
|
|
312
|
+
*/
|
|
313
|
+
export async function getCustomRules() {
|
|
314
|
+
const ruleSets = loadRuleSets();
|
|
315
|
+
let totalRules = 0;
|
|
316
|
+
|
|
317
|
+
const summary = {};
|
|
318
|
+
for (const [name, ruleSet] of Object.entries(ruleSets)) {
|
|
319
|
+
summary[name] = {
|
|
320
|
+
description: ruleSet.description,
|
|
321
|
+
ruleCount: ruleSet.rules.length,
|
|
322
|
+
rules: ruleSet.rules.map(r => ({
|
|
323
|
+
id: r.id,
|
|
324
|
+
name: r.name,
|
|
325
|
+
severity: r.severity,
|
|
326
|
+
})),
|
|
327
|
+
};
|
|
328
|
+
totalRules += ruleSet.rules.length;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return { ruleSets: summary, totalRules };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Add or update a custom rule
|
|
336
|
+
* @param {string} ruleSetName
|
|
337
|
+
* @param {Rule} rule
|
|
338
|
+
* @returns {Promise<{success: boolean, message: string}>}
|
|
339
|
+
*/
|
|
340
|
+
export async function setCustomRule(ruleSetName, rule) {
|
|
341
|
+
const ruleSets = loadRuleSets();
|
|
342
|
+
|
|
343
|
+
// Create new ruleset if doesn't exist
|
|
344
|
+
if (!ruleSets[ruleSetName]) {
|
|
345
|
+
ruleSets[ruleSetName] = {
|
|
346
|
+
name: ruleSetName,
|
|
347
|
+
description: `Custom rules for ${ruleSetName}`,
|
|
348
|
+
rules: [],
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const ruleSet = ruleSets[ruleSetName];
|
|
353
|
+
const existingIndex = ruleSet.rules.findIndex(r => r.id === rule.id);
|
|
354
|
+
|
|
355
|
+
if (existingIndex >= 0) {
|
|
356
|
+
ruleSet.rules[existingIndex] = rule;
|
|
357
|
+
} else {
|
|
358
|
+
ruleSet.rules.push(rule);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
saveRuleSet(ruleSet);
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
success: true,
|
|
365
|
+
message: existingIndex >= 0
|
|
366
|
+
? `Updated rule "${rule.id}" in ${ruleSetName}`
|
|
367
|
+
: `Added rule "${rule.id}" to ${ruleSetName}`,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Delete a custom rule
|
|
373
|
+
* @param {string} ruleSetName
|
|
374
|
+
* @param {string} ruleId
|
|
375
|
+
* @returns {Promise<{success: boolean, message: string}>}
|
|
376
|
+
*/
|
|
377
|
+
export async function deleteCustomRule(ruleSetName, ruleId) {
|
|
378
|
+
const ruleSets = loadRuleSets();
|
|
379
|
+
|
|
380
|
+
if (!ruleSets[ruleSetName]) {
|
|
381
|
+
return { success: false, message: `Ruleset "${ruleSetName}" not found` };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const ruleSet = ruleSets[ruleSetName];
|
|
385
|
+
const index = ruleSet.rules.findIndex(r => r.id === ruleId);
|
|
386
|
+
|
|
387
|
+
if (index < 0) {
|
|
388
|
+
return { success: false, message: `Rule "${ruleId}" not found` };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
ruleSet.rules.splice(index, 1);
|
|
392
|
+
saveRuleSet(ruleSet);
|
|
393
|
+
|
|
394
|
+
return { success: true, message: `Deleted rule "${ruleId}" from ${ruleSetName}` };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Detect which rulesets apply to a project
|
|
399
|
+
* @param {string} dir
|
|
400
|
+
* @returns {{detected: string[], reasons: Object<string, string>}}
|
|
401
|
+
*/
|
|
402
|
+
export function detectProjectRuleSets(dir) {
|
|
403
|
+
const ruleSets = loadRuleSets();
|
|
404
|
+
const detected = [];
|
|
405
|
+
const reasons = {};
|
|
406
|
+
|
|
407
|
+
// Check package.json
|
|
408
|
+
let packageDeps = [];
|
|
409
|
+
try {
|
|
410
|
+
const pkgPath = join(dir, 'package.json');
|
|
411
|
+
if (existsSync(pkgPath)) {
|
|
412
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
413
|
+
packageDeps = [
|
|
414
|
+
...Object.keys(pkg.dependencies || {}),
|
|
415
|
+
...Object.keys(pkg.devDependencies || {}),
|
|
416
|
+
];
|
|
417
|
+
}
|
|
418
|
+
} catch (e) { }
|
|
419
|
+
|
|
420
|
+
for (const [name, ruleSet] of Object.entries(ruleSets)) {
|
|
421
|
+
if (!ruleSet.detect) continue;
|
|
422
|
+
const detect = ruleSet.detect;
|
|
423
|
+
|
|
424
|
+
// Check packageJson deps
|
|
425
|
+
if (detect.packageJson) {
|
|
426
|
+
for (const dep of detect.packageJson) {
|
|
427
|
+
if (packageDeps.includes(dep)) {
|
|
428
|
+
detected.push(name);
|
|
429
|
+
reasons[name] = `Found "${dep}" in package.json`;
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Skip further checks if already detected
|
|
436
|
+
if (detected.includes(name)) continue;
|
|
437
|
+
|
|
438
|
+
// Check for import patterns in source files
|
|
439
|
+
if (detect.imports || detect.patterns) {
|
|
440
|
+
const jsFiles = findFiles(dir, '*.js');
|
|
441
|
+
|
|
442
|
+
fileLoop:
|
|
443
|
+
for (const file of jsFiles.slice(0, 50)) { // Limit for performance
|
|
444
|
+
try {
|
|
445
|
+
const content = readFileSync(file, 'utf-8');
|
|
446
|
+
|
|
447
|
+
if (detect.imports) {
|
|
448
|
+
for (const pattern of detect.imports) {
|
|
449
|
+
if (content.includes(pattern)) {
|
|
450
|
+
detected.push(name);
|
|
451
|
+
reasons[name] = `Found "${pattern}" in ${relative(dir, file)}`;
|
|
452
|
+
break fileLoop;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (detect.patterns) {
|
|
458
|
+
for (const pattern of detect.patterns) {
|
|
459
|
+
if (content.includes(pattern)) {
|
|
460
|
+
detected.push(name);
|
|
461
|
+
reasons[name] = `Found "${pattern}" in ${relative(dir, file)}`;
|
|
462
|
+
break fileLoop;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
} catch (e) { }
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return { detected, reasons };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Check directory against custom rules
|
|
476
|
+
* @param {string} dir
|
|
477
|
+
* @param {Object} [options]
|
|
478
|
+
* @param {string} [options.ruleSet] - Specific ruleset to use
|
|
479
|
+
* @param {string} [options.severity] - Filter by severity
|
|
480
|
+
* @param {boolean} [options.autoDetect] - Auto-detect applicable rulesets
|
|
481
|
+
* @returns {Promise<{total: number, bySeverity: Object, byRule: Object, violations: Violation[], detected?: Object}>}
|
|
482
|
+
*/
|
|
483
|
+
export async function checkCustomRules(dir, options = {}) {
|
|
484
|
+
const resolvedDir = resolve(dir);
|
|
485
|
+
const ruleSets = loadRuleSets();
|
|
486
|
+
let allRules = [];
|
|
487
|
+
let detectionResult = null;
|
|
488
|
+
|
|
489
|
+
// Collect rules
|
|
490
|
+
if (options.ruleSet) {
|
|
491
|
+
if (ruleSets[options.ruleSet]) {
|
|
492
|
+
allRules = ruleSets[options.ruleSet].rules;
|
|
493
|
+
}
|
|
494
|
+
} else if (options.autoDetect !== false) {
|
|
495
|
+
// Auto-detect by default
|
|
496
|
+
detectionResult = detectProjectRuleSets(dir);
|
|
497
|
+
|
|
498
|
+
if (detectionResult.detected.length > 0) {
|
|
499
|
+
for (const name of detectionResult.detected) {
|
|
500
|
+
if (ruleSets[name]) {
|
|
501
|
+
allRules.push(...ruleSets[name].rules);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Always add universal rulesets (alwaysApply: true)
|
|
507
|
+
for (const [name, rs] of Object.entries(ruleSets)) {
|
|
508
|
+
if (rs.alwaysApply && !detectionResult.detected.includes(name)) {
|
|
509
|
+
allRules.push(...rs.rules);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
} else {
|
|
513
|
+
for (const ruleSet of Object.values(ruleSets)) {
|
|
514
|
+
allRules.push(...ruleSet.rules);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Group rules by file pattern
|
|
519
|
+
const rulesByPattern = {};
|
|
520
|
+
for (const rule of allRules) {
|
|
521
|
+
const pattern = rule.filePattern || '*.js';
|
|
522
|
+
if (!rulesByPattern[pattern]) rulesByPattern[pattern] = [];
|
|
523
|
+
rulesByPattern[pattern].push(rule);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Find and check files
|
|
527
|
+
const allViolations = [];
|
|
528
|
+
|
|
529
|
+
for (const [pattern, rules] of Object.entries(rulesByPattern)) {
|
|
530
|
+
const files = findFiles(dir, pattern);
|
|
531
|
+
|
|
532
|
+
for (const file of files) {
|
|
533
|
+
for (const rule of rules) {
|
|
534
|
+
const violations = checkFileAgainstRule(file, rule, resolvedDir);
|
|
535
|
+
allViolations.push(...violations);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Deduplicate violations across rulesets (same file:line:match)
|
|
541
|
+
const seen = new Set();
|
|
542
|
+
const deduped = allViolations.filter(v => {
|
|
543
|
+
const key = `${v.file}:${v.line}:${v.match}`;
|
|
544
|
+
if (seen.has(key)) return false;
|
|
545
|
+
seen.add(key);
|
|
546
|
+
return true;
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// Filter by severity if specified
|
|
550
|
+
let filtered = deduped;
|
|
551
|
+
if (options.severity) {
|
|
552
|
+
filtered = deduped.filter(v => v.severity === options.severity);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Sort by severity, then file
|
|
556
|
+
const severityOrder = { error: 0, warning: 1, info: 2 };
|
|
557
|
+
filtered.sort((a, b) => {
|
|
558
|
+
const sevDiff = severityOrder[a.severity] - severityOrder[b.severity];
|
|
559
|
+
if (sevDiff !== 0) return sevDiff;
|
|
560
|
+
return a.file.localeCompare(b.file);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// Calculate stats
|
|
564
|
+
const bySeverity = {
|
|
565
|
+
error: filtered.filter(v => v.severity === 'error').length,
|
|
566
|
+
warning: filtered.filter(v => v.severity === 'warning').length,
|
|
567
|
+
info: filtered.filter(v => v.severity === 'info').length,
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
const byRule = {};
|
|
571
|
+
for (const v of filtered) {
|
|
572
|
+
byRule[v.ruleId] = (byRule[v.ruleId] || 0) + 1;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return {
|
|
576
|
+
basePath: dir,
|
|
577
|
+
total: filtered.length,
|
|
578
|
+
bySeverity,
|
|
579
|
+
byRule,
|
|
580
|
+
violations: filtered.slice(0, 50),
|
|
581
|
+
...(detectionResult && { detected: detectionResult }),
|
|
582
|
+
};
|
|
583
|
+
}
|