typegraph-mcp 0.9.0 → 0.9.2
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/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/cli.ts +3 -1
- package/dist/check.js +376 -0
- package/dist/cli.js +2842 -0
- package/dist/config.js +13 -0
- package/dist/graph-queries.js +279 -0
- package/dist/module-graph.js +336 -0
- package/dist/server.js +1479 -0
- package/dist/smoke-test.js +1148 -0
- package/dist/tsserver-client.js +266 -0
- package/package.json +19 -5
- package/tsup.config.ts +39 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1479 @@
|
|
|
1
|
+
|
|
2
|
+
// server.ts
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
// tsserver-client.ts
|
|
8
|
+
import { spawn } from "child_process";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
import { createRequire } from "module";
|
|
12
|
+
var log = (...args) => console.error("[typegraph/tsserver]", ...args);
|
|
13
|
+
var REQUEST_TIMEOUT_MS = 1e4;
|
|
14
|
+
var TsServerClient = class {
|
|
15
|
+
constructor(projectRoot2, tsconfigPath2 = "./tsconfig.json") {
|
|
16
|
+
this.projectRoot = projectRoot2;
|
|
17
|
+
this.tsconfigPath = tsconfigPath2;
|
|
18
|
+
}
|
|
19
|
+
child = null;
|
|
20
|
+
seq = 0;
|
|
21
|
+
pending = /* @__PURE__ */ new Map();
|
|
22
|
+
openFiles = /* @__PURE__ */ new Set();
|
|
23
|
+
buffer = Buffer.alloc(0);
|
|
24
|
+
ready = false;
|
|
25
|
+
shuttingDown = false;
|
|
26
|
+
restartCount = 0;
|
|
27
|
+
maxRestarts = 3;
|
|
28
|
+
// ─── Path Resolution ────────────────────────────────────────────────────
|
|
29
|
+
resolvePath(file) {
|
|
30
|
+
return path.isAbsolute(file) ? file : path.resolve(this.projectRoot, file);
|
|
31
|
+
}
|
|
32
|
+
relativePath(file) {
|
|
33
|
+
return path.relative(this.projectRoot, file);
|
|
34
|
+
}
|
|
35
|
+
/** Read a line from a file (1-based line number). Returns trimmed content. */
|
|
36
|
+
readLine(file, line) {
|
|
37
|
+
try {
|
|
38
|
+
const absPath2 = this.resolvePath(file);
|
|
39
|
+
const content = fs.readFileSync(absPath2, "utf-8");
|
|
40
|
+
const lines = content.split("\n");
|
|
41
|
+
return lines[line - 1]?.trim() ?? "";
|
|
42
|
+
} catch {
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// ─── Lifecycle ──────────────────────────────────────────────────────────
|
|
47
|
+
async start() {
|
|
48
|
+
if (this.child) return;
|
|
49
|
+
const require2 = createRequire(path.resolve(this.projectRoot, "package.json"));
|
|
50
|
+
const tsserverPath = require2.resolve("typescript/lib/tsserver.js");
|
|
51
|
+
log(`Spawning tsserver: ${tsserverPath}`);
|
|
52
|
+
log(`Project root: ${this.projectRoot}`);
|
|
53
|
+
log(`tsconfig: ${this.tsconfigPath}`);
|
|
54
|
+
this.child = spawn("node", [tsserverPath, "--disableAutomaticTypingAcquisition"], {
|
|
55
|
+
cwd: this.projectRoot,
|
|
56
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
57
|
+
env: { ...process.env, TSS_LOG: void 0 }
|
|
58
|
+
});
|
|
59
|
+
this.child.stdout.on("data", (chunk) => this.onData(chunk));
|
|
60
|
+
this.child.stderr.on("data", (chunk) => {
|
|
61
|
+
const text = chunk.toString().trim();
|
|
62
|
+
if (text) log(`[stderr] ${text}`);
|
|
63
|
+
});
|
|
64
|
+
this.child.on("close", (code) => {
|
|
65
|
+
log(`tsserver exited with code ${code}`);
|
|
66
|
+
this.child = null;
|
|
67
|
+
this.rejectAllPending(new Error(`tsserver exited with code ${code}`));
|
|
68
|
+
this.tryRestart();
|
|
69
|
+
});
|
|
70
|
+
this.child.on("error", (err) => {
|
|
71
|
+
log(`tsserver error: ${err.message}`);
|
|
72
|
+
this.rejectAllPending(err);
|
|
73
|
+
});
|
|
74
|
+
await this.sendRequest("configure", {
|
|
75
|
+
preferences: {
|
|
76
|
+
disableSuggestions: true
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
const warmStart = performance.now();
|
|
80
|
+
const tsconfigAbs = this.resolvePath(this.tsconfigPath);
|
|
81
|
+
if (fs.existsSync(tsconfigAbs)) {
|
|
82
|
+
await this.sendRequest("compilerOptionsForInferredProjects", {
|
|
83
|
+
options: { allowJs: true, checkJs: false }
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
this.ready = true;
|
|
87
|
+
log(`Ready [${(performance.now() - warmStart).toFixed(0)}ms configure]`);
|
|
88
|
+
}
|
|
89
|
+
shutdown() {
|
|
90
|
+
this.shuttingDown = true;
|
|
91
|
+
if (this.child) {
|
|
92
|
+
this.child.kill("SIGTERM");
|
|
93
|
+
this.child = null;
|
|
94
|
+
}
|
|
95
|
+
this.rejectAllPending(new Error("Client shutdown"));
|
|
96
|
+
}
|
|
97
|
+
tryRestart() {
|
|
98
|
+
if (this.shuttingDown) return;
|
|
99
|
+
if (this.restartCount >= this.maxRestarts) {
|
|
100
|
+
log(`Max restarts (${this.maxRestarts}) reached, not restarting`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
this.restartCount++;
|
|
104
|
+
log(`Restarting tsserver (attempt ${this.restartCount})...`);
|
|
105
|
+
this.buffer = Buffer.alloc(0);
|
|
106
|
+
const filesToReopen = [...this.openFiles];
|
|
107
|
+
this.openFiles.clear();
|
|
108
|
+
this.start().then(async () => {
|
|
109
|
+
for (const file of filesToReopen) {
|
|
110
|
+
await this.ensureOpen(file).catch(() => {
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
rejectAllPending(err) {
|
|
116
|
+
for (const [seq, pending] of this.pending) {
|
|
117
|
+
clearTimeout(pending.timer);
|
|
118
|
+
pending.reject(err);
|
|
119
|
+
}
|
|
120
|
+
this.pending.clear();
|
|
121
|
+
}
|
|
122
|
+
// ─── Protocol: Parsing ──────────────────────────────────────────────────
|
|
123
|
+
onData(chunk) {
|
|
124
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
125
|
+
this.processBuffer();
|
|
126
|
+
}
|
|
127
|
+
processBuffer() {
|
|
128
|
+
while (true) {
|
|
129
|
+
const headerEnd = this.buffer.indexOf("\r\n\r\n");
|
|
130
|
+
if (headerEnd === -1) return;
|
|
131
|
+
const header = this.buffer.subarray(0, headerEnd).toString("utf-8");
|
|
132
|
+
const match = header.match(/Content-Length:\s*(\d+)/i);
|
|
133
|
+
if (!match) {
|
|
134
|
+
this.buffer = this.buffer.subarray(headerEnd + 4);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const contentLength = parseInt(match[1], 10);
|
|
138
|
+
const bodyStart = headerEnd + 4;
|
|
139
|
+
if (this.buffer.length < bodyStart + contentLength) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const bodyBytes = this.buffer.subarray(bodyStart, bodyStart + contentLength);
|
|
143
|
+
this.buffer = this.buffer.subarray(bodyStart + contentLength);
|
|
144
|
+
try {
|
|
145
|
+
const message = JSON.parse(bodyBytes.toString("utf-8"));
|
|
146
|
+
this.onMessage(message);
|
|
147
|
+
} catch {
|
|
148
|
+
log("Failed to parse tsserver message");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
onMessage(message) {
|
|
153
|
+
if (message.type === "response" && message.request_seq !== void 0) {
|
|
154
|
+
const pending = this.pending.get(message.request_seq);
|
|
155
|
+
if (pending) {
|
|
156
|
+
clearTimeout(pending.timer);
|
|
157
|
+
this.pending.delete(message.request_seq);
|
|
158
|
+
if (message.success) {
|
|
159
|
+
pending.resolve(message.body);
|
|
160
|
+
} else {
|
|
161
|
+
pending.reject(
|
|
162
|
+
new Error(`tsserver ${pending.command} failed: ${message.message ?? "unknown error"}`)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// ─── Protocol: Sending ──────────────────────────────────────────────────
|
|
169
|
+
sendRequest(command, args) {
|
|
170
|
+
if (!this.child?.stdin?.writable) {
|
|
171
|
+
return Promise.reject(new Error("tsserver not running"));
|
|
172
|
+
}
|
|
173
|
+
const seq = ++this.seq;
|
|
174
|
+
const request = {
|
|
175
|
+
seq,
|
|
176
|
+
type: "request",
|
|
177
|
+
command,
|
|
178
|
+
arguments: args
|
|
179
|
+
};
|
|
180
|
+
return new Promise((resolve6, reject) => {
|
|
181
|
+
const timer = setTimeout(() => {
|
|
182
|
+
this.pending.delete(seq);
|
|
183
|
+
reject(new Error(`tsserver ${command} timed out after ${REQUEST_TIMEOUT_MS}ms`));
|
|
184
|
+
}, REQUEST_TIMEOUT_MS);
|
|
185
|
+
this.pending.set(seq, { resolve: resolve6, reject, timer, command });
|
|
186
|
+
this.child.stdin.write(JSON.stringify(request) + "\n");
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// Fire-and-forget — for commands like `open` that may not send a response
|
|
190
|
+
sendNotification(command, args) {
|
|
191
|
+
if (!this.child?.stdin?.writable) return;
|
|
192
|
+
const seq = ++this.seq;
|
|
193
|
+
const request = { seq, type: "request", command, arguments: args };
|
|
194
|
+
this.child.stdin.write(JSON.stringify(request) + "\n");
|
|
195
|
+
}
|
|
196
|
+
// ─── File Management ───────────────────────────────────────────────────
|
|
197
|
+
async ensureOpen(file) {
|
|
198
|
+
const absPath2 = this.resolvePath(file);
|
|
199
|
+
if (this.openFiles.has(absPath2)) return;
|
|
200
|
+
this.openFiles.add(absPath2);
|
|
201
|
+
this.sendNotification("open", { file: absPath2 });
|
|
202
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
203
|
+
}
|
|
204
|
+
// ─── Public API ────────────────────────────────────────────────────────
|
|
205
|
+
async definition(file, line, offset) {
|
|
206
|
+
const absPath2 = this.resolvePath(file);
|
|
207
|
+
await this.ensureOpen(absPath2);
|
|
208
|
+
const body = await this.sendRequest("definition", {
|
|
209
|
+
file: absPath2,
|
|
210
|
+
line,
|
|
211
|
+
offset
|
|
212
|
+
});
|
|
213
|
+
if (!body || !Array.isArray(body)) return [];
|
|
214
|
+
return body.map((d) => ({
|
|
215
|
+
...d,
|
|
216
|
+
file: this.relativePath(d.file)
|
|
217
|
+
}));
|
|
218
|
+
}
|
|
219
|
+
async references(file, line, offset) {
|
|
220
|
+
const absPath2 = this.resolvePath(file);
|
|
221
|
+
await this.ensureOpen(absPath2);
|
|
222
|
+
const body = await this.sendRequest("references", {
|
|
223
|
+
file: absPath2,
|
|
224
|
+
line,
|
|
225
|
+
offset
|
|
226
|
+
});
|
|
227
|
+
if (!body?.refs) return [];
|
|
228
|
+
return body.refs.map((r) => ({
|
|
229
|
+
...r,
|
|
230
|
+
file: this.relativePath(r.file)
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
async quickinfo(file, line, offset) {
|
|
234
|
+
const absPath2 = this.resolvePath(file);
|
|
235
|
+
await this.ensureOpen(absPath2);
|
|
236
|
+
try {
|
|
237
|
+
const body = await this.sendRequest("quickinfo", {
|
|
238
|
+
file: absPath2,
|
|
239
|
+
line,
|
|
240
|
+
offset
|
|
241
|
+
});
|
|
242
|
+
return body ?? null;
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async navto(searchValue, maxResults = 10, file) {
|
|
248
|
+
if (file) await this.ensureOpen(file);
|
|
249
|
+
const args = {
|
|
250
|
+
searchValue,
|
|
251
|
+
maxResultCount: maxResults
|
|
252
|
+
};
|
|
253
|
+
if (file) args["file"] = this.resolvePath(file);
|
|
254
|
+
const body = await this.sendRequest("navto", args);
|
|
255
|
+
if (!body || !Array.isArray(body)) return [];
|
|
256
|
+
return body.map((item) => ({
|
|
257
|
+
...item,
|
|
258
|
+
file: this.relativePath(item.file)
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
async navbar(file) {
|
|
262
|
+
const absPath2 = this.resolvePath(file);
|
|
263
|
+
await this.ensureOpen(absPath2);
|
|
264
|
+
const body = await this.sendRequest("navbar", {
|
|
265
|
+
file: absPath2
|
|
266
|
+
});
|
|
267
|
+
return body ?? [];
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
// module-graph.ts
|
|
272
|
+
import { parseSync } from "oxc-parser";
|
|
273
|
+
import { ResolverFactory } from "oxc-resolver";
|
|
274
|
+
import * as fs2 from "fs";
|
|
275
|
+
import * as path2 from "path";
|
|
276
|
+
var log2 = (...args) => console.error("[typegraph/graph]", ...args);
|
|
277
|
+
var TS_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".mts", ".cts"]);
|
|
278
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
279
|
+
"node_modules",
|
|
280
|
+
"dist",
|
|
281
|
+
"build",
|
|
282
|
+
"out",
|
|
283
|
+
".wrangler",
|
|
284
|
+
".mf",
|
|
285
|
+
".git",
|
|
286
|
+
".next",
|
|
287
|
+
".turbo",
|
|
288
|
+
"coverage"
|
|
289
|
+
]);
|
|
290
|
+
var SKIP_FILES = /* @__PURE__ */ new Set(["routeTree.gen.ts"]);
|
|
291
|
+
function discoverFiles(rootDir) {
|
|
292
|
+
const files = [];
|
|
293
|
+
function walk(dir) {
|
|
294
|
+
let entries;
|
|
295
|
+
try {
|
|
296
|
+
entries = fs2.readdirSync(dir, { withFileTypes: true });
|
|
297
|
+
} catch {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
for (const entry of entries) {
|
|
301
|
+
if (entry.isDirectory()) {
|
|
302
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
303
|
+
if (entry.name.startsWith(".") && dir !== rootDir) continue;
|
|
304
|
+
walk(path2.join(dir, entry.name));
|
|
305
|
+
} else if (entry.isFile()) {
|
|
306
|
+
const name = entry.name;
|
|
307
|
+
if (SKIP_FILES.has(name)) continue;
|
|
308
|
+
if (name.endsWith(".d.ts") || name.endsWith(".d.mts") || name.endsWith(".d.cts")) continue;
|
|
309
|
+
const ext = path2.extname(name);
|
|
310
|
+
if (TS_EXTENSIONS.has(ext)) {
|
|
311
|
+
files.push(path2.join(dir, name));
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
walk(rootDir);
|
|
317
|
+
return files;
|
|
318
|
+
}
|
|
319
|
+
function parseFileImports(filePath, source) {
|
|
320
|
+
const result = parseSync(filePath, source);
|
|
321
|
+
const imports = [];
|
|
322
|
+
for (const imp of result.module.staticImports) {
|
|
323
|
+
const specifier = imp.moduleRequest.value;
|
|
324
|
+
const names = [];
|
|
325
|
+
let allTypeOnly = true;
|
|
326
|
+
for (const entry of imp.entries) {
|
|
327
|
+
const kind = entry.importName.kind;
|
|
328
|
+
const name = kind === "Default" ? "default" : kind === "All" || kind === "AllButDefault" || kind === "NamespaceObject" ? "*" : entry.importName.name ?? entry.localName.value;
|
|
329
|
+
names.push(name);
|
|
330
|
+
if (!entry.isType) allTypeOnly = false;
|
|
331
|
+
}
|
|
332
|
+
if (names.length === 0) {
|
|
333
|
+
imports.push({ specifier, names: ["*"], isTypeOnly: false, isDynamic: false });
|
|
334
|
+
} else {
|
|
335
|
+
imports.push({ specifier, names, isTypeOnly: allTypeOnly, isDynamic: false });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
for (const exp of result.module.staticExports) {
|
|
339
|
+
for (const entry of exp.entries) {
|
|
340
|
+
const moduleRequest = entry.moduleRequest;
|
|
341
|
+
if (!moduleRequest) continue;
|
|
342
|
+
const specifier = moduleRequest.value;
|
|
343
|
+
const entryKind = entry.importName.kind;
|
|
344
|
+
const name = entryKind === "AllButDefault" || entryKind === "All" || entryKind === "NamespaceObject" ? "*" : entry.importName.name ?? "*";
|
|
345
|
+
const existing = imports.find((i) => i.specifier === specifier && !i.isDynamic);
|
|
346
|
+
if (existing) {
|
|
347
|
+
if (!existing.names.includes(name)) existing.names.push(name);
|
|
348
|
+
} else {
|
|
349
|
+
imports.push({ specifier, names: [name], isTypeOnly: false, isDynamic: false });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
for (const di of result.module.dynamicImports) {
|
|
354
|
+
if (di.moduleRequest) {
|
|
355
|
+
const sliced = source.slice(di.moduleRequest.start, di.moduleRequest.end);
|
|
356
|
+
if (sliced.startsWith("'") || sliced.startsWith('"')) {
|
|
357
|
+
const specifier = sliced.slice(1, -1);
|
|
358
|
+
imports.push({ specifier, names: ["*"], isTypeOnly: false, isDynamic: true });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return imports;
|
|
363
|
+
}
|
|
364
|
+
var SOURCE_EXTS = [".ts", ".tsx", ".mts", ".cts"];
|
|
365
|
+
function distToSource(resolvedPath, projectRoot2) {
|
|
366
|
+
if (!resolvedPath.startsWith(projectRoot2)) return resolvedPath;
|
|
367
|
+
const rel = path2.relative(projectRoot2, resolvedPath);
|
|
368
|
+
const distIdx = rel.indexOf("dist" + path2.sep);
|
|
369
|
+
if (distIdx === -1) return resolvedPath;
|
|
370
|
+
const prefix = rel.slice(0, distIdx);
|
|
371
|
+
const afterDist = rel.slice(distIdx + 5);
|
|
372
|
+
const withoutExt = afterDist.replace(/\.(m?j|c)s$/, "");
|
|
373
|
+
for (const ext of SOURCE_EXTS) {
|
|
374
|
+
const candidate = path2.resolve(projectRoot2, prefix, "src", withoutExt + ext);
|
|
375
|
+
if (fs2.existsSync(candidate)) return candidate;
|
|
376
|
+
}
|
|
377
|
+
for (const ext of SOURCE_EXTS) {
|
|
378
|
+
const candidate = path2.resolve(projectRoot2, prefix, withoutExt + ext);
|
|
379
|
+
if (fs2.existsSync(candidate)) return candidate;
|
|
380
|
+
}
|
|
381
|
+
if (withoutExt.endsWith("/index")) {
|
|
382
|
+
const dirPath = withoutExt.slice(0, -6);
|
|
383
|
+
for (const ext of SOURCE_EXTS) {
|
|
384
|
+
const candidate = path2.resolve(projectRoot2, prefix, "src", dirPath + ext);
|
|
385
|
+
if (fs2.existsSync(candidate)) return candidate;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return resolvedPath;
|
|
389
|
+
}
|
|
390
|
+
function resolveImport(resolver, fromDir, specifier, projectRoot2) {
|
|
391
|
+
try {
|
|
392
|
+
const result = resolver.sync(fromDir, specifier);
|
|
393
|
+
if (result.path && !result.path.includes("node_modules")) {
|
|
394
|
+
const mapped = distToSource(result.path, projectRoot2);
|
|
395
|
+
const ext = path2.extname(mapped);
|
|
396
|
+
if (!TS_EXTENSIONS.has(ext)) return null;
|
|
397
|
+
if (SKIP_FILES.has(path2.basename(mapped))) return null;
|
|
398
|
+
return mapped;
|
|
399
|
+
}
|
|
400
|
+
} catch {
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
function createResolver(projectRoot2, tsconfigPath2) {
|
|
405
|
+
return new ResolverFactory({
|
|
406
|
+
tsconfig: {
|
|
407
|
+
configFile: path2.resolve(projectRoot2, tsconfigPath2),
|
|
408
|
+
references: "auto"
|
|
409
|
+
},
|
|
410
|
+
extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"],
|
|
411
|
+
extensionAlias: {
|
|
412
|
+
".js": [".ts", ".tsx", ".js"],
|
|
413
|
+
".jsx": [".tsx", ".jsx"],
|
|
414
|
+
".mjs": [".mts", ".mjs"],
|
|
415
|
+
".cjs": [".cts", ".cjs"]
|
|
416
|
+
},
|
|
417
|
+
conditionNames: ["import", "require"],
|
|
418
|
+
mainFields: ["module", "main"]
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
function buildForwardEdges(files, resolver, projectRoot2) {
|
|
422
|
+
const forward = /* @__PURE__ */ new Map();
|
|
423
|
+
const parseFailures = [];
|
|
424
|
+
for (const filePath of files) {
|
|
425
|
+
let source;
|
|
426
|
+
try {
|
|
427
|
+
source = fs2.readFileSync(filePath, "utf-8");
|
|
428
|
+
} catch {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
let rawImports;
|
|
432
|
+
try {
|
|
433
|
+
rawImports = parseFileImports(filePath, source);
|
|
434
|
+
} catch (err) {
|
|
435
|
+
parseFailures.push(filePath);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const edges = [];
|
|
439
|
+
const fromDir = path2.dirname(filePath);
|
|
440
|
+
for (const raw of rawImports) {
|
|
441
|
+
const target = resolveImport(resolver, fromDir, raw.specifier, projectRoot2);
|
|
442
|
+
if (target) {
|
|
443
|
+
edges.push({
|
|
444
|
+
target,
|
|
445
|
+
specifiers: raw.names,
|
|
446
|
+
isTypeOnly: raw.isTypeOnly,
|
|
447
|
+
isDynamic: raw.isDynamic
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
forward.set(filePath, edges);
|
|
452
|
+
}
|
|
453
|
+
return { forward, parseFailures };
|
|
454
|
+
}
|
|
455
|
+
function buildReverseMap(forward) {
|
|
456
|
+
const reverse = /* @__PURE__ */ new Map();
|
|
457
|
+
for (const [source, edges] of forward) {
|
|
458
|
+
for (const edge of edges) {
|
|
459
|
+
let revEdges = reverse.get(edge.target);
|
|
460
|
+
if (!revEdges) {
|
|
461
|
+
revEdges = [];
|
|
462
|
+
reverse.set(edge.target, revEdges);
|
|
463
|
+
}
|
|
464
|
+
revEdges.push({
|
|
465
|
+
target: source,
|
|
466
|
+
// reverse: the "target" is the file that imports
|
|
467
|
+
specifiers: edge.specifiers,
|
|
468
|
+
isTypeOnly: edge.isTypeOnly,
|
|
469
|
+
isDynamic: edge.isDynamic
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return reverse;
|
|
474
|
+
}
|
|
475
|
+
async function buildGraph(projectRoot2, tsconfigPath2) {
|
|
476
|
+
const startTime = performance.now();
|
|
477
|
+
const resolver = createResolver(projectRoot2, tsconfigPath2);
|
|
478
|
+
const fileList = discoverFiles(projectRoot2);
|
|
479
|
+
log2(`Discovered ${fileList.length} source files`);
|
|
480
|
+
const { forward, parseFailures } = buildForwardEdges(fileList, resolver, projectRoot2);
|
|
481
|
+
const reverse = buildReverseMap(forward);
|
|
482
|
+
const files = new Set(fileList);
|
|
483
|
+
const edgeCount = [...forward.values()].reduce((sum, edges) => sum + edges.length, 0);
|
|
484
|
+
const elapsed = (performance.now() - startTime).toFixed(0);
|
|
485
|
+
log2(`Graph built: ${files.size} files, ${edgeCount} edges [${elapsed}ms]`);
|
|
486
|
+
if (parseFailures.length > 0) {
|
|
487
|
+
log2(`Parse failures: ${parseFailures.length} files`);
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
graph: { forward, reverse, files },
|
|
491
|
+
resolver
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
function updateFile(graph, filePath, resolver, projectRoot2) {
|
|
495
|
+
const oldEdges = graph.forward.get(filePath) ?? [];
|
|
496
|
+
for (const edge of oldEdges) {
|
|
497
|
+
const revEdges = graph.reverse.get(edge.target);
|
|
498
|
+
if (revEdges) {
|
|
499
|
+
const idx = revEdges.findIndex((r) => r.target === filePath);
|
|
500
|
+
if (idx !== -1) revEdges.splice(idx, 1);
|
|
501
|
+
if (revEdges.length === 0) graph.reverse.delete(edge.target);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
let source;
|
|
505
|
+
try {
|
|
506
|
+
source = fs2.readFileSync(filePath, "utf-8");
|
|
507
|
+
} catch {
|
|
508
|
+
removeFile(graph, filePath);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
let rawImports;
|
|
512
|
+
try {
|
|
513
|
+
rawImports = parseFileImports(filePath, source);
|
|
514
|
+
} catch {
|
|
515
|
+
log2(`Parse error on update: ${filePath}`);
|
|
516
|
+
graph.forward.set(filePath, []);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const fromDir = path2.dirname(filePath);
|
|
520
|
+
const newEdges = [];
|
|
521
|
+
for (const raw of rawImports) {
|
|
522
|
+
const target = resolveImport(resolver, fromDir, raw.specifier, projectRoot2);
|
|
523
|
+
if (target) {
|
|
524
|
+
newEdges.push({
|
|
525
|
+
target,
|
|
526
|
+
specifiers: raw.names,
|
|
527
|
+
isTypeOnly: raw.isTypeOnly,
|
|
528
|
+
isDynamic: raw.isDynamic
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
graph.forward.set(filePath, newEdges);
|
|
533
|
+
graph.files.add(filePath);
|
|
534
|
+
for (const edge of newEdges) {
|
|
535
|
+
let revEdges = graph.reverse.get(edge.target);
|
|
536
|
+
if (!revEdges) {
|
|
537
|
+
revEdges = [];
|
|
538
|
+
graph.reverse.set(edge.target, revEdges);
|
|
539
|
+
}
|
|
540
|
+
revEdges.push({
|
|
541
|
+
target: filePath,
|
|
542
|
+
specifiers: edge.specifiers,
|
|
543
|
+
isTypeOnly: edge.isTypeOnly,
|
|
544
|
+
isDynamic: edge.isDynamic
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
function removeFile(graph, filePath) {
|
|
549
|
+
const edges = graph.forward.get(filePath) ?? [];
|
|
550
|
+
for (const edge of edges) {
|
|
551
|
+
const revEdges2 = graph.reverse.get(edge.target);
|
|
552
|
+
if (revEdges2) {
|
|
553
|
+
const idx = revEdges2.findIndex((r) => r.target === filePath);
|
|
554
|
+
if (idx !== -1) revEdges2.splice(idx, 1);
|
|
555
|
+
if (revEdges2.length === 0) graph.reverse.delete(edge.target);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const revEdges = graph.reverse.get(filePath) ?? [];
|
|
559
|
+
for (const revEdge of revEdges) {
|
|
560
|
+
const fwdEdges = graph.forward.get(revEdge.target);
|
|
561
|
+
if (fwdEdges) {
|
|
562
|
+
const idx = fwdEdges.findIndex((e) => e.target === filePath);
|
|
563
|
+
if (idx !== -1) fwdEdges.splice(idx, 1);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
graph.forward.delete(filePath);
|
|
567
|
+
graph.reverse.delete(filePath);
|
|
568
|
+
graph.files.delete(filePath);
|
|
569
|
+
}
|
|
570
|
+
function startWatcher(projectRoot2, graph, resolver) {
|
|
571
|
+
try {
|
|
572
|
+
const watcher = fs2.watch(
|
|
573
|
+
projectRoot2,
|
|
574
|
+
{ recursive: true },
|
|
575
|
+
(_eventType, filename) => {
|
|
576
|
+
if (!filename) return;
|
|
577
|
+
const ext = path2.extname(filename);
|
|
578
|
+
if (!TS_EXTENSIONS.has(ext)) return;
|
|
579
|
+
const parts = filename.split(path2.sep);
|
|
580
|
+
if (parts.some((p) => SKIP_DIRS.has(p))) return;
|
|
581
|
+
if (SKIP_FILES.has(path2.basename(filename))) return;
|
|
582
|
+
if (filename.endsWith(".d.ts") || filename.endsWith(".d.mts") || filename.endsWith(".d.cts"))
|
|
583
|
+
return;
|
|
584
|
+
const absPath2 = path2.resolve(projectRoot2, filename);
|
|
585
|
+
if (fs2.existsSync(absPath2)) {
|
|
586
|
+
updateFile(graph, absPath2, resolver, projectRoot2);
|
|
587
|
+
} else {
|
|
588
|
+
removeFile(graph, absPath2);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
);
|
|
592
|
+
process.on("SIGINT", () => watcher.close());
|
|
593
|
+
process.on("SIGTERM", () => watcher.close());
|
|
594
|
+
log2("File watcher started");
|
|
595
|
+
} catch (err) {
|
|
596
|
+
log2("Failed to start file watcher:", err);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// graph-queries.ts
|
|
601
|
+
import * as fs3 from "fs";
|
|
602
|
+
import * as path3 from "path";
|
|
603
|
+
function shouldIncludeEdge(edge, includeTypeOnly) {
|
|
604
|
+
if (!includeTypeOnly && edge.isTypeOnly) return false;
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
function dependencyTree(graph, file, opts = {}) {
|
|
608
|
+
const { depth = Infinity, includeTypeOnly = false } = opts;
|
|
609
|
+
const visited = /* @__PURE__ */ new Set();
|
|
610
|
+
const result = [];
|
|
611
|
+
let frontier = [file];
|
|
612
|
+
visited.add(file);
|
|
613
|
+
let currentDepth = 0;
|
|
614
|
+
while (frontier.length > 0 && currentDepth < depth) {
|
|
615
|
+
const nextFrontier = [];
|
|
616
|
+
for (const f of frontier) {
|
|
617
|
+
const edges = graph.forward.get(f) ?? [];
|
|
618
|
+
for (const edge of edges) {
|
|
619
|
+
if (!shouldIncludeEdge(edge, includeTypeOnly)) continue;
|
|
620
|
+
if (visited.has(edge.target)) continue;
|
|
621
|
+
visited.add(edge.target);
|
|
622
|
+
result.push(edge.target);
|
|
623
|
+
nextFrontier.push(edge.target);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
frontier = nextFrontier;
|
|
627
|
+
currentDepth++;
|
|
628
|
+
}
|
|
629
|
+
return { root: file, nodes: result.length, files: result };
|
|
630
|
+
}
|
|
631
|
+
var packageNameCache = /* @__PURE__ */ new Map();
|
|
632
|
+
function findPackageName(filePath) {
|
|
633
|
+
let dir = path3.dirname(filePath);
|
|
634
|
+
while (dir !== path3.dirname(dir)) {
|
|
635
|
+
if (packageNameCache.has(dir)) return packageNameCache.get(dir);
|
|
636
|
+
const pkgJsonPath = path3.join(dir, "package.json");
|
|
637
|
+
try {
|
|
638
|
+
if (fs3.existsSync(pkgJsonPath)) {
|
|
639
|
+
const pkg = JSON.parse(fs3.readFileSync(pkgJsonPath, "utf-8"));
|
|
640
|
+
const name = pkg.name ?? path3.basename(dir);
|
|
641
|
+
packageNameCache.set(dir, name);
|
|
642
|
+
return name;
|
|
643
|
+
}
|
|
644
|
+
} catch {
|
|
645
|
+
}
|
|
646
|
+
dir = path3.dirname(dir);
|
|
647
|
+
}
|
|
648
|
+
return "<root>";
|
|
649
|
+
}
|
|
650
|
+
function dependents(graph, file, opts = {}) {
|
|
651
|
+
const { depth = Infinity, includeTypeOnly = false } = opts;
|
|
652
|
+
const visited = /* @__PURE__ */ new Set();
|
|
653
|
+
const result = [];
|
|
654
|
+
let directCount = 0;
|
|
655
|
+
let frontier = [file];
|
|
656
|
+
visited.add(file);
|
|
657
|
+
let currentDepth = 0;
|
|
658
|
+
while (frontier.length > 0 && currentDepth < depth) {
|
|
659
|
+
const nextFrontier = [];
|
|
660
|
+
for (const f of frontier) {
|
|
661
|
+
const edges = graph.reverse.get(f) ?? [];
|
|
662
|
+
for (const edge of edges) {
|
|
663
|
+
if (!shouldIncludeEdge(edge, includeTypeOnly)) continue;
|
|
664
|
+
if (visited.has(edge.target)) continue;
|
|
665
|
+
visited.add(edge.target);
|
|
666
|
+
result.push(edge.target);
|
|
667
|
+
if (currentDepth === 0) directCount++;
|
|
668
|
+
nextFrontier.push(edge.target);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
frontier = nextFrontier;
|
|
672
|
+
currentDepth++;
|
|
673
|
+
}
|
|
674
|
+
const byPackage = {};
|
|
675
|
+
for (const f of result) {
|
|
676
|
+
const pkgName = findPackageName(f);
|
|
677
|
+
if (!byPackage[pkgName]) byPackage[pkgName] = [];
|
|
678
|
+
byPackage[pkgName].push(f);
|
|
679
|
+
}
|
|
680
|
+
return { root: file, nodes: result.length, directCount, files: result, byPackage };
|
|
681
|
+
}
|
|
682
|
+
function importCycles(graph, opts = {}) {
|
|
683
|
+
const { file, package: pkgDir } = opts;
|
|
684
|
+
let index = 0;
|
|
685
|
+
const stack = [];
|
|
686
|
+
const onStack = /* @__PURE__ */ new Set();
|
|
687
|
+
const indices = /* @__PURE__ */ new Map();
|
|
688
|
+
const lowlinks = /* @__PURE__ */ new Map();
|
|
689
|
+
const sccs = [];
|
|
690
|
+
function strongconnect(v) {
|
|
691
|
+
indices.set(v, index);
|
|
692
|
+
lowlinks.set(v, index);
|
|
693
|
+
index++;
|
|
694
|
+
stack.push(v);
|
|
695
|
+
onStack.add(v);
|
|
696
|
+
const edges = graph.forward.get(v) ?? [];
|
|
697
|
+
for (const edge of edges) {
|
|
698
|
+
const w = edge.target;
|
|
699
|
+
if (!graph.files.has(w)) continue;
|
|
700
|
+
if (!indices.has(w)) {
|
|
701
|
+
strongconnect(w);
|
|
702
|
+
lowlinks.set(v, Math.min(lowlinks.get(v), lowlinks.get(w)));
|
|
703
|
+
} else if (onStack.has(w)) {
|
|
704
|
+
lowlinks.set(v, Math.min(lowlinks.get(v), indices.get(w)));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (lowlinks.get(v) === indices.get(v)) {
|
|
708
|
+
const scc = [];
|
|
709
|
+
let w;
|
|
710
|
+
do {
|
|
711
|
+
w = stack.pop();
|
|
712
|
+
onStack.delete(w);
|
|
713
|
+
scc.push(w);
|
|
714
|
+
} while (w !== v);
|
|
715
|
+
if (scc.length > 1) {
|
|
716
|
+
sccs.push(scc);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
for (const f of graph.files) {
|
|
721
|
+
if (!indices.has(f)) {
|
|
722
|
+
strongconnect(f);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
let cycles = sccs;
|
|
726
|
+
if (file) {
|
|
727
|
+
cycles = cycles.filter((scc) => scc.includes(file));
|
|
728
|
+
}
|
|
729
|
+
if (pkgDir) {
|
|
730
|
+
const absPkgDir = path3.resolve(pkgDir);
|
|
731
|
+
cycles = cycles.filter(
|
|
732
|
+
(scc) => scc.every((f) => f.startsWith(absPkgDir))
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
return { count: cycles.length, cycles };
|
|
736
|
+
}
|
|
737
|
+
function shortestPath(graph, from, to, opts = {}) {
|
|
738
|
+
const { includeTypeOnly = false } = opts;
|
|
739
|
+
if (from === to) {
|
|
740
|
+
return { path: [from], hops: 0, chain: [{ file: from, imports: [] }] };
|
|
741
|
+
}
|
|
742
|
+
const visited = /* @__PURE__ */ new Set();
|
|
743
|
+
const parent = /* @__PURE__ */ new Map();
|
|
744
|
+
visited.add(from);
|
|
745
|
+
let frontier = [from];
|
|
746
|
+
while (frontier.length > 0) {
|
|
747
|
+
const nextFrontier = [];
|
|
748
|
+
for (const f of frontier) {
|
|
749
|
+
const edges = graph.forward.get(f) ?? [];
|
|
750
|
+
for (const edge of edges) {
|
|
751
|
+
if (!shouldIncludeEdge(edge, includeTypeOnly)) continue;
|
|
752
|
+
if (visited.has(edge.target)) continue;
|
|
753
|
+
visited.add(edge.target);
|
|
754
|
+
parent.set(edge.target, { from: f, specifiers: edge.specifiers });
|
|
755
|
+
if (edge.target === to) {
|
|
756
|
+
const filePath = [to];
|
|
757
|
+
let current = to;
|
|
758
|
+
while (parent.has(current)) {
|
|
759
|
+
current = parent.get(current).from;
|
|
760
|
+
filePath.unshift(current);
|
|
761
|
+
}
|
|
762
|
+
const chain = [];
|
|
763
|
+
for (let i = 0; i < filePath.length; i++) {
|
|
764
|
+
const p = parent.get(filePath[i]);
|
|
765
|
+
chain.push({
|
|
766
|
+
file: filePath[i],
|
|
767
|
+
imports: p?.specifiers ?? []
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
return { path: filePath, hops: filePath.length - 1, chain };
|
|
771
|
+
}
|
|
772
|
+
nextFrontier.push(edge.target);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
frontier = nextFrontier;
|
|
776
|
+
}
|
|
777
|
+
return { path: null, hops: -1, chain: [] };
|
|
778
|
+
}
|
|
779
|
+
function subgraph(graph, files, opts = {}) {
|
|
780
|
+
const { depth = 1, direction = "both" } = opts;
|
|
781
|
+
const visited = new Set(files);
|
|
782
|
+
let frontier = [...files];
|
|
783
|
+
for (let d = 0; d < depth && frontier.length > 0; d++) {
|
|
784
|
+
const nextFrontier = [];
|
|
785
|
+
for (const f of frontier) {
|
|
786
|
+
if (direction === "imports" || direction === "both") {
|
|
787
|
+
for (const edge of graph.forward.get(f) ?? []) {
|
|
788
|
+
if (!visited.has(edge.target)) {
|
|
789
|
+
visited.add(edge.target);
|
|
790
|
+
nextFrontier.push(edge.target);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
if (direction === "dependents" || direction === "both") {
|
|
795
|
+
for (const edge of graph.reverse.get(f) ?? []) {
|
|
796
|
+
if (!visited.has(edge.target)) {
|
|
797
|
+
visited.add(edge.target);
|
|
798
|
+
nextFrontier.push(edge.target);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
frontier = nextFrontier;
|
|
804
|
+
}
|
|
805
|
+
const nodes = [...visited];
|
|
806
|
+
const edges = [];
|
|
807
|
+
for (const f of nodes) {
|
|
808
|
+
for (const edge of graph.forward.get(f) ?? []) {
|
|
809
|
+
if (visited.has(edge.target)) {
|
|
810
|
+
edges.push({
|
|
811
|
+
from: f,
|
|
812
|
+
to: edge.target,
|
|
813
|
+
specifiers: edge.specifiers,
|
|
814
|
+
isTypeOnly: edge.isTypeOnly
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return { nodes, edges, stats: { nodeCount: nodes.length, edgeCount: edges.length } };
|
|
820
|
+
}
|
|
821
|
+
function moduleBoundary(graph, files) {
|
|
822
|
+
const fileSet = new Set(files);
|
|
823
|
+
let internalEdges = 0;
|
|
824
|
+
const incomingEdges = [];
|
|
825
|
+
const outgoingEdges = [];
|
|
826
|
+
const outgoingTargets = /* @__PURE__ */ new Set();
|
|
827
|
+
for (const f of files) {
|
|
828
|
+
for (const edge of graph.forward.get(f) ?? []) {
|
|
829
|
+
if (fileSet.has(edge.target)) {
|
|
830
|
+
internalEdges++;
|
|
831
|
+
} else {
|
|
832
|
+
outgoingEdges.push({
|
|
833
|
+
from: f,
|
|
834
|
+
to: edge.target,
|
|
835
|
+
specifiers: edge.specifiers
|
|
836
|
+
});
|
|
837
|
+
outgoingTargets.add(edge.target);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
for (const f of files) {
|
|
842
|
+
for (const edge of graph.reverse.get(f) ?? []) {
|
|
843
|
+
if (!fileSet.has(edge.target)) {
|
|
844
|
+
incomingEdges.push({
|
|
845
|
+
from: edge.target,
|
|
846
|
+
to: f,
|
|
847
|
+
specifiers: edge.specifiers
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
const depCounts = /* @__PURE__ */ new Map();
|
|
853
|
+
for (const f of files) {
|
|
854
|
+
for (const edge of graph.forward.get(f) ?? []) {
|
|
855
|
+
if (!fileSet.has(edge.target)) {
|
|
856
|
+
depCounts.set(edge.target, (depCounts.get(edge.target) ?? 0) + 1);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
const sharedDependencies = [...depCounts.entries()].filter(([, count]) => count > 1).map(([dep]) => dep);
|
|
861
|
+
const total = internalEdges + incomingEdges.length + outgoingEdges.length;
|
|
862
|
+
const isolationScore = total === 0 ? 1 : internalEdges / total;
|
|
863
|
+
return {
|
|
864
|
+
internalEdges,
|
|
865
|
+
incomingEdges,
|
|
866
|
+
outgoingEdges,
|
|
867
|
+
sharedDependencies,
|
|
868
|
+
isolationScore
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// server.ts
|
|
873
|
+
import * as fs4 from "fs";
|
|
874
|
+
import * as path5 from "path";
|
|
875
|
+
|
|
876
|
+
// config.ts
|
|
877
|
+
import * as path4 from "path";
|
|
878
|
+
function resolveConfig(toolDir) {
|
|
879
|
+
const cwd = process.cwd();
|
|
880
|
+
const projectRoot2 = process.env["TYPEGRAPH_PROJECT_ROOT"] ? path4.resolve(cwd, process.env["TYPEGRAPH_PROJECT_ROOT"]) : path4.basename(path4.dirname(toolDir)) === "plugins" ? path4.resolve(toolDir, "../..") : cwd;
|
|
881
|
+
const tsconfigPath2 = process.env["TYPEGRAPH_TSCONFIG"] || "./tsconfig.json";
|
|
882
|
+
const toolIsEmbedded = toolDir.startsWith(projectRoot2 + path4.sep);
|
|
883
|
+
const toolRelPath = toolIsEmbedded ? path4.relative(projectRoot2, toolDir) : toolDir;
|
|
884
|
+
return { projectRoot: projectRoot2, tsconfigPath: tsconfigPath2, toolDir, toolIsEmbedded, toolRelPath };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// server.ts
|
|
888
|
+
var { projectRoot, tsconfigPath } = resolveConfig(import.meta.dirname);
|
|
889
|
+
var log3 = (...args) => console.error("[typegraph]", ...args);
|
|
890
|
+
var client = new TsServerClient(projectRoot, tsconfigPath);
|
|
891
|
+
var moduleGraph;
|
|
892
|
+
var mcpServer = new McpServer({
|
|
893
|
+
name: "typegraph",
|
|
894
|
+
version: "1.0.0"
|
|
895
|
+
});
|
|
896
|
+
function readPreview(file, line) {
|
|
897
|
+
try {
|
|
898
|
+
const absPath2 = client.resolvePath(file);
|
|
899
|
+
const content = fs4.readFileSync(absPath2, "utf-8");
|
|
900
|
+
return content.split("\n")[line - 1]?.trim() ?? "";
|
|
901
|
+
} catch {
|
|
902
|
+
return "";
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
function findInNavBar(items, symbol) {
|
|
906
|
+
for (const item of items) {
|
|
907
|
+
if (item.text === symbol && item.spans.length > 0) {
|
|
908
|
+
const span = item.spans[0];
|
|
909
|
+
return { line: span.start.line, offset: span.start.offset, kind: item.kind };
|
|
910
|
+
}
|
|
911
|
+
if (item.childItems?.length > 0) {
|
|
912
|
+
const found = findInNavBar(item.childItems, symbol);
|
|
913
|
+
if (found) return found;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return null;
|
|
917
|
+
}
|
|
918
|
+
async function resolveSymbol(file, symbol) {
|
|
919
|
+
const bar = await client.navbar(file);
|
|
920
|
+
const found = findInNavBar(bar, symbol);
|
|
921
|
+
if (found) {
|
|
922
|
+
return {
|
|
923
|
+
file,
|
|
924
|
+
line: found.line,
|
|
925
|
+
column: found.offset,
|
|
926
|
+
kind: found.kind,
|
|
927
|
+
preview: readPreview(file, found.line)
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
const items = await client.navto(symbol, 10, file);
|
|
931
|
+
const inFile = items.find((i) => i.name === symbol && i.file === file);
|
|
932
|
+
const best = inFile ?? items.find((i) => i.name === symbol) ?? items[0];
|
|
933
|
+
if (best) {
|
|
934
|
+
return {
|
|
935
|
+
file: best.file,
|
|
936
|
+
line: best.start.line,
|
|
937
|
+
column: best.start.offset,
|
|
938
|
+
kind: best.kind,
|
|
939
|
+
preview: readPreview(best.file, best.start.line)
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
return null;
|
|
943
|
+
}
|
|
944
|
+
var locationOrSymbol = {
|
|
945
|
+
file: z.string().describe("File path (relative to project root or absolute)"),
|
|
946
|
+
line: z.number().int().positive().optional().describe("Line number (1-based). Required if symbol is not provided."),
|
|
947
|
+
column: z.number().int().positive().optional().describe("Column/offset (1-based). Required if symbol is not provided."),
|
|
948
|
+
symbol: z.string().optional().describe("Symbol name to find. Alternative to line+column.")
|
|
949
|
+
};
|
|
950
|
+
async function resolveParams(params) {
|
|
951
|
+
if (params.line !== void 0 && params.column !== void 0) {
|
|
952
|
+
return { file: params.file, line: params.line, column: params.column };
|
|
953
|
+
}
|
|
954
|
+
if (params.symbol) {
|
|
955
|
+
const resolved = await resolveSymbol(params.file, params.symbol);
|
|
956
|
+
if (!resolved) {
|
|
957
|
+
return { error: `Symbol "${params.symbol}" not found in ${params.file}` };
|
|
958
|
+
}
|
|
959
|
+
return { file: resolved.file, line: resolved.line, column: resolved.column };
|
|
960
|
+
}
|
|
961
|
+
return { error: "Either line+column or symbol must be provided" };
|
|
962
|
+
}
|
|
963
|
+
mcpServer.tool(
|
|
964
|
+
"ts_find_symbol",
|
|
965
|
+
"Find a symbol's location in a file by name. Entry point for navigating without exact coordinates.",
|
|
966
|
+
{
|
|
967
|
+
file: z.string().describe("File to search in (relative or absolute path)"),
|
|
968
|
+
symbol: z.string().describe("Symbol name to find")
|
|
969
|
+
},
|
|
970
|
+
async ({ file, symbol }) => {
|
|
971
|
+
const result = await resolveSymbol(file, symbol);
|
|
972
|
+
if (!result) {
|
|
973
|
+
return {
|
|
974
|
+
content: [
|
|
975
|
+
{
|
|
976
|
+
type: "text",
|
|
977
|
+
text: JSON.stringify({ error: `Symbol "${symbol}" not found in ${file}` })
|
|
978
|
+
}
|
|
979
|
+
]
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
return {
|
|
983
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
);
|
|
987
|
+
mcpServer.tool(
|
|
988
|
+
"ts_definition",
|
|
989
|
+
"Go to definition. Resolves through imports, re-exports, barrel files, interfaces, generics. Provide either line+column coordinates or a symbol name.",
|
|
990
|
+
locationOrSymbol,
|
|
991
|
+
async (params) => {
|
|
992
|
+
const loc = await resolveParams(params);
|
|
993
|
+
if ("error" in loc) {
|
|
994
|
+
return { content: [{ type: "text", text: JSON.stringify(loc) }] };
|
|
995
|
+
}
|
|
996
|
+
const defs = await client.definition(loc.file, loc.line, loc.column);
|
|
997
|
+
if (defs.length === 0) {
|
|
998
|
+
return {
|
|
999
|
+
content: [
|
|
1000
|
+
{
|
|
1001
|
+
type: "text",
|
|
1002
|
+
text: JSON.stringify({ definitions: [], source: readPreview(loc.file, loc.line) })
|
|
1003
|
+
}
|
|
1004
|
+
]
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
const results = defs.map((d) => ({
|
|
1008
|
+
file: d.file,
|
|
1009
|
+
line: d.start.line,
|
|
1010
|
+
column: d.start.offset,
|
|
1011
|
+
preview: readPreview(d.file, d.start.line)
|
|
1012
|
+
}));
|
|
1013
|
+
return {
|
|
1014
|
+
content: [{ type: "text", text: JSON.stringify({ definitions: results }) }]
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
);
|
|
1018
|
+
mcpServer.tool(
|
|
1019
|
+
"ts_references",
|
|
1020
|
+
"Find all references to a symbol. Returns semantic code references only (not string matches). Provide either line+column or symbol name.",
|
|
1021
|
+
locationOrSymbol,
|
|
1022
|
+
async (params) => {
|
|
1023
|
+
const loc = await resolveParams(params);
|
|
1024
|
+
if ("error" in loc) {
|
|
1025
|
+
return { content: [{ type: "text", text: JSON.stringify(loc) }] };
|
|
1026
|
+
}
|
|
1027
|
+
const refs = await client.references(loc.file, loc.line, loc.column);
|
|
1028
|
+
const results = refs.map((r) => ({
|
|
1029
|
+
file: r.file,
|
|
1030
|
+
line: r.start.line,
|
|
1031
|
+
column: r.start.offset,
|
|
1032
|
+
preview: r.lineText.trim(),
|
|
1033
|
+
isDefinition: r.isDefinition
|
|
1034
|
+
}));
|
|
1035
|
+
return {
|
|
1036
|
+
content: [
|
|
1037
|
+
{
|
|
1038
|
+
type: "text",
|
|
1039
|
+
text: JSON.stringify({ references: results, count: results.length })
|
|
1040
|
+
}
|
|
1041
|
+
]
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
);
|
|
1045
|
+
mcpServer.tool(
|
|
1046
|
+
"ts_type_info",
|
|
1047
|
+
"Get the TypeScript type and documentation for a symbol. Returns the same info you see when hovering in VS Code. Provide either line+column or symbol name.",
|
|
1048
|
+
locationOrSymbol,
|
|
1049
|
+
async (params) => {
|
|
1050
|
+
const loc = await resolveParams(params);
|
|
1051
|
+
if ("error" in loc) {
|
|
1052
|
+
return { content: [{ type: "text", text: JSON.stringify(loc) }] };
|
|
1053
|
+
}
|
|
1054
|
+
const info = await client.quickinfo(loc.file, loc.line, loc.column);
|
|
1055
|
+
if (!info) {
|
|
1056
|
+
return {
|
|
1057
|
+
content: [
|
|
1058
|
+
{
|
|
1059
|
+
type: "text",
|
|
1060
|
+
text: JSON.stringify({
|
|
1061
|
+
type: null,
|
|
1062
|
+
documentation: null,
|
|
1063
|
+
source: readPreview(loc.file, loc.line)
|
|
1064
|
+
})
|
|
1065
|
+
}
|
|
1066
|
+
]
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
return {
|
|
1070
|
+
content: [
|
|
1071
|
+
{
|
|
1072
|
+
type: "text",
|
|
1073
|
+
text: JSON.stringify({
|
|
1074
|
+
type: info.displayString,
|
|
1075
|
+
documentation: info.documentation || null,
|
|
1076
|
+
kind: info.kind
|
|
1077
|
+
})
|
|
1078
|
+
}
|
|
1079
|
+
]
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
);
|
|
1083
|
+
mcpServer.tool(
|
|
1084
|
+
"ts_navigate_to",
|
|
1085
|
+
"Search for a symbol across the entire project without knowing which file it's in. Returns matching declarations. Optionally provide a file hint to also search that file's navbar (useful for object literal keys like RPC handlers that navto doesn't index).",
|
|
1086
|
+
{
|
|
1087
|
+
symbol: z.string().describe("Symbol name to search for"),
|
|
1088
|
+
file: z.string().optional().describe(
|
|
1089
|
+
"Optional file to also search via navbar (covers object literal keys not indexed by navto)"
|
|
1090
|
+
),
|
|
1091
|
+
maxResults: z.number().int().positive().optional().default(10).describe("Maximum results (default 10)")
|
|
1092
|
+
},
|
|
1093
|
+
async ({ symbol, file, maxResults }) => {
|
|
1094
|
+
const items = await client.navto(symbol, maxResults);
|
|
1095
|
+
const results = items.map((item) => ({
|
|
1096
|
+
file: item.file,
|
|
1097
|
+
line: item.start.line,
|
|
1098
|
+
column: item.start.offset,
|
|
1099
|
+
kind: item.kind,
|
|
1100
|
+
containerName: item.containerName,
|
|
1101
|
+
matchKind: item.matchKind
|
|
1102
|
+
}));
|
|
1103
|
+
if (file) {
|
|
1104
|
+
const navbarHit = await resolveSymbol(file, symbol);
|
|
1105
|
+
if (navbarHit) {
|
|
1106
|
+
const alreadyFound = results.some(
|
|
1107
|
+
(r) => r.file === navbarHit.file && r.line === navbarHit.line
|
|
1108
|
+
);
|
|
1109
|
+
if (!alreadyFound) {
|
|
1110
|
+
results.unshift({
|
|
1111
|
+
file: navbarHit.file,
|
|
1112
|
+
line: navbarHit.line,
|
|
1113
|
+
column: navbarHit.column,
|
|
1114
|
+
kind: navbarHit.kind,
|
|
1115
|
+
containerName: "",
|
|
1116
|
+
matchKind: "navbar"
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return {
|
|
1122
|
+
content: [
|
|
1123
|
+
{
|
|
1124
|
+
type: "text",
|
|
1125
|
+
text: JSON.stringify({ results, count: results.length })
|
|
1126
|
+
}
|
|
1127
|
+
]
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
);
|
|
1131
|
+
mcpServer.tool(
|
|
1132
|
+
"ts_trace_chain",
|
|
1133
|
+
"Automatically follow go-to-definition hops from a symbol, building a call chain from entry point to implementation. Stops when it reaches the bottom or a cycle.",
|
|
1134
|
+
{
|
|
1135
|
+
file: z.string().describe("Starting file"),
|
|
1136
|
+
symbol: z.string().describe("Starting symbol name"),
|
|
1137
|
+
maxHops: z.number().int().positive().optional().default(5).describe("Maximum hops to follow (default 5)")
|
|
1138
|
+
},
|
|
1139
|
+
async ({ file, symbol, maxHops }) => {
|
|
1140
|
+
const start = await resolveSymbol(file, symbol);
|
|
1141
|
+
if (!start) {
|
|
1142
|
+
return {
|
|
1143
|
+
content: [
|
|
1144
|
+
{
|
|
1145
|
+
type: "text",
|
|
1146
|
+
text: JSON.stringify({ error: `Symbol "${symbol}" not found in ${file}` })
|
|
1147
|
+
}
|
|
1148
|
+
]
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
const chain = [
|
|
1152
|
+
{
|
|
1153
|
+
file: start.file,
|
|
1154
|
+
line: start.line,
|
|
1155
|
+
column: start.column,
|
|
1156
|
+
preview: start.preview
|
|
1157
|
+
}
|
|
1158
|
+
];
|
|
1159
|
+
let current = { file: start.file, line: start.line, offset: start.column };
|
|
1160
|
+
for (let i = 0; i < maxHops; i++) {
|
|
1161
|
+
const defs = await client.definition(current.file, current.line, current.offset);
|
|
1162
|
+
if (defs.length === 0) break;
|
|
1163
|
+
const def = defs[0];
|
|
1164
|
+
if (def.file === current.file && def.start.line === current.line) break;
|
|
1165
|
+
if (def.file.includes("node_modules")) break;
|
|
1166
|
+
const preview = readPreview(def.file, def.start.line);
|
|
1167
|
+
chain.push({
|
|
1168
|
+
file: def.file,
|
|
1169
|
+
line: def.start.line,
|
|
1170
|
+
column: def.start.offset,
|
|
1171
|
+
preview
|
|
1172
|
+
});
|
|
1173
|
+
current = { file: def.file, line: def.start.line, offset: def.start.offset };
|
|
1174
|
+
}
|
|
1175
|
+
return {
|
|
1176
|
+
content: [
|
|
1177
|
+
{
|
|
1178
|
+
type: "text",
|
|
1179
|
+
text: JSON.stringify({ chain, hops: chain.length - 1 })
|
|
1180
|
+
}
|
|
1181
|
+
]
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
);
|
|
1185
|
+
mcpServer.tool(
|
|
1186
|
+
"ts_blast_radius",
|
|
1187
|
+
"Analyze the impact of changing a symbol. Finds all references, filters to usage sites, and reports affected files.",
|
|
1188
|
+
{
|
|
1189
|
+
file: z.string().describe("File containing the symbol"),
|
|
1190
|
+
symbol: z.string().describe("Symbol to analyze")
|
|
1191
|
+
},
|
|
1192
|
+
async ({ file, symbol }) => {
|
|
1193
|
+
const start = await resolveSymbol(file, symbol);
|
|
1194
|
+
if (!start) {
|
|
1195
|
+
return {
|
|
1196
|
+
content: [
|
|
1197
|
+
{
|
|
1198
|
+
type: "text",
|
|
1199
|
+
text: JSON.stringify({ error: `Symbol "${symbol}" not found in ${file}` })
|
|
1200
|
+
}
|
|
1201
|
+
]
|
|
1202
|
+
};
|
|
1203
|
+
}
|
|
1204
|
+
const refs = await client.references(start.file, start.line, start.column);
|
|
1205
|
+
const callers = refs.filter((r) => !r.isDefinition);
|
|
1206
|
+
const filesAffected = [...new Set(callers.map((r) => r.file))];
|
|
1207
|
+
const callerList = callers.map((r) => ({
|
|
1208
|
+
file: r.file,
|
|
1209
|
+
line: r.start.line,
|
|
1210
|
+
preview: r.lineText.trim()
|
|
1211
|
+
}));
|
|
1212
|
+
return {
|
|
1213
|
+
content: [
|
|
1214
|
+
{
|
|
1215
|
+
type: "text",
|
|
1216
|
+
text: JSON.stringify({
|
|
1217
|
+
directCallers: callers.length,
|
|
1218
|
+
filesAffected,
|
|
1219
|
+
callers: callerList
|
|
1220
|
+
})
|
|
1221
|
+
}
|
|
1222
|
+
]
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
);
|
|
1226
|
+
mcpServer.tool(
|
|
1227
|
+
"ts_module_exports",
|
|
1228
|
+
"List all exported symbols from a module with their resolved types. Gives an at-a-glance understanding of what a file provides.",
|
|
1229
|
+
{
|
|
1230
|
+
file: z.string().describe("File to inspect")
|
|
1231
|
+
},
|
|
1232
|
+
async ({ file }) => {
|
|
1233
|
+
const bar = await client.navbar(file);
|
|
1234
|
+
if (bar.length === 0) {
|
|
1235
|
+
return {
|
|
1236
|
+
content: [
|
|
1237
|
+
{
|
|
1238
|
+
type: "text",
|
|
1239
|
+
text: JSON.stringify({ error: `No symbols found in ${file}` })
|
|
1240
|
+
}
|
|
1241
|
+
]
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
const moduleItem = bar.find((item) => item.kind === "module");
|
|
1245
|
+
const topItems = moduleItem?.childItems ?? bar;
|
|
1246
|
+
const exportKinds = /* @__PURE__ */ new Set([
|
|
1247
|
+
"function",
|
|
1248
|
+
"const",
|
|
1249
|
+
"class",
|
|
1250
|
+
"interface",
|
|
1251
|
+
"type",
|
|
1252
|
+
"enum",
|
|
1253
|
+
"var",
|
|
1254
|
+
"let",
|
|
1255
|
+
"method"
|
|
1256
|
+
]);
|
|
1257
|
+
const candidates = topItems.filter((item) => exportKinds.has(item.kind));
|
|
1258
|
+
const exports = [];
|
|
1259
|
+
for (const item of candidates) {
|
|
1260
|
+
if (!item.spans[0]) continue;
|
|
1261
|
+
const span = item.spans[0];
|
|
1262
|
+
const info = await client.quickinfo(file, span.start.line, span.start.offset);
|
|
1263
|
+
exports.push({
|
|
1264
|
+
symbol: item.text,
|
|
1265
|
+
kind: item.kind,
|
|
1266
|
+
line: span.start.line,
|
|
1267
|
+
type: info?.displayString ?? null
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
return {
|
|
1271
|
+
content: [
|
|
1272
|
+
{
|
|
1273
|
+
type: "text",
|
|
1274
|
+
text: JSON.stringify({ file, exports, count: exports.length })
|
|
1275
|
+
}
|
|
1276
|
+
]
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
);
|
|
1280
|
+
function relPath(absPath2) {
|
|
1281
|
+
return path5.relative(projectRoot, absPath2);
|
|
1282
|
+
}
|
|
1283
|
+
function absPath(file) {
|
|
1284
|
+
return path5.isAbsolute(file) ? file : path5.resolve(projectRoot, file);
|
|
1285
|
+
}
|
|
1286
|
+
mcpServer.tool(
|
|
1287
|
+
"ts_dependency_tree",
|
|
1288
|
+
"Get the transitive dependency tree (imports) of a file. Shows what a file depends on, directly and transitively.",
|
|
1289
|
+
{
|
|
1290
|
+
file: z.string().describe("File to analyze (relative or absolute path)"),
|
|
1291
|
+
depth: z.number().int().positive().optional().describe("Max traversal depth (default: unlimited)"),
|
|
1292
|
+
includeTypeOnly: z.boolean().optional().default(false).describe("Include type-only imports (default: false)")
|
|
1293
|
+
},
|
|
1294
|
+
async ({ file, depth, includeTypeOnly }) => {
|
|
1295
|
+
const result = dependencyTree(moduleGraph, absPath(file), { depth, includeTypeOnly });
|
|
1296
|
+
return {
|
|
1297
|
+
content: [
|
|
1298
|
+
{
|
|
1299
|
+
type: "text",
|
|
1300
|
+
text: JSON.stringify({
|
|
1301
|
+
root: relPath(result.root),
|
|
1302
|
+
nodes: result.nodes,
|
|
1303
|
+
files: result.files.map(relPath)
|
|
1304
|
+
})
|
|
1305
|
+
}
|
|
1306
|
+
]
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
);
|
|
1310
|
+
mcpServer.tool(
|
|
1311
|
+
"ts_dependents",
|
|
1312
|
+
"Find all files that depend on (import) a given file, directly and transitively. Groups results by package.",
|
|
1313
|
+
{
|
|
1314
|
+
file: z.string().describe("File to analyze (relative or absolute path)"),
|
|
1315
|
+
depth: z.number().int().positive().optional().describe("Max traversal depth (default: unlimited)"),
|
|
1316
|
+
includeTypeOnly: z.boolean().optional().default(false).describe("Include type-only imports (default: false)")
|
|
1317
|
+
},
|
|
1318
|
+
async ({ file, depth, includeTypeOnly }) => {
|
|
1319
|
+
const result = dependents(moduleGraph, absPath(file), { depth, includeTypeOnly });
|
|
1320
|
+
const byPackageRel = {};
|
|
1321
|
+
for (const [pkg, files] of Object.entries(result.byPackage)) {
|
|
1322
|
+
byPackageRel[pkg] = files.map(relPath);
|
|
1323
|
+
}
|
|
1324
|
+
return {
|
|
1325
|
+
content: [
|
|
1326
|
+
{
|
|
1327
|
+
type: "text",
|
|
1328
|
+
text: JSON.stringify({
|
|
1329
|
+
root: relPath(result.root),
|
|
1330
|
+
nodes: result.nodes,
|
|
1331
|
+
directCount: result.directCount,
|
|
1332
|
+
files: result.files.map(relPath),
|
|
1333
|
+
byPackage: byPackageRel
|
|
1334
|
+
})
|
|
1335
|
+
}
|
|
1336
|
+
]
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
);
|
|
1340
|
+
mcpServer.tool(
|
|
1341
|
+
"ts_import_cycles",
|
|
1342
|
+
"Detect circular import dependencies in the project. Returns strongly connected components (cycles) in the import graph.",
|
|
1343
|
+
{
|
|
1344
|
+
file: z.string().optional().describe("Filter to cycles containing this file"),
|
|
1345
|
+
package: z.string().optional().describe("Filter to cycles within this directory")
|
|
1346
|
+
},
|
|
1347
|
+
async ({ file, package: pkg }) => {
|
|
1348
|
+
const result = importCycles(moduleGraph, {
|
|
1349
|
+
file: file ? absPath(file) : void 0,
|
|
1350
|
+
package: pkg ? absPath(pkg) : void 0
|
|
1351
|
+
});
|
|
1352
|
+
return {
|
|
1353
|
+
content: [
|
|
1354
|
+
{
|
|
1355
|
+
type: "text",
|
|
1356
|
+
text: JSON.stringify({
|
|
1357
|
+
count: result.count,
|
|
1358
|
+
cycles: result.cycles.map((cycle) => cycle.map(relPath))
|
|
1359
|
+
})
|
|
1360
|
+
}
|
|
1361
|
+
]
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
);
|
|
1365
|
+
mcpServer.tool(
|
|
1366
|
+
"ts_shortest_path",
|
|
1367
|
+
"Find the shortest import path between two files. Shows how one module reaches another through the import graph.",
|
|
1368
|
+
{
|
|
1369
|
+
from: z.string().describe("Source file (relative or absolute path)"),
|
|
1370
|
+
to: z.string().describe("Target file (relative or absolute path)"),
|
|
1371
|
+
includeTypeOnly: z.boolean().optional().default(false).describe("Include type-only imports (default: false)")
|
|
1372
|
+
},
|
|
1373
|
+
async ({ from, to, includeTypeOnly }) => {
|
|
1374
|
+
const result = shortestPath(moduleGraph, absPath(from), absPath(to), { includeTypeOnly });
|
|
1375
|
+
return {
|
|
1376
|
+
content: [
|
|
1377
|
+
{
|
|
1378
|
+
type: "text",
|
|
1379
|
+
text: JSON.stringify({
|
|
1380
|
+
path: result.path?.map(relPath) ?? null,
|
|
1381
|
+
hops: result.hops,
|
|
1382
|
+
chain: result.chain.map((c) => ({
|
|
1383
|
+
file: relPath(c.file),
|
|
1384
|
+
imports: c.imports
|
|
1385
|
+
}))
|
|
1386
|
+
})
|
|
1387
|
+
}
|
|
1388
|
+
]
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
);
|
|
1392
|
+
mcpServer.tool(
|
|
1393
|
+
"ts_subgraph",
|
|
1394
|
+
"Extract a subgraph around seed files. Expands by depth hops in the specified direction (imports, dependents, or both).",
|
|
1395
|
+
{
|
|
1396
|
+
files: z.array(z.string()).describe("Seed files to expand from (relative or absolute paths)"),
|
|
1397
|
+
depth: z.number().int().positive().optional().default(1).describe("Hops to expand (default: 1)"),
|
|
1398
|
+
direction: z.enum(["imports", "dependents", "both"]).optional().default("both").describe("Direction to expand (default: both)")
|
|
1399
|
+
},
|
|
1400
|
+
async ({ files, depth, direction }) => {
|
|
1401
|
+
const result = subgraph(moduleGraph, files.map(absPath), { depth, direction });
|
|
1402
|
+
return {
|
|
1403
|
+
content: [
|
|
1404
|
+
{
|
|
1405
|
+
type: "text",
|
|
1406
|
+
text: JSON.stringify({
|
|
1407
|
+
nodes: result.nodes.map(relPath),
|
|
1408
|
+
edges: result.edges.map((e) => ({
|
|
1409
|
+
from: relPath(e.from),
|
|
1410
|
+
to: relPath(e.to),
|
|
1411
|
+
specifiers: e.specifiers,
|
|
1412
|
+
isTypeOnly: e.isTypeOnly
|
|
1413
|
+
})),
|
|
1414
|
+
stats: result.stats
|
|
1415
|
+
})
|
|
1416
|
+
}
|
|
1417
|
+
]
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
);
|
|
1421
|
+
mcpServer.tool(
|
|
1422
|
+
"ts_module_boundary",
|
|
1423
|
+
"Analyze the boundary of a set of files: incoming/outgoing edges, shared dependencies, and an isolation score. Useful for understanding module coupling.",
|
|
1424
|
+
{
|
|
1425
|
+
files: z.array(z.string()).describe("Files defining the module boundary (relative or absolute paths)")
|
|
1426
|
+
},
|
|
1427
|
+
async ({ files }) => {
|
|
1428
|
+
const result = moduleBoundary(moduleGraph, files.map(absPath));
|
|
1429
|
+
return {
|
|
1430
|
+
content: [
|
|
1431
|
+
{
|
|
1432
|
+
type: "text",
|
|
1433
|
+
text: JSON.stringify({
|
|
1434
|
+
internalEdges: result.internalEdges,
|
|
1435
|
+
incomingEdges: result.incomingEdges.map((e) => ({
|
|
1436
|
+
from: relPath(e.from),
|
|
1437
|
+
to: relPath(e.to),
|
|
1438
|
+
specifiers: e.specifiers
|
|
1439
|
+
})),
|
|
1440
|
+
outgoingEdges: result.outgoingEdges.map((e) => ({
|
|
1441
|
+
from: relPath(e.from),
|
|
1442
|
+
to: relPath(e.to),
|
|
1443
|
+
specifiers: e.specifiers
|
|
1444
|
+
})),
|
|
1445
|
+
sharedDependencies: result.sharedDependencies.map(relPath),
|
|
1446
|
+
isolationScore: Math.round(result.isolationScore * 1e3) / 1e3
|
|
1447
|
+
})
|
|
1448
|
+
}
|
|
1449
|
+
]
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
);
|
|
1453
|
+
async function main() {
|
|
1454
|
+
log3("Starting TypeGraph MCP server...");
|
|
1455
|
+
log3(`Project root: ${projectRoot}`);
|
|
1456
|
+
log3(`tsconfig: ${tsconfigPath}`);
|
|
1457
|
+
const [, graphResult] = await Promise.all([
|
|
1458
|
+
client.start(),
|
|
1459
|
+
buildGraph(projectRoot, tsconfigPath)
|
|
1460
|
+
]);
|
|
1461
|
+
moduleGraph = graphResult.graph;
|
|
1462
|
+
startWatcher(projectRoot, moduleGraph, graphResult.resolver);
|
|
1463
|
+
const transport = new StdioServerTransport();
|
|
1464
|
+
await mcpServer.connect(transport);
|
|
1465
|
+
log3("MCP server connected and ready");
|
|
1466
|
+
}
|
|
1467
|
+
process.on("SIGINT", () => {
|
|
1468
|
+
log3("Shutting down...");
|
|
1469
|
+
client.shutdown();
|
|
1470
|
+
process.exit(0);
|
|
1471
|
+
});
|
|
1472
|
+
process.on("SIGTERM", () => {
|
|
1473
|
+
client.shutdown();
|
|
1474
|
+
process.exit(0);
|
|
1475
|
+
});
|
|
1476
|
+
main().catch((err) => {
|
|
1477
|
+
log3("Fatal error:", err);
|
|
1478
|
+
process.exit(1);
|
|
1479
|
+
});
|