typegraph-mcp 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/benchmark.ts ADDED
@@ -0,0 +1,735 @@
1
+ #!/usr/bin/env npx tsx
2
+ /**
3
+ * typegraph-mcp Benchmark — Token comparison, latency, and accuracy tests.
4
+ *
5
+ * Fully dynamic — discovers symbols, barrel chains, and test scenarios
6
+ * from whatever TypeScript project it's pointed at.
7
+ *
8
+ * Usage:
9
+ * npx tsx benchmark.ts
10
+ * TYPEGRAPH_PROJECT_ROOT=/path/to/project npx tsx benchmark.ts
11
+ */
12
+
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import { execSync } from "node:child_process";
16
+ import { TsServerClient, type NavBarItem } from "./tsserver-client.js";
17
+ import { buildGraph, type ModuleGraph } from "./module-graph.js";
18
+ import {
19
+ dependencyTree,
20
+ dependents,
21
+ importCycles,
22
+ shortestPath,
23
+ subgraph,
24
+ moduleBoundary,
25
+ } from "./graph-queries.js";
26
+ import { resolveConfig } from "./config.js";
27
+
28
+ // ─── Configuration ───────────────────────────────────────────────────────────
29
+
30
+ const { projectRoot, tsconfigPath } = resolveConfig(import.meta.dirname);
31
+
32
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
33
+
34
+ /** Estimate tokens in a string (~4 chars per token for code) */
35
+ function estimateTokens(text: string): number {
36
+ return Math.ceil(text.length / 4);
37
+ }
38
+
39
+ /** Count grep matches for a pattern across the project */
40
+ function grepCount(pattern: string): { matches: number; files: number; totalBytes: number } {
41
+ try {
42
+ const result = execSync(
43
+ `grep -r --include='*.ts' --include='*.tsx' -l "${pattern}" . 2>/dev/null || true`,
44
+ { cwd: projectRoot, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }
45
+ ).trim();
46
+ const files = result ? result.split("\n").filter(Boolean) : [];
47
+
48
+ const countResult = execSync(
49
+ `grep -r --include='*.ts' --include='*.tsx' -c "${pattern}" . 2>/dev/null || true`,
50
+ { cwd: projectRoot, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }
51
+ ).trim();
52
+ const matches = countResult
53
+ .split("\n")
54
+ .filter(Boolean)
55
+ .reduce((sum, line) => {
56
+ const count = parseInt(line.split(":").pop()!, 10);
57
+ return sum + (isNaN(count) ? 0 : count);
58
+ }, 0);
59
+
60
+ let totalBytes = 0;
61
+ for (const file of files) {
62
+ try {
63
+ totalBytes += fs.statSync(path.resolve(projectRoot, file)).size;
64
+ } catch {
65
+ // skip
66
+ }
67
+ }
68
+
69
+ return { matches, files: files.length, totalBytes };
70
+ } catch {
71
+ return { matches: 0, files: 0, totalBytes: 0 };
72
+ }
73
+ }
74
+
75
+ function relPath(abs: string): string {
76
+ return path.relative(projectRoot, abs);
77
+ }
78
+
79
+ function percentile(sorted: number[], p: number): number {
80
+ const idx = Math.ceil((p / 100) * sorted.length) - 1;
81
+ return sorted[Math.max(0, idx)]!;
82
+ }
83
+
84
+ /** Flatten navbar tree into a list of symbols */
85
+ function flattenNavBar(items: NavBarItem[]): NavBarItem[] {
86
+ const result: NavBarItem[] = [];
87
+ for (const item of items) {
88
+ result.push(item);
89
+ if (item.childItems?.length > 0) result.push(...flattenNavBar(item.childItems));
90
+ }
91
+ return result;
92
+ }
93
+
94
+ // ─── Discovery ───────────────────────────────────────────────────────────────
95
+
96
+ /** Find a barrel re-export chain: an index.ts that re-exports from non-index files */
97
+ function findBarrelChain(graph: ModuleGraph): { barrelFile: string; sourceFile: string; specifiers: string[] } | null {
98
+ const barrels = [...graph.files].filter((f) => path.basename(f) === "index.ts");
99
+
100
+ for (const barrel of barrels) {
101
+ const edges = graph.forward.get(barrel) ?? [];
102
+ // Look for edges that carry named specifiers (re-exports, not star)
103
+ for (const edge of edges) {
104
+ if (
105
+ edge.specifiers.length > 0 &&
106
+ !edge.specifiers.includes("*") &&
107
+ !edge.target.endsWith("index.ts") &&
108
+ !edge.isTypeOnly
109
+ ) {
110
+ // Check if the source file is also re-exported by another barrel (deeper chain)
111
+ const parentBarrels = (graph.reverse.get(barrel) ?? []).filter(
112
+ (e) => path.basename(e.target) === "index.ts"
113
+ );
114
+ if (parentBarrels.length > 0) {
115
+ return { barrelFile: barrel, sourceFile: edge.target, specifiers: edge.specifiers };
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ // Fallback: any barrel with named re-exports
122
+ for (const barrel of barrels) {
123
+ const edges = graph.forward.get(barrel) ?? [];
124
+ for (const edge of edges) {
125
+ if (edge.specifiers.length > 0 && !edge.specifiers.includes("*") && !edge.target.endsWith("index.ts")) {
126
+ return { barrelFile: barrel, sourceFile: edge.target, specifiers: edge.specifiers };
127
+ }
128
+ }
129
+ }
130
+
131
+ return null;
132
+ }
133
+
134
+ /** Find a symbol name that appears in many files (high grep noise) */
135
+ function findHighFanoutSymbol(graph: ModuleGraph): string | null {
136
+ // Collect specifiers from all import edges, count frequency
137
+ const specCounts = new Map<string, number>();
138
+ for (const edges of graph.forward.values()) {
139
+ for (const edge of edges) {
140
+ for (const spec of edge.specifiers) {
141
+ if (spec === "*" || spec === "default" || spec.length < 4) continue;
142
+ specCounts.set(spec, (specCounts.get(spec) ?? 0) + 1);
143
+ }
144
+ }
145
+ }
146
+
147
+ // Sort by frequency, pick the top one
148
+ const sorted = [...specCounts.entries()].sort((a, b) => b[1] - a[1]);
149
+ return sorted[0]?.[0] ?? null;
150
+ }
151
+
152
+ /** Find a symbol whose name is a prefix of other symbols (disambiguation scenario) */
153
+ function findPrefixSymbol(graph: ModuleGraph): { base: string; variants: string[] } | null {
154
+ const specCounts = new Map<string, number>();
155
+ for (const edges of graph.forward.values()) {
156
+ for (const edge of edges) {
157
+ for (const spec of edge.specifiers) {
158
+ if (spec === "*" || spec === "default" || spec.length < 5) continue;
159
+ specCounts.set(spec, (specCounts.get(spec) ?? 0) + 1);
160
+ }
161
+ }
162
+ }
163
+
164
+ // Find a symbol that is a prefix of other symbols
165
+ const allSpecs = [...specCounts.keys()];
166
+ for (const [base, count] of [...specCounts.entries()].sort((a, b) => b[1] - a[1])) {
167
+ if (count < 3) continue;
168
+ const variants = allSpecs.filter((s) => s !== base && s.startsWith(base) && s[base.length]?.match(/[A-Z]/));
169
+ if (variants.length >= 2) {
170
+ return { base, variants: variants.slice(0, 5) };
171
+ }
172
+ }
173
+
174
+ return null;
175
+ }
176
+
177
+ /** Find a file with mixed type-only and runtime imports */
178
+ function findMixedImportFile(graph: ModuleGraph): string | null {
179
+ for (const [file, edges] of graph.forward) {
180
+ const hasTypeOnly = edges.some((e) => e.isTypeOnly);
181
+ const hasRuntime = edges.some((e) => !e.isTypeOnly);
182
+ if (hasTypeOnly && hasRuntime && edges.length >= 4) {
183
+ return file;
184
+ }
185
+ }
186
+ return null;
187
+ }
188
+
189
+ /** Find the most-depended-on file in the project */
190
+ function findMostDependedFile(graph: ModuleGraph): string | null {
191
+ let maxDeps = 0;
192
+ let maxFile: string | null = null;
193
+
194
+ for (const [file, revEdges] of graph.reverse) {
195
+ if (file.endsWith("index.ts")) continue; // Skip barrels — find actual source files
196
+ if (revEdges.length > maxDeps) {
197
+ maxDeps = revEdges.length;
198
+ maxFile = file;
199
+ }
200
+ }
201
+
202
+ return maxFile;
203
+ }
204
+
205
+ /** Find a file with a traceable call chain (imports something that imports something) */
206
+ function findChainFile(graph: ModuleGraph): { file: string; symbol: string } | null {
207
+ for (const [file, edges] of graph.forward) {
208
+ if (file.endsWith("index.ts") || file.includes(".test.")) continue;
209
+
210
+ for (const edge of edges) {
211
+ if (edge.isTypeOnly || edge.specifiers.includes("*")) continue;
212
+ // Check if the target also imports something
213
+ const targetEdges = graph.forward.get(edge.target) ?? [];
214
+ const nonTrivialTarget = targetEdges.filter((e) => !e.isTypeOnly && !e.specifiers.includes("*"));
215
+ if (nonTrivialTarget.length > 0 && edge.specifiers.length > 0) {
216
+ return { file, symbol: edge.specifiers[0]! };
217
+ }
218
+ }
219
+ }
220
+
221
+ return null;
222
+ }
223
+
224
+ /** Find a good test file for latency benchmarks (medium-sized, has symbols) */
225
+ function findLatencyTestFile(graph: ModuleGraph): string | null {
226
+ const candidates = [...graph.files]
227
+ .filter((f) => !f.endsWith("index.ts") && !f.includes(".test.") && !f.includes(".spec."))
228
+ .map((f) => {
229
+ try {
230
+ return { file: f, size: fs.statSync(f).size };
231
+ } catch {
232
+ return null;
233
+ }
234
+ })
235
+ .filter((c): c is { file: string; size: number } => c !== null && c.size > 500 && c.size < 30000)
236
+ .sort((a, b) => b.size - a.size);
237
+
238
+ // Prefer files with "service", "handler", etc. in the name
239
+ const preferred = candidates.find((c) =>
240
+ /service|handler|controller|repository|provider/i.test(path.basename(c.file))
241
+ );
242
+
243
+ return preferred?.file ?? candidates[0]?.file ?? null;
244
+ }
245
+
246
+ // ─── Benchmark 1: Token Comparison ──────────────────────────────────────────
247
+
248
+ interface TokenScenario {
249
+ name: string;
250
+ symbol: string;
251
+ description: string;
252
+ grep: { matches: number; files: number; tokensToRead: number };
253
+ typegraph: { responseTokens: number; toolCalls: number };
254
+ reduction: string;
255
+ }
256
+
257
+ async function benchmarkTokens(
258
+ client: TsServerClient,
259
+ graph: ModuleGraph
260
+ ): Promise<TokenScenario[]> {
261
+ console.log("=== Benchmark 1: Token Comparison (grep vs typegraph-mcp) ===");
262
+ console.log("");
263
+
264
+ const scenarios: TokenScenario[] = [];
265
+
266
+ // Scenario A: Barrel re-export resolution
267
+ const barrel = findBarrelChain(graph);
268
+ if (barrel) {
269
+ const symbol = barrel.specifiers[0]!;
270
+ const grep = grepCount(symbol);
271
+ const grepTokens = estimateTokens("x".repeat(Math.min(grep.totalBytes, 500000)));
272
+
273
+ const navItems = await client.navto(symbol, 5);
274
+ const def = navItems.find((i) => i.name === symbol);
275
+ let responseText = JSON.stringify({ results: navItems, count: navItems.length });
276
+ if (def) {
277
+ const defs = await client.definition(def.file, def.start.line, def.start.offset);
278
+ responseText += JSON.stringify({ definitions: defs });
279
+ }
280
+
281
+ scenarios.push({
282
+ name: "Barrel re-export resolution",
283
+ symbol,
284
+ description: `Re-exported through ${relPath(barrel.barrelFile)}`,
285
+ grep: { matches: grep.matches, files: grep.files, tokensToRead: grepTokens },
286
+ typegraph: { responseTokens: estimateTokens(responseText), toolCalls: 2 },
287
+ reduction: grepTokens > 0
288
+ ? `${((1 - estimateTokens(responseText) / grepTokens) * 100).toFixed(0)}%`
289
+ : "N/A",
290
+ });
291
+ }
292
+
293
+ // Scenario B: High-fanout symbol (many grep matches)
294
+ const highFanout = findHighFanoutSymbol(graph);
295
+ if (highFanout) {
296
+ const grep = grepCount(highFanout);
297
+ const grepTokens = estimateTokens("x".repeat(Math.min(grep.totalBytes, 500000)));
298
+
299
+ const navItems = await client.navto(highFanout, 10);
300
+ const refs = navItems.length > 0
301
+ ? await client.references(navItems[0]!.file, navItems[0]!.start.line, navItems[0]!.start.offset)
302
+ : [];
303
+ const responseText = JSON.stringify({ results: navItems }) + JSON.stringify({ count: refs.length });
304
+
305
+ scenarios.push({
306
+ name: "High-fanout symbol lookup",
307
+ symbol: highFanout,
308
+ description: `Most-imported symbol in the project`,
309
+ grep: { matches: grep.matches, files: grep.files, tokensToRead: grepTokens },
310
+ typegraph: { responseTokens: estimateTokens(responseText), toolCalls: 2 },
311
+ reduction: grepTokens > 0
312
+ ? `${((1 - estimateTokens(responseText) / grepTokens) * 100).toFixed(0)}%`
313
+ : "N/A",
314
+ });
315
+ }
316
+
317
+ // Scenario C: Call chain tracing
318
+ const chainTarget = findChainFile(graph);
319
+ if (chainTarget) {
320
+ const grep = grepCount(chainTarget.symbol);
321
+ const grepTokens = estimateTokens("x".repeat(Math.min(grep.totalBytes, 500000)));
322
+
323
+ const navItems = await client.navto(chainTarget.symbol, 5);
324
+ let totalResponse = JSON.stringify({ results: navItems });
325
+ let hops = 0;
326
+
327
+ if (navItems.length > 0) {
328
+ let cur = { file: navItems[0]!.file, line: navItems[0]!.start.line, offset: navItems[0]!.start.offset };
329
+ for (let i = 0; i < 5; i++) {
330
+ const defs = await client.definition(cur.file, cur.line, cur.offset);
331
+ if (defs.length === 0) break;
332
+ const hop = defs[0]!;
333
+ if (hop.file === cur.file && hop.start.line === cur.line) break;
334
+ if (hop.file.includes("node_modules")) break;
335
+ hops++;
336
+ totalResponse += JSON.stringify({ definitions: defs });
337
+ cur = { file: hop.file, line: hop.start.line, offset: hop.start.offset };
338
+ }
339
+ }
340
+
341
+ scenarios.push({
342
+ name: "Call chain tracing",
343
+ symbol: chainTarget.symbol,
344
+ description: `${hops} hop(s) from ${relPath(chainTarget.file)}`,
345
+ grep: { matches: grep.matches, files: grep.files, tokensToRead: grepTokens },
346
+ typegraph: { responseTokens: estimateTokens(totalResponse), toolCalls: 1 + hops },
347
+ reduction: grepTokens > 0
348
+ ? `${((1 - estimateTokens(totalResponse) / grepTokens) * 100).toFixed(0)}%`
349
+ : "N/A",
350
+ });
351
+ }
352
+
353
+ // Scenario D: Most-depended-on file — impact analysis
354
+ const mostDepended = findMostDependedFile(graph);
355
+ if (mostDepended) {
356
+ const basename = path.basename(mostDepended, path.extname(mostDepended));
357
+ const grep = grepCount(basename);
358
+ const grepTokens = estimateTokens("x".repeat(Math.min(grep.totalBytes, 500000)));
359
+
360
+ const deps = dependents(graph, mostDepended);
361
+ const responseText = JSON.stringify({
362
+ root: relPath(mostDepended),
363
+ nodes: deps.nodes,
364
+ directCount: deps.directCount,
365
+ byPackage: deps.byPackage,
366
+ });
367
+
368
+ scenarios.push({
369
+ name: "Impact analysis (most-depended file)",
370
+ symbol: basename,
371
+ description: `${relPath(mostDepended)} — ${deps.directCount} direct, ${deps.nodes} transitive`,
372
+ grep: { matches: grep.matches, files: grep.files, tokensToRead: grepTokens },
373
+ typegraph: { responseTokens: estimateTokens(responseText), toolCalls: 1 },
374
+ reduction: grepTokens > 0
375
+ ? `${((1 - estimateTokens(responseText) / grepTokens) * 100).toFixed(0)}%`
376
+ : "N/A",
377
+ });
378
+ }
379
+
380
+ // Print results
381
+ if (scenarios.length > 0) {
382
+ console.log("| Scenario | Symbol | grep matches | grep files | grep tokens | tg tokens | tg calls | reduction |");
383
+ console.log("|----------|--------|-------------|-----------|-------------|-----------|----------|-----------|");
384
+ for (const s of scenarios) {
385
+ console.log(
386
+ `| ${s.name} | \`${s.symbol}\` | ${s.grep.matches} | ${s.grep.files} | ${s.grep.tokensToRead.toLocaleString()} | ${s.typegraph.responseTokens.toLocaleString()} | ${s.typegraph.toolCalls} | ${s.reduction} |`
387
+ );
388
+ }
389
+ } else {
390
+ console.log(" No suitable scenarios discovered for this codebase.");
391
+ }
392
+ console.log("");
393
+
394
+ return scenarios;
395
+ }
396
+
397
+ // ─── Benchmark 2: Latency ───────────────────────────────────────────────────
398
+
399
+ interface LatencyResult {
400
+ tool: string;
401
+ runs: number;
402
+ p50: number;
403
+ p95: number;
404
+ avg: number;
405
+ min: number;
406
+ max: number;
407
+ }
408
+
409
+ async function benchmarkLatency(
410
+ client: TsServerClient,
411
+ graph: ModuleGraph
412
+ ): Promise<LatencyResult[]> {
413
+ console.log("=== Benchmark 2: Latency (ms per tool call) ===");
414
+ console.log("");
415
+
416
+ const results: LatencyResult[] = [];
417
+ const RUNS = 5;
418
+
419
+ const testFile = findLatencyTestFile(graph);
420
+ if (!testFile) {
421
+ console.log(" No suitable test file found for latency benchmark.");
422
+ console.log("");
423
+ return results;
424
+ }
425
+ const testFileRel = relPath(testFile);
426
+ console.log(`Test file: ${testFileRel}`);
427
+ console.log(`Runs per tool: ${RUNS}`);
428
+ console.log("");
429
+
430
+ // Discover a concrete symbol from the file
431
+ const bar = await client.navbar(testFileRel);
432
+ const allSymbols = flattenNavBar(bar);
433
+ const concreteKinds = new Set(["const", "function", "class", "var", "let", "enum"]);
434
+ const sym = allSymbols.find(
435
+ (item) => concreteKinds.has(item.kind) && item.text !== "<function>" && item.spans.length > 0
436
+ );
437
+
438
+ if (!sym) {
439
+ console.log(" No concrete symbol found in test file.");
440
+ console.log("");
441
+ return results;
442
+ }
443
+ const span = sym.spans[0]!;
444
+ console.log(`Test symbol: ${sym.text} [${sym.kind}]`);
445
+ console.log("");
446
+
447
+ // tsserver tools
448
+ const tsserverTools: Array<{ name: string; fn: () => Promise<unknown> }> = [
449
+ { name: "ts_find_symbol", fn: () => client.navbar(testFileRel) },
450
+ { name: "ts_definition", fn: () => client.definition(testFileRel, span.start.line, span.start.offset) },
451
+ { name: "ts_references", fn: () => client.references(testFileRel, span.start.line, span.start.offset) },
452
+ { name: "ts_type_info", fn: () => client.quickinfo(testFileRel, span.start.line, span.start.offset) },
453
+ { name: "ts_navigate_to", fn: () => client.navto(sym.text, 10) },
454
+ { name: "ts_module_exports", fn: () => client.navbar(testFileRel) },
455
+ ];
456
+
457
+ for (const tool of tsserverTools) {
458
+ const times: number[] = [];
459
+ for (let i = 0; i < RUNS; i++) {
460
+ const t0 = performance.now();
461
+ await tool.fn();
462
+ times.push(performance.now() - t0);
463
+ }
464
+ times.sort((a, b) => a - b);
465
+ results.push({
466
+ tool: tool.name,
467
+ runs: RUNS,
468
+ p50: percentile(times, 50),
469
+ p95: percentile(times, 95),
470
+ avg: times.reduce((a, b) => a + b, 0) / times.length,
471
+ min: times[0]!,
472
+ max: times[times.length - 1]!,
473
+ });
474
+ }
475
+
476
+ // Graph tools
477
+ const graphTools: Array<{ name: string; fn: () => unknown }> = [
478
+ { name: "ts_dependency_tree", fn: () => dependencyTree(graph, testFile) },
479
+ { name: "ts_dependents", fn: () => dependents(graph, testFile) },
480
+ { name: "ts_import_cycles", fn: () => importCycles(graph) },
481
+ {
482
+ name: "ts_shortest_path",
483
+ fn: () => {
484
+ const rev = graph.reverse.get(testFile);
485
+ if (rev && rev.length > 0) return shortestPath(graph, rev[0]!.target, testFile);
486
+ return null;
487
+ },
488
+ },
489
+ { name: "ts_subgraph", fn: () => subgraph(graph, [testFile], { depth: 2, direction: "both" }) },
490
+ {
491
+ name: "ts_module_boundary",
492
+ fn: () => {
493
+ const dir = path.dirname(testFile);
494
+ const siblings = [...graph.files].filter((f) => path.dirname(f) === dir);
495
+ return moduleBoundary(graph, siblings);
496
+ },
497
+ },
498
+ ];
499
+
500
+ for (const tool of graphTools) {
501
+ const times: number[] = [];
502
+ for (let i = 0; i < RUNS; i++) {
503
+ const t0 = performance.now();
504
+ tool.fn();
505
+ times.push(performance.now() - t0);
506
+ }
507
+ times.sort((a, b) => a - b);
508
+ results.push({
509
+ tool: tool.name,
510
+ runs: RUNS,
511
+ p50: percentile(times, 50),
512
+ p95: percentile(times, 95),
513
+ avg: times.reduce((a, b) => a + b, 0) / times.length,
514
+ min: times[0]!,
515
+ max: times[times.length - 1]!,
516
+ });
517
+ }
518
+
519
+ // Print results
520
+ console.log("| Tool | p50 | p95 | avg | min | max |");
521
+ console.log("|------|-----|-----|-----|-----|-----|");
522
+ for (const r of results) {
523
+ console.log(
524
+ `| ${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 |`
525
+ );
526
+ }
527
+ console.log("");
528
+
529
+ return results;
530
+ }
531
+
532
+ // ─── Benchmark 3: Accuracy ──────────────────────────────────────────────────
533
+
534
+ interface AccuracyScenario {
535
+ name: string;
536
+ description: string;
537
+ grepResult: string;
538
+ typegraphResult: string;
539
+ verdict: "typegraph wins" | "equivalent" | "grep wins";
540
+ }
541
+
542
+ async function benchmarkAccuracy(
543
+ client: TsServerClient,
544
+ graph: ModuleGraph
545
+ ): Promise<AccuracyScenario[]> {
546
+ console.log("=== Benchmark 3: Accuracy (grep vs typegraph-mcp) ===");
547
+ console.log("");
548
+
549
+ const scenarios: AccuracyScenario[] = [];
550
+
551
+ // A: Barrel file resolution — find the actual definition through re-exports
552
+ const barrel = findBarrelChain(graph);
553
+ if (barrel) {
554
+ const symbol = barrel.specifiers[0]!;
555
+ const grep = grepCount(symbol);
556
+
557
+ const navItems = await client.navto(symbol, 10);
558
+ const defItem = navItems.find((i) => i.name === symbol && i.matchKind === "exact");
559
+ let defLocation = "";
560
+
561
+ if (defItem) {
562
+ const defs = await client.definition(defItem.file, defItem.start.line, defItem.start.offset);
563
+ if (defs.length > 0) {
564
+ defLocation = `${defs[0]!.file}:${defs[0]!.start.line}`;
565
+ }
566
+ }
567
+
568
+ scenarios.push({
569
+ name: "Barrel file resolution",
570
+ description: `Find where \`${symbol}\` is actually defined (not re-exported)`,
571
+ grepResult: `${grep.matches} matches across ${grep.files} files — agent must read files to distinguish definition from re-exports`,
572
+ typegraphResult: defLocation
573
+ ? `Direct: ${defLocation} (1 tool call)`
574
+ : `Found ${navItems.length} declarations via navto`,
575
+ verdict: "typegraph wins",
576
+ });
577
+ }
578
+
579
+ // B: Same-name disambiguation
580
+ const prefixSymbol = findPrefixSymbol(graph);
581
+ if (prefixSymbol) {
582
+ const grep = grepCount(prefixSymbol.base);
583
+ const grepVariants = grepCount(`${prefixSymbol.base}[A-Z]`);
584
+
585
+ const navItems = await client.navto(prefixSymbol.base, 10);
586
+ const exactMatches = navItems.filter((i) => i.name === prefixSymbol.base);
587
+
588
+ scenarios.push({
589
+ name: "Same-name disambiguation",
590
+ description: `Distinguish \`${prefixSymbol.base}\` from ${prefixSymbol.variants.map((v) => `\`${v}\``).join(", ")}`,
591
+ grepResult: `${grep.matches} total matches (includes ${grepVariants.matches} variant-name matches sharing the prefix)`,
592
+ typegraphResult: `${exactMatches.length} exact match(es): ${exactMatches.map((i) => `${i.file}:${i.start.line} [${i.kind}]`).join(", ")}`,
593
+ verdict: "typegraph wins",
594
+ });
595
+ }
596
+
597
+ // C: Type-only vs runtime import distinction
598
+ const mixedFile = findMixedImportFile(graph);
599
+ if (mixedFile) {
600
+ const fwdEdges = graph.forward.get(mixedFile) ?? [];
601
+ const typeOnly = fwdEdges.filter((e) => e.isTypeOnly);
602
+ const runtime = fwdEdges.filter((e) => !e.isTypeOnly);
603
+
604
+ scenarios.push({
605
+ name: "Type-only vs runtime imports",
606
+ description: `In \`${relPath(mixedFile)}\`, distinguish type-only from runtime imports`,
607
+ grepResult: `grep "import" shows all imports without distinguishing \`import type\` — agent must parse each line manually`,
608
+ typegraphResult: `${typeOnly.length} type-only imports, ${runtime.length} runtime imports (module graph distinguishes automatically)`,
609
+ verdict: "typegraph wins",
610
+ });
611
+ }
612
+
613
+ // D: Cross-package / transitive dependency tracking
614
+ const mostDepended = findMostDependedFile(graph);
615
+ if (mostDepended) {
616
+ const basename = path.basename(mostDepended, path.extname(mostDepended));
617
+ const grep = grepCount(basename);
618
+ const deps = dependents(graph, mostDepended);
619
+
620
+ const byPackageSummary = Object.entries(deps.byPackage)
621
+ .map(([pkg, files]) => `${pkg}: ${files.length}`)
622
+ .join(", ");
623
+
624
+ scenarios.push({
625
+ name: "Cross-package impact analysis",
626
+ description: `Find everything that depends on \`${relPath(mostDepended)}\``,
627
+ grepResult: `grep for "${basename}" finds ${grep.matches} matches — cannot distinguish direct vs transitive, cannot follow re-exports`,
628
+ typegraphResult: `${deps.directCount} direct dependents, ${deps.nodes} total (transitive)${byPackageSummary ? `. By package: ${byPackageSummary}` : ""}`,
629
+ verdict: "typegraph wins",
630
+ });
631
+ }
632
+
633
+ // E: Circular dependency detection
634
+ {
635
+ const cycles = importCycles(graph);
636
+
637
+ const cycleDetail = cycles.cycles.length > 0
638
+ ? cycles.cycles
639
+ .slice(0, 3)
640
+ .map((c) => c.map(relPath).join(" -> "))
641
+ .join("; ")
642
+ : "none";
643
+
644
+ scenarios.push({
645
+ name: "Circular dependency detection",
646
+ description: "Find all circular import chains in the project",
647
+ grepResult: "Impossible with grep — requires full graph analysis",
648
+ typegraphResult: `${cycles.count} cycle(s)${cycles.count > 0 ? `: ${cycleDetail}` : ""}`,
649
+ verdict: "typegraph wins",
650
+ });
651
+ }
652
+
653
+ // Print results
654
+ for (const s of scenarios) {
655
+ console.log(`### ${s.name}`);
656
+ console.log(`${s.description}`);
657
+ console.log(` grep: ${s.grepResult}`);
658
+ console.log(` typegraph: ${s.typegraphResult}`);
659
+ console.log(` verdict: ${s.verdict}`);
660
+ console.log("");
661
+ }
662
+
663
+ return scenarios;
664
+ }
665
+
666
+ // ─── Main ────────────────────────────────────────────────────────────────────
667
+
668
+ async function main() {
669
+ console.log("");
670
+ console.log("typegraph-mcp Benchmark");
671
+ console.log("=======================");
672
+ console.log(`Project: ${projectRoot}`);
673
+ console.log("");
674
+
675
+ // Build graph
676
+ const graphStart = performance.now();
677
+ const { graph } = await buildGraph(projectRoot, tsconfigPath);
678
+ const graphMs = performance.now() - graphStart;
679
+ const edgeCount = [...graph.forward.values()].reduce((s, e) => s + e.length, 0);
680
+ console.log(`Module graph: ${graph.files.size} files, ${edgeCount} edges [${graphMs.toFixed(0)}ms]`);
681
+ console.log("");
682
+
683
+ // Start tsserver
684
+ const client = new TsServerClient(projectRoot, tsconfigPath);
685
+ const tsStart = performance.now();
686
+ await client.start();
687
+ console.log(`tsserver ready [${(performance.now() - tsStart).toFixed(0)}ms]`);
688
+
689
+ // Warm up tsserver
690
+ const warmFile = [...graph.files][0]!;
691
+ await client.navbar(relPath(warmFile));
692
+ console.log("");
693
+
694
+ // Run benchmarks
695
+ const tokenResults = await benchmarkTokens(client, graph);
696
+ const latencyResults = await benchmarkLatency(client, graph);
697
+ const accuracyResults = await benchmarkAccuracy(client, graph);
698
+
699
+ // Summary
700
+ console.log("=== Summary ===");
701
+ console.log("");
702
+
703
+ if (tokenResults.length > 0) {
704
+ const avgReduction =
705
+ tokenResults.reduce((sum, s) => {
706
+ const pct = parseFloat(s.reduction);
707
+ return sum + (isNaN(pct) ? 0 : pct);
708
+ }, 0) / tokenResults.length;
709
+ console.log(`Average token reduction: ${avgReduction.toFixed(0)}%`);
710
+ }
711
+
712
+ const tsserverLatencies = latencyResults.filter((r) =>
713
+ ["ts_find_symbol", "ts_definition", "ts_references", "ts_type_info", "ts_navigate_to", "ts_module_exports"].includes(r.tool)
714
+ );
715
+ const graphLatencies = latencyResults.filter((r) => !tsserverLatencies.includes(r));
716
+
717
+ if (tsserverLatencies.length > 0) {
718
+ const tsAvg = tsserverLatencies.reduce((s, r) => s + r.avg, 0) / tsserverLatencies.length;
719
+ console.log(`Average tsserver query: ${tsAvg.toFixed(1)}ms`);
720
+ }
721
+ if (graphLatencies.length > 0) {
722
+ const graphAvg = graphLatencies.reduce((s, r) => s + r.avg, 0) / graphLatencies.length;
723
+ console.log(`Average graph query: ${graphAvg.toFixed(1)}ms`);
724
+ }
725
+
726
+ console.log(`Accuracy scenarios: ${accuracyResults.filter((s) => s.verdict === "typegraph wins").length}/${accuracyResults.length} typegraph wins`);
727
+ console.log("");
728
+
729
+ client.shutdown();
730
+ }
731
+
732
+ main().catch((err) => {
733
+ console.error("Fatal:", err);
734
+ process.exit(1);
735
+ });