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/src/parser.js ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * AST Parser for JavaScript files using Acorn
3
+ * Extracts classes, functions, methods, properties, imports, and calls
4
+ */
5
+
6
+ import { readFileSync, readdirSync, statSync } from 'fs';
7
+ import { join, relative, resolve } 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} ClassInfo
14
+ * @property {string} name
15
+ * @property {string} [extends]
16
+ * @property {string[]} methods
17
+ * @property {string[]} properties
18
+ * @property {string[]} calls
19
+ * @property {string} file
20
+ * @property {number} line
21
+ */
22
+
23
+ /**
24
+ * @typedef {Object} FunctionInfo
25
+ * @property {string} name
26
+ * @property {boolean} exported
27
+ * @property {string[]} calls
28
+ * @property {string} file
29
+ * @property {number} line
30
+ */
31
+
32
+ /**
33
+ * @typedef {Object} ParseResult
34
+ * @property {string[]} files
35
+ * @property {ClassInfo[]} classes
36
+ * @property {FunctionInfo[]} functions
37
+ * @property {string[]} imports
38
+ * @property {string[]} exports
39
+ */
40
+
41
+ /**
42
+ * Parse a JavaScript file content using AST
43
+ * @param {string} code
44
+ * @param {string} filename
45
+ * @returns {Promise<ParseResult>}
46
+ */
47
+ export async function parseFile(code, filename) {
48
+ const result = {
49
+ file: filename,
50
+ classes: [],
51
+ functions: [],
52
+ imports: [],
53
+ exports: [],
54
+ };
55
+
56
+ let ast;
57
+ try {
58
+ ast = parse(code, {
59
+ ecmaVersion: 'latest',
60
+ sourceType: 'module',
61
+ locations: true,
62
+ });
63
+ } catch (e) {
64
+ // If parsing fails, return empty result
65
+ console.warn(`Parse error in ${filename}:`, e.message);
66
+ return result;
67
+ }
68
+
69
+ // Track exported names
70
+ const exportedNames = new Set();
71
+
72
+ // Walk the AST
73
+ walk.simple(ast, {
74
+ // Import declarations
75
+ ImportDeclaration(node) {
76
+ for (const spec of node.specifiers) {
77
+ if (spec.type === 'ImportDefaultSpecifier') {
78
+ result.imports.push(spec.local.name);
79
+ } else if (spec.type === 'ImportSpecifier') {
80
+ result.imports.push(spec.imported.name);
81
+ }
82
+ }
83
+ },
84
+
85
+ // Export declarations
86
+ ExportNamedDeclaration(node) {
87
+ if (node.declaration) {
88
+ if (node.declaration.id) {
89
+ exportedNames.add(node.declaration.id.name);
90
+ } else if (node.declaration.declarations) {
91
+ for (const decl of node.declaration.declarations) {
92
+ exportedNames.add(decl.id.name);
93
+ }
94
+ }
95
+ }
96
+ if (node.specifiers) {
97
+ for (const spec of node.specifiers) {
98
+ exportedNames.add(spec.exported.name);
99
+ }
100
+ }
101
+ },
102
+
103
+ ExportDefaultDeclaration(node) {
104
+ if (node.declaration && node.declaration.id) {
105
+ exportedNames.add(node.declaration.id.name);
106
+ }
107
+ },
108
+
109
+ // Class declarations
110
+ ClassDeclaration(node) {
111
+ const classInfo = {
112
+ name: node.id.name,
113
+ extends: node.superClass ? node.superClass.name : null,
114
+ methods: [],
115
+ properties: [],
116
+ calls: [],
117
+ file: filename,
118
+ line: node.loc.start.line,
119
+ };
120
+
121
+ // Extract methods and properties from class body
122
+ for (const element of node.body.body) {
123
+ if (element.type === 'MethodDefinition' && element.key.name !== 'constructor') {
124
+ classInfo.methods.push(element.key.name);
125
+
126
+ // Extract calls from method body
127
+ extractCalls(element.value.body, classInfo.calls);
128
+ } else if (element.type === 'PropertyDefinition') {
129
+ const propName = element.key.name;
130
+
131
+ // Check for init$ object properties
132
+ if (propName === 'init$' && element.value && element.value.type === 'ObjectExpression') {
133
+ for (const prop of element.value.properties) {
134
+ if (prop.key && prop.key.name) {
135
+ classInfo.properties.push(prop.key.name);
136
+ }
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ result.classes.push(classInfo);
143
+ },
144
+
145
+ // Standalone function declarations
146
+ FunctionDeclaration(node) {
147
+ if (node.id) {
148
+ const funcInfo = {
149
+ name: node.id.name,
150
+ exported: false, // Will be updated later
151
+ calls: [],
152
+ file: filename,
153
+ line: node.loc.start.line,
154
+ };
155
+
156
+ extractCalls(node.body, funcInfo.calls);
157
+ result.functions.push(funcInfo);
158
+ }
159
+ },
160
+ });
161
+
162
+ // Mark exported functions
163
+ for (const func of result.functions) {
164
+ func.exported = exportedNames.has(func.name);
165
+ }
166
+
167
+ // Collect exports
168
+ result.exports = [...exportedNames];
169
+
170
+ return result;
171
+ }
172
+
173
+ /**
174
+ * Extract method calls from AST node
175
+ * @param {Object} node
176
+ * @param {string[]} calls
177
+ */
178
+ function extractCalls(node, calls) {
179
+ if (!node) return;
180
+
181
+ walk.simple(node, {
182
+ CallExpression(callNode) {
183
+ const callee = callNode.callee;
184
+
185
+ if (callee.type === 'MemberExpression') {
186
+ // obj.method() or this.method()
187
+ const object = callee.object;
188
+ const property = callee.property;
189
+
190
+ if (property.type === 'Identifier') {
191
+ if (object.type === 'Identifier') {
192
+ // Class.method() or obj.method()
193
+ const call = `${object.name}.${property.name}`;
194
+ if (!calls.includes(call)) {
195
+ calls.push(call);
196
+ }
197
+ } else if (object.type === 'MemberExpression' && object.property.type === 'Identifier') {
198
+ // this.obj.method()
199
+ const call = `${object.property.name}.${property.name}`;
200
+ if (!calls.includes(call)) {
201
+ calls.push(call);
202
+ }
203
+ } else if (object.type === 'ThisExpression') {
204
+ // this.method() - internal call
205
+ const call = property.name;
206
+ if (!calls.includes(call)) {
207
+ calls.push(call);
208
+ }
209
+ }
210
+ }
211
+ } else if (callee.type === 'Identifier') {
212
+ // Direct function call: funcName()
213
+ const call = callee.name;
214
+ if (!calls.includes(call)) {
215
+ calls.push(call);
216
+ }
217
+ }
218
+ },
219
+ });
220
+ }
221
+
222
+ /**
223
+ * Parse all JS files in a directory
224
+ * @param {string} dir
225
+ * @returns {Promise<ParseResult>}
226
+ */
227
+ export async function parseProject(dir) {
228
+ const result = {
229
+ files: [],
230
+ classes: [],
231
+ functions: [],
232
+ imports: [],
233
+ exports: [],
234
+ };
235
+
236
+ const resolvedDir = resolve(dir);
237
+ const files = findJSFiles(dir);
238
+
239
+ for (const file of files) {
240
+ const content = readFileSync(file, 'utf-8');
241
+ const relPath = relative(resolvedDir, file);
242
+ const parsed = await parseFile(content, relPath);
243
+
244
+ result.files.push(relPath);
245
+ result.classes.push(...parsed.classes);
246
+ result.functions.push(...parsed.functions);
247
+ result.imports.push(...parsed.imports);
248
+ result.exports.push(...parsed.exports);
249
+ }
250
+
251
+ // Dedupe imports/exports
252
+ result.imports = [...new Set(result.imports)];
253
+ result.exports = [...new Set(result.exports)];
254
+
255
+ return result;
256
+ }
257
+
258
+ /**
259
+ * Find all JS files recursively (uses filter configuration)
260
+ * @param {string} dir
261
+ * @param {string} [rootDir] - Root directory for relative path calculation
262
+ * @returns {string[]}
263
+ */
264
+ function findJSFiles(dir, rootDir = dir) {
265
+ // Parse gitignore on first call
266
+ if (dir === rootDir) {
267
+ parseGitignore(rootDir);
268
+ }
269
+
270
+ const files = [];
271
+
272
+ try {
273
+ for (const entry of readdirSync(dir)) {
274
+ const fullPath = join(dir, entry);
275
+ const stat = statSync(fullPath);
276
+ const relativePath = relative(rootDir, dir);
277
+
278
+ if (stat.isDirectory()) {
279
+ if (!shouldExcludeDir(entry, relativePath)) {
280
+ files.push(...findJSFiles(fullPath, rootDir));
281
+ }
282
+ } else if (entry.endsWith('.js') && !entry.endsWith('.css.js') && !entry.endsWith('.tpl.js')) {
283
+ if (!shouldExcludeFile(entry, relativePath)) {
284
+ files.push(fullPath);
285
+ }
286
+ }
287
+ }
288
+ } catch (e) {
289
+ console.warn(`Cannot read directory ${dir}:`, e.message);
290
+ }
291
+
292
+ return files;
293
+ }
package/src/server.js ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Entry Point for Project Graph MCP
4
+ *
5
+ * Decides whether to run in CLI mode or MCP Server mode (stdio)
6
+ * Usage:
7
+ * node src/server.js -> stdio server
8
+ * node src/server.js <cmd> [args] -> CLI execution
9
+ */
10
+
11
+ import { startStdioServer } from './mcp-server.js';
12
+ import { runCLI } from './cli.js';
13
+
14
+ // Main execution logic
15
+ // We check endsWith('server.js') to verify this is the main module being run
16
+ if (process.argv[1] && process.argv[1].endsWith('server.js')) {
17
+ const [, , command, ...args] = process.argv;
18
+
19
+ if (command) {
20
+ // CLI mode
21
+ runCLI(command, args);
22
+ } else {
23
+ // MCP stdio mode
24
+ // Use stderr for logs so stdout remains clean for JSON-RPC
25
+ console.error('Starting Project Graph MCP (stdio)...');
26
+ startStdioServer();
27
+ }
28
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Similar Functions Detector
3
+ * Finds functionally similar functions across the codebase
4
+ */
5
+
6
+ import { readFileSync, readdirSync, statSync } from 'fs';
7
+ import { join, relative, resolve } 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} FunctionSignature
14
+ * @property {string} name
15
+ * @property {string} file
16
+ * @property {number} line
17
+ * @property {number} paramCount
18
+ * @property {string[]} paramNames
19
+ * @property {boolean} async
20
+ * @property {string} bodyHash - Structural hash of function body
21
+ * @property {string[]} calls - Functions called inside
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} SimilarPair
26
+ * @property {FunctionSignature} a
27
+ * @property {FunctionSignature} b
28
+ * @property {number} similarity - 0-100
29
+ * @property {string[]} reasons
30
+ */
31
+
32
+ /**
33
+ * Find all JS files
34
+ * @param {string} dir
35
+ * @param {string} rootDir
36
+ * @returns {string[]}
37
+ */
38
+ function findJSFiles(dir, rootDir = dir) {
39
+ if (dir === rootDir) parseGitignore(rootDir);
40
+ const files = [];
41
+
42
+ try {
43
+ for (const entry of readdirSync(dir)) {
44
+ const fullPath = join(dir, entry);
45
+ const relativePath = relative(rootDir, fullPath);
46
+ const stat = statSync(fullPath);
47
+
48
+ if (stat.isDirectory()) {
49
+ if (!shouldExcludeDir(entry, relativePath)) {
50
+ files.push(...findJSFiles(fullPath, rootDir));
51
+ }
52
+ } else if (entry.endsWith('.js') && !entry.endsWith('.css.js') && !entry.endsWith('.tpl.js')) {
53
+ if (!shouldExcludeFile(entry, relativePath)) {
54
+ files.push(fullPath);
55
+ }
56
+ }
57
+ }
58
+ } catch (e) { }
59
+
60
+ return files;
61
+ }
62
+
63
+ /**
64
+ * Extract function signatures from a file
65
+ * @param {string} filePath
66
+ * @returns {FunctionSignature[]}
67
+ */
68
+ function extractSignatures(filePath, rootDir) {
69
+ const code = readFileSync(filePath, 'utf-8');
70
+ const relPath = relative(rootDir, filePath);
71
+ const signatures = [];
72
+
73
+ let ast;
74
+ try {
75
+ ast = parse(code, { ecmaVersion: 'latest', sourceType: 'module', locations: true });
76
+ } catch (e) {
77
+ return signatures;
78
+ }
79
+
80
+ walk.simple(ast, {
81
+ FunctionDeclaration(node) {
82
+ if (!node.id) return;
83
+ signatures.push(buildSignature(node, node.id.name, relPath));
84
+ },
85
+
86
+ MethodDefinition(node) {
87
+ if (node.kind !== 'method') return;
88
+ const name = node.key.name || node.key.value;
89
+ if (name.startsWith('_')) return;
90
+ signatures.push(buildSignature(node.value, name, relPath));
91
+ },
92
+ });
93
+
94
+ return signatures;
95
+ }
96
+
97
+ /**
98
+ * Build signature from function node
99
+ * @param {Object} node
100
+ * @param {string} name
101
+ * @param {string} file
102
+ * @returns {FunctionSignature}
103
+ */
104
+ function buildSignature(node, name, file) {
105
+ const paramNames = node.params.map(p => extractParamName(p));
106
+ const calls = [];
107
+
108
+ // Extract function calls
109
+ walk.simple(node.body, {
110
+ CallExpression(callNode) {
111
+ if (callNode.callee.type === 'Identifier') {
112
+ calls.push(callNode.callee.name);
113
+ } else if (callNode.callee.type === 'MemberExpression' && callNode.callee.property.type === 'Identifier') {
114
+ calls.push(callNode.callee.property.name);
115
+ }
116
+ },
117
+ });
118
+
119
+ // Create structural hash
120
+ const bodyHash = hashBodyStructure(node.body);
121
+
122
+ return {
123
+ name,
124
+ file,
125
+ line: node.loc?.start?.line || 0,
126
+ paramCount: node.params.length,
127
+ paramNames,
128
+ async: node.async || false,
129
+ bodyHash,
130
+ calls: [...new Set(calls)],
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Extract param name
136
+ * @param {Object} param
137
+ * @returns {string}
138
+ */
139
+ function extractParamName(param) {
140
+ if (param.type === 'Identifier') return param.name;
141
+ if (param.type === 'AssignmentPattern' && param.left.type === 'Identifier') return param.left.name;
142
+ if (param.type === 'RestElement' && param.argument.type === 'Identifier') return param.argument.name;
143
+ return 'param';
144
+ }
145
+
146
+ /**
147
+ * Create structural hash of function body
148
+ * @param {Object} body
149
+ * @returns {string}
150
+ */
151
+ function hashBodyStructure(body) {
152
+ const structure = [];
153
+
154
+ walk.simple(body, {
155
+ IfStatement() { structure.push('IF'); },
156
+ ForStatement() { structure.push('FOR'); },
157
+ ForOfStatement() { structure.push('FOROF'); },
158
+ ForInStatement() { structure.push('FORIN'); },
159
+ WhileStatement() { structure.push('WHILE'); },
160
+ SwitchStatement() { structure.push('SWITCH'); },
161
+ TryStatement() { structure.push('TRY'); },
162
+ ReturnStatement() { structure.push('RET'); },
163
+ ThrowStatement() { structure.push('THROW'); },
164
+ AwaitExpression() { structure.push('AWAIT'); },
165
+ });
166
+
167
+ return structure.join('|');
168
+ }
169
+
170
+ /**
171
+ * Calculate similarity between two functions
172
+ * @param {FunctionSignature} a
173
+ * @param {FunctionSignature} b
174
+ * @returns {{similarity: number, reasons: string[]}}
175
+ */
176
+ function calculateSimilarity(a, b) {
177
+ const reasons = [];
178
+ let score = 0;
179
+ let maxScore = 0;
180
+
181
+ // Same param count (important)
182
+ maxScore += 30;
183
+ if (a.paramCount === b.paramCount) {
184
+ score += 30;
185
+ reasons.push('Same param count');
186
+ }
187
+
188
+ // Similar param names
189
+ maxScore += 20;
190
+ const commonParams = a.paramNames.filter(p => b.paramNames.includes(p));
191
+ if (commonParams.length > 0 && a.paramNames.length > 0) {
192
+ const paramSim = commonParams.length / Math.max(a.paramNames.length, b.paramNames.length);
193
+ score += Math.round(paramSim * 20);
194
+ if (paramSim >= 0.5) reasons.push(`Similar params: ${commonParams.join(', ')}`);
195
+ }
196
+
197
+ // Same async status
198
+ maxScore += 10;
199
+ if (a.async === b.async) {
200
+ score += 10;
201
+ }
202
+
203
+ // Similar body structure
204
+ maxScore += 25;
205
+ if (a.bodyHash === b.bodyHash && a.bodyHash.length > 0) {
206
+ score += 25;
207
+ reasons.push('Identical structure');
208
+ } else if (a.bodyHash.length > 0 && b.bodyHash.length > 0) {
209
+ const aTokens = a.bodyHash.split('|');
210
+ const bTokens = b.bodyHash.split('|');
211
+ const commonTokens = aTokens.filter(t => bTokens.includes(t));
212
+ if (commonTokens.length > 0) {
213
+ const structSim = commonTokens.length / Math.max(aTokens.length, bTokens.length);
214
+ score += Math.round(structSim * 25);
215
+ if (structSim >= 0.5) reasons.push('Similar control flow');
216
+ }
217
+ }
218
+
219
+ // Common function calls
220
+ maxScore += 15;
221
+ const commonCalls = a.calls.filter(c => b.calls.includes(c));
222
+ if (commonCalls.length > 0 && a.calls.length > 0 && b.calls.length > 0) {
223
+ const callSim = commonCalls.length / Math.max(a.calls.length, b.calls.length);
224
+ score += Math.round(callSim * 15);
225
+ if (commonCalls.length >= 2) reasons.push(`Common calls: ${commonCalls.slice(0, 3).join(', ')}`);
226
+ }
227
+
228
+ const similarity = Math.round((score / maxScore) * 100);
229
+ return { similarity, reasons };
230
+ }
231
+
232
+ /**
233
+ * Get similar functions in directory
234
+ * @param {string} dir
235
+ * @param {Object} [options]
236
+ * @param {number} [options.threshold=60] - Minimum similarity percentage
237
+ * @returns {Promise<{total: number, pairs: SimilarPair[]}>}
238
+ */
239
+ export async function getSimilarFunctions(dir, options = {}) {
240
+ const threshold = options.threshold || 60;
241
+ const resolvedDir = resolve(dir);
242
+ const files = findJSFiles(dir);
243
+ const allSignatures = [];
244
+
245
+ // Collect all signatures
246
+ for (const file of files) {
247
+ allSignatures.push(...extractSignatures(file, resolvedDir));
248
+ }
249
+
250
+ // Compare all pairs
251
+ const pairs = [];
252
+ for (let i = 0; i < allSignatures.length; i++) {
253
+ for (let j = i + 1; j < allSignatures.length; j++) {
254
+ const a = allSignatures[i];
255
+ const b = allSignatures[j];
256
+
257
+ // Skip same file same name (likely intentional overload)
258
+ if (a.file === b.file && a.name === b.name) continue;
259
+
260
+ // Skip very small functions
261
+ if (a.bodyHash.length < 3 && b.bodyHash.length < 3) continue;
262
+
263
+ const { similarity, reasons } = calculateSimilarity(a, b);
264
+
265
+ if (similarity >= threshold && reasons.length > 0) {
266
+ pairs.push({ a, b, similarity, reasons });
267
+ }
268
+ }
269
+ }
270
+
271
+ // Sort by similarity descending
272
+ pairs.sort((x, y) => y.similarity - x.similarity);
273
+
274
+ return {
275
+ total: pairs.length,
276
+ pairs: pairs.slice(0, 20),
277
+ };
278
+ }