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,468 @@
1
+ /**
2
+ * Dead Code Detector
3
+ * Finds unused functions, classes, exports, variables, and imports
4
+ */
5
+
6
+ import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
7
+ import { join, relative, resolve, dirname } from 'path';
8
+ import { parse } from '../vendor/acorn.mjs';
9
+ import * as walk from '../vendor/walk.mjs';
10
+ import { shouldExcludeDir, shouldExcludeFile, parseGitignore } from './filters.js';
11
+
12
+ /**
13
+ * @typedef {Object} DeadCodeItem
14
+ * @property {string} name
15
+ * @property {string} type - 'function' | 'class' | 'export' | 'variable' | 'import'
16
+ * @property {string} file
17
+ * @property {number} line
18
+ * @property {string} reason
19
+ */
20
+
21
+ /**
22
+ * Find all JS files
23
+ * @param {string} dir
24
+ * @param {string} rootDir
25
+ * @returns {string[]}
26
+ */
27
+ function findJSFiles(dir, rootDir = dir) {
28
+ if (dir === rootDir) parseGitignore(rootDir);
29
+ const files = [];
30
+
31
+ try {
32
+ for (const entry of readdirSync(dir)) {
33
+ const fullPath = join(dir, entry);
34
+ const relativePath = relative(rootDir, fullPath);
35
+ const stat = statSync(fullPath);
36
+
37
+ if (stat.isDirectory()) {
38
+ if (!shouldExcludeDir(entry, relativePath)) {
39
+ files.push(...findJSFiles(fullPath, rootDir));
40
+ }
41
+ } else if (entry.endsWith('.js') && !entry.endsWith('.css.js') && !entry.endsWith('.tpl.js')) {
42
+ if (!shouldExcludeFile(entry, relativePath)) {
43
+ files.push(fullPath);
44
+ }
45
+ } else if (entry.endsWith('.css.js') || entry.endsWith('.tpl.js')) {
46
+ if (!shouldExcludeFile(entry, relativePath)) {
47
+ files.push(fullPath);
48
+ }
49
+ }
50
+ }
51
+ } catch (e) { }
52
+
53
+ return files;
54
+ }
55
+
56
+ /**
57
+ * Find project root by walking up from dir to find package.json
58
+ * @param {string} dir
59
+ * @returns {string}
60
+ */
61
+ function findProjectRoot(dir) {
62
+ let current = resolve(dir);
63
+ while (current !== dirname(current)) {
64
+ if (existsSync(join(current, 'package.json'))) return current;
65
+ current = dirname(current);
66
+ }
67
+ return resolve(dir);
68
+ }
69
+
70
+ /**
71
+ * @typedef {Object} ImportInfo
72
+ * @property {string} name - imported name
73
+ * @property {string} source - import source path
74
+ */
75
+
76
+ /**
77
+ * @typedef {Object} ExportInfo
78
+ * @property {string} name - exported name
79
+ * @property {number} line - line number
80
+ */
81
+
82
+ /**
83
+ * Parse file and extract definitions, calls, exports, and imports
84
+ * @param {string} code
85
+ * @returns {{definitions: Set<string>, calls: Set<string>, exports: Set<string>, imports: ImportInfo[], namedExports: ExportInfo[]}}
86
+ */
87
+ function analyzeFile(code) {
88
+ const definitions = new Set();
89
+ const calls = new Set();
90
+ const exports = new Set();
91
+ /** @type {ImportInfo[]} */
92
+ const imports = [];
93
+ /** @type {ExportInfo[]} */
94
+ const namedExports = [];
95
+
96
+ let ast;
97
+ try {
98
+ ast = parse(code, { ecmaVersion: 'latest', sourceType: 'module', locations: true });
99
+ } catch (e) {
100
+ return { definitions, calls, exports, imports, namedExports };
101
+ }
102
+
103
+ walk.simple(ast, {
104
+ FunctionDeclaration(node) {
105
+ if (node.id) {
106
+ definitions.add(node.id.name);
107
+ }
108
+ },
109
+
110
+ ClassDeclaration(node) {
111
+ if (node.id) {
112
+ definitions.add(node.id.name);
113
+ }
114
+ },
115
+
116
+ CallExpression(node) {
117
+ if (node.callee.type === 'Identifier') {
118
+ calls.add(node.callee.name);
119
+ } else if (node.callee.type === 'MemberExpression' && node.callee.object.type === 'Identifier') {
120
+ calls.add(node.callee.object.name);
121
+ }
122
+ // Track function references passed as arguments: .map(funcName)
123
+ for (const arg of node.arguments) {
124
+ if (arg.type === 'Identifier') {
125
+ calls.add(arg.name);
126
+ }
127
+ }
128
+ },
129
+
130
+ NewExpression(node) {
131
+ if (node.callee.type === 'Identifier') {
132
+ calls.add(node.callee.name);
133
+ }
134
+ },
135
+
136
+ ImportDeclaration(node) {
137
+ const source = node.source.value;
138
+ for (const spec of node.specifiers) {
139
+ if (spec.type === 'ImportSpecifier') {
140
+ imports.push({ name: spec.imported.name, source });
141
+ } else if (spec.type === 'ImportDefaultSpecifier') {
142
+ imports.push({ name: 'default', source });
143
+ }
144
+ }
145
+ },
146
+
147
+ ExportNamedDeclaration(node) {
148
+ if (node.declaration) {
149
+ if (node.declaration.id) {
150
+ const name = node.declaration.id.name;
151
+ exports.add(name);
152
+ namedExports.push({ name, line: node.loc.start.line });
153
+ } else if (node.declaration.declarations) {
154
+ for (const decl of node.declaration.declarations) {
155
+ if (decl.id.type === 'Identifier') {
156
+ const name = decl.id.name;
157
+ exports.add(name);
158
+ namedExports.push({ name, line: node.loc.start.line });
159
+ }
160
+ }
161
+ }
162
+ }
163
+ if (node.specifiers) {
164
+ for (const spec of node.specifiers) {
165
+ const name = spec.exported.name;
166
+ exports.add(name);
167
+ namedExports.push({ name, line: node.loc.start.line });
168
+ }
169
+ }
170
+ },
171
+
172
+ ExportDefaultDeclaration(node) {
173
+ if (node.declaration?.id) {
174
+ exports.add(node.declaration.id.name);
175
+ }
176
+ },
177
+ });
178
+
179
+ return { definitions, calls, exports, imports, namedExports };
180
+ }
181
+
182
+ /**
183
+ * Analyze file for unused local variables and imports
184
+ * @param {string} code
185
+ * @returns {{unusedVars: Array<{name: string, line: number}>, unusedImports: Array<{name: string, local: string, source: string, line: number}>}}
186
+ */
187
+ function analyzeFileLocals(code) {
188
+ const unusedVars = [];
189
+ const unusedImports = [];
190
+
191
+ let ast;
192
+ try {
193
+ ast = parse(code, { ecmaVersion: 'latest', sourceType: 'module', locations: true });
194
+ } catch (e) {
195
+ return { unusedVars, unusedImports };
196
+ }
197
+
198
+ // Collect all identifier references (non-declaration sites)
199
+ const refs = new Set();
200
+ // Collect variable declarations: { name, line, isExported }
201
+ const varDecls = [];
202
+ // Collect import specifiers: { name, local, source, line }
203
+ const importDecls = [];
204
+ // Track names at declaration sites to exclude
205
+ const declSites = new Set();
206
+
207
+ // First pass: collect declarations
208
+ walk.simple(ast, {
209
+ VariableDeclaration(node) {
210
+ const isExported = node.parent?.type === 'ExportNamedDeclaration';
211
+ for (const decl of node.declarations) {
212
+ if (decl.id.type === 'Identifier') {
213
+ varDecls.push({ name: decl.id.name, line: decl.loc.start.line, isExported });
214
+ declSites.add(decl.id);
215
+ }
216
+ }
217
+ },
218
+ ImportDeclaration(node) {
219
+ for (const spec of node.specifiers) {
220
+ const local = spec.local.name;
221
+ const imported = spec.type === 'ImportSpecifier'
222
+ ? spec.imported.name
223
+ : spec.type === 'ImportDefaultSpecifier'
224
+ ? 'default' : '*';
225
+ importDecls.push({
226
+ name: imported,
227
+ local,
228
+ source: node.source.value,
229
+ line: node.loc.start.line,
230
+ });
231
+ declSites.add(spec.local);
232
+ }
233
+ },
234
+ });
235
+
236
+ // Walk AST to find variable declaration sites more precisely
237
+ // (the walk.simple above doesn't set parents, so we need ancestor walk)
238
+ // Instead, use top-level statement analysis
239
+ const topLevelExportedNames = new Set();
240
+ for (const node of ast.body) {
241
+ if (node.type === 'ExportNamedDeclaration' && node.declaration) {
242
+ if (node.declaration.declarations) {
243
+ for (const decl of node.declaration.declarations) {
244
+ if (decl.id.type === 'Identifier') topLevelExportedNames.add(decl.id.name);
245
+ }
246
+ }
247
+ if (node.declaration.id) topLevelExportedNames.add(node.declaration.id.name);
248
+ }
249
+ if (node.type === 'ExportNamedDeclaration' && node.specifiers) {
250
+ for (const spec of node.specifiers) {
251
+ topLevelExportedNames.add(spec.local.name);
252
+ }
253
+ }
254
+ }
255
+
256
+ // Collect all identifier references across the AST
257
+ walk.simple(ast, {
258
+ Identifier(node) {
259
+ refs.add(node.name);
260
+ },
261
+ });
262
+
263
+ // Find unused variables (declared but never referenced beyond declaration)
264
+ // We count total references: if name appears only in declaration, it's unused
265
+ for (const v of varDecls) {
266
+ // Skip exported variables (handled by orphan exports)
267
+ if (topLevelExportedNames.has(v.name)) continue;
268
+ // Skip destructuring names and common patterns
269
+ if (v.name.startsWith('_')) continue;
270
+ // Check if name is referenced anywhere in the code beyond its declaration
271
+ // Simple heuristic: count occurrences in the source
272
+ const regex = new RegExp(`\\b${v.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
273
+ const matches = code.match(regex);
274
+ // If only 1 occurrence, it's the declaration itself
275
+ if (matches && matches.length <= 1) {
276
+ unusedVars.push({ name: v.name, line: v.line });
277
+ }
278
+ }
279
+
280
+ // Find unused imports
281
+ for (const imp of importDecls) {
282
+ // Skip namespace imports
283
+ if (imp.name === '*') continue;
284
+ // Check if local name is referenced beyond the import declaration
285
+ const regex = new RegExp(`\\b${imp.local.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'g');
286
+ const matches = code.match(regex);
287
+ // If only 1 occurrence, it's the import declaration itself
288
+ if (matches && matches.length <= 1) {
289
+ unusedImports.push(imp);
290
+ }
291
+ }
292
+
293
+ return { unusedVars, unusedImports };
294
+ }
295
+
296
+ /**
297
+ * Get dead code items
298
+ * @param {string} dir
299
+ * @returns {Promise<{total: number, byType: Object, items: DeadCodeItem[]}>}
300
+ */
301
+ export async function getDeadCode(dir) {
302
+ const resolvedDir = resolve(dir);
303
+ const files = findJSFiles(dir);
304
+ const items = [];
305
+
306
+ // Collect all calls and exports across target directory
307
+ const allCalls = new Set();
308
+ const allExports = new Set();
309
+ const fileData = [];
310
+
311
+ // Track imports project-wide: key = "importedName@resolvedSourcePath", value = consumer files
312
+ /** @type {Map<string, Set<string>>} */
313
+ const importConsumers = new Map();
314
+
315
+ // Scan entire project for import consumers (not just target dir)
316
+ const projectRoot = findProjectRoot(dir);
317
+ const projectFiles = findJSFiles(projectRoot);
318
+
319
+ for (const file of projectFiles) {
320
+ let code;
321
+ try { code = readFileSync(file, 'utf-8'); } catch { continue; }
322
+ const relPath = relative(resolvedDir, file);
323
+ const { imports } = analyzeFile(code);
324
+
325
+ for (const imp of imports) {
326
+ if (!imp.source.startsWith('.')) continue;
327
+ const fileDir = dirname(file);
328
+ let resolvedSource = resolve(fileDir, imp.source);
329
+ if (!resolvedSource.endsWith('.js')) resolvedSource += '.js';
330
+ const relSource = relative(resolvedDir, resolvedSource);
331
+ const key = `${imp.name}@${relSource}`;
332
+ if (!importConsumers.has(key)) importConsumers.set(key, new Set());
333
+ importConsumers.get(key).add(relPath);
334
+ }
335
+ }
336
+
337
+ // Analyze target directory files for definitions, calls, exports
338
+ for (const file of files) {
339
+ const code = readFileSync(file, 'utf-8');
340
+ const relPath = relative(resolvedDir, file);
341
+ const { definitions, calls, exports, namedExports } = analyzeFile(code);
342
+
343
+ for (const call of calls) allCalls.add(call);
344
+ for (const exp of exports) allExports.add(exp);
345
+
346
+ fileData.push({ file: relPath, code, definitions, calls, exports, namedExports });
347
+ }
348
+
349
+ // Find dead code (functions/classes)
350
+ for (const { file, code, definitions, exports } of fileData) {
351
+ // Skip test files and presentation files
352
+ if (file.includes('.test.') || file.includes('/tests/')) continue;
353
+ if (file.endsWith('.css.js') || file.endsWith('.tpl.js')) continue;
354
+
355
+ let ast;
356
+ try {
357
+ ast = parse(code, { ecmaVersion: 'latest', sourceType: 'module', locations: true });
358
+ } catch (e) {
359
+ continue;
360
+ }
361
+
362
+ walk.simple(ast, {
363
+ FunctionDeclaration(node) {
364
+ if (!node.id) return;
365
+ const name = node.id.name;
366
+
367
+ // Skip if exported
368
+ if (exports.has(name) || allExports.has(name)) return;
369
+
370
+ // Skip if called anywhere
371
+ if (allCalls.has(name)) return;
372
+
373
+ // Skip private functions
374
+ if (name.startsWith('_')) return;
375
+
376
+ items.push({
377
+ name,
378
+ type: 'function',
379
+ file,
380
+ line: node.loc.start.line,
381
+ reason: 'Never called',
382
+ });
383
+ },
384
+
385
+ ClassDeclaration(node) {
386
+ if (!node.id) return;
387
+ const name = node.id.name;
388
+
389
+ // Skip if exported
390
+ if (exports.has(name) || allExports.has(name)) return;
391
+
392
+ // Skip if used (new Class() or extended)
393
+ if (allCalls.has(name)) return;
394
+
395
+ items.push({
396
+ name,
397
+ type: 'class',
398
+ file,
399
+ line: node.loc.start.line,
400
+ reason: 'Never instantiated',
401
+ });
402
+ },
403
+ });
404
+ }
405
+
406
+ // Find orphan exports (named exports not imported by any other file)
407
+ for (const { file, calls, namedExports } of fileData) {
408
+ if (file.includes('.test.') || file.includes('/tests/')) continue;
409
+
410
+ for (const exp of namedExports) {
411
+ // Skip exports used as call targets within the same file (e.g. ClassName.reg())
412
+ if (calls.has(exp.name)) continue;
413
+ const key = `${exp.name}@${file}`;
414
+ const consumers = importConsumers.get(key);
415
+ if (!consumers || consumers.size === 0) {
416
+ items.push({
417
+ name: exp.name,
418
+ type: 'export',
419
+ file,
420
+ line: exp.line,
421
+ reason: 'Exported but never imported',
422
+ });
423
+ }
424
+ }
425
+ }
426
+
427
+ // Find unused variables and imports per file
428
+ for (const { file, code } of fileData) {
429
+ if (file.includes('.test.') || file.includes('/tests/')) continue;
430
+ if (file.endsWith('.css.js') || file.endsWith('.tpl.js')) continue;
431
+
432
+ const { unusedVars, unusedImports } = analyzeFileLocals(code);
433
+
434
+ for (const v of unusedVars) {
435
+ items.push({
436
+ name: v.name,
437
+ type: 'variable',
438
+ file,
439
+ line: v.line,
440
+ reason: 'Declared but never used',
441
+ });
442
+ }
443
+
444
+ for (const imp of unusedImports) {
445
+ items.push({
446
+ name: imp.local,
447
+ type: 'import',
448
+ file,
449
+ line: imp.line,
450
+ reason: `Imported from '${imp.source}' but never used`,
451
+ });
452
+ }
453
+ }
454
+
455
+ const byType = {
456
+ function: items.filter(i => i.type === 'function').length,
457
+ class: items.filter(i => i.type === 'class').length,
458
+ export: items.filter(i => i.type === 'export').length,
459
+ variable: items.filter(i => i.type === 'variable').length,
460
+ import: items.filter(i => i.type === 'import').length,
461
+ };
462
+
463
+ return {
464
+ total: items.length,
465
+ byType,
466
+ items: items.slice(0, 50),
467
+ };
468
+ }
package/src/filters.js ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Filter Configuration for Project Graph
3
+ * Manages excludes, includes, and gitignore parsing
4
+ */
5
+
6
+ import { readFileSync, existsSync } from 'fs';
7
+ import { join, relative } from 'path';
8
+
9
+ /**
10
+ * Default directories to exclude
11
+ */
12
+ const DEFAULT_EXCLUDES = [
13
+ 'node_modules',
14
+ 'dist',
15
+ 'build',
16
+ 'coverage',
17
+ '.next',
18
+ '.nuxt',
19
+ '.output',
20
+ '__pycache__',
21
+ '.cache',
22
+ '.turbo',
23
+ 'out',
24
+ ];
25
+
26
+ /**
27
+ * Default file patterns to exclude
28
+ */
29
+ const DEFAULT_EXCLUDE_PATTERNS = [
30
+ '*.test.js',
31
+ '*.spec.js',
32
+ '*.min.js',
33
+ '*.bundle.js',
34
+ '*.d.ts',
35
+ ];
36
+
37
+ // Current filter configuration (mutable via MCP)
38
+ let config = {
39
+ excludeDirs: [...DEFAULT_EXCLUDES],
40
+ excludePatterns: [...DEFAULT_EXCLUDE_PATTERNS],
41
+ includeHidden: false,
42
+ useGitignore: true,
43
+ gitignorePatterns: [],
44
+ };
45
+
46
+ /**
47
+ * Get current filter configuration
48
+ * @returns {Object}
49
+ */
50
+ export function getFilters() {
51
+ return { ...config };
52
+ }
53
+
54
+ /**
55
+ * Update filter configuration
56
+ * @param {Object} updates
57
+ * @returns {Object}
58
+ */
59
+ export function setFilters(updates) {
60
+ if (updates.excludeDirs !== undefined) {
61
+ config.excludeDirs = updates.excludeDirs;
62
+ }
63
+ if (updates.excludePatterns !== undefined) {
64
+ config.excludePatterns = updates.excludePatterns;
65
+ }
66
+ if (updates.includeHidden !== undefined) {
67
+ config.includeHidden = updates.includeHidden;
68
+ }
69
+ if (updates.useGitignore !== undefined) {
70
+ config.useGitignore = updates.useGitignore;
71
+ }
72
+ return getFilters();
73
+ }
74
+
75
+ /**
76
+ * Add directories to exclude list
77
+ * @param {string[]} dirs
78
+ * @returns {Object}
79
+ */
80
+ export function addExcludes(dirs) {
81
+ config.excludeDirs = [...new Set([...config.excludeDirs, ...dirs])];
82
+ return getFilters();
83
+ }
84
+
85
+ /**
86
+ * Remove directories from exclude list
87
+ * @param {string[]} dirs
88
+ * @returns {Object}
89
+ */
90
+ export function removeExcludes(dirs) {
91
+ config.excludeDirs = config.excludeDirs.filter(d => !dirs.includes(d));
92
+ return getFilters();
93
+ }
94
+
95
+ /**
96
+ * Reset filters to defaults
97
+ * @returns {Object}
98
+ */
99
+ export function resetFilters() {
100
+ config = {
101
+ excludeDirs: [...DEFAULT_EXCLUDES],
102
+ excludePatterns: [...DEFAULT_EXCLUDE_PATTERNS],
103
+ includeHidden: false,
104
+ useGitignore: true,
105
+ gitignorePatterns: [],
106
+ };
107
+ return getFilters();
108
+ }
109
+
110
+ /**
111
+ * Parse .gitignore file
112
+ * @param {string} rootDir
113
+ * @returns {string[]}
114
+ */
115
+ export function parseGitignore(rootDir) {
116
+ const gitignorePath = join(rootDir, '.gitignore');
117
+
118
+ if (!existsSync(gitignorePath)) {
119
+ return [];
120
+ }
121
+
122
+ try {
123
+ const content = readFileSync(gitignorePath, 'utf-8');
124
+ const patterns = content
125
+ .split('\n')
126
+ .map(line => line.trim())
127
+ .filter(line => line && !line.startsWith('#'))
128
+ .map(line => line.replace(/\/$/, '')); // Remove trailing slashes
129
+
130
+ config.gitignorePatterns = patterns;
131
+ return patterns;
132
+ } catch (e) {
133
+ return [];
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Check if a directory should be excluded
139
+ * @param {string} dirName - Directory name (not path)
140
+ * @param {string} relativePath - Relative path from root
141
+ * @returns {boolean}
142
+ */
143
+ export function shouldExcludeDir(dirName, relativePath = '') {
144
+ // Check hidden directories
145
+ if (!config.includeHidden && dirName.startsWith('.')) {
146
+ return true;
147
+ }
148
+
149
+ // Check default excludes
150
+ if (config.excludeDirs.includes(dirName)) {
151
+ return true;
152
+ }
153
+
154
+ // Check gitignore patterns
155
+ if (config.useGitignore) {
156
+ for (const pattern of config.gitignorePatterns) {
157
+ if (matchGitignorePattern(pattern, dirName, relativePath)) {
158
+ return true;
159
+ }
160
+ }
161
+ }
162
+
163
+ return false;
164
+ }
165
+
166
+ /**
167
+ * Check if a file should be excluded
168
+ * @param {string} fileName
169
+ * @param {string} relativePath
170
+ * @returns {boolean}
171
+ */
172
+ export function shouldExcludeFile(fileName, relativePath = '') {
173
+ // Check exclude patterns
174
+ for (const pattern of config.excludePatterns) {
175
+ if (matchWildcard(pattern, fileName)) {
176
+ return true;
177
+ }
178
+ }
179
+
180
+ // Check gitignore patterns
181
+ if (config.useGitignore) {
182
+ for (const pattern of config.gitignorePatterns) {
183
+ if (matchGitignorePattern(pattern, fileName, relativePath)) {
184
+ return true;
185
+ }
186
+ }
187
+ }
188
+
189
+ return false;
190
+ }
191
+
192
+ /**
193
+ * Match simple wildcard pattern (*.js, *.test.js)
194
+ * @param {string} pattern
195
+ * @param {string} str
196
+ * @returns {boolean}
197
+ */
198
+ function matchWildcard(pattern, str) {
199
+ const regex = pattern
200
+ .replace(/\./g, '\\.')
201
+ .replace(/\*/g, '.*');
202
+ return new RegExp(`^${regex}$`).test(str);
203
+ }
204
+
205
+ /**
206
+ * Match gitignore pattern
207
+ * @param {string} pattern
208
+ * @param {string} name
209
+ * @param {string} relativePath
210
+ * @returns {boolean}
211
+ */
212
+ function matchGitignorePattern(pattern, name, relativePath) {
213
+ // Simple matching: exact name or wildcard
214
+ if (pattern === name) return true;
215
+
216
+ // Pattern with wildcard
217
+ if (pattern.includes('*')) {
218
+ return matchWildcard(pattern, name);
219
+ }
220
+
221
+ // Pattern in path
222
+ const fullPath = relativePath ? `${relativePath}/${name}` : name;
223
+ if (fullPath.includes(pattern)) return true;
224
+
225
+ return false;
226
+ }