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.
@@ -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
+ }