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