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