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/.claude-plugin/plugin.json +17 -0
- package/.cursor-plugin/plugin.json +17 -0
- package/.mcp.json +10 -0
- package/LICENSE +21 -0
- package/README.md +451 -0
- package/benchmark.ts +735 -0
- package/check.ts +459 -0
- package/cli.ts +778 -0
- package/commands/check.md +23 -0
- package/commands/test.md +23 -0
- package/config.ts +50 -0
- package/gemini-extension.json +16 -0
- package/graph-queries.ts +462 -0
- package/hooks/hooks.json +15 -0
- package/module-graph.ts +507 -0
- package/package.json +39 -0
- package/scripts/ensure-deps.sh +34 -0
- package/server.ts +837 -0
- package/skills/code-exploration/SKILL.md +55 -0
- package/skills/dependency-audit/SKILL.md +50 -0
- package/skills/impact-analysis/SKILL.md +52 -0
- package/skills/refactor-safety/SKILL.md +50 -0
- package/skills/tool-selection/SKILL.md +79 -0
- package/smoke-test.ts +500 -0
- package/tsserver-client.ts +413 -0
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
|
+
});
|