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 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.1",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
- "description": "MCP server for AI agents — AST-based project graph, code quality analysis, and framework-specific lint rules. Zero dependencies.",
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
@@ -32,6 +32,7 @@ const DEFAULT_EXCLUDE_PATTERNS = [
32
32
  '*.min.js',
33
33
  '*.bundle.js',
34
34
  '*.d.ts',
35
+ '.project-graph-cache.json',
35
36
  ];
36
37
 
37
38
  // Current filter configuration (mutable via MCP)
@@ -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 parseFile(content, relPath);
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 (entry.endsWith('.js') && !entry.endsWith('.css.js') && !entry.endsWith('.tpl.js')) {
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
- * Get or build graph with caching
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
- return cachedGraph;
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: `Class not found: ${fullName}` };
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
  }