project-graph-mcp 1.0.1 → 1.1.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/README.md +10 -2
- package/package.json +7 -3
- package/src/.project-graph-cache.json +1 -0
- package/src/filters.js +1 -0
- package/src/graph-builder.js +1 -1
- package/src/lang-go.js +285 -0
- package/src/lang-python.js +197 -0
- package/src/lang-typescript.js +190 -0
- package/src/lang-utils.js +124 -0
- package/src/mcp-server.js +9 -1
- package/src/parser.js +42 -3
- package/src/server.js +0 -0
- package/src/tool-defs.js +13 -0
- package/src/tools.js +237 -7
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# project-graph-mcp
|
|
2
2
|
|
|
3
|
-
**MCP server for AI agents** — project graph, code quality analysis, and framework-specific lint rules.
|
|
3
|
+
**MCP server for AI agents** — multi-language project graph, code quality analysis, and framework-specific lint rules.
|
|
4
4
|
|
|
5
5
|
> Developed by [RND-PRO](https://rnd-pro.com)
|
|
6
6
|
|
|
@@ -14,6 +14,7 @@ AI agents struggle with large codebases:
|
|
|
14
14
|
|
|
15
15
|
**Project Graph MCP solves this:**
|
|
16
16
|
- 📦 **10-50x compression** — skeleton view fits in context window
|
|
17
|
+
- 🌐 **Multi-language** — JavaScript, TypeScript, Python, Go
|
|
17
18
|
- 🔍 **Code quality analysis** — dead code, complexity, duplicates
|
|
18
19
|
- 🎯 **Framework-specific rules** — auto-detect and apply (React, Vue, Express, Node.js, Symbiote)
|
|
19
20
|
- ✅ **Test checklists** — track @test/@expect annotations
|
|
@@ -26,6 +27,9 @@ AI agents struggle with large codebases:
|
|
|
26
27
|
- `deps` — Dependency tree for any symbol
|
|
27
28
|
- `usages` — Find all usages of a symbol
|
|
28
29
|
- `get_focus_zone` — Auto-enriched context from git diff
|
|
30
|
+
- `get_call_chain` — BFS call path analysis between symbols
|
|
31
|
+
|
|
32
|
+
**Supported languages:** JavaScript (AST via Acorn), TypeScript/TSX, Python, Go — all with unified ParseResult API.
|
|
29
33
|
|
|
30
34
|
### 🧪 Test Checklists (Universal)
|
|
31
35
|
- `get_pending_tests` — List tests from `@test/@expect` JSDoc annotations
|
|
@@ -253,7 +257,11 @@ project-graph-mcp/
|
|
|
253
257
|
│ ├── tool-defs.js # MCP tool schemas
|
|
254
258
|
│ ├── tools.js # Graph tools (skeleton, expand, deps)
|
|
255
259
|
│ ├── workspace.js # Path resolution + traversal protection
|
|
256
|
-
│ ├── parser.js # AST parser (Acorn)
|
|
260
|
+
│ ├── parser.js # AST parser (Acorn) + language routing
|
|
261
|
+
│ ├── lang-typescript.js # TypeScript/TSX regex parser
|
|
262
|
+
│ ├── lang-python.js # Python regex parser
|
|
263
|
+
│ ├── lang-go.js # Go regex parser
|
|
264
|
+
│ ├── lang-utils.js # Shared: stripStringsAndComments
|
|
257
265
|
│ ├── graph-builder.js # Minified graph + legend
|
|
258
266
|
│ ├── filters.js # Exclude patterns, .gitignore
|
|
259
267
|
│ ├── dead-code.js # Unused code detection
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "project-graph-mcp",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "MCP server for AI agents —
|
|
5
|
+
"description": "MCP server for AI agents — multi-language project graph (JS, TS, Python, Go), code quality analysis, and framework-specific lint rules. Zero dependencies.",
|
|
6
6
|
"main": "src/server.js",
|
|
7
7
|
"bin": {
|
|
8
8
|
"project-graph-mcp": "src/server.js"
|
|
@@ -32,7 +32,11 @@
|
|
|
32
32
|
"dead-code",
|
|
33
33
|
"complexity",
|
|
34
34
|
"lint",
|
|
35
|
-
"gemini"
|
|
35
|
+
"gemini",
|
|
36
|
+
"typescript",
|
|
37
|
+
"python",
|
|
38
|
+
"golang",
|
|
39
|
+
"multi-language"
|
|
36
40
|
],
|
|
37
41
|
"repository": {
|
|
38
42
|
"type": "git",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":1,"path":"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src","mtimes":{"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/cli-handlers.js":1770656908396.6091,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/cli.js":1770402560025.795,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/complexity.js":1770659410318.8738,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/custom-rules.js":1772229894400.4248,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/dead-code.js":1771039661152.3376,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/filters.js":1773680641811.5923,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/framework-references.js":1772230204706.0527,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/full-analysis.js":1770398073907.7559,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/graph-builder.js":1773699507591.4272,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/instructions.js":1770399269998.5479,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/jsdoc-generator.js":1770659484514.0476,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/lang-go.js":1773698053155.2214,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/lang-python.js":1773699559544.998,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/lang-typescript.js":1773698053134.5566,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/lang-utils.js":1773697910333.1853,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/large-files.js":1770659415051.0068,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/mcp-server.js":1773680723694.6929,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/outdated-patterns.js":1770659431382.597,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/parser.js":1773683193338.3542,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/server.js":1773497829401.444,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/similar-functions.js":1770659421339.757,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/test-annotations.js":1772223332771.1304,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/tool-defs.js":1773680708907.4548,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/tools.js":1773699507598.5554,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/undocumented.js":1770659436429.1372,"/Users/v.matiyasevich/Documents/GitHub/project-graph-mcp/src/workspace.js":1773699468757.9526},"graph":{"v":1,"legend":{"getArg":"gA","getPath":"gP","printHelp":"pH","runCLI":"CLI","findJSFiles":"JSF","calculateComplexity":"cC","getRating":"gR","analyzeFile":"aF","getComplexity":"gC","parseGraphignore":"pG","isGraphignored":"iG","loadRuleSets":"RS","saveRuleSet":"RS1","findFiles":"fF","isExcluded":"iE","isInStringOrComment":"ISO","isWithinContext":"WC","checkFileAgainstRule":"FAR","getCustomRules":"CR","setCustomRule":"CR1","deleteCustomRule":"CR2","detectProjectRuleSets":"PRS","checkCustomRules":"CR3","findProjectRoot":"PR","analyzeFileLocals":"FL","getDeadCode":"DC","getFilters":"gF","setFilters":"sF","addExcludes":"aE","removeExcludes":"rE","resetFilters":"rF","parseGitignore":"pG1","shouldExcludeDir":"ED","shouldExcludeFile":"EF","matchWildcard":"mW","matchGitignorePattern":"GP","fetchReference":"fR","listAvailable":"lA","getFrameworkReference":"FR","calculateHealthScore":"HS","getFullAnalysis":"FA","minifyLegend":"mL","createShortName":"SN","buildGraph":"bG","createSkeleton":"cS","getInstructions":"gI","generateJSDoc":"JSD","buildJSDoc":"JSD1","extractParamName":"PN","inferParamType":"PT","generateJSDocFor":"JSD2","parseGo":"pG2","extractImports":"eI","getBody":"gB","extractCalls":"eC","parsePython":"pP","parseTypeScript":"TS","extractParams":"eP","stripStringsAndComments":"SAC","getLargeFiles":"LF","createServer":"cS1","startStdioServer":"SS","analyzeFilePatterns":"FP","analyzePackageJson":"PJ","getOutdatedPatterns":"OP","parseFile":"pF","parseProject":"pP1","parseFileByExtension":"FBE","isSourceFile":"SF","extractSignatures":"eS","buildSignature":"bS","hashBodyStructure":"BS","calculateSimilarity":"cS2","getSimilarFunctions":"SF1","parseAnnotations":"pA","getAllFeatures":"AF","getPendingTests":"PT1","markTestPassed":"TP","markTestFailed":"TF","getTestSummary":"TS1","resetTestState":"TS2","generateMarkdown":"gM","saveDiskCache":"DC1","loadDiskCache":"DC2","getGraph":"gG","detectChanges":"dC","snapshotMtimes":"sM","getSkeleton":"gS","getFocusZone":"FZ","expand":"ex","deps":"de","usages":"us","extractMethod":"eM","getCallChain":"CC","invalidateCache":"iC","extractComments":"eC1","findJSDocBefore":"JSD3","checkMissing":"cM","getUndocumented":"gU","getUndocumentedSummary":"US","setRoots":"sR","getWorkspaceRoot":"WR","resolvePath":"rP"},"reverseLegend":{"gA":"getArg","gP":"getPath","pH":"printHelp","CLI":"runCLI","JSF":"findJSFiles","cC":"calculateComplexity","gR":"getRating","aF":"analyzeFile","gC":"getComplexity","pG":"parseGraphignore","iG":"isGraphignored","RS":"loadRuleSets","RS1":"saveRuleSet","fF":"findFiles","iE":"isExcluded","ISO":"isInStringOrComment","WC":"isWithinContext","FAR":"checkFileAgainstRule","CR":"getCustomRules","CR1":"setCustomRule","CR2":"deleteCustomRule","PRS":"detectProjectRuleSets","CR3":"checkCustomRules","PR":"findProjectRoot","FL":"analyzeFileLocals","DC":"getDeadCode","gF":"getFilters","sF":"setFilters","aE":"addExcludes","rE":"removeExcludes","rF":"resetFilters","pG1":"parseGitignore","ED":"shouldExcludeDir","EF":"shouldExcludeFile","mW":"matchWildcard","GP":"matchGitignorePattern","fR":"fetchReference","lA":"listAvailable","FR":"getFrameworkReference","HS":"calculateHealthScore","FA":"getFullAnalysis","mL":"minifyLegend","SN":"createShortName","bG":"buildGraph","cS":"createSkeleton","gI":"getInstructions","JSD":"generateJSDoc","JSD1":"buildJSDoc","PN":"extractParamName","PT":"inferParamType","JSD2":"generateJSDocFor","pG2":"parseGo","eI":"extractImports","gB":"getBody","eC":"extractCalls","pP":"parsePython","TS":"parseTypeScript","eP":"extractParams","SAC":"stripStringsAndComments","LF":"getLargeFiles","cS1":"createServer","SS":"startStdioServer","FP":"analyzeFilePatterns","PJ":"analyzePackageJson","OP":"getOutdatedPatterns","pF":"parseFile","pP1":"parseProject","FBE":"parseFileByExtension","SF":"isSourceFile","eS":"extractSignatures","bS":"buildSignature","BS":"hashBodyStructure","cS2":"calculateSimilarity","SF1":"getSimilarFunctions","pA":"parseAnnotations","AF":"getAllFeatures","PT1":"getPendingTests","TP":"markTestPassed","TF":"markTestFailed","TS1":"getTestSummary","TS2":"resetTestState","gM":"generateMarkdown","DC1":"saveDiskCache","DC2":"loadDiskCache","gG":"getGraph","dC":"detectChanges","sM":"snapshotMtimes","gS":"getSkeleton","FZ":"getFocusZone","ex":"expand","de":"deps","us":"usages","eM":"extractMethod","CC":"getCallChain","iC":"invalidateCache","eC1":"extractComments","JSD3":"findJSDocBefore","cM":"checkMissing","gU":"getUndocumented","US":"getUndocumentedSummary","sR":"setRoots","WR":"getWorkspaceRoot","rP":"resolvePath"},"stats":{"files":26,"classes":0,"functions":115},"nodes":{"gA":{"t":"F","e":false,"f":"cli-handlers.js"},"gP":{"t":"F","e":false,"f":"cli-handlers.js"},"pH":{"t":"F","e":true,"f":"cli.js"},"CLI":{"t":"F","e":true,"f":"cli.js"},"JSF":{"t":"F","e":false,"f":"undocumented.js"},"cC":{"t":"F","e":false,"f":"complexity.js"},"gR":{"t":"F","e":false,"f":"complexity.js"},"aF":{"t":"F","e":false,"f":"large-files.js"},"gC":{"t":"F","e":true,"f":"complexity.js"},"pG":{"t":"F","e":false,"f":"custom-rules.js"},"iG":{"t":"F","e":false,"f":"custom-rules.js"},"RS":{"t":"F","e":false,"f":"custom-rules.js"},"RS1":{"t":"F","e":false,"f":"custom-rules.js"},"fF":{"t":"F","e":false,"f":"custom-rules.js"},"iE":{"t":"F","e":false,"f":"custom-rules.js"},"ISO":{"t":"F","e":false,"f":"custom-rules.js"},"WC":{"t":"F","e":false,"f":"custom-rules.js"},"FAR":{"t":"F","e":false,"f":"custom-rules.js"},"CR":{"t":"F","e":true,"f":"custom-rules.js"},"CR1":{"t":"F","e":true,"f":"custom-rules.js"},"CR2":{"t":"F","e":true,"f":"custom-rules.js"},"PRS":{"t":"F","e":true,"f":"custom-rules.js"},"CR3":{"t":"F","e":true,"f":"custom-rules.js"},"PR":{"t":"F","e":false,"f":"dead-code.js"},"FL":{"t":"F","e":false,"f":"dead-code.js"},"DC":{"t":"F","e":true,"f":"dead-code.js"},"gF":{"t":"F","e":true,"f":"filters.js"},"sF":{"t":"F","e":true,"f":"filters.js"},"aE":{"t":"F","e":true,"f":"filters.js"},"rE":{"t":"F","e":true,"f":"filters.js"},"rF":{"t":"F","e":true,"f":"filters.js"},"pG1":{"t":"F","e":true,"f":"filters.js"},"ED":{"t":"F","e":true,"f":"filters.js"},"EF":{"t":"F","e":true,"f":"filters.js"},"mW":{"t":"F","e":false,"f":"filters.js"},"GP":{"t":"F","e":false,"f":"filters.js"},"fR":{"t":"F","e":false,"f":"framework-references.js"},"lA":{"t":"F","e":false,"f":"framework-references.js"},"FR":{"t":"F","e":true,"f":"framework-references.js"},"HS":{"t":"F","e":false,"f":"full-analysis.js"},"FA":{"t":"F","e":true,"f":"full-analysis.js"},"mL":{"t":"F","e":true,"f":"graph-builder.js"},"SN":{"t":"F","e":false,"f":"graph-builder.js"},"bG":{"t":"F","e":true,"f":"graph-builder.js"},"cS":{"t":"F","e":true,"f":"graph-builder.js"},"gI":{"t":"F","e":true,"f":"instructions.js"},"JSD":{"t":"F","e":true,"f":"jsdoc-generator.js"},"JSD1":{"t":"F","e":false,"f":"jsdoc-generator.js"},"PN":{"t":"F","e":false,"f":"similar-functions.js"},"PT":{"t":"F","e":false,"f":"jsdoc-generator.js"},"JSD2":{"t":"F","e":true,"f":"jsdoc-generator.js"},"pG2":{"t":"F","e":true,"f":"lang-go.js"},"eI":{"t":"F","e":false,"f":"lang-go.js"},"gB":{"t":"F","e":false,"f":"lang-go.js"},"eC":{"t":"F","e":false,"f":"parser.js"},"pP":{"t":"F","e":true,"f":"lang-python.js"},"TS":{"t":"F","e":true,"f":"lang-typescript.js"},"eP":{"t":"F","e":false,"f":"lang-typescript.js"},"SAC":{"t":"F","e":true,"f":"lang-utils.js"},"LF":{"t":"F","e":true,"f":"large-files.js"},"cS1":{"t":"F","e":true,"f":"mcp-server.js"},"SS":{"t":"F","e":true,"f":"mcp-server.js"},"FP":{"t":"F","e":false,"f":"outdated-patterns.js"},"PJ":{"t":"F","e":false,"f":"outdated-patterns.js"},"OP":{"t":"F","e":true,"f":"outdated-patterns.js"},"pF":{"t":"F","e":false,"f":"undocumented.js"},"pP1":{"t":"F","e":true,"f":"parser.js"},"FBE":{"t":"F","e":false,"f":"parser.js"},"SF":{"t":"F","e":false,"f":"parser.js"},"eS":{"t":"F","e":false,"f":"similar-functions.js"},"bS":{"t":"F","e":false,"f":"similar-functions.js"},"BS":{"t":"F","e":false,"f":"similar-functions.js"},"cS2":{"t":"F","e":false,"f":"similar-functions.js"},"SF1":{"t":"F","e":true,"f":"similar-functions.js"},"pA":{"t":"F","e":true,"f":"test-annotations.js"},"AF":{"t":"F","e":true,"f":"test-annotations.js"},"PT1":{"t":"F","e":true,"f":"test-annotations.js"},"TP":{"t":"F","e":true,"f":"test-annotations.js"},"TF":{"t":"F","e":true,"f":"test-annotations.js"},"TS1":{"t":"F","e":true,"f":"test-annotations.js"},"TS2":{"t":"F","e":true,"f":"test-annotations.js"},"gM":{"t":"F","e":true,"f":"test-annotations.js"},"DC1":{"t":"F","e":false,"f":"tools.js"},"DC2":{"t":"F","e":false,"f":"tools.js"},"gG":{"t":"F","e":false,"f":"tools.js"},"dC":{"t":"F","e":false,"f":"tools.js"},"sM":{"t":"F","e":false,"f":"tools.js"},"gS":{"t":"F","e":true,"f":"tools.js"},"FZ":{"t":"F","e":true,"f":"tools.js"},"ex":{"t":"F","e":true,"f":"tools.js"},"de":{"t":"F","e":true,"f":"tools.js"},"us":{"t":"F","e":true,"f":"tools.js"},"eM":{"t":"F","e":false,"f":"tools.js"},"CC":{"t":"F","e":true,"f":"tools.js"},"iC":{"t":"F","e":true,"f":"tools.js"},"eC1":{"t":"F","e":false,"f":"undocumented.js"},"JSD3":{"t":"F","e":false,"f":"undocumented.js"},"cM":{"t":"F","e":false,"f":"undocumented.js"},"gU":{"t":"F","e":true,"f":"undocumented.js"},"US":{"t":"F","e":true,"f":"undocumented.js"},"sR":{"t":"F","e":true,"f":"workspace.js"},"WR":{"t":"F","e":true,"f":"workspace.js"},"rP":{"t":"F","e":true,"f":"workspace.js"}},"edges":[],"orphans":["getArg","getPath","findJSFiles","calculateComplexity","getRating","analyzeFile","parseGraphignore","isGraphignored","loadRuleSets","saveRuleSet","findFiles","isExcluded","isInStringOrComment","isWithinContext","checkFileAgainstRule","findProjectRoot","analyzeFileLocals","matchWildcard","matchGitignorePattern","fetchReference","listAvailable","calculateHealthScore","createShortName","buildJSDoc","extractParamName","inferParamType","extractImports","getBody","extractCalls","extractParams","analyzeFilePatterns","analyzePackageJson","parseFile","parseFileByExtension","isSourceFile","extractSignatures","buildSignature","hashBodyStructure","calculateSimilarity","saveDiskCache","loadDiskCache","getGraph","detectChanges","snapshotMtimes","extractMethod","extractComments","findJSDocBefore","checkMissing"],"duplicates":{},"files":["cli-handlers.js","cli.js","complexity.js","custom-rules.js","dead-code.js","filters.js","framework-references.js","full-analysis.js","graph-builder.js","instructions.js","jsdoc-generator.js","lang-go.js","lang-python.js","lang-typescript.js","lang-utils.js","large-files.js","mcp-server.js","outdated-patterns.js","parser.js","server.js","similar-functions.js","test-annotations.js","tool-defs.js","tools.js","undocumented.js","workspace.js"]}}
|
package/src/filters.js
CHANGED
package/src/graph-builder.js
CHANGED
|
@@ -169,7 +169,7 @@ export function buildGraph(parsed) {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
// Detect duplicates (same method name in multiple classes)
|
|
172
|
-
const methodLocations =
|
|
172
|
+
const methodLocations = Object.create(null);
|
|
173
173
|
for (const cls of classes) {
|
|
174
174
|
for (const method of cls.methods || []) {
|
|
175
175
|
if (!methodLocations[method]) {
|
package/src/lang-go.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { stripStringsAndComments } from './lang-utils.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse Go file using regex-based structural extraction.
|
|
5
|
+
* @param {string} code - Go source code
|
|
6
|
+
* @param {string} filename - File path
|
|
7
|
+
* @returns {ParseResult}
|
|
8
|
+
*/
|
|
9
|
+
export function parseGo(code, filename) {
|
|
10
|
+
const result = {
|
|
11
|
+
file: filename,
|
|
12
|
+
classes: [],
|
|
13
|
+
functions: [],
|
|
14
|
+
imports: [],
|
|
15
|
+
exports: []
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const { imports, packageNames } = extractImports(code);
|
|
19
|
+
result.imports = imports;
|
|
20
|
+
|
|
21
|
+
const cleanCode = stripStringsAndComments(code, {
|
|
22
|
+
singleQuote: false,
|
|
23
|
+
backtick: true,
|
|
24
|
+
templateInterpolation: false
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const classesMap = new Map();
|
|
28
|
+
|
|
29
|
+
// Extract Structs (mapped to classes)
|
|
30
|
+
const structRegex = /^\s*type\s+([a-zA-Z_]\w*)\s+struct\s*\{/gm;
|
|
31
|
+
let match;
|
|
32
|
+
while ((match = structRegex.exec(cleanCode)) !== null) {
|
|
33
|
+
const name = match[1];
|
|
34
|
+
const start = match.index + match[0].length;
|
|
35
|
+
const body = getBody(cleanCode, start);
|
|
36
|
+
const line = code.substring(0, match.index).split('\n').length;
|
|
37
|
+
|
|
38
|
+
let extendsName = null;
|
|
39
|
+
const properties = [];
|
|
40
|
+
|
|
41
|
+
const lines = body.split('\n').map(l => l.trim()).filter(l => l);
|
|
42
|
+
for (const lineStr of lines) {
|
|
43
|
+
const parts = lineStr.split(/\s+/);
|
|
44
|
+
if (parts.length === 1) {
|
|
45
|
+
extendsName = parts[0].replace(/^\*/, ''); // Remove pointer if embedded
|
|
46
|
+
} else if (parts.length >= 2) {
|
|
47
|
+
const propName = parts[0].replace(/,$/, '');
|
|
48
|
+
properties.push(propName);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
classesMap.set(name, {
|
|
53
|
+
name,
|
|
54
|
+
extends: extendsName,
|
|
55
|
+
methods: [],
|
|
56
|
+
properties,
|
|
57
|
+
calls: [],
|
|
58
|
+
file: filename,
|
|
59
|
+
line
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Extract Interfaces (mapped to classes)
|
|
64
|
+
const interfaceRegex = /^\s*type\s+([a-zA-Z_]\w*)\s+interface\s*\{/gm;
|
|
65
|
+
while ((match = interfaceRegex.exec(cleanCode)) !== null) {
|
|
66
|
+
const name = match[1];
|
|
67
|
+
const start = match.index + match[0].length;
|
|
68
|
+
const body = getBody(cleanCode, start);
|
|
69
|
+
const line = code.substring(0, match.index).split('\n').length;
|
|
70
|
+
|
|
71
|
+
let extendsName = null;
|
|
72
|
+
const methods = [];
|
|
73
|
+
|
|
74
|
+
const lines = body.split('\n').map(l => l.trim()).filter(l => l);
|
|
75
|
+
for (const lineStr of lines) {
|
|
76
|
+
const parenIndex = lineStr.indexOf('(');
|
|
77
|
+
if (parenIndex !== -1) {
|
|
78
|
+
const beforeParen = lineStr.substring(0, parenIndex).trim();
|
|
79
|
+
const parts = beforeParen.split(/\s+/);
|
|
80
|
+
const methodName = parts[parts.length - 1];
|
|
81
|
+
if (methodName) {
|
|
82
|
+
methods.push(methodName);
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
const parts = lineStr.split(/\s+/);
|
|
86
|
+
if (parts.length === 1) {
|
|
87
|
+
extendsName = parts[0];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
classesMap.set(name, {
|
|
93
|
+
name,
|
|
94
|
+
extends: extendsName,
|
|
95
|
+
methods,
|
|
96
|
+
properties: [],
|
|
97
|
+
calls: [],
|
|
98
|
+
file: filename,
|
|
99
|
+
line
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Extract Methods
|
|
104
|
+
const methodRegex = /^\s*func\s+\(\s*[a-zA-Z_]\w*\s+\*?([a-zA-Z_]\w*)\s*\)\s+([a-zA-Z_]\w*)[^{]*\{/gm;
|
|
105
|
+
while ((match = methodRegex.exec(cleanCode)) !== null) {
|
|
106
|
+
const className = match[1];
|
|
107
|
+
const methodName = match[2];
|
|
108
|
+
const start = match.index + match[0].length;
|
|
109
|
+
const body = getBody(cleanCode, start);
|
|
110
|
+
const line = code.substring(0, match.index).split('\n').length;
|
|
111
|
+
|
|
112
|
+
const methodCalls = extractCalls(body, packageNames);
|
|
113
|
+
|
|
114
|
+
if (!classesMap.has(className)) {
|
|
115
|
+
classesMap.set(className, {
|
|
116
|
+
name: className,
|
|
117
|
+
extends: null,
|
|
118
|
+
methods: [],
|
|
119
|
+
properties: [],
|
|
120
|
+
calls: [],
|
|
121
|
+
file: filename,
|
|
122
|
+
line
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const classInfo = classesMap.get(className);
|
|
127
|
+
classInfo.methods.push(methodName);
|
|
128
|
+
|
|
129
|
+
for (const call of methodCalls) {
|
|
130
|
+
if (!classInfo.calls.includes(call)) {
|
|
131
|
+
classInfo.calls.push(call);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Extract Functions (top-level)
|
|
137
|
+
const funcRegex = /^\s*func\s+([a-zA-Z_]\w*)\s*\(([^)]*)\)[^{]*\{/gm;
|
|
138
|
+
while ((match = funcRegex.exec(cleanCode)) !== null) {
|
|
139
|
+
const name = match[1];
|
|
140
|
+
const paramsStr = match[2];
|
|
141
|
+
const params = paramsStr.split(',')
|
|
142
|
+
.map(p => p.trim().split(/\s+/)[0])
|
|
143
|
+
.filter(p => p);
|
|
144
|
+
|
|
145
|
+
const exported = /^[A-Z]/.test(name);
|
|
146
|
+
const start = match.index + match[0].length;
|
|
147
|
+
const body = getBody(cleanCode, start);
|
|
148
|
+
const line = code.substring(0, match.index).split('\n').length;
|
|
149
|
+
|
|
150
|
+
const calls = extractCalls(body, packageNames);
|
|
151
|
+
|
|
152
|
+
result.functions.push({
|
|
153
|
+
name,
|
|
154
|
+
exported,
|
|
155
|
+
calls,
|
|
156
|
+
params,
|
|
157
|
+
file: filename,
|
|
158
|
+
line
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
result.classes = Array.from(classesMap.values());
|
|
163
|
+
|
|
164
|
+
// Extract Exports
|
|
165
|
+
for (const cls of result.classes) {
|
|
166
|
+
if (/^[A-Z]/.test(cls.name)) {
|
|
167
|
+
result.exports.push(cls.name);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const fn of result.functions) {
|
|
171
|
+
if (fn.exported) {
|
|
172
|
+
result.exports.push(fn.name);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function extractImports(text) {
|
|
180
|
+
const imports = [];
|
|
181
|
+
const packageNames = new Set();
|
|
182
|
+
|
|
183
|
+
// Strip comments to avoid commented out imports
|
|
184
|
+
const noComments = text.replace(/\/\/.*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
185
|
+
|
|
186
|
+
const importBlockRegex = /import\s*\(([\s\S]*?)\)/g;
|
|
187
|
+
let match;
|
|
188
|
+
while ((match = importBlockRegex.exec(noComments)) !== null) {
|
|
189
|
+
const block = match[1];
|
|
190
|
+
const lines = block.split('\n');
|
|
191
|
+
for (const line of lines) {
|
|
192
|
+
const lineMatch = line.match(/(?:([a-zA-Z_]\w*)\s+)?"([^"]+)"/);
|
|
193
|
+
if (lineMatch) {
|
|
194
|
+
const alias = lineMatch[1];
|
|
195
|
+
const pkgPath = lineMatch[2];
|
|
196
|
+
if (alias) {
|
|
197
|
+
if (!imports.includes(alias)) {
|
|
198
|
+
imports.push(alias);
|
|
199
|
+
packageNames.add(alias);
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
if (!imports.includes(pkgPath)) {
|
|
203
|
+
imports.push(pkgPath);
|
|
204
|
+
const parts = pkgPath.split('/');
|
|
205
|
+
packageNames.add(parts[parts.length - 1]);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const singleImportRegex = /import\s+(?:([a-zA-Z_]\w*)\s+)?"([^"]+)"/g;
|
|
213
|
+
while ((match = singleImportRegex.exec(noComments)) !== null) {
|
|
214
|
+
const alias = match[1];
|
|
215
|
+
const pkgPath = match[2];
|
|
216
|
+
if (alias) {
|
|
217
|
+
if (!imports.includes(alias)) {
|
|
218
|
+
imports.push(alias);
|
|
219
|
+
packageNames.add(alias);
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
if (!imports.includes(pkgPath)) {
|
|
223
|
+
imports.push(pkgPath);
|
|
224
|
+
const parts = pkgPath.split('/');
|
|
225
|
+
packageNames.add(parts[parts.length - 1]);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { imports, packageNames };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Extract block body correctly handling nested braces.
|
|
235
|
+
* @param {string} code
|
|
236
|
+
* @param {number} startIndex
|
|
237
|
+
* @returns {string}
|
|
238
|
+
*/
|
|
239
|
+
function getBody(code, startIndex) {
|
|
240
|
+
let braces = 1;
|
|
241
|
+
let end = startIndex;
|
|
242
|
+
while (end < code.length && braces > 0) {
|
|
243
|
+
if (code[end] === '{') braces++;
|
|
244
|
+
else if (code[end] === '}') braces--;
|
|
245
|
+
end++;
|
|
246
|
+
}
|
|
247
|
+
return code.substring(startIndex, end - 1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Extract method calls from a block of code, filtering out Go keywords.
|
|
252
|
+
* @param {string} body
|
|
253
|
+
* @param {Set<string>} packageNames
|
|
254
|
+
* @returns {string[]}
|
|
255
|
+
*/
|
|
256
|
+
function extractCalls(body, packageNames) {
|
|
257
|
+
const calls = [];
|
|
258
|
+
const callRegex = /([a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)?)\s*\(/g;
|
|
259
|
+
let match;
|
|
260
|
+
while ((match = callRegex.exec(body)) !== null) {
|
|
261
|
+
let callName = match[1];
|
|
262
|
+
|
|
263
|
+
const keywords = [
|
|
264
|
+
'if', 'for', 'switch', 'func', 'panic', 'recover', 'len', 'cap',
|
|
265
|
+
'make', 'new', 'append', 'copy', 'delete', 'close',
|
|
266
|
+
'int', 'string', 'bool', 'byte', 'rune', 'float32', 'float64',
|
|
267
|
+
'int32', 'int64', 'uint32', 'uint64', 'complex64', 'complex128'
|
|
268
|
+
];
|
|
269
|
+
if (keywords.includes(callName)) continue;
|
|
270
|
+
|
|
271
|
+
if (callName.includes('.')) {
|
|
272
|
+
const parts = callName.split('.');
|
|
273
|
+
// If the first part is a known package name, keep it (e.g., fmt.Println)
|
|
274
|
+
// Otherwise, assume it's a method call on a variable and strip it (e.g., s.Handle -> Handle)
|
|
275
|
+
if (!packageNames.has(parts[0])) {
|
|
276
|
+
callName = parts[1];
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (!calls.includes(callName)) {
|
|
281
|
+
calls.push(callName);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return calls;
|
|
285
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { stripStringsAndComments } from './lang-utils.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse Python file using regex-based structural extraction.
|
|
5
|
+
* @param {string} code - Python source code
|
|
6
|
+
* @param {string} filename - File path
|
|
7
|
+
* @returns {ParseResult}
|
|
8
|
+
*/
|
|
9
|
+
export function parsePython(code = '', filename = '') {
|
|
10
|
+
const result = {
|
|
11
|
+
file: filename,
|
|
12
|
+
classes: [],
|
|
13
|
+
functions: [],
|
|
14
|
+
imports: [],
|
|
15
|
+
exports: []
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Pre-process: remove docstrings, triple-quoted strings, and line comments
|
|
19
|
+
const cleanCode = stripStringsAndComments(code, {
|
|
20
|
+
singleQuote: true,
|
|
21
|
+
hashComment: true,
|
|
22
|
+
tripleQuote: true
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const lines = cleanCode.split('\n');
|
|
26
|
+
|
|
27
|
+
let currentClass = null;
|
|
28
|
+
let currentFunc = null;
|
|
29
|
+
let classIndent = -1;
|
|
30
|
+
|
|
31
|
+
for (let i = 0; i < lines.length; i++) {
|
|
32
|
+
const line = lines[i];
|
|
33
|
+
if (!line.trim()) continue;
|
|
34
|
+
|
|
35
|
+
const indentMatch = line.match(/^([ \t]*)/);
|
|
36
|
+
const indent = indentMatch ? indentMatch[1].length : 0;
|
|
37
|
+
|
|
38
|
+
// Check if we exited a class scope
|
|
39
|
+
if (currentClass && indent <= classIndent) {
|
|
40
|
+
currentClass = null;
|
|
41
|
+
classIndent = -1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Check if we exited a function scope
|
|
45
|
+
if (currentFunc && indent === 0) {
|
|
46
|
+
currentFunc = null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Match Class (top-level)
|
|
50
|
+
const classMatch = line.match(/^class\s+([a-zA-Z_]\w*)(?:\s*\((.*?)\))?\s*:/);
|
|
51
|
+
if (classMatch) {
|
|
52
|
+
currentClass = {
|
|
53
|
+
name: classMatch[1],
|
|
54
|
+
extends: classMatch[2] ? classMatch[2].trim() : null,
|
|
55
|
+
methods: [],
|
|
56
|
+
properties: [],
|
|
57
|
+
calls: [],
|
|
58
|
+
file: filename,
|
|
59
|
+
line: i + 1
|
|
60
|
+
};
|
|
61
|
+
result.classes.push(currentClass);
|
|
62
|
+
classIndent = indent;
|
|
63
|
+
currentFunc = null;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Match top-level function
|
|
68
|
+
const funcMatch = line.match(/^(?:async\s+)?def\s+([a-zA-Z_]\w*)\s*\(([^)]*)\)?/);
|
|
69
|
+
if (funcMatch) {
|
|
70
|
+
const paramsStr = funcMatch[2] || '';
|
|
71
|
+
const params = paramsStr.split(',')
|
|
72
|
+
.map(p => p.split(/[:=]/)[0].trim())
|
|
73
|
+
.filter(p => p && p !== 'self' && p !== 'cls');
|
|
74
|
+
|
|
75
|
+
currentFunc = {
|
|
76
|
+
name: funcMatch[1],
|
|
77
|
+
exported: true, // we'll adjust later if __all__ is present
|
|
78
|
+
calls: [],
|
|
79
|
+
params: params,
|
|
80
|
+
file: filename,
|
|
81
|
+
line: i + 1
|
|
82
|
+
};
|
|
83
|
+
result.functions.push(currentFunc);
|
|
84
|
+
currentClass = null;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Match Method (inside class)
|
|
89
|
+
const methodMatch = line.match(/^[ \t]+(?:async\s+)?def\s+([a-zA-Z_]\w*)\s*\(/);
|
|
90
|
+
if (methodMatch && currentClass && indent > classIndent) {
|
|
91
|
+
const methodName = methodMatch[1];
|
|
92
|
+
if (methodName !== '__init__') {
|
|
93
|
+
currentClass.methods.push(methodName);
|
|
94
|
+
}
|
|
95
|
+
currentFunc = null; // not a top-level function
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Match Imports
|
|
100
|
+
const importMatch = line.match(/^\s*import\s+(.+)/);
|
|
101
|
+
if (importMatch) {
|
|
102
|
+
const parts = importMatch[1].split(',');
|
|
103
|
+
for (const part of parts) {
|
|
104
|
+
const p = part.trim();
|
|
105
|
+
const asMatch = p.match(/(?:.+)\s+as\s+([a-zA-Z_]\w*)/);
|
|
106
|
+
if (asMatch) {
|
|
107
|
+
result.imports.push(asMatch[1]);
|
|
108
|
+
} else {
|
|
109
|
+
result.imports.push(p.split('.')[0]); // take root module
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const fromImportMatch = line.match(/^\s*from\s+([.\w]+)\s+import\s*(.*)/);
|
|
116
|
+
if (fromImportMatch) {
|
|
117
|
+
let imported = fromImportMatch[2];
|
|
118
|
+
if (imported.includes('(') && !imported.includes(')')) {
|
|
119
|
+
let j = i + 1;
|
|
120
|
+
while (j < lines.length) {
|
|
121
|
+
imported += ' ' + lines[j];
|
|
122
|
+
if (lines[j].includes(')')) {
|
|
123
|
+
i = j;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
j++;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
imported = imported.replace(/[()]/g, '');
|
|
130
|
+
const parts = imported.split(',');
|
|
131
|
+
for (const part of parts) {
|
|
132
|
+
const p = part.trim();
|
|
133
|
+
if (!p) continue;
|
|
134
|
+
const asMatch = p.match(/(?:.+)\s+as\s+([a-zA-Z_]\w*)/);
|
|
135
|
+
if (asMatch) {
|
|
136
|
+
result.imports.push(asMatch[1]);
|
|
137
|
+
} else {
|
|
138
|
+
result.imports.push(p);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Extract calls: look for func(...)
|
|
145
|
+
const callRegex = /([a-zA-Z_][\w.]*)\s*\(/g;
|
|
146
|
+
let match;
|
|
147
|
+
const keywords = new Set(['if', 'while', 'for', 'elif', 'return', 'yield', 'def', 'class', 'and', 'or', 'not', 'in', 'is', 'print']);
|
|
148
|
+
while ((match = callRegex.exec(line)) !== null) {
|
|
149
|
+
const callName = match[1];
|
|
150
|
+
if (keywords.has(callName)) continue;
|
|
151
|
+
|
|
152
|
+
let cleanCallName = callName;
|
|
153
|
+
if (cleanCallName.startsWith('self.')) {
|
|
154
|
+
cleanCallName = cleanCallName.substring(5);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (currentFunc) {
|
|
158
|
+
if (!currentFunc.calls.includes(cleanCallName)) {
|
|
159
|
+
currentFunc.calls.push(cleanCallName);
|
|
160
|
+
}
|
|
161
|
+
} else if (currentClass) {
|
|
162
|
+
if (!currentClass.calls.includes(cleanCallName)) {
|
|
163
|
+
currentClass.calls.push(cleanCallName);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Handle Exports (__all__)
|
|
170
|
+
const allMatch = code.match(/__all__\s*=\s*\[(.*?)\]/s);
|
|
171
|
+
if (allMatch) {
|
|
172
|
+
const exportsRaw = allMatch[1];
|
|
173
|
+
const exportRegex = /['"]([^'"]+)['"]/g;
|
|
174
|
+
let exMatch;
|
|
175
|
+
while ((exMatch = exportRegex.exec(exportsRaw)) !== null) {
|
|
176
|
+
result.exports.push(exMatch[1]);
|
|
177
|
+
}
|
|
178
|
+
// Update exported flags for functions
|
|
179
|
+
for (const fn of result.functions) {
|
|
180
|
+
fn.exported = result.exports.includes(fn.name);
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
// Implicit exports: all top-level functions and classes are exported
|
|
184
|
+
for (const cls of result.classes) {
|
|
185
|
+
result.exports.push(cls.name);
|
|
186
|
+
}
|
|
187
|
+
for (const fn of result.functions) {
|
|
188
|
+
result.exports.push(fn.name);
|
|
189
|
+
fn.exported = true;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Deduplicate imports
|
|
194
|
+
result.imports = [...new Set(result.imports)];
|
|
195
|
+
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { stripStringsAndComments } from './lang-utils.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TypeScript/TSX regex-based parser.
|
|
5
|
+
* Extracts structural information (classes, functions, imports, exports, calls)
|
|
6
|
+
* directly from TypeScript code without relying on Acorn.
|
|
7
|
+
*
|
|
8
|
+
* Strategy: Instead of stripping TS syntax to feed Acorn (which causes
|
|
9
|
+
* catastrophic backtracking in regex), parse structural elements directly
|
|
10
|
+
* — same approach as lang-python.js and lang-go.js.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} code - TypeScript source code
|
|
13
|
+
* @param {string} filename - File path for the result
|
|
14
|
+
* @returns {ParseResult}
|
|
15
|
+
*/
|
|
16
|
+
export function parseTypeScript(code, filename) {
|
|
17
|
+
const result = {
|
|
18
|
+
file: filename,
|
|
19
|
+
classes: [],
|
|
20
|
+
functions: [],
|
|
21
|
+
imports: [],
|
|
22
|
+
exports: [],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Strip strings, template literals, and comments to avoid false matches
|
|
26
|
+
const cleaned = stripStringsAndComments(code);
|
|
27
|
+
const lines = cleaned.split('\n');
|
|
28
|
+
|
|
29
|
+
let currentClass = null;
|
|
30
|
+
let currentFunc = null;
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < lines.length; i++) {
|
|
33
|
+
const line = lines[i];
|
|
34
|
+
const lineNum = i + 1;
|
|
35
|
+
|
|
36
|
+
// --- Imports ---
|
|
37
|
+
// import { A, B } from 'module'
|
|
38
|
+
const importFromMatch = line.match(/^\s*import\s+(?:type\s+)?(?:\{([^}]+)\}|(\w+))\s+from\s/);
|
|
39
|
+
if (importFromMatch) {
|
|
40
|
+
if (importFromMatch[1]) {
|
|
41
|
+
importFromMatch[1].split(',').forEach(s => {
|
|
42
|
+
const name = s.trim().replace(/\s+as\s+\w+/, '').replace(/^type\s+/, '');
|
|
43
|
+
if (name) result.imports.push(name);
|
|
44
|
+
});
|
|
45
|
+
} else if (importFromMatch[2]) {
|
|
46
|
+
result.imports.push(importFromMatch[2]);
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
// import * as name from 'module'
|
|
51
|
+
const importStarMatch = line.match(/^\s*import\s+\*\s+as\s+(\w+)\s+from\s/);
|
|
52
|
+
if (importStarMatch) {
|
|
53
|
+
result.imports.push(importStarMatch[1]);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- Exports ---
|
|
58
|
+
const exportMatch = line.match(/^\s*export\s+(?:default\s+)?(?:class|function|const|let|var|type|interface|enum|abstract)\s+(\w+)/);
|
|
59
|
+
if (exportMatch) {
|
|
60
|
+
result.exports.push(exportMatch[1]);
|
|
61
|
+
}
|
|
62
|
+
// export { A, B }
|
|
63
|
+
const exportBraceMatch = line.match(/^\s*export\s+\{([^}]+)\}/);
|
|
64
|
+
if (exportBraceMatch) {
|
|
65
|
+
exportBraceMatch[1].split(',').forEach(s => {
|
|
66
|
+
const name = s.trim().replace(/\s+as\s+\w+/, '');
|
|
67
|
+
if (name) result.exports.push(name);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Skip type-only declarations (no runtime code)
|
|
72
|
+
if (/^\s*(type|interface)\s+\w+/.test(line)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- Classes ---
|
|
77
|
+
const classMatch = line.match(/^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?/);
|
|
78
|
+
if (classMatch) {
|
|
79
|
+
currentClass = {
|
|
80
|
+
name: classMatch[1],
|
|
81
|
+
extends: classMatch[2] || null,
|
|
82
|
+
methods: [],
|
|
83
|
+
properties: [],
|
|
84
|
+
calls: [],
|
|
85
|
+
file: filename,
|
|
86
|
+
line: lineNum,
|
|
87
|
+
};
|
|
88
|
+
result.classes.push(currentClass);
|
|
89
|
+
currentFunc = null;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Detect end of class or function (closing brace at col 0)
|
|
94
|
+
if (/^}/.test(line)) {
|
|
95
|
+
currentClass = null;
|
|
96
|
+
currentFunc = null;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- Methods (inside class) ---
|
|
101
|
+
if (currentClass) {
|
|
102
|
+
// public/private/protected/static/async methodName(
|
|
103
|
+
const methodMatch = line.match(/^\s+(?:(?:public|private|protected|static|readonly|abstract|override|async)\s+)*(\w+)\s*(?:<[^>]*>)?\s*\(/);
|
|
104
|
+
if (methodMatch && methodMatch[1] !== 'if' && methodMatch[1] !== 'for' &&
|
|
105
|
+
methodMatch[1] !== 'while' && methodMatch[1] !== 'switch' &&
|
|
106
|
+
methodMatch[1] !== 'catch' && methodMatch[1] !== 'return' &&
|
|
107
|
+
methodMatch[1] !== 'new' && methodMatch[1] !== 'constructor' &&
|
|
108
|
+
methodMatch[1] !== 'super') {
|
|
109
|
+
currentClass.methods.push(methodMatch[1]);
|
|
110
|
+
}
|
|
111
|
+
// constructor
|
|
112
|
+
if (/^\s+constructor\s*\(/.test(line)) {
|
|
113
|
+
currentClass.methods.push('constructor');
|
|
114
|
+
}
|
|
115
|
+
// Property: name: Type or name = value
|
|
116
|
+
const propMatch = line.match(/^\s+(?:(?:public|private|protected|static|readonly|declare|override|abstract)\s+)*(\w+)\s*[?!]?\s*[:=]/);
|
|
117
|
+
if (propMatch && !methodMatch && propMatch[1] !== 'if' && propMatch[1] !== 'const' &&
|
|
118
|
+
propMatch[1] !== 'let' && propMatch[1] !== 'var' && propMatch[1] !== 'return') {
|
|
119
|
+
currentClass.properties.push(propMatch[1]);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- Functions (top-level) ---
|
|
124
|
+
if (!currentClass) {
|
|
125
|
+
// function name(, async function name(, export function, export default function
|
|
126
|
+
const fnMatch = line.match(/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+(\w+)/);
|
|
127
|
+
if (fnMatch) {
|
|
128
|
+
currentFunc = {
|
|
129
|
+
name: fnMatch[1],
|
|
130
|
+
exported: /^\s*export\s+/.test(line),
|
|
131
|
+
calls: [],
|
|
132
|
+
params: extractParams(line),
|
|
133
|
+
file: filename,
|
|
134
|
+
line: lineNum,
|
|
135
|
+
};
|
|
136
|
+
result.functions.push(currentFunc);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
// Arrow functions: const name = (...) => or export const name = (
|
|
140
|
+
const arrowMatch = line.match(/^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_]\w*)\s*(?::\s*\w+(?:<[^>]*>)?)?\s*=>/);
|
|
141
|
+
if (arrowMatch) {
|
|
142
|
+
currentFunc = {
|
|
143
|
+
name: arrowMatch[1],
|
|
144
|
+
exported: /^\s*export\s+/.test(line),
|
|
145
|
+
calls: [],
|
|
146
|
+
params: extractParams(line),
|
|
147
|
+
file: filename,
|
|
148
|
+
line: lineNum,
|
|
149
|
+
};
|
|
150
|
+
result.functions.push(currentFunc);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- Calls ---
|
|
156
|
+
const callRegex = /\b([a-zA-Z_$]\w*)\s*(?:<[^>]*>)?\s*\(/g;
|
|
157
|
+
let callMatch;
|
|
158
|
+
while ((callMatch = callRegex.exec(line)) !== null) {
|
|
159
|
+
const name = callMatch[1];
|
|
160
|
+
// Skip keywords and common built-ins
|
|
161
|
+
if (['if', 'for', 'while', 'switch', 'catch', 'return', 'new', 'throw',
|
|
162
|
+
'typeof', 'delete', 'void', 'import', 'export', 'class', 'function',
|
|
163
|
+
'const', 'let', 'var', 'async', 'await', 'super', 'this',
|
|
164
|
+
'interface', 'type', 'enum', 'declare', 'abstract'].includes(name)) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (currentClass) {
|
|
168
|
+
currentClass.calls.push(name);
|
|
169
|
+
} else if (currentFunc) {
|
|
170
|
+
currentFunc.calls.push(name);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Extract parameter names from a function signature line.
|
|
180
|
+
* @param {string} line
|
|
181
|
+
* @returns {string[]}
|
|
182
|
+
*/
|
|
183
|
+
function extractParams(line) {
|
|
184
|
+
const match = line.match(/\(([^)]*)\)/);
|
|
185
|
+
if (!match) return [];
|
|
186
|
+
return match[1]
|
|
187
|
+
.split(',')
|
|
188
|
+
.map(p => p.trim().replace(/[?!]?\s*:.*$/, '').replace(/\s*=.*$/, '').trim())
|
|
189
|
+
.filter(p => p && !p.startsWith('...'));
|
|
190
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip strings, template literals, and comments from source code.
|
|
3
|
+
* Preserves line structure (newlines are kept) and character positions.
|
|
4
|
+
* @param {string} code
|
|
5
|
+
* @param {Object} [options]
|
|
6
|
+
* @param {boolean} [options.singleQuote=true] - Handle single-quoted strings
|
|
7
|
+
* @param {boolean} [options.backtick=true] - Handle backtick strings/templates
|
|
8
|
+
* @param {boolean} [options.hashComment=false] - Handle # comments (Python)
|
|
9
|
+
* @param {boolean} [options.tripleQuote=false] - Handle ''' and """ (Python)
|
|
10
|
+
* @param {boolean} [options.templateInterpolation=true] - Handle ${} in backticks
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function stripStringsAndComments(code, options = {}) {
|
|
14
|
+
const {
|
|
15
|
+
singleQuote = true,
|
|
16
|
+
backtick = true,
|
|
17
|
+
hashComment = false,
|
|
18
|
+
tripleQuote = false,
|
|
19
|
+
templateInterpolation = true
|
|
20
|
+
} = options;
|
|
21
|
+
|
|
22
|
+
let result = '';
|
|
23
|
+
let i = 0;
|
|
24
|
+
|
|
25
|
+
while (i < code.length) {
|
|
26
|
+
// Hash comment
|
|
27
|
+
if (hashComment && code[i] === '#') {
|
|
28
|
+
while (i < code.length && code[i] !== '\n') {
|
|
29
|
+
result += ' ';
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Triple quotes
|
|
36
|
+
if (tripleQuote && (
|
|
37
|
+
(code[i] === "'" && code[i+1] === "'" && code[i+2] === "'") ||
|
|
38
|
+
(code[i] === '"' && code[i+1] === '"' && code[i+2] === '"')
|
|
39
|
+
)) {
|
|
40
|
+
const quote = code[i];
|
|
41
|
+
result += ' ';
|
|
42
|
+
i += 3;
|
|
43
|
+
while (i < code.length) {
|
|
44
|
+
if (code[i] === '\\') {
|
|
45
|
+
result += ' ';
|
|
46
|
+
i += 2;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (code[i] === quote && code[i+1] === quote && code[i+2] === quote) {
|
|
50
|
+
result += ' ';
|
|
51
|
+
i += 3;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
result += code[i] === '\n' ? '\n' : ' ';
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Single-line comment //
|
|
61
|
+
if (!hashComment && code[i] === '/' && code[i + 1] === '/') {
|
|
62
|
+
while (i < code.length && code[i] !== '\n') {
|
|
63
|
+
result += ' ';
|
|
64
|
+
i++;
|
|
65
|
+
}
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Multi-line comment /* ... */
|
|
70
|
+
if (!hashComment && code[i] === '/' && code[i + 1] === '*') {
|
|
71
|
+
i += 2;
|
|
72
|
+
result += ' ';
|
|
73
|
+
while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) {
|
|
74
|
+
result += code[i] === '\n' ? '\n' : ' ';
|
|
75
|
+
i++;
|
|
76
|
+
}
|
|
77
|
+
if (i < code.length) { result += ' '; i += 2; }
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// String literals
|
|
82
|
+
if (code[i] === '"' || (singleQuote && code[i] === "'") || (backtick && code[i] === '`')) {
|
|
83
|
+
const quote = code[i];
|
|
84
|
+
result += ' ';
|
|
85
|
+
i++;
|
|
86
|
+
while (i < code.length) {
|
|
87
|
+
if (code[i] === '\\') {
|
|
88
|
+
result += ' ';
|
|
89
|
+
i += 2;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (code[i] === quote) {
|
|
93
|
+
result += ' ';
|
|
94
|
+
i++;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
// Template literal: ${...} — keep the expression
|
|
98
|
+
if (templateInterpolation && quote === '`' && code[i] === '$' && code[i + 1] === '{') {
|
|
99
|
+
result += '${';
|
|
100
|
+
i += 2;
|
|
101
|
+
let depth = 1;
|
|
102
|
+
while (i < code.length && depth > 0) {
|
|
103
|
+
if (code[i] === '{') depth++;
|
|
104
|
+
if (code[i] === '}') depth--;
|
|
105
|
+
if (depth > 0) {
|
|
106
|
+
result += code[i] === '\n' ? '\n' : code[i];
|
|
107
|
+
} else {
|
|
108
|
+
result += '}';
|
|
109
|
+
}
|
|
110
|
+
i++;
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
result += code[i] === '\n' ? '\n' : ' ';
|
|
115
|
+
i++;
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
result += code[i];
|
|
120
|
+
i++;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return result;
|
|
124
|
+
}
|
package/src/mcp-server.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { TOOLS } from './tool-defs.js';
|
|
10
|
-
import { getSkeleton, getFocusZone, expand, deps, usages, invalidateCache } from './tools.js';
|
|
10
|
+
import { getSkeleton, getFocusZone, expand, deps, usages, invalidateCache, getCallChain } from './tools.js';
|
|
11
11
|
import { getPendingTests, markTestPassed, markTestFailed, getTestSummary, resetTestState } from './test-annotations.js';
|
|
12
12
|
import { getFilters, setFilters, addExcludes, removeExcludes, resetFilters } from './filters.js';
|
|
13
13
|
import { getInstructions } from './instructions.js';
|
|
@@ -34,6 +34,7 @@ const TOOL_HANDLERS = {
|
|
|
34
34
|
expand: (args) => expand(args.symbol),
|
|
35
35
|
deps: (args) => deps(args.symbol),
|
|
36
36
|
usages: (args) => usages(args.symbol),
|
|
37
|
+
get_call_chain: (args) => getCallChain({ from: args.from, to: args.to, path: args.path ? resolvePath(args.path) : undefined }),
|
|
37
38
|
invalidate_cache: () => { invalidateCache(); return { success: true }; },
|
|
38
39
|
|
|
39
40
|
// Test Checklist Tools
|
|
@@ -115,6 +116,13 @@ const RESPONSE_HINTS = {
|
|
|
115
116
|
'💡 Use usages() for cross-project reference search.',
|
|
116
117
|
],
|
|
117
118
|
|
|
119
|
+
get_call_chain: (result) => {
|
|
120
|
+
if (result.error) return [];
|
|
121
|
+
return [
|
|
122
|
+
'💡 Use expand() on intermediate steps to understand how data is passed along the chain.',
|
|
123
|
+
];
|
|
124
|
+
},
|
|
125
|
+
|
|
118
126
|
invalidate_cache: () => [
|
|
119
127
|
'✅ Cache cleared. Run get_skeleton() to rebuild the project graph.',
|
|
120
128
|
],
|
package/src/parser.js
CHANGED
|
@@ -8,6 +8,12 @@ import { join, relative, resolve } from 'path';
|
|
|
8
8
|
import { parse } from '../vendor/acorn.mjs';
|
|
9
9
|
import * as walk from '../vendor/walk.mjs';
|
|
10
10
|
import { shouldExcludeDir, shouldExcludeFile, parseGitignore } from './filters.js';
|
|
11
|
+
import { parseTypeScript } from './lang-typescript.js';
|
|
12
|
+
import { parsePython } from './lang-python.js';
|
|
13
|
+
import { parseGo } from './lang-go.js';
|
|
14
|
+
|
|
15
|
+
/** Supported source file extensions */
|
|
16
|
+
const SOURCE_EXTENSIONS = ['.js', '.ts', '.tsx', '.py', '.go'];
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
19
|
* @typedef {Object} ClassInfo
|
|
@@ -239,7 +245,7 @@ export async function parseProject(dir) {
|
|
|
239
245
|
for (const file of files) {
|
|
240
246
|
const content = readFileSync(file, 'utf-8');
|
|
241
247
|
const relPath = relative(resolvedDir, file);
|
|
242
|
-
const parsed = await
|
|
248
|
+
const parsed = await parseFileByExtension(content, relPath);
|
|
243
249
|
|
|
244
250
|
result.files.push(relPath);
|
|
245
251
|
result.classes.push(...parsed.classes);
|
|
@@ -255,13 +261,46 @@ export async function parseProject(dir) {
|
|
|
255
261
|
return result;
|
|
256
262
|
}
|
|
257
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Route file to appropriate parser based on extension.
|
|
266
|
+
* @param {string} code
|
|
267
|
+
* @param {string} filename
|
|
268
|
+
* @returns {Promise<ParseResult>}
|
|
269
|
+
*/
|
|
270
|
+
async function parseFileByExtension(code, filename) {
|
|
271
|
+
if (filename.endsWith('.py')) {
|
|
272
|
+
return parsePython(code, filename);
|
|
273
|
+
}
|
|
274
|
+
if (filename.endsWith('.go')) {
|
|
275
|
+
return parseGo(code, filename);
|
|
276
|
+
}
|
|
277
|
+
if (filename.endsWith('.ts') || filename.endsWith('.tsx')) {
|
|
278
|
+
return parseTypeScript(code, filename);
|
|
279
|
+
}
|
|
280
|
+
// Default: JS via Acorn
|
|
281
|
+
return parseFile(code, filename);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Check if file is a supported source file.
|
|
286
|
+
* @param {string} filename
|
|
287
|
+
* @returns {boolean}
|
|
288
|
+
*/
|
|
289
|
+
function isSourceFile(filename) {
|
|
290
|
+
// Exclude Symbiote.js presentation files
|
|
291
|
+
if (filename.endsWith('.css.js') || filename.endsWith('.tpl.js')) {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
return SOURCE_EXTENSIONS.some(ext => filename.endsWith(ext));
|
|
295
|
+
}
|
|
296
|
+
|
|
258
297
|
/**
|
|
259
298
|
* Find all JS files recursively (uses filter configuration)
|
|
260
299
|
* @param {string} dir
|
|
261
300
|
* @param {string} [rootDir] - Root directory for relative path calculation
|
|
262
301
|
* @returns {string[]}
|
|
263
302
|
*/
|
|
264
|
-
function findJSFiles(dir, rootDir = dir) {
|
|
303
|
+
export function findJSFiles(dir, rootDir = dir) {
|
|
265
304
|
// Parse gitignore on first call
|
|
266
305
|
if (dir === rootDir) {
|
|
267
306
|
parseGitignore(rootDir);
|
|
@@ -279,7 +318,7 @@ function findJSFiles(dir, rootDir = dir) {
|
|
|
279
318
|
if (!shouldExcludeDir(entry, relativePath)) {
|
|
280
319
|
files.push(...findJSFiles(fullPath, rootDir));
|
|
281
320
|
}
|
|
282
|
-
} else if (
|
|
321
|
+
} else if (isSourceFile(entry)) {
|
|
283
322
|
if (!shouldExcludeFile(entry, relativePath)) {
|
|
284
323
|
files.push(fullPath);
|
|
285
324
|
}
|
package/src/server.js
CHANGED
|
File without changes
|
package/src/tool-defs.js
CHANGED
|
@@ -73,6 +73,19 @@ export const TOOLS = [
|
|
|
73
73
|
required: ['symbol'],
|
|
74
74
|
},
|
|
75
75
|
},
|
|
76
|
+
{
|
|
77
|
+
name: 'get_call_chain',
|
|
78
|
+
description: 'Find the shortest call chain from one function/class to another through the dependency graph.',
|
|
79
|
+
inputSchema: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: {
|
|
82
|
+
from: { type: 'string', description: 'Starting symbol (e.g., "authMiddleware")' },
|
|
83
|
+
to: { type: 'string', description: 'Target symbol (e.g., "renderDashboard")' },
|
|
84
|
+
path: { type: 'string', description: 'Path to scan (optional)' }
|
|
85
|
+
},
|
|
86
|
+
required: ['from', 'to'],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
76
89
|
{
|
|
77
90
|
name: 'invalidate_cache',
|
|
78
91
|
description: 'Invalidate the cached graph. Use after making code changes.',
|
package/src/tools.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* MCP Tools for Project Graph
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { parseProject, parseFile } from './parser.js';
|
|
5
|
+
import { parseProject, parseFile, findJSFiles } from './parser.js';
|
|
6
6
|
import { buildGraph, createSkeleton } from './graph-builder.js';
|
|
7
|
-
import { readFileSync } from 'fs';
|
|
7
|
+
import { readFileSync, statSync, writeFileSync, existsSync, unlinkSync } from 'fs';
|
|
8
8
|
import { execSync } from 'child_process';
|
|
9
|
+
import { join } from 'path';
|
|
9
10
|
|
|
10
11
|
/** @type {import('./graph-builder.js').Graph|null} */
|
|
11
12
|
let cachedGraph = null;
|
|
@@ -13,23 +14,158 @@ let cachedGraph = null;
|
|
|
13
14
|
/** @type {string|null} */
|
|
14
15
|
let cachedPath = null;
|
|
15
16
|
|
|
17
|
+
/** @type {Map<string, number>} file path -> mtimeMs */
|
|
18
|
+
let cachedMtimes = new Map();
|
|
19
|
+
|
|
16
20
|
/**
|
|
17
|
-
*
|
|
21
|
+
* Save cache to disk
|
|
22
|
+
* @param {string} path
|
|
23
|
+
* @param {import('./graph-builder.js').Graph} graph
|
|
24
|
+
*/
|
|
25
|
+
function saveDiskCache(path, graph) {
|
|
26
|
+
try {
|
|
27
|
+
const cachePath = join(path, '.project-graph-cache.json');
|
|
28
|
+
const cacheData = {
|
|
29
|
+
version: 1,
|
|
30
|
+
path: path,
|
|
31
|
+
mtimes: Object.fromEntries(cachedMtimes),
|
|
32
|
+
graph: graph
|
|
33
|
+
};
|
|
34
|
+
writeFileSync(cachePath, JSON.stringify(cacheData), 'utf-8');
|
|
35
|
+
} catch (e) {
|
|
36
|
+
// Ignore cache save errors
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Load cache from disk
|
|
42
|
+
* @param {string} path
|
|
43
|
+
* @returns {boolean} true if cache was successfully loaded and is valid
|
|
44
|
+
*/
|
|
45
|
+
function loadDiskCache(path) {
|
|
46
|
+
try {
|
|
47
|
+
const cachePath = join(path, '.project-graph-cache.json');
|
|
48
|
+
if (!existsSync(cachePath)) return false;
|
|
49
|
+
|
|
50
|
+
const content = readFileSync(cachePath, 'utf-8');
|
|
51
|
+
const data = JSON.parse(content);
|
|
52
|
+
|
|
53
|
+
if (data.version !== 1 || data.path !== path) return false;
|
|
54
|
+
|
|
55
|
+
cachedMtimes.clear();
|
|
56
|
+
for (const [file, mtime] of Object.entries(data.mtimes)) {
|
|
57
|
+
cachedMtimes.set(file, mtime);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
cachedGraph = data.graph;
|
|
61
|
+
cachedPath = path;
|
|
62
|
+
|
|
63
|
+
const changed = detectChanges(path);
|
|
64
|
+
if (changed) {
|
|
65
|
+
cachedGraph = null;
|
|
66
|
+
cachedPath = null;
|
|
67
|
+
cachedMtimes.clear();
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return true;
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get or build graph with smart mtime-based caching.
|
|
79
|
+
* On first call: full parse + build.
|
|
80
|
+
* On subsequent calls: check file mtimes, rebuild only if changes detected.
|
|
18
81
|
* @param {string} path
|
|
19
82
|
* @returns {Promise<import('./graph-builder.js').Graph>}
|
|
20
83
|
*/
|
|
21
84
|
async function getGraph(path) {
|
|
85
|
+
// Different path = full rebuild
|
|
22
86
|
if (cachedGraph && cachedPath === path) {
|
|
23
|
-
|
|
87
|
+
// Check for file changes via mtime
|
|
88
|
+
const changed = detectChanges(path);
|
|
89
|
+
if (!changed) {
|
|
90
|
+
return cachedGraph;
|
|
91
|
+
}
|
|
92
|
+
// Files changed - full rebuild (incremental would need graph-builder changes)
|
|
93
|
+
} else if (!cachedGraph) {
|
|
94
|
+
if (loadDiskCache(path)) {
|
|
95
|
+
return cachedGraph;
|
|
96
|
+
}
|
|
24
97
|
}
|
|
25
98
|
|
|
26
99
|
const parsed = await parseProject(path);
|
|
27
100
|
cachedGraph = buildGraph(parsed);
|
|
28
101
|
cachedPath = path;
|
|
29
102
|
|
|
103
|
+
// Snapshot mtimes for all parsed files
|
|
104
|
+
snapshotMtimes(path);
|
|
105
|
+
saveDiskCache(path, cachedGraph);
|
|
106
|
+
|
|
30
107
|
return cachedGraph;
|
|
31
108
|
}
|
|
32
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Detect if any JS files changed since last snapshot.
|
|
112
|
+
* Checks: new files, deleted files, modified files (via mtimeMs).
|
|
113
|
+
* @param {string} path
|
|
114
|
+
* @returns {boolean} true if changes detected
|
|
115
|
+
*/
|
|
116
|
+
function detectChanges(path) {
|
|
117
|
+
if (cachedMtimes.size === 0) return true;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const currentFiles = findJSFiles(path);
|
|
121
|
+
const currentSet = new Set(currentFiles);
|
|
122
|
+
const cachedSet = new Set(cachedMtimes.keys());
|
|
123
|
+
|
|
124
|
+
// New or deleted files
|
|
125
|
+
if (currentFiles.length !== cachedMtimes.size) return true;
|
|
126
|
+
for (const f of currentFiles) {
|
|
127
|
+
if (!cachedSet.has(f)) return true;
|
|
128
|
+
}
|
|
129
|
+
for (const f of cachedSet) {
|
|
130
|
+
if (!currentSet.has(f)) return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check mtimes
|
|
134
|
+
for (const file of currentFiles) {
|
|
135
|
+
try {
|
|
136
|
+
const mtime = statSync(file).mtimeMs;
|
|
137
|
+
if (mtime !== cachedMtimes.get(file)) return true;
|
|
138
|
+
} catch {
|
|
139
|
+
return true; // File gone or unreadable
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return false;
|
|
144
|
+
} catch {
|
|
145
|
+
return true; // Safety: rebuild on error
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Snapshot current mtimes for all JS files in path.
|
|
151
|
+
* @param {string} path
|
|
152
|
+
*/
|
|
153
|
+
function snapshotMtimes(path) {
|
|
154
|
+
cachedMtimes.clear();
|
|
155
|
+
try {
|
|
156
|
+
const files = findJSFiles(path);
|
|
157
|
+
for (const file of files) {
|
|
158
|
+
try {
|
|
159
|
+
cachedMtimes.set(file, statSync(file).mtimeMs);
|
|
160
|
+
} catch {
|
|
161
|
+
// Skip unreadable files
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// Ignore errors
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
33
169
|
/**
|
|
34
170
|
* Get compact project skeleton
|
|
35
171
|
* @param {string} path
|
|
@@ -110,13 +246,26 @@ export async function expand(symbol) {
|
|
|
110
246
|
// Find the source file
|
|
111
247
|
const parsed = await parseProject(path);
|
|
112
248
|
const cls = parsed.classes.find(c => c.name === fullName);
|
|
249
|
+
const fn = parsed.functions.find(f => f.name === fullName);
|
|
113
250
|
|
|
114
|
-
if (!cls) {
|
|
115
|
-
return { error: `
|
|
251
|
+
if (!cls && !fn) {
|
|
252
|
+
return { error: `Symbol not found: ${fullName}` };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (fn && !methodKey) {
|
|
256
|
+
return {
|
|
257
|
+
symbol,
|
|
258
|
+
fullName,
|
|
259
|
+
type: 'function',
|
|
260
|
+
file: fn.file,
|
|
261
|
+
line: fn.line,
|
|
262
|
+
exported: fn.exported,
|
|
263
|
+
calls: fn.calls,
|
|
264
|
+
};
|
|
116
265
|
}
|
|
117
266
|
|
|
118
267
|
// If method specified, extract method code
|
|
119
|
-
if (methodKey) {
|
|
268
|
+
if (methodKey && cls) {
|
|
120
269
|
const methodName = graph.reverseLegend[methodKey] || methodKey;
|
|
121
270
|
const content = readFileSync(cls.file, 'utf-8');
|
|
122
271
|
const methodCode = extractMethod(content, methodName);
|
|
@@ -231,10 +380,91 @@ function extractMethod(content, methodName) {
|
|
|
231
380
|
return content.slice(start);
|
|
232
381
|
}
|
|
233
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Find call chain from one symbol to another
|
|
385
|
+
* @param {Object} options
|
|
386
|
+
* @param {string} options.from - Starting symbol (full or minified)
|
|
387
|
+
* @param {string} options.to - Target symbol (full or minified)
|
|
388
|
+
* @param {string} [options.path] - Project path
|
|
389
|
+
* @returns {Promise<string[]|Object>}
|
|
390
|
+
*/
|
|
391
|
+
export async function getCallChain(options = {}) {
|
|
392
|
+
const { from, to, path } = options;
|
|
393
|
+
if (!from || !to) {
|
|
394
|
+
return { error: 'Both "from" and "to" parameters are required' };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const projectPath = path || cachedPath || 'src/components';
|
|
398
|
+
const graph = await getGraph(projectPath);
|
|
399
|
+
|
|
400
|
+
const fromSym = graph.legend[from] || from;
|
|
401
|
+
const toSym = graph.legend[to] || to;
|
|
402
|
+
|
|
403
|
+
// Build adjacency list for fast lookup
|
|
404
|
+
const adj = {};
|
|
405
|
+
for (const [caller, _, target] of graph.edges) {
|
|
406
|
+
if (!adj[caller]) adj[caller] = [];
|
|
407
|
+
adj[caller].push(target);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Queue stores { current: string, path: string[] }
|
|
411
|
+
const queue = [{ current: fromSym, path: [fromSym] }];
|
|
412
|
+
const visitedNodes = new Set();
|
|
413
|
+
const expandedBases = new Set();
|
|
414
|
+
visitedNodes.add(fromSym);
|
|
415
|
+
|
|
416
|
+
while (queue.length > 0) {
|
|
417
|
+
const { current, path: currentPath } = queue.shift();
|
|
418
|
+
|
|
419
|
+
const currentBase = current.split('.')[0];
|
|
420
|
+
const currentMethod = current.split('.')[1];
|
|
421
|
+
|
|
422
|
+
if (current === toSym || currentBase === toSym || currentMethod === toSym) {
|
|
423
|
+
const fullPath = currentPath.map(sym => {
|
|
424
|
+
const parts = sym.split('.');
|
|
425
|
+
const base = graph.reverseLegend[parts[0]] || parts[0];
|
|
426
|
+
if (parts.length === 2) {
|
|
427
|
+
const method = graph.reverseLegend[parts[1]] || parts[1];
|
|
428
|
+
return `${base}.${method}`;
|
|
429
|
+
}
|
|
430
|
+
return base;
|
|
431
|
+
});
|
|
432
|
+
return fullPath;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (expandedBases.has(currentBase)) {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
expandedBases.add(currentBase);
|
|
439
|
+
|
|
440
|
+
const neighbors = adj[currentBase] || [];
|
|
441
|
+
for (const neighbor of neighbors) {
|
|
442
|
+
if (!visitedNodes.has(neighbor)) {
|
|
443
|
+
visitedNodes.add(neighbor);
|
|
444
|
+
queue.push({
|
|
445
|
+
current: neighbor,
|
|
446
|
+
path: [...currentPath, neighbor]
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return { error: `No call path found from "${from}" to "${to}"` };
|
|
453
|
+
}
|
|
454
|
+
|
|
234
455
|
/**
|
|
235
456
|
* Invalidate cache
|
|
236
457
|
*/
|
|
237
458
|
export function invalidateCache() {
|
|
459
|
+
if (cachedPath) {
|
|
460
|
+
try {
|
|
461
|
+
const cachePath = join(cachedPath, '.project-graph-cache.json');
|
|
462
|
+
if (existsSync(cachePath)) {
|
|
463
|
+
unlinkSync(cachePath);
|
|
464
|
+
}
|
|
465
|
+
} catch (e) {}
|
|
466
|
+
}
|
|
238
467
|
cachedGraph = null;
|
|
239
468
|
cachedPath = null;
|
|
469
|
+
cachedMtimes.clear();
|
|
240
470
|
}
|