typegraph-mcp 0.9.19 → 0.9.21
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 +117 -338
- package/benchmark.ts +4 -2
- package/cli.ts +18 -2
- package/commands/bench.md +24 -0
- package/dist/benchmark.js +1284 -0
- package/dist/cli.js +761 -229
- package/package.json +1 -1
- package/tsup.config.ts +1 -0
|
@@ -0,0 +1,1284 @@
|
|
|
1
|
+
|
|
2
|
+
// benchmark.ts
|
|
3
|
+
import * as fs4 from "fs";
|
|
4
|
+
import * as path5 from "path";
|
|
5
|
+
import { execSync } from "child_process";
|
|
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 absPath = this.resolvePath(file);
|
|
39
|
+
const content = fs.readFileSync(absPath, "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 absPath = this.resolvePath(file);
|
|
199
|
+
if (this.openFiles.has(absPath)) return;
|
|
200
|
+
this.openFiles.add(absPath);
|
|
201
|
+
this.sendNotification("open", { file: absPath });
|
|
202
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
203
|
+
}
|
|
204
|
+
// ─── Public API ────────────────────────────────────────────────────────
|
|
205
|
+
async definition(file, line, offset) {
|
|
206
|
+
const absPath = this.resolvePath(file);
|
|
207
|
+
await this.ensureOpen(absPath);
|
|
208
|
+
const body = await this.sendRequest("definition", {
|
|
209
|
+
file: absPath,
|
|
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 absPath = this.resolvePath(file);
|
|
221
|
+
await this.ensureOpen(absPath);
|
|
222
|
+
const body = await this.sendRequest("references", {
|
|
223
|
+
file: absPath,
|
|
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 absPath = this.resolvePath(file);
|
|
235
|
+
await this.ensureOpen(absPath);
|
|
236
|
+
try {
|
|
237
|
+
const body = await this.sendRequest("quickinfo", {
|
|
238
|
+
file: absPath,
|
|
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 absPath = this.resolvePath(file);
|
|
263
|
+
await this.ensureOpen(absPath);
|
|
264
|
+
const body = await this.sendRequest("navbar", {
|
|
265
|
+
file: absPath
|
|
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
|
+
|
|
495
|
+
// graph-queries.ts
|
|
496
|
+
import * as fs3 from "fs";
|
|
497
|
+
import * as path3 from "path";
|
|
498
|
+
function shouldIncludeEdge(edge, includeTypeOnly) {
|
|
499
|
+
if (!includeTypeOnly && edge.isTypeOnly) return false;
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
function dependencyTree(graph, file, opts = {}) {
|
|
503
|
+
const { depth = Infinity, includeTypeOnly = false } = opts;
|
|
504
|
+
const visited = /* @__PURE__ */ new Set();
|
|
505
|
+
const result = [];
|
|
506
|
+
let frontier = [file];
|
|
507
|
+
visited.add(file);
|
|
508
|
+
let currentDepth = 0;
|
|
509
|
+
while (frontier.length > 0 && currentDepth < depth) {
|
|
510
|
+
const nextFrontier = [];
|
|
511
|
+
for (const f of frontier) {
|
|
512
|
+
const edges = graph.forward.get(f) ?? [];
|
|
513
|
+
for (const edge of edges) {
|
|
514
|
+
if (!shouldIncludeEdge(edge, includeTypeOnly)) continue;
|
|
515
|
+
if (visited.has(edge.target)) continue;
|
|
516
|
+
visited.add(edge.target);
|
|
517
|
+
result.push(edge.target);
|
|
518
|
+
nextFrontier.push(edge.target);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
frontier = nextFrontier;
|
|
522
|
+
currentDepth++;
|
|
523
|
+
}
|
|
524
|
+
return { root: file, nodes: result.length, files: result };
|
|
525
|
+
}
|
|
526
|
+
var packageNameCache = /* @__PURE__ */ new Map();
|
|
527
|
+
function findPackageName(filePath) {
|
|
528
|
+
let dir = path3.dirname(filePath);
|
|
529
|
+
while (dir !== path3.dirname(dir)) {
|
|
530
|
+
if (packageNameCache.has(dir)) return packageNameCache.get(dir);
|
|
531
|
+
const pkgJsonPath = path3.join(dir, "package.json");
|
|
532
|
+
try {
|
|
533
|
+
if (fs3.existsSync(pkgJsonPath)) {
|
|
534
|
+
const pkg = JSON.parse(fs3.readFileSync(pkgJsonPath, "utf-8"));
|
|
535
|
+
const name = pkg.name ?? path3.basename(dir);
|
|
536
|
+
packageNameCache.set(dir, name);
|
|
537
|
+
return name;
|
|
538
|
+
}
|
|
539
|
+
} catch {
|
|
540
|
+
}
|
|
541
|
+
dir = path3.dirname(dir);
|
|
542
|
+
}
|
|
543
|
+
return "<root>";
|
|
544
|
+
}
|
|
545
|
+
function dependents(graph, file, opts = {}) {
|
|
546
|
+
const { depth = Infinity, includeTypeOnly = false } = opts;
|
|
547
|
+
const visited = /* @__PURE__ */ new Set();
|
|
548
|
+
const result = [];
|
|
549
|
+
let directCount = 0;
|
|
550
|
+
let frontier = [file];
|
|
551
|
+
visited.add(file);
|
|
552
|
+
let currentDepth = 0;
|
|
553
|
+
while (frontier.length > 0 && currentDepth < depth) {
|
|
554
|
+
const nextFrontier = [];
|
|
555
|
+
for (const f of frontier) {
|
|
556
|
+
const edges = graph.reverse.get(f) ?? [];
|
|
557
|
+
for (const edge of edges) {
|
|
558
|
+
if (!shouldIncludeEdge(edge, includeTypeOnly)) continue;
|
|
559
|
+
if (visited.has(edge.target)) continue;
|
|
560
|
+
visited.add(edge.target);
|
|
561
|
+
result.push(edge.target);
|
|
562
|
+
if (currentDepth === 0) directCount++;
|
|
563
|
+
nextFrontier.push(edge.target);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
frontier = nextFrontier;
|
|
567
|
+
currentDepth++;
|
|
568
|
+
}
|
|
569
|
+
const byPackage = {};
|
|
570
|
+
for (const f of result) {
|
|
571
|
+
const pkgName = findPackageName(f);
|
|
572
|
+
if (!byPackage[pkgName]) byPackage[pkgName] = [];
|
|
573
|
+
byPackage[pkgName].push(f);
|
|
574
|
+
}
|
|
575
|
+
return { root: file, nodes: result.length, directCount, files: result, byPackage };
|
|
576
|
+
}
|
|
577
|
+
function importCycles(graph, opts = {}) {
|
|
578
|
+
const { file, package: pkgDir } = opts;
|
|
579
|
+
let index = 0;
|
|
580
|
+
const stack = [];
|
|
581
|
+
const onStack = /* @__PURE__ */ new Set();
|
|
582
|
+
const indices = /* @__PURE__ */ new Map();
|
|
583
|
+
const lowlinks = /* @__PURE__ */ new Map();
|
|
584
|
+
const sccs = [];
|
|
585
|
+
function strongconnect(v) {
|
|
586
|
+
indices.set(v, index);
|
|
587
|
+
lowlinks.set(v, index);
|
|
588
|
+
index++;
|
|
589
|
+
stack.push(v);
|
|
590
|
+
onStack.add(v);
|
|
591
|
+
const edges = graph.forward.get(v) ?? [];
|
|
592
|
+
for (const edge of edges) {
|
|
593
|
+
const w = edge.target;
|
|
594
|
+
if (!graph.files.has(w)) continue;
|
|
595
|
+
if (!indices.has(w)) {
|
|
596
|
+
strongconnect(w);
|
|
597
|
+
lowlinks.set(v, Math.min(lowlinks.get(v), lowlinks.get(w)));
|
|
598
|
+
} else if (onStack.has(w)) {
|
|
599
|
+
lowlinks.set(v, Math.min(lowlinks.get(v), indices.get(w)));
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (lowlinks.get(v) === indices.get(v)) {
|
|
603
|
+
const scc = [];
|
|
604
|
+
let w;
|
|
605
|
+
do {
|
|
606
|
+
w = stack.pop();
|
|
607
|
+
onStack.delete(w);
|
|
608
|
+
scc.push(w);
|
|
609
|
+
} while (w !== v);
|
|
610
|
+
if (scc.length > 1) {
|
|
611
|
+
sccs.push(scc);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
for (const f of graph.files) {
|
|
616
|
+
if (!indices.has(f)) {
|
|
617
|
+
strongconnect(f);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
let cycles = sccs;
|
|
621
|
+
if (file) {
|
|
622
|
+
cycles = cycles.filter((scc) => scc.includes(file));
|
|
623
|
+
}
|
|
624
|
+
if (pkgDir) {
|
|
625
|
+
const absPkgDir = path3.resolve(pkgDir);
|
|
626
|
+
cycles = cycles.filter(
|
|
627
|
+
(scc) => scc.every((f) => f.startsWith(absPkgDir))
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
return { count: cycles.length, cycles };
|
|
631
|
+
}
|
|
632
|
+
function shortestPath(graph, from, to, opts = {}) {
|
|
633
|
+
const { includeTypeOnly = false } = opts;
|
|
634
|
+
if (from === to) {
|
|
635
|
+
return { path: [from], hops: 0, chain: [{ file: from, imports: [] }] };
|
|
636
|
+
}
|
|
637
|
+
const visited = /* @__PURE__ */ new Set();
|
|
638
|
+
const parent = /* @__PURE__ */ new Map();
|
|
639
|
+
visited.add(from);
|
|
640
|
+
let frontier = [from];
|
|
641
|
+
while (frontier.length > 0) {
|
|
642
|
+
const nextFrontier = [];
|
|
643
|
+
for (const f of frontier) {
|
|
644
|
+
const edges = graph.forward.get(f) ?? [];
|
|
645
|
+
for (const edge of edges) {
|
|
646
|
+
if (!shouldIncludeEdge(edge, includeTypeOnly)) continue;
|
|
647
|
+
if (visited.has(edge.target)) continue;
|
|
648
|
+
visited.add(edge.target);
|
|
649
|
+
parent.set(edge.target, { from: f, specifiers: edge.specifiers });
|
|
650
|
+
if (edge.target === to) {
|
|
651
|
+
const filePath = [to];
|
|
652
|
+
let current = to;
|
|
653
|
+
while (parent.has(current)) {
|
|
654
|
+
current = parent.get(current).from;
|
|
655
|
+
filePath.unshift(current);
|
|
656
|
+
}
|
|
657
|
+
const chain = [];
|
|
658
|
+
for (let i = 0; i < filePath.length; i++) {
|
|
659
|
+
const p = parent.get(filePath[i]);
|
|
660
|
+
chain.push({
|
|
661
|
+
file: filePath[i],
|
|
662
|
+
imports: p?.specifiers ?? []
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
return { path: filePath, hops: filePath.length - 1, chain };
|
|
666
|
+
}
|
|
667
|
+
nextFrontier.push(edge.target);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
frontier = nextFrontier;
|
|
671
|
+
}
|
|
672
|
+
return { path: null, hops: -1, chain: [] };
|
|
673
|
+
}
|
|
674
|
+
function subgraph(graph, files, opts = {}) {
|
|
675
|
+
const { depth = 1, direction = "both" } = opts;
|
|
676
|
+
const visited = new Set(files);
|
|
677
|
+
let frontier = [...files];
|
|
678
|
+
for (let d = 0; d < depth && frontier.length > 0; d++) {
|
|
679
|
+
const nextFrontier = [];
|
|
680
|
+
for (const f of frontier) {
|
|
681
|
+
if (direction === "imports" || direction === "both") {
|
|
682
|
+
for (const edge of graph.forward.get(f) ?? []) {
|
|
683
|
+
if (!visited.has(edge.target)) {
|
|
684
|
+
visited.add(edge.target);
|
|
685
|
+
nextFrontier.push(edge.target);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (direction === "dependents" || direction === "both") {
|
|
690
|
+
for (const edge of graph.reverse.get(f) ?? []) {
|
|
691
|
+
if (!visited.has(edge.target)) {
|
|
692
|
+
visited.add(edge.target);
|
|
693
|
+
nextFrontier.push(edge.target);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
frontier = nextFrontier;
|
|
699
|
+
}
|
|
700
|
+
const nodes = [...visited];
|
|
701
|
+
const edges = [];
|
|
702
|
+
for (const f of nodes) {
|
|
703
|
+
for (const edge of graph.forward.get(f) ?? []) {
|
|
704
|
+
if (visited.has(edge.target)) {
|
|
705
|
+
edges.push({
|
|
706
|
+
from: f,
|
|
707
|
+
to: edge.target,
|
|
708
|
+
specifiers: edge.specifiers,
|
|
709
|
+
isTypeOnly: edge.isTypeOnly
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return { nodes, edges, stats: { nodeCount: nodes.length, edgeCount: edges.length } };
|
|
715
|
+
}
|
|
716
|
+
function moduleBoundary(graph, files) {
|
|
717
|
+
const fileSet = new Set(files);
|
|
718
|
+
let internalEdges = 0;
|
|
719
|
+
const incomingEdges = [];
|
|
720
|
+
const outgoingEdges = [];
|
|
721
|
+
const outgoingTargets = /* @__PURE__ */ new Set();
|
|
722
|
+
for (const f of files) {
|
|
723
|
+
for (const edge of graph.forward.get(f) ?? []) {
|
|
724
|
+
if (fileSet.has(edge.target)) {
|
|
725
|
+
internalEdges++;
|
|
726
|
+
} else {
|
|
727
|
+
outgoingEdges.push({
|
|
728
|
+
from: f,
|
|
729
|
+
to: edge.target,
|
|
730
|
+
specifiers: edge.specifiers
|
|
731
|
+
});
|
|
732
|
+
outgoingTargets.add(edge.target);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
for (const f of files) {
|
|
737
|
+
for (const edge of graph.reverse.get(f) ?? []) {
|
|
738
|
+
if (!fileSet.has(edge.target)) {
|
|
739
|
+
incomingEdges.push({
|
|
740
|
+
from: edge.target,
|
|
741
|
+
to: f,
|
|
742
|
+
specifiers: edge.specifiers
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const depCounts = /* @__PURE__ */ new Map();
|
|
748
|
+
for (const f of files) {
|
|
749
|
+
for (const edge of graph.forward.get(f) ?? []) {
|
|
750
|
+
if (!fileSet.has(edge.target)) {
|
|
751
|
+
depCounts.set(edge.target, (depCounts.get(edge.target) ?? 0) + 1);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
const sharedDependencies = [...depCounts.entries()].filter(([, count]) => count > 1).map(([dep]) => dep);
|
|
756
|
+
const total = internalEdges + incomingEdges.length + outgoingEdges.length;
|
|
757
|
+
const isolationScore = total === 0 ? 1 : internalEdges / total;
|
|
758
|
+
return {
|
|
759
|
+
internalEdges,
|
|
760
|
+
incomingEdges,
|
|
761
|
+
outgoingEdges,
|
|
762
|
+
sharedDependencies,
|
|
763
|
+
isolationScore
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// config.ts
|
|
768
|
+
import * as path4 from "path";
|
|
769
|
+
function resolveConfig(toolDir) {
|
|
770
|
+
const cwd = process.cwd();
|
|
771
|
+
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;
|
|
772
|
+
const tsconfigPath2 = process.env["TYPEGRAPH_TSCONFIG"] || "./tsconfig.json";
|
|
773
|
+
const toolIsEmbedded = toolDir.startsWith(projectRoot2 + path4.sep);
|
|
774
|
+
const toolRelPath = toolIsEmbedded ? path4.relative(projectRoot2, toolDir) : toolDir;
|
|
775
|
+
return { projectRoot: projectRoot2, tsconfigPath: tsconfigPath2, toolDir, toolIsEmbedded, toolRelPath };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// benchmark.ts
|
|
779
|
+
var projectRoot;
|
|
780
|
+
var tsconfigPath;
|
|
781
|
+
function estimateTokens(text) {
|
|
782
|
+
return Math.ceil(text.length / 4);
|
|
783
|
+
}
|
|
784
|
+
function grepCount(pattern) {
|
|
785
|
+
try {
|
|
786
|
+
const result = execSync(
|
|
787
|
+
`grep -r --include='*.ts' --include='*.tsx' -l "${pattern}" . 2>/dev/null || true`,
|
|
788
|
+
{ cwd: projectRoot, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }
|
|
789
|
+
).trim();
|
|
790
|
+
const files = result ? result.split("\n").filter(Boolean) : [];
|
|
791
|
+
const countResult = execSync(
|
|
792
|
+
`grep -r --include='*.ts' --include='*.tsx' -c "${pattern}" . 2>/dev/null || true`,
|
|
793
|
+
{ cwd: projectRoot, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }
|
|
794
|
+
).trim();
|
|
795
|
+
const matches = countResult.split("\n").filter(Boolean).reduce((sum, line) => {
|
|
796
|
+
const count = parseInt(line.split(":").pop(), 10);
|
|
797
|
+
return sum + (isNaN(count) ? 0 : count);
|
|
798
|
+
}, 0);
|
|
799
|
+
let totalBytes = 0;
|
|
800
|
+
for (const file of files) {
|
|
801
|
+
try {
|
|
802
|
+
totalBytes += fs4.statSync(path5.resolve(projectRoot, file)).size;
|
|
803
|
+
} catch {
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return { matches, files: files.length, totalBytes };
|
|
807
|
+
} catch {
|
|
808
|
+
return { matches: 0, files: 0, totalBytes: 0 };
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
function relPath(abs) {
|
|
812
|
+
return path5.relative(projectRoot, abs);
|
|
813
|
+
}
|
|
814
|
+
function percentile(sorted, p) {
|
|
815
|
+
const idx = Math.ceil(p / 100 * sorted.length) - 1;
|
|
816
|
+
return sorted[Math.max(0, idx)];
|
|
817
|
+
}
|
|
818
|
+
function flattenNavBar(items) {
|
|
819
|
+
const result = [];
|
|
820
|
+
for (const item of items) {
|
|
821
|
+
result.push(item);
|
|
822
|
+
if (item.childItems?.length > 0) result.push(...flattenNavBar(item.childItems));
|
|
823
|
+
}
|
|
824
|
+
return result;
|
|
825
|
+
}
|
|
826
|
+
function findBarrelChain(graph) {
|
|
827
|
+
const barrels = [...graph.files].filter((f) => path5.basename(f) === "index.ts");
|
|
828
|
+
for (const barrel of barrels) {
|
|
829
|
+
const edges = graph.forward.get(barrel) ?? [];
|
|
830
|
+
for (const edge of edges) {
|
|
831
|
+
if (edge.specifiers.length > 0 && !edge.specifiers.includes("*") && !edge.target.endsWith("index.ts") && !edge.isTypeOnly) {
|
|
832
|
+
const parentBarrels = (graph.reverse.get(barrel) ?? []).filter(
|
|
833
|
+
(e) => path5.basename(e.target) === "index.ts"
|
|
834
|
+
);
|
|
835
|
+
if (parentBarrels.length > 0) {
|
|
836
|
+
return { barrelFile: barrel, sourceFile: edge.target, specifiers: edge.specifiers };
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
for (const barrel of barrels) {
|
|
842
|
+
const edges = graph.forward.get(barrel) ?? [];
|
|
843
|
+
for (const edge of edges) {
|
|
844
|
+
if (edge.specifiers.length > 0 && !edge.specifiers.includes("*") && !edge.target.endsWith("index.ts")) {
|
|
845
|
+
return { barrelFile: barrel, sourceFile: edge.target, specifiers: edge.specifiers };
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
function findHighFanoutSymbol(graph) {
|
|
852
|
+
const specCounts = /* @__PURE__ */ new Map();
|
|
853
|
+
for (const edges of graph.forward.values()) {
|
|
854
|
+
for (const edge of edges) {
|
|
855
|
+
for (const spec of edge.specifiers) {
|
|
856
|
+
if (spec === "*" || spec === "default" || spec.length < 4) continue;
|
|
857
|
+
specCounts.set(spec, (specCounts.get(spec) ?? 0) + 1);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
const sorted = [...specCounts.entries()].sort((a, b) => b[1] - a[1]);
|
|
862
|
+
return sorted[0]?.[0] ?? null;
|
|
863
|
+
}
|
|
864
|
+
function findPrefixSymbol(graph) {
|
|
865
|
+
const specCounts = /* @__PURE__ */ new Map();
|
|
866
|
+
for (const edges of graph.forward.values()) {
|
|
867
|
+
for (const edge of edges) {
|
|
868
|
+
for (const spec of edge.specifiers) {
|
|
869
|
+
if (spec === "*" || spec === "default" || spec.length < 5) continue;
|
|
870
|
+
specCounts.set(spec, (specCounts.get(spec) ?? 0) + 1);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
const allSpecs = [...specCounts.keys()];
|
|
875
|
+
for (const [base, count] of [...specCounts.entries()].sort((a, b) => b[1] - a[1])) {
|
|
876
|
+
if (count < 3) continue;
|
|
877
|
+
const variants = allSpecs.filter((s) => s !== base && s.startsWith(base) && s[base.length]?.match(/[A-Z]/));
|
|
878
|
+
if (variants.length >= 2) {
|
|
879
|
+
return { base, variants: variants.slice(0, 5) };
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return null;
|
|
883
|
+
}
|
|
884
|
+
function findMixedImportFile(graph) {
|
|
885
|
+
for (const [file, edges] of graph.forward) {
|
|
886
|
+
const hasTypeOnly = edges.some((e) => e.isTypeOnly);
|
|
887
|
+
const hasRuntime = edges.some((e) => !e.isTypeOnly);
|
|
888
|
+
if (hasTypeOnly && hasRuntime && edges.length >= 4) {
|
|
889
|
+
return file;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return null;
|
|
893
|
+
}
|
|
894
|
+
function findMostDependedFile(graph) {
|
|
895
|
+
let maxDeps = 0;
|
|
896
|
+
let maxFile = null;
|
|
897
|
+
for (const [file, revEdges] of graph.reverse) {
|
|
898
|
+
if (file.endsWith("index.ts")) continue;
|
|
899
|
+
if (revEdges.length > maxDeps) {
|
|
900
|
+
maxDeps = revEdges.length;
|
|
901
|
+
maxFile = file;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return maxFile;
|
|
905
|
+
}
|
|
906
|
+
function findChainFile(graph) {
|
|
907
|
+
for (const [file, edges] of graph.forward) {
|
|
908
|
+
if (file.endsWith("index.ts") || file.includes(".test.")) continue;
|
|
909
|
+
for (const edge of edges) {
|
|
910
|
+
if (edge.isTypeOnly || edge.specifiers.includes("*")) continue;
|
|
911
|
+
const targetEdges = graph.forward.get(edge.target) ?? [];
|
|
912
|
+
const nonTrivialTarget = targetEdges.filter((e) => !e.isTypeOnly && !e.specifiers.includes("*"));
|
|
913
|
+
if (nonTrivialTarget.length > 0 && edge.specifiers.length > 0) {
|
|
914
|
+
return { file, symbol: edge.specifiers[0] };
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
return null;
|
|
919
|
+
}
|
|
920
|
+
function findLatencyTestFile(graph) {
|
|
921
|
+
const candidates = [...graph.files].filter((f) => !f.endsWith("index.ts") && !f.includes(".test.") && !f.includes(".spec.")).map((f) => {
|
|
922
|
+
try {
|
|
923
|
+
return { file: f, size: fs4.statSync(f).size };
|
|
924
|
+
} catch {
|
|
925
|
+
return null;
|
|
926
|
+
}
|
|
927
|
+
}).filter((c) => c !== null && c.size > 500 && c.size < 3e4).sort((a, b) => b.size - a.size);
|
|
928
|
+
const preferred = candidates.find(
|
|
929
|
+
(c) => /service|handler|controller|repository|provider/i.test(path5.basename(c.file))
|
|
930
|
+
);
|
|
931
|
+
return preferred?.file ?? candidates[0]?.file ?? null;
|
|
932
|
+
}
|
|
933
|
+
async function benchmarkTokens(client, graph) {
|
|
934
|
+
console.log("=== Benchmark 1: Token Comparison (grep vs typegraph-mcp) ===");
|
|
935
|
+
console.log("");
|
|
936
|
+
const scenarios = [];
|
|
937
|
+
const barrel = findBarrelChain(graph);
|
|
938
|
+
if (barrel) {
|
|
939
|
+
const symbol = barrel.specifiers[0];
|
|
940
|
+
const grep = grepCount(symbol);
|
|
941
|
+
const grepTokens = estimateTokens("x".repeat(Math.min(grep.totalBytes, 5e5)));
|
|
942
|
+
const navItems = await client.navto(symbol, 5);
|
|
943
|
+
const def = navItems.find((i) => i.name === symbol);
|
|
944
|
+
let responseText = JSON.stringify({ results: navItems, count: navItems.length });
|
|
945
|
+
if (def) {
|
|
946
|
+
const defs = await client.definition(def.file, def.start.line, def.start.offset);
|
|
947
|
+
responseText += JSON.stringify({ definitions: defs });
|
|
948
|
+
}
|
|
949
|
+
scenarios.push({
|
|
950
|
+
name: "Barrel re-export resolution",
|
|
951
|
+
symbol,
|
|
952
|
+
description: `Re-exported through ${relPath(barrel.barrelFile)}`,
|
|
953
|
+
grep: { matches: grep.matches, files: grep.files, tokensToRead: grepTokens },
|
|
954
|
+
typegraph: { responseTokens: estimateTokens(responseText), toolCalls: 2 },
|
|
955
|
+
reduction: grepTokens > 0 ? `${((1 - estimateTokens(responseText) / grepTokens) * 100).toFixed(0)}%` : "N/A"
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
const highFanout = findHighFanoutSymbol(graph);
|
|
959
|
+
if (highFanout) {
|
|
960
|
+
const grep = grepCount(highFanout);
|
|
961
|
+
const grepTokens = estimateTokens("x".repeat(Math.min(grep.totalBytes, 5e5)));
|
|
962
|
+
const navItems = await client.navto(highFanout, 10);
|
|
963
|
+
const refs = navItems.length > 0 ? await client.references(navItems[0].file, navItems[0].start.line, navItems[0].start.offset) : [];
|
|
964
|
+
const responseText = JSON.stringify({ results: navItems }) + JSON.stringify({ count: refs.length });
|
|
965
|
+
scenarios.push({
|
|
966
|
+
name: "High-fanout symbol lookup",
|
|
967
|
+
symbol: highFanout,
|
|
968
|
+
description: `Most-imported symbol in the project`,
|
|
969
|
+
grep: { matches: grep.matches, files: grep.files, tokensToRead: grepTokens },
|
|
970
|
+
typegraph: { responseTokens: estimateTokens(responseText), toolCalls: 2 },
|
|
971
|
+
reduction: grepTokens > 0 ? `${((1 - estimateTokens(responseText) / grepTokens) * 100).toFixed(0)}%` : "N/A"
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
const chainTarget = findChainFile(graph);
|
|
975
|
+
if (chainTarget) {
|
|
976
|
+
const grep = grepCount(chainTarget.symbol);
|
|
977
|
+
const grepTokens = estimateTokens("x".repeat(Math.min(grep.totalBytes, 5e5)));
|
|
978
|
+
const navItems = await client.navto(chainTarget.symbol, 5);
|
|
979
|
+
let totalResponse = JSON.stringify({ results: navItems });
|
|
980
|
+
let hops = 0;
|
|
981
|
+
if (navItems.length > 0) {
|
|
982
|
+
let cur = { file: navItems[0].file, line: navItems[0].start.line, offset: navItems[0].start.offset };
|
|
983
|
+
for (let i = 0; i < 5; i++) {
|
|
984
|
+
const defs = await client.definition(cur.file, cur.line, cur.offset);
|
|
985
|
+
if (defs.length === 0) break;
|
|
986
|
+
const hop = defs[0];
|
|
987
|
+
if (hop.file === cur.file && hop.start.line === cur.line) break;
|
|
988
|
+
if (hop.file.includes("node_modules")) break;
|
|
989
|
+
hops++;
|
|
990
|
+
totalResponse += JSON.stringify({ definitions: defs });
|
|
991
|
+
cur = { file: hop.file, line: hop.start.line, offset: hop.start.offset };
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
scenarios.push({
|
|
995
|
+
name: "Call chain tracing",
|
|
996
|
+
symbol: chainTarget.symbol,
|
|
997
|
+
description: `${hops} hop(s) from ${relPath(chainTarget.file)}`,
|
|
998
|
+
grep: { matches: grep.matches, files: grep.files, tokensToRead: grepTokens },
|
|
999
|
+
typegraph: { responseTokens: estimateTokens(totalResponse), toolCalls: 1 + hops },
|
|
1000
|
+
reduction: grepTokens > 0 ? `${((1 - estimateTokens(totalResponse) / grepTokens) * 100).toFixed(0)}%` : "N/A"
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
const mostDepended = findMostDependedFile(graph);
|
|
1004
|
+
if (mostDepended) {
|
|
1005
|
+
const basename5 = path5.basename(mostDepended, path5.extname(mostDepended));
|
|
1006
|
+
const grep = grepCount(basename5);
|
|
1007
|
+
const grepTokens = estimateTokens("x".repeat(Math.min(grep.totalBytes, 5e5)));
|
|
1008
|
+
const deps = dependents(graph, mostDepended);
|
|
1009
|
+
const responseText = JSON.stringify({
|
|
1010
|
+
root: relPath(mostDepended),
|
|
1011
|
+
nodes: deps.nodes,
|
|
1012
|
+
directCount: deps.directCount,
|
|
1013
|
+
byPackage: deps.byPackage
|
|
1014
|
+
});
|
|
1015
|
+
scenarios.push({
|
|
1016
|
+
name: "Impact analysis (most-depended file)",
|
|
1017
|
+
symbol: basename5,
|
|
1018
|
+
description: `${relPath(mostDepended)} \u2014 ${deps.directCount} direct, ${deps.nodes} transitive`,
|
|
1019
|
+
grep: { matches: grep.matches, files: grep.files, tokensToRead: grepTokens },
|
|
1020
|
+
typegraph: { responseTokens: estimateTokens(responseText), toolCalls: 1 },
|
|
1021
|
+
reduction: grepTokens > 0 ? `${((1 - estimateTokens(responseText) / grepTokens) * 100).toFixed(0)}%` : "N/A"
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
if (scenarios.length > 0) {
|
|
1025
|
+
console.log("| Scenario | Symbol | grep matches | grep files | grep tokens | tg tokens | tg calls | reduction |");
|
|
1026
|
+
console.log("|----------|--------|-------------|-----------|-------------|-----------|----------|-----------|");
|
|
1027
|
+
for (const s of scenarios) {
|
|
1028
|
+
console.log(
|
|
1029
|
+
`| ${s.name} | \`${s.symbol}\` | ${s.grep.matches} | ${s.grep.files} | ${s.grep.tokensToRead.toLocaleString()} | ${s.typegraph.responseTokens.toLocaleString()} | ${s.typegraph.toolCalls} | ${s.reduction} |`
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
} else {
|
|
1033
|
+
console.log(" No suitable scenarios discovered for this codebase.");
|
|
1034
|
+
}
|
|
1035
|
+
console.log("");
|
|
1036
|
+
return scenarios;
|
|
1037
|
+
}
|
|
1038
|
+
async function benchmarkLatency(client, graph) {
|
|
1039
|
+
console.log("=== Benchmark 2: Latency (ms per tool call) ===");
|
|
1040
|
+
console.log("");
|
|
1041
|
+
const results = [];
|
|
1042
|
+
const RUNS = 5;
|
|
1043
|
+
const testFile = findLatencyTestFile(graph);
|
|
1044
|
+
if (!testFile) {
|
|
1045
|
+
console.log(" No suitable test file found for latency benchmark.");
|
|
1046
|
+
console.log("");
|
|
1047
|
+
return results;
|
|
1048
|
+
}
|
|
1049
|
+
const testFileRel = relPath(testFile);
|
|
1050
|
+
console.log(`Test file: ${testFileRel}`);
|
|
1051
|
+
console.log(`Runs per tool: ${RUNS}`);
|
|
1052
|
+
console.log("");
|
|
1053
|
+
const bar = await client.navbar(testFileRel);
|
|
1054
|
+
const allSymbols = flattenNavBar(bar);
|
|
1055
|
+
const concreteKinds = /* @__PURE__ */ new Set(["const", "function", "class", "var", "let", "enum"]);
|
|
1056
|
+
const sym = allSymbols.find(
|
|
1057
|
+
(item) => concreteKinds.has(item.kind) && item.text !== "<function>" && item.spans.length > 0
|
|
1058
|
+
);
|
|
1059
|
+
if (!sym) {
|
|
1060
|
+
console.log(" No concrete symbol found in test file.");
|
|
1061
|
+
console.log("");
|
|
1062
|
+
return results;
|
|
1063
|
+
}
|
|
1064
|
+
const span = sym.spans[0];
|
|
1065
|
+
console.log(`Test symbol: ${sym.text} [${sym.kind}]`);
|
|
1066
|
+
console.log("");
|
|
1067
|
+
const tsserverTools = [
|
|
1068
|
+
{ name: "ts_find_symbol", fn: () => client.navbar(testFileRel) },
|
|
1069
|
+
{ name: "ts_definition", fn: () => client.definition(testFileRel, span.start.line, span.start.offset) },
|
|
1070
|
+
{ name: "ts_references", fn: () => client.references(testFileRel, span.start.line, span.start.offset) },
|
|
1071
|
+
{ name: "ts_type_info", fn: () => client.quickinfo(testFileRel, span.start.line, span.start.offset) },
|
|
1072
|
+
{ name: "ts_navigate_to", fn: () => client.navto(sym.text, 10) },
|
|
1073
|
+
{ name: "ts_module_exports", fn: () => client.navbar(testFileRel) }
|
|
1074
|
+
];
|
|
1075
|
+
for (const tool of tsserverTools) {
|
|
1076
|
+
const times = [];
|
|
1077
|
+
for (let i = 0; i < RUNS; i++) {
|
|
1078
|
+
const t0 = performance.now();
|
|
1079
|
+
await tool.fn();
|
|
1080
|
+
times.push(performance.now() - t0);
|
|
1081
|
+
}
|
|
1082
|
+
times.sort((a, b) => a - b);
|
|
1083
|
+
results.push({
|
|
1084
|
+
tool: tool.name,
|
|
1085
|
+
runs: RUNS,
|
|
1086
|
+
p50: percentile(times, 50),
|
|
1087
|
+
p95: percentile(times, 95),
|
|
1088
|
+
avg: times.reduce((a, b) => a + b, 0) / times.length,
|
|
1089
|
+
min: times[0],
|
|
1090
|
+
max: times[times.length - 1]
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
const graphTools = [
|
|
1094
|
+
{ name: "ts_dependency_tree", fn: () => dependencyTree(graph, testFile) },
|
|
1095
|
+
{ name: "ts_dependents", fn: () => dependents(graph, testFile) },
|
|
1096
|
+
{ name: "ts_import_cycles", fn: () => importCycles(graph) },
|
|
1097
|
+
{
|
|
1098
|
+
name: "ts_shortest_path",
|
|
1099
|
+
fn: () => {
|
|
1100
|
+
const rev = graph.reverse.get(testFile);
|
|
1101
|
+
if (rev && rev.length > 0) return shortestPath(graph, rev[0].target, testFile);
|
|
1102
|
+
return null;
|
|
1103
|
+
}
|
|
1104
|
+
},
|
|
1105
|
+
{ name: "ts_subgraph", fn: () => subgraph(graph, [testFile], { depth: 2, direction: "both" }) },
|
|
1106
|
+
{
|
|
1107
|
+
name: "ts_module_boundary",
|
|
1108
|
+
fn: () => {
|
|
1109
|
+
const dir = path5.dirname(testFile);
|
|
1110
|
+
const siblings = [...graph.files].filter((f) => path5.dirname(f) === dir);
|
|
1111
|
+
return moduleBoundary(graph, siblings);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
];
|
|
1115
|
+
for (const tool of graphTools) {
|
|
1116
|
+
const times = [];
|
|
1117
|
+
for (let i = 0; i < RUNS; i++) {
|
|
1118
|
+
const t0 = performance.now();
|
|
1119
|
+
tool.fn();
|
|
1120
|
+
times.push(performance.now() - t0);
|
|
1121
|
+
}
|
|
1122
|
+
times.sort((a, b) => a - b);
|
|
1123
|
+
results.push({
|
|
1124
|
+
tool: tool.name,
|
|
1125
|
+
runs: RUNS,
|
|
1126
|
+
p50: percentile(times, 50),
|
|
1127
|
+
p95: percentile(times, 95),
|
|
1128
|
+
avg: times.reduce((a, b) => a + b, 0) / times.length,
|
|
1129
|
+
min: times[0],
|
|
1130
|
+
max: times[times.length - 1]
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
console.log("| Tool | p50 | p95 | avg | min | max |");
|
|
1134
|
+
console.log("|------|-----|-----|-----|-----|-----|");
|
|
1135
|
+
for (const r of results) {
|
|
1136
|
+
console.log(
|
|
1137
|
+
`| ${r.tool} | ${r.p50.toFixed(1)}ms | ${r.p95.toFixed(1)}ms | ${r.avg.toFixed(1)}ms | ${r.min.toFixed(1)}ms | ${r.max.toFixed(1)}ms |`
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
console.log("");
|
|
1141
|
+
return results;
|
|
1142
|
+
}
|
|
1143
|
+
async function benchmarkAccuracy(client, graph) {
|
|
1144
|
+
console.log("=== Benchmark 3: Accuracy (grep vs typegraph-mcp) ===");
|
|
1145
|
+
console.log("");
|
|
1146
|
+
const scenarios = [];
|
|
1147
|
+
const barrel = findBarrelChain(graph);
|
|
1148
|
+
if (barrel) {
|
|
1149
|
+
const symbol = barrel.specifiers[0];
|
|
1150
|
+
const grep = grepCount(symbol);
|
|
1151
|
+
const navItems = await client.navto(symbol, 10);
|
|
1152
|
+
const defItem = navItems.find((i) => i.name === symbol && i.matchKind === "exact");
|
|
1153
|
+
let defLocation = "";
|
|
1154
|
+
if (defItem) {
|
|
1155
|
+
const defs = await client.definition(defItem.file, defItem.start.line, defItem.start.offset);
|
|
1156
|
+
if (defs.length > 0) {
|
|
1157
|
+
defLocation = `${defs[0].file}:${defs[0].start.line}`;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
scenarios.push({
|
|
1161
|
+
name: "Barrel file resolution",
|
|
1162
|
+
description: `Find where \`${symbol}\` is actually defined (not re-exported)`,
|
|
1163
|
+
grepResult: `${grep.matches} matches across ${grep.files} files \u2014 agent must read files to distinguish definition from re-exports`,
|
|
1164
|
+
typegraphResult: defLocation ? `Direct: ${defLocation} (1 tool call)` : `Found ${navItems.length} declarations via navto`,
|
|
1165
|
+
verdict: "typegraph wins"
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
const prefixSymbol = findPrefixSymbol(graph);
|
|
1169
|
+
if (prefixSymbol) {
|
|
1170
|
+
const grep = grepCount(prefixSymbol.base);
|
|
1171
|
+
const grepVariants = grepCount(`${prefixSymbol.base}[A-Z]`);
|
|
1172
|
+
const navItems = await client.navto(prefixSymbol.base, 10);
|
|
1173
|
+
const exactMatches = navItems.filter((i) => i.name === prefixSymbol.base);
|
|
1174
|
+
scenarios.push({
|
|
1175
|
+
name: "Same-name disambiguation",
|
|
1176
|
+
description: `Distinguish \`${prefixSymbol.base}\` from ${prefixSymbol.variants.map((v) => `\`${v}\``).join(", ")}`,
|
|
1177
|
+
grepResult: `${grep.matches} total matches (includes ${grepVariants.matches} variant-name matches sharing the prefix)`,
|
|
1178
|
+
typegraphResult: `${exactMatches.length} exact match(es): ${exactMatches.map((i) => `${i.file}:${i.start.line} [${i.kind}]`).join(", ")}`,
|
|
1179
|
+
verdict: "typegraph wins"
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
const mixedFile = findMixedImportFile(graph);
|
|
1183
|
+
if (mixedFile) {
|
|
1184
|
+
const fwdEdges = graph.forward.get(mixedFile) ?? [];
|
|
1185
|
+
const typeOnly = fwdEdges.filter((e) => e.isTypeOnly);
|
|
1186
|
+
const runtime = fwdEdges.filter((e) => !e.isTypeOnly);
|
|
1187
|
+
scenarios.push({
|
|
1188
|
+
name: "Type-only vs runtime imports",
|
|
1189
|
+
description: `In \`${relPath(mixedFile)}\`, distinguish type-only from runtime imports`,
|
|
1190
|
+
grepResult: `grep "import" shows all imports without distinguishing \`import type\` \u2014 agent must parse each line manually`,
|
|
1191
|
+
typegraphResult: `${typeOnly.length} type-only imports, ${runtime.length} runtime imports (module graph distinguishes automatically)`,
|
|
1192
|
+
verdict: "typegraph wins"
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
const mostDepended = findMostDependedFile(graph);
|
|
1196
|
+
if (mostDepended) {
|
|
1197
|
+
const basename5 = path5.basename(mostDepended, path5.extname(mostDepended));
|
|
1198
|
+
const grep = grepCount(basename5);
|
|
1199
|
+
const deps = dependents(graph, mostDepended);
|
|
1200
|
+
const byPackageSummary = Object.entries(deps.byPackage).map(([pkg, files]) => `${pkg}: ${files.length}`).join(", ");
|
|
1201
|
+
scenarios.push({
|
|
1202
|
+
name: "Cross-package impact analysis",
|
|
1203
|
+
description: `Find everything that depends on \`${relPath(mostDepended)}\``,
|
|
1204
|
+
grepResult: `grep for "${basename5}" finds ${grep.matches} matches \u2014 cannot distinguish direct vs transitive, cannot follow re-exports`,
|
|
1205
|
+
typegraphResult: `${deps.directCount} direct dependents, ${deps.nodes} total (transitive)${byPackageSummary ? `. By package: ${byPackageSummary}` : ""}`,
|
|
1206
|
+
verdict: "typegraph wins"
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
{
|
|
1210
|
+
const cycles = importCycles(graph);
|
|
1211
|
+
const cycleDetail = cycles.cycles.length > 0 ? cycles.cycles.slice(0, 3).map((c) => c.map(relPath).join(" -> ")).join("; ") : "none";
|
|
1212
|
+
scenarios.push({
|
|
1213
|
+
name: "Circular dependency detection",
|
|
1214
|
+
description: "Find all circular import chains in the project",
|
|
1215
|
+
grepResult: "Impossible with grep \u2014 requires full graph analysis",
|
|
1216
|
+
typegraphResult: `${cycles.count} cycle(s)${cycles.count > 0 ? `: ${cycleDetail}` : ""}`,
|
|
1217
|
+
verdict: "typegraph wins"
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
for (const s of scenarios) {
|
|
1221
|
+
console.log(`### ${s.name}`);
|
|
1222
|
+
console.log(`${s.description}`);
|
|
1223
|
+
console.log(` grep: ${s.grepResult}`);
|
|
1224
|
+
console.log(` typegraph: ${s.typegraphResult}`);
|
|
1225
|
+
console.log(` verdict: ${s.verdict}`);
|
|
1226
|
+
console.log("");
|
|
1227
|
+
}
|
|
1228
|
+
return scenarios;
|
|
1229
|
+
}
|
|
1230
|
+
async function main(config) {
|
|
1231
|
+
({ projectRoot, tsconfigPath } = config ?? resolveConfig(import.meta.dirname));
|
|
1232
|
+
console.log("");
|
|
1233
|
+
console.log("typegraph-mcp Benchmark");
|
|
1234
|
+
console.log("=======================");
|
|
1235
|
+
console.log(`Project: ${projectRoot}`);
|
|
1236
|
+
console.log("");
|
|
1237
|
+
const graphStart = performance.now();
|
|
1238
|
+
const { graph } = await buildGraph(projectRoot, tsconfigPath);
|
|
1239
|
+
const graphMs = performance.now() - graphStart;
|
|
1240
|
+
const edgeCount = [...graph.forward.values()].reduce((s, e) => s + e.length, 0);
|
|
1241
|
+
console.log(`Module graph: ${graph.files.size} files, ${edgeCount} edges [${graphMs.toFixed(0)}ms]`);
|
|
1242
|
+
console.log("");
|
|
1243
|
+
const client = new TsServerClient(projectRoot, tsconfigPath);
|
|
1244
|
+
const tsStart = performance.now();
|
|
1245
|
+
await client.start();
|
|
1246
|
+
console.log(`tsserver ready [${(performance.now() - tsStart).toFixed(0)}ms]`);
|
|
1247
|
+
const warmFile = [...graph.files][0];
|
|
1248
|
+
await client.navbar(relPath(warmFile));
|
|
1249
|
+
console.log("");
|
|
1250
|
+
const tokenResults = await benchmarkTokens(client, graph);
|
|
1251
|
+
const latencyResults = await benchmarkLatency(client, graph);
|
|
1252
|
+
const accuracyResults = await benchmarkAccuracy(client, graph);
|
|
1253
|
+
console.log("=== Summary ===");
|
|
1254
|
+
console.log("");
|
|
1255
|
+
if (tokenResults.length > 0) {
|
|
1256
|
+
const avgReduction = tokenResults.reduce((sum, s) => {
|
|
1257
|
+
const pct = parseFloat(s.reduction);
|
|
1258
|
+
return sum + (isNaN(pct) ? 0 : pct);
|
|
1259
|
+
}, 0) / tokenResults.length;
|
|
1260
|
+
console.log(`Average token reduction: ${avgReduction.toFixed(0)}%`);
|
|
1261
|
+
}
|
|
1262
|
+
const tsserverLatencies = latencyResults.filter(
|
|
1263
|
+
(r) => ["ts_find_symbol", "ts_definition", "ts_references", "ts_type_info", "ts_navigate_to", "ts_module_exports"].includes(r.tool)
|
|
1264
|
+
);
|
|
1265
|
+
const graphLatencies = latencyResults.filter((r) => !tsserverLatencies.includes(r));
|
|
1266
|
+
if (tsserverLatencies.length > 0) {
|
|
1267
|
+
const tsAvg = tsserverLatencies.reduce((s, r) => s + r.avg, 0) / tsserverLatencies.length;
|
|
1268
|
+
console.log(`Average tsserver query: ${tsAvg.toFixed(1)}ms`);
|
|
1269
|
+
}
|
|
1270
|
+
if (graphLatencies.length > 0) {
|
|
1271
|
+
const graphAvg = graphLatencies.reduce((s, r) => s + r.avg, 0) / graphLatencies.length;
|
|
1272
|
+
console.log(`Average graph query: ${graphAvg.toFixed(1)}ms`);
|
|
1273
|
+
}
|
|
1274
|
+
console.log(`Accuracy scenarios: ${accuracyResults.filter((s) => s.verdict === "typegraph wins").length}/${accuracyResults.length} typegraph wins`);
|
|
1275
|
+
console.log("");
|
|
1276
|
+
client.shutdown();
|
|
1277
|
+
}
|
|
1278
|
+
main().catch((err) => {
|
|
1279
|
+
console.error("Fatal:", err);
|
|
1280
|
+
process.exit(1);
|
|
1281
|
+
});
|
|
1282
|
+
export {
|
|
1283
|
+
main
|
|
1284
|
+
};
|