project-graph-mcp 1.0.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
@@ -6,8 +6,11 @@
6
6
  * - Sends server→client requests (roots/list) to get workspace info
7
7
  */
8
8
 
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { fileURLToPath } from 'url';
9
12
  import { TOOLS } from './tool-defs.js';
10
- import { getSkeleton, getFocusZone, expand, deps, usages, invalidateCache } from './tools.js';
13
+ import { getSkeleton, getFocusZone, expand, deps, usages, invalidateCache, getCallChain } from './tools.js';
11
14
  import { getPendingTests, markTestPassed, markTestFailed, getTestSummary, resetTestState } from './test-annotations.js';
12
15
  import { getFilters, setFilters, addExcludes, removeExcludes, resetFilters } from './filters.js';
13
16
  import { getInstructions } from './instructions.js';
@@ -23,6 +26,8 @@ import { getCustomRules, setCustomRule, checkCustomRules } from './custom-rules.
23
26
  import { getFrameworkReference } from './framework-references.js';
24
27
  import { setRoots, resolvePath } from './workspace.js';
25
28
 
29
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
30
+
26
31
  /**
27
32
  * Tool handlers registry
28
33
  * Maps tool names to their handler functions
@@ -34,6 +39,7 @@ const TOOL_HANDLERS = {
34
39
  expand: (args) => expand(args.symbol),
35
40
  deps: (args) => deps(args.symbol),
36
41
  usages: (args) => usages(args.symbol),
42
+ get_call_chain: (args) => getCallChain({ from: args.from, to: args.to, path: args.path ? resolvePath(args.path) : undefined }),
37
43
  invalidate_cache: () => { invalidateCache(); return { success: true }; },
38
44
 
39
45
  // Test Checklist Tools
@@ -51,6 +57,22 @@ const TOOL_HANDLERS = {
51
57
  reset_filters: () => resetFilters(),
52
58
 
53
59
  // Guidelines
60
+ get_usage_guide: (args) => {
61
+ try {
62
+ const guidePath = path.join(__dirname, '..', 'GUIDE.md');
63
+ const content = fs.readFileSync(guidePath, 'utf8');
64
+ if (!args.topic) return content;
65
+ const regex = new RegExp(`## ${args.topic}`, 'i');
66
+ const match = content.match(regex);
67
+ if (!match) return `Topic '${args.topic}' not found in guide.`;
68
+ const start = match.index;
69
+ let end = content.indexOf('\n## ', start + 1);
70
+ if (end === -1) end = content.length;
71
+ return content.substring(start, end).trim();
72
+ } catch (e) {
73
+ return `Failed to read usage guide: ${e.message}`;
74
+ }
75
+ },
54
76
  get_agent_instructions: () => getInstructions(),
55
77
 
56
78
  // Documentation
@@ -115,6 +137,13 @@ const RESPONSE_HINTS = {
115
137
  '💡 Use usages() for cross-project reference search.',
116
138
  ],
117
139
 
140
+ get_call_chain: (result) => {
141
+ if (result.error) return [];
142
+ return [
143
+ '💡 Use expand() on intermediate steps to understand how data is passed along the chain.',
144
+ ];
145
+ },
146
+
118
147
  invalidate_cache: () => [
119
148
  '✅ Cache cleared. Run get_skeleton() to rebuild the project graph.',
120
149
  ],
@@ -212,11 +241,47 @@ export function createServer(sendToClient) {
212
241
  id,
213
242
  result: {
214
243
  protocolVersion: '2024-11-05',
215
- capabilities: { tools: {} },
244
+ capabilities: { tools: {}, resources: {} },
216
245
  serverInfo: { name: 'project-graph', version: '1.1.0' },
217
246
  },
218
247
  };
219
248
 
249
+ case 'resources/list':
250
+ return {
251
+ jsonrpc: '2.0',
252
+ id,
253
+ result: {
254
+ resources: [
255
+ {
256
+ uri: 'project-graph://guide',
257
+ name: 'Project Graph Usage Guide',
258
+ description: 'Comprehensive guide with workflows and examples',
259
+ mimeType: 'text/markdown',
260
+ },
261
+ ],
262
+ },
263
+ };
264
+
265
+ case 'resources/read': {
266
+ if (params.uri !== 'project-graph://guide') {
267
+ return { jsonrpc: '2.0', id, error: { code: -32602, message: `Resource not found: ${params.uri}` } };
268
+ }
269
+ const content = fs.readFileSync(path.join(__dirname, '..', 'GUIDE.md'), 'utf8');
270
+ return {
271
+ jsonrpc: '2.0',
272
+ id,
273
+ result: {
274
+ contents: [
275
+ {
276
+ uri: 'project-graph://guide',
277
+ mimeType: 'text/markdown',
278
+ text: content,
279
+ },
280
+ ],
281
+ },
282
+ };
283
+ }
284
+
220
285
  case 'tools/list':
221
286
  return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
222
287
 
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.',
@@ -213,6 +226,26 @@ export const TOOLS = [
213
226
  },
214
227
 
215
228
  // Guidelines
229
+ {
230
+ name: 'get_usage_guide',
231
+ description: [
232
+ 'Get the comprehensive usage guide for project-graph with examples and best practices.',
233
+ 'Call this FIRST when planning how to analyze, navigate, or audit a codebase.',
234
+ 'Returns practical examples and recommended workflow for each feature area.',
235
+ '',
236
+ 'Available topics: navigation, analysis, testing, documentation, rules, workflow.',
237
+ 'Omit topic to get the full guide.',
238
+ ].join('\n'),
239
+ inputSchema: {
240
+ type: 'object',
241
+ properties: {
242
+ topic: {
243
+ type: 'string',
244
+ description: 'Optional topic filter: navigation, analysis, testing, documentation, rules, workflow',
245
+ },
246
+ },
247
+ },
248
+ },
216
249
  {
217
250
  name: 'get_agent_instructions',
218
251
  description: 'Get coding guidelines, architectural standards, and JSDoc rules for this project.',