typegraph-mcp 0.9.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.md +4 -4
- package/check.ts +7 -7
- package/cli.ts +4 -2
- package/dist/check.js +376 -0
- package/dist/cli.js +2842 -0
- package/dist/config.js +13 -0
- package/dist/graph-queries.js +279 -0
- package/dist/module-graph.js +336 -0
- package/dist/server.js +1479 -0
- package/dist/smoke-test.js +1148 -0
- package/dist/tsserver-client.js +266 -0
- package/package.json +10 -6
- package/scripts/ensure-deps.sh +2 -5
- package/tsup.config.ts +39 -0
|
@@ -0,0 +1,1148 @@
|
|
|
1
|
+
|
|
2
|
+
// smoke-test.ts
|
|
3
|
+
import * as fs4 from "fs";
|
|
4
|
+
import * as path5 from "path";
|
|
5
|
+
|
|
6
|
+
// tsserver-client.ts
|
|
7
|
+
import { spawn } from "child_process";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
import { createRequire } from "module";
|
|
11
|
+
var log = (...args) => console.error("[typegraph/tsserver]", ...args);
|
|
12
|
+
var REQUEST_TIMEOUT_MS = 1e4;
|
|
13
|
+
var TsServerClient = class {
|
|
14
|
+
constructor(projectRoot, tsconfigPath = "./tsconfig.json") {
|
|
15
|
+
this.projectRoot = projectRoot;
|
|
16
|
+
this.tsconfigPath = tsconfigPath;
|
|
17
|
+
}
|
|
18
|
+
child = null;
|
|
19
|
+
seq = 0;
|
|
20
|
+
pending = /* @__PURE__ */ new Map();
|
|
21
|
+
openFiles = /* @__PURE__ */ new Set();
|
|
22
|
+
buffer = Buffer.alloc(0);
|
|
23
|
+
ready = false;
|
|
24
|
+
shuttingDown = false;
|
|
25
|
+
restartCount = 0;
|
|
26
|
+
maxRestarts = 3;
|
|
27
|
+
// ─── Path Resolution ────────────────────────────────────────────────────
|
|
28
|
+
resolvePath(file) {
|
|
29
|
+
return path.isAbsolute(file) ? file : path.resolve(this.projectRoot, file);
|
|
30
|
+
}
|
|
31
|
+
relativePath(file) {
|
|
32
|
+
return path.relative(this.projectRoot, file);
|
|
33
|
+
}
|
|
34
|
+
/** Read a line from a file (1-based line number). Returns trimmed content. */
|
|
35
|
+
readLine(file, line) {
|
|
36
|
+
try {
|
|
37
|
+
const absPath = this.resolvePath(file);
|
|
38
|
+
const content = fs.readFileSync(absPath, "utf-8");
|
|
39
|
+
const lines = content.split("\n");
|
|
40
|
+
return lines[line - 1]?.trim() ?? "";
|
|
41
|
+
} catch {
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// ─── Lifecycle ──────────────────────────────────────────────────────────
|
|
46
|
+
async start() {
|
|
47
|
+
if (this.child) return;
|
|
48
|
+
const require2 = createRequire(path.resolve(this.projectRoot, "package.json"));
|
|
49
|
+
const tsserverPath = require2.resolve("typescript/lib/tsserver.js");
|
|
50
|
+
log(`Spawning tsserver: ${tsserverPath}`);
|
|
51
|
+
log(`Project root: ${this.projectRoot}`);
|
|
52
|
+
log(`tsconfig: ${this.tsconfigPath}`);
|
|
53
|
+
this.child = spawn("node", [tsserverPath, "--disableAutomaticTypingAcquisition"], {
|
|
54
|
+
cwd: this.projectRoot,
|
|
55
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
56
|
+
env: { ...process.env, TSS_LOG: void 0 }
|
|
57
|
+
});
|
|
58
|
+
this.child.stdout.on("data", (chunk) => this.onData(chunk));
|
|
59
|
+
this.child.stderr.on("data", (chunk) => {
|
|
60
|
+
const text = chunk.toString().trim();
|
|
61
|
+
if (text) log(`[stderr] ${text}`);
|
|
62
|
+
});
|
|
63
|
+
this.child.on("close", (code) => {
|
|
64
|
+
log(`tsserver exited with code ${code}`);
|
|
65
|
+
this.child = null;
|
|
66
|
+
this.rejectAllPending(new Error(`tsserver exited with code ${code}`));
|
|
67
|
+
this.tryRestart();
|
|
68
|
+
});
|
|
69
|
+
this.child.on("error", (err) => {
|
|
70
|
+
log(`tsserver error: ${err.message}`);
|
|
71
|
+
this.rejectAllPending(err);
|
|
72
|
+
});
|
|
73
|
+
await this.sendRequest("configure", {
|
|
74
|
+
preferences: {
|
|
75
|
+
disableSuggestions: true
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
const warmStart = performance.now();
|
|
79
|
+
const tsconfigAbs = this.resolvePath(this.tsconfigPath);
|
|
80
|
+
if (fs.existsSync(tsconfigAbs)) {
|
|
81
|
+
await this.sendRequest("compilerOptionsForInferredProjects", {
|
|
82
|
+
options: { allowJs: true, checkJs: false }
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
this.ready = true;
|
|
86
|
+
log(`Ready [${(performance.now() - warmStart).toFixed(0)}ms configure]`);
|
|
87
|
+
}
|
|
88
|
+
shutdown() {
|
|
89
|
+
this.shuttingDown = true;
|
|
90
|
+
if (this.child) {
|
|
91
|
+
this.child.kill("SIGTERM");
|
|
92
|
+
this.child = null;
|
|
93
|
+
}
|
|
94
|
+
this.rejectAllPending(new Error("Client shutdown"));
|
|
95
|
+
}
|
|
96
|
+
tryRestart() {
|
|
97
|
+
if (this.shuttingDown) return;
|
|
98
|
+
if (this.restartCount >= this.maxRestarts) {
|
|
99
|
+
log(`Max restarts (${this.maxRestarts}) reached, not restarting`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
this.restartCount++;
|
|
103
|
+
log(`Restarting tsserver (attempt ${this.restartCount})...`);
|
|
104
|
+
this.buffer = Buffer.alloc(0);
|
|
105
|
+
const filesToReopen = [...this.openFiles];
|
|
106
|
+
this.openFiles.clear();
|
|
107
|
+
this.start().then(async () => {
|
|
108
|
+
for (const file of filesToReopen) {
|
|
109
|
+
await this.ensureOpen(file).catch(() => {
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
rejectAllPending(err) {
|
|
115
|
+
for (const [seq, pending] of this.pending) {
|
|
116
|
+
clearTimeout(pending.timer);
|
|
117
|
+
pending.reject(err);
|
|
118
|
+
}
|
|
119
|
+
this.pending.clear();
|
|
120
|
+
}
|
|
121
|
+
// ─── Protocol: Parsing ──────────────────────────────────────────────────
|
|
122
|
+
onData(chunk) {
|
|
123
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
124
|
+
this.processBuffer();
|
|
125
|
+
}
|
|
126
|
+
processBuffer() {
|
|
127
|
+
while (true) {
|
|
128
|
+
const headerEnd = this.buffer.indexOf("\r\n\r\n");
|
|
129
|
+
if (headerEnd === -1) return;
|
|
130
|
+
const header = this.buffer.subarray(0, headerEnd).toString("utf-8");
|
|
131
|
+
const match = header.match(/Content-Length:\s*(\d+)/i);
|
|
132
|
+
if (!match) {
|
|
133
|
+
this.buffer = this.buffer.subarray(headerEnd + 4);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const contentLength = parseInt(match[1], 10);
|
|
137
|
+
const bodyStart = headerEnd + 4;
|
|
138
|
+
if (this.buffer.length < bodyStart + contentLength) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const bodyBytes = this.buffer.subarray(bodyStart, bodyStart + contentLength);
|
|
142
|
+
this.buffer = this.buffer.subarray(bodyStart + contentLength);
|
|
143
|
+
try {
|
|
144
|
+
const message = JSON.parse(bodyBytes.toString("utf-8"));
|
|
145
|
+
this.onMessage(message);
|
|
146
|
+
} catch {
|
|
147
|
+
log("Failed to parse tsserver message");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
onMessage(message) {
|
|
152
|
+
if (message.type === "response" && message.request_seq !== void 0) {
|
|
153
|
+
const pending = this.pending.get(message.request_seq);
|
|
154
|
+
if (pending) {
|
|
155
|
+
clearTimeout(pending.timer);
|
|
156
|
+
this.pending.delete(message.request_seq);
|
|
157
|
+
if (message.success) {
|
|
158
|
+
pending.resolve(message.body);
|
|
159
|
+
} else {
|
|
160
|
+
pending.reject(
|
|
161
|
+
new Error(`tsserver ${pending.command} failed: ${message.message ?? "unknown error"}`)
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// ─── Protocol: Sending ──────────────────────────────────────────────────
|
|
168
|
+
sendRequest(command, args) {
|
|
169
|
+
if (!this.child?.stdin?.writable) {
|
|
170
|
+
return Promise.reject(new Error("tsserver not running"));
|
|
171
|
+
}
|
|
172
|
+
const seq = ++this.seq;
|
|
173
|
+
const request = {
|
|
174
|
+
seq,
|
|
175
|
+
type: "request",
|
|
176
|
+
command,
|
|
177
|
+
arguments: args
|
|
178
|
+
};
|
|
179
|
+
return new Promise((resolve5, reject) => {
|
|
180
|
+
const timer = setTimeout(() => {
|
|
181
|
+
this.pending.delete(seq);
|
|
182
|
+
reject(new Error(`tsserver ${command} timed out after ${REQUEST_TIMEOUT_MS}ms`));
|
|
183
|
+
}, REQUEST_TIMEOUT_MS);
|
|
184
|
+
this.pending.set(seq, { resolve: resolve5, reject, timer, command });
|
|
185
|
+
this.child.stdin.write(JSON.stringify(request) + "\n");
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
// Fire-and-forget — for commands like `open` that may not send a response
|
|
189
|
+
sendNotification(command, args) {
|
|
190
|
+
if (!this.child?.stdin?.writable) return;
|
|
191
|
+
const seq = ++this.seq;
|
|
192
|
+
const request = { seq, type: "request", command, arguments: args };
|
|
193
|
+
this.child.stdin.write(JSON.stringify(request) + "\n");
|
|
194
|
+
}
|
|
195
|
+
// ─── File Management ───────────────────────────────────────────────────
|
|
196
|
+
async ensureOpen(file) {
|
|
197
|
+
const absPath = this.resolvePath(file);
|
|
198
|
+
if (this.openFiles.has(absPath)) return;
|
|
199
|
+
this.openFiles.add(absPath);
|
|
200
|
+
this.sendNotification("open", { file: absPath });
|
|
201
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
202
|
+
}
|
|
203
|
+
// ─── Public API ────────────────────────────────────────────────────────
|
|
204
|
+
async definition(file, line, offset) {
|
|
205
|
+
const absPath = this.resolvePath(file);
|
|
206
|
+
await this.ensureOpen(absPath);
|
|
207
|
+
const body = await this.sendRequest("definition", {
|
|
208
|
+
file: absPath,
|
|
209
|
+
line,
|
|
210
|
+
offset
|
|
211
|
+
});
|
|
212
|
+
if (!body || !Array.isArray(body)) return [];
|
|
213
|
+
return body.map((d) => ({
|
|
214
|
+
...d,
|
|
215
|
+
file: this.relativePath(d.file)
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
async references(file, line, offset) {
|
|
219
|
+
const absPath = this.resolvePath(file);
|
|
220
|
+
await this.ensureOpen(absPath);
|
|
221
|
+
const body = await this.sendRequest("references", {
|
|
222
|
+
file: absPath,
|
|
223
|
+
line,
|
|
224
|
+
offset
|
|
225
|
+
});
|
|
226
|
+
if (!body?.refs) return [];
|
|
227
|
+
return body.refs.map((r) => ({
|
|
228
|
+
...r,
|
|
229
|
+
file: this.relativePath(r.file)
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
async quickinfo(file, line, offset) {
|
|
233
|
+
const absPath = this.resolvePath(file);
|
|
234
|
+
await this.ensureOpen(absPath);
|
|
235
|
+
try {
|
|
236
|
+
const body = await this.sendRequest("quickinfo", {
|
|
237
|
+
file: absPath,
|
|
238
|
+
line,
|
|
239
|
+
offset
|
|
240
|
+
});
|
|
241
|
+
return body ?? null;
|
|
242
|
+
} catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async navto(searchValue, maxResults = 10, file) {
|
|
247
|
+
if (file) await this.ensureOpen(file);
|
|
248
|
+
const args = {
|
|
249
|
+
searchValue,
|
|
250
|
+
maxResultCount: maxResults
|
|
251
|
+
};
|
|
252
|
+
if (file) args["file"] = this.resolvePath(file);
|
|
253
|
+
const body = await this.sendRequest("navto", args);
|
|
254
|
+
if (!body || !Array.isArray(body)) return [];
|
|
255
|
+
return body.map((item) => ({
|
|
256
|
+
...item,
|
|
257
|
+
file: this.relativePath(item.file)
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
async navbar(file) {
|
|
261
|
+
const absPath = this.resolvePath(file);
|
|
262
|
+
await this.ensureOpen(absPath);
|
|
263
|
+
const body = await this.sendRequest("navbar", {
|
|
264
|
+
file: absPath
|
|
265
|
+
});
|
|
266
|
+
return body ?? [];
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
// module-graph.ts
|
|
271
|
+
import { parseSync } from "oxc-parser";
|
|
272
|
+
import { ResolverFactory } from "oxc-resolver";
|
|
273
|
+
import * as fs2 from "fs";
|
|
274
|
+
import * as path2 from "path";
|
|
275
|
+
var log2 = (...args) => console.error("[typegraph/graph]", ...args);
|
|
276
|
+
var TS_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".mts", ".cts"]);
|
|
277
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
278
|
+
"node_modules",
|
|
279
|
+
"dist",
|
|
280
|
+
"build",
|
|
281
|
+
"out",
|
|
282
|
+
".wrangler",
|
|
283
|
+
".mf",
|
|
284
|
+
".git",
|
|
285
|
+
".next",
|
|
286
|
+
".turbo",
|
|
287
|
+
"coverage"
|
|
288
|
+
]);
|
|
289
|
+
var SKIP_FILES = /* @__PURE__ */ new Set(["routeTree.gen.ts"]);
|
|
290
|
+
function discoverFiles(rootDir) {
|
|
291
|
+
const files = [];
|
|
292
|
+
function walk(dir) {
|
|
293
|
+
let entries;
|
|
294
|
+
try {
|
|
295
|
+
entries = fs2.readdirSync(dir, { withFileTypes: true });
|
|
296
|
+
} catch {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
for (const entry of entries) {
|
|
300
|
+
if (entry.isDirectory()) {
|
|
301
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
302
|
+
if (entry.name.startsWith(".") && dir !== rootDir) continue;
|
|
303
|
+
walk(path2.join(dir, entry.name));
|
|
304
|
+
} else if (entry.isFile()) {
|
|
305
|
+
const name = entry.name;
|
|
306
|
+
if (SKIP_FILES.has(name)) continue;
|
|
307
|
+
if (name.endsWith(".d.ts") || name.endsWith(".d.mts") || name.endsWith(".d.cts")) continue;
|
|
308
|
+
const ext = path2.extname(name);
|
|
309
|
+
if (TS_EXTENSIONS.has(ext)) {
|
|
310
|
+
files.push(path2.join(dir, name));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
walk(rootDir);
|
|
316
|
+
return files;
|
|
317
|
+
}
|
|
318
|
+
function parseFileImports(filePath, source) {
|
|
319
|
+
const result = parseSync(filePath, source);
|
|
320
|
+
const imports = [];
|
|
321
|
+
for (const imp of result.module.staticImports) {
|
|
322
|
+
const specifier = imp.moduleRequest.value;
|
|
323
|
+
const names = [];
|
|
324
|
+
let allTypeOnly = true;
|
|
325
|
+
for (const entry of imp.entries) {
|
|
326
|
+
const kind = entry.importName.kind;
|
|
327
|
+
const name = kind === "Default" ? "default" : kind === "All" || kind === "AllButDefault" || kind === "NamespaceObject" ? "*" : entry.importName.name ?? entry.localName.value;
|
|
328
|
+
names.push(name);
|
|
329
|
+
if (!entry.isType) allTypeOnly = false;
|
|
330
|
+
}
|
|
331
|
+
if (names.length === 0) {
|
|
332
|
+
imports.push({ specifier, names: ["*"], isTypeOnly: false, isDynamic: false });
|
|
333
|
+
} else {
|
|
334
|
+
imports.push({ specifier, names, isTypeOnly: allTypeOnly, isDynamic: false });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
for (const exp of result.module.staticExports) {
|
|
338
|
+
for (const entry of exp.entries) {
|
|
339
|
+
const moduleRequest = entry.moduleRequest;
|
|
340
|
+
if (!moduleRequest) continue;
|
|
341
|
+
const specifier = moduleRequest.value;
|
|
342
|
+
const entryKind = entry.importName.kind;
|
|
343
|
+
const name = entryKind === "AllButDefault" || entryKind === "All" || entryKind === "NamespaceObject" ? "*" : entry.importName.name ?? "*";
|
|
344
|
+
const existing = imports.find((i) => i.specifier === specifier && !i.isDynamic);
|
|
345
|
+
if (existing) {
|
|
346
|
+
if (!existing.names.includes(name)) existing.names.push(name);
|
|
347
|
+
} else {
|
|
348
|
+
imports.push({ specifier, names: [name], isTypeOnly: false, isDynamic: false });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
for (const di of result.module.dynamicImports) {
|
|
353
|
+
if (di.moduleRequest) {
|
|
354
|
+
const sliced = source.slice(di.moduleRequest.start, di.moduleRequest.end);
|
|
355
|
+
if (sliced.startsWith("'") || sliced.startsWith('"')) {
|
|
356
|
+
const specifier = sliced.slice(1, -1);
|
|
357
|
+
imports.push({ specifier, names: ["*"], isTypeOnly: false, isDynamic: true });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return imports;
|
|
362
|
+
}
|
|
363
|
+
var SOURCE_EXTS = [".ts", ".tsx", ".mts", ".cts"];
|
|
364
|
+
function distToSource(resolvedPath, projectRoot) {
|
|
365
|
+
if (!resolvedPath.startsWith(projectRoot)) return resolvedPath;
|
|
366
|
+
const rel2 = path2.relative(projectRoot, resolvedPath);
|
|
367
|
+
const distIdx = rel2.indexOf("dist" + path2.sep);
|
|
368
|
+
if (distIdx === -1) return resolvedPath;
|
|
369
|
+
const prefix = rel2.slice(0, distIdx);
|
|
370
|
+
const afterDist = rel2.slice(distIdx + 5);
|
|
371
|
+
const withoutExt = afterDist.replace(/\.(m?j|c)s$/, "");
|
|
372
|
+
for (const ext of SOURCE_EXTS) {
|
|
373
|
+
const candidate = path2.resolve(projectRoot, prefix, "src", withoutExt + ext);
|
|
374
|
+
if (fs2.existsSync(candidate)) return candidate;
|
|
375
|
+
}
|
|
376
|
+
for (const ext of SOURCE_EXTS) {
|
|
377
|
+
const candidate = path2.resolve(projectRoot, prefix, withoutExt + ext);
|
|
378
|
+
if (fs2.existsSync(candidate)) return candidate;
|
|
379
|
+
}
|
|
380
|
+
if (withoutExt.endsWith("/index")) {
|
|
381
|
+
const dirPath = withoutExt.slice(0, -6);
|
|
382
|
+
for (const ext of SOURCE_EXTS) {
|
|
383
|
+
const candidate = path2.resolve(projectRoot, prefix, "src", dirPath + ext);
|
|
384
|
+
if (fs2.existsSync(candidate)) return candidate;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return resolvedPath;
|
|
388
|
+
}
|
|
389
|
+
function resolveImport(resolver, fromDir, specifier, projectRoot) {
|
|
390
|
+
try {
|
|
391
|
+
const result = resolver.sync(fromDir, specifier);
|
|
392
|
+
if (result.path && !result.path.includes("node_modules")) {
|
|
393
|
+
const mapped = distToSource(result.path, projectRoot);
|
|
394
|
+
const ext = path2.extname(mapped);
|
|
395
|
+
if (!TS_EXTENSIONS.has(ext)) return null;
|
|
396
|
+
if (SKIP_FILES.has(path2.basename(mapped))) return null;
|
|
397
|
+
return mapped;
|
|
398
|
+
}
|
|
399
|
+
} catch {
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
function createResolver(projectRoot, tsconfigPath) {
|
|
404
|
+
return new ResolverFactory({
|
|
405
|
+
tsconfig: {
|
|
406
|
+
configFile: path2.resolve(projectRoot, tsconfigPath),
|
|
407
|
+
references: "auto"
|
|
408
|
+
},
|
|
409
|
+
extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"],
|
|
410
|
+
extensionAlias: {
|
|
411
|
+
".js": [".ts", ".tsx", ".js"],
|
|
412
|
+
".jsx": [".tsx", ".jsx"],
|
|
413
|
+
".mjs": [".mts", ".mjs"],
|
|
414
|
+
".cjs": [".cts", ".cjs"]
|
|
415
|
+
},
|
|
416
|
+
conditionNames: ["import", "require"],
|
|
417
|
+
mainFields: ["module", "main"]
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
function buildForwardEdges(files, resolver, projectRoot) {
|
|
421
|
+
const forward = /* @__PURE__ */ new Map();
|
|
422
|
+
const parseFailures = [];
|
|
423
|
+
for (const filePath of files) {
|
|
424
|
+
let source;
|
|
425
|
+
try {
|
|
426
|
+
source = fs2.readFileSync(filePath, "utf-8");
|
|
427
|
+
} catch {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
let rawImports;
|
|
431
|
+
try {
|
|
432
|
+
rawImports = parseFileImports(filePath, source);
|
|
433
|
+
} catch (err) {
|
|
434
|
+
parseFailures.push(filePath);
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const edges = [];
|
|
438
|
+
const fromDir = path2.dirname(filePath);
|
|
439
|
+
for (const raw of rawImports) {
|
|
440
|
+
const target = resolveImport(resolver, fromDir, raw.specifier, projectRoot);
|
|
441
|
+
if (target) {
|
|
442
|
+
edges.push({
|
|
443
|
+
target,
|
|
444
|
+
specifiers: raw.names,
|
|
445
|
+
isTypeOnly: raw.isTypeOnly,
|
|
446
|
+
isDynamic: raw.isDynamic
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
forward.set(filePath, edges);
|
|
451
|
+
}
|
|
452
|
+
return { forward, parseFailures };
|
|
453
|
+
}
|
|
454
|
+
function buildReverseMap(forward) {
|
|
455
|
+
const reverse = /* @__PURE__ */ new Map();
|
|
456
|
+
for (const [source, edges] of forward) {
|
|
457
|
+
for (const edge of edges) {
|
|
458
|
+
let revEdges = reverse.get(edge.target);
|
|
459
|
+
if (!revEdges) {
|
|
460
|
+
revEdges = [];
|
|
461
|
+
reverse.set(edge.target, revEdges);
|
|
462
|
+
}
|
|
463
|
+
revEdges.push({
|
|
464
|
+
target: source,
|
|
465
|
+
// reverse: the "target" is the file that imports
|
|
466
|
+
specifiers: edge.specifiers,
|
|
467
|
+
isTypeOnly: edge.isTypeOnly,
|
|
468
|
+
isDynamic: edge.isDynamic
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return reverse;
|
|
473
|
+
}
|
|
474
|
+
async function buildGraph(projectRoot, tsconfigPath) {
|
|
475
|
+
const startTime = performance.now();
|
|
476
|
+
const resolver = createResolver(projectRoot, tsconfigPath);
|
|
477
|
+
const fileList = discoverFiles(projectRoot);
|
|
478
|
+
log2(`Discovered ${fileList.length} source files`);
|
|
479
|
+
const { forward, parseFailures } = buildForwardEdges(fileList, resolver, projectRoot);
|
|
480
|
+
const reverse = buildReverseMap(forward);
|
|
481
|
+
const files = new Set(fileList);
|
|
482
|
+
const edgeCount = [...forward.values()].reduce((sum, edges) => sum + edges.length, 0);
|
|
483
|
+
const elapsed = (performance.now() - startTime).toFixed(0);
|
|
484
|
+
log2(`Graph built: ${files.size} files, ${edgeCount} edges [${elapsed}ms]`);
|
|
485
|
+
if (parseFailures.length > 0) {
|
|
486
|
+
log2(`Parse failures: ${parseFailures.length} files`);
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
graph: { forward, reverse, files },
|
|
490
|
+
resolver
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// graph-queries.ts
|
|
495
|
+
import * as fs3 from "fs";
|
|
496
|
+
import * as path3 from "path";
|
|
497
|
+
function shouldIncludeEdge(edge, includeTypeOnly) {
|
|
498
|
+
if (!includeTypeOnly && edge.isTypeOnly) return false;
|
|
499
|
+
return true;
|
|
500
|
+
}
|
|
501
|
+
function dependencyTree(graph, file, opts = {}) {
|
|
502
|
+
const { depth = Infinity, includeTypeOnly = false } = opts;
|
|
503
|
+
const visited = /* @__PURE__ */ new Set();
|
|
504
|
+
const result = [];
|
|
505
|
+
let frontier = [file];
|
|
506
|
+
visited.add(file);
|
|
507
|
+
let currentDepth = 0;
|
|
508
|
+
while (frontier.length > 0 && currentDepth < depth) {
|
|
509
|
+
const nextFrontier = [];
|
|
510
|
+
for (const f of frontier) {
|
|
511
|
+
const edges = graph.forward.get(f) ?? [];
|
|
512
|
+
for (const edge of edges) {
|
|
513
|
+
if (!shouldIncludeEdge(edge, includeTypeOnly)) continue;
|
|
514
|
+
if (visited.has(edge.target)) continue;
|
|
515
|
+
visited.add(edge.target);
|
|
516
|
+
result.push(edge.target);
|
|
517
|
+
nextFrontier.push(edge.target);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
frontier = nextFrontier;
|
|
521
|
+
currentDepth++;
|
|
522
|
+
}
|
|
523
|
+
return { root: file, nodes: result.length, files: result };
|
|
524
|
+
}
|
|
525
|
+
var packageNameCache = /* @__PURE__ */ new Map();
|
|
526
|
+
function findPackageName(filePath) {
|
|
527
|
+
let dir = path3.dirname(filePath);
|
|
528
|
+
while (dir !== path3.dirname(dir)) {
|
|
529
|
+
if (packageNameCache.has(dir)) return packageNameCache.get(dir);
|
|
530
|
+
const pkgJsonPath = path3.join(dir, "package.json");
|
|
531
|
+
try {
|
|
532
|
+
if (fs3.existsSync(pkgJsonPath)) {
|
|
533
|
+
const pkg = JSON.parse(fs3.readFileSync(pkgJsonPath, "utf-8"));
|
|
534
|
+
const name = pkg.name ?? path3.basename(dir);
|
|
535
|
+
packageNameCache.set(dir, name);
|
|
536
|
+
return name;
|
|
537
|
+
}
|
|
538
|
+
} catch {
|
|
539
|
+
}
|
|
540
|
+
dir = path3.dirname(dir);
|
|
541
|
+
}
|
|
542
|
+
return "<root>";
|
|
543
|
+
}
|
|
544
|
+
function dependents(graph, file, opts = {}) {
|
|
545
|
+
const { depth = Infinity, includeTypeOnly = false } = opts;
|
|
546
|
+
const visited = /* @__PURE__ */ new Set();
|
|
547
|
+
const result = [];
|
|
548
|
+
let directCount = 0;
|
|
549
|
+
let frontier = [file];
|
|
550
|
+
visited.add(file);
|
|
551
|
+
let currentDepth = 0;
|
|
552
|
+
while (frontier.length > 0 && currentDepth < depth) {
|
|
553
|
+
const nextFrontier = [];
|
|
554
|
+
for (const f of frontier) {
|
|
555
|
+
const edges = graph.reverse.get(f) ?? [];
|
|
556
|
+
for (const edge of edges) {
|
|
557
|
+
if (!shouldIncludeEdge(edge, includeTypeOnly)) continue;
|
|
558
|
+
if (visited.has(edge.target)) continue;
|
|
559
|
+
visited.add(edge.target);
|
|
560
|
+
result.push(edge.target);
|
|
561
|
+
if (currentDepth === 0) directCount++;
|
|
562
|
+
nextFrontier.push(edge.target);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
frontier = nextFrontier;
|
|
566
|
+
currentDepth++;
|
|
567
|
+
}
|
|
568
|
+
const byPackage = {};
|
|
569
|
+
for (const f of result) {
|
|
570
|
+
const pkgName = findPackageName(f);
|
|
571
|
+
if (!byPackage[pkgName]) byPackage[pkgName] = [];
|
|
572
|
+
byPackage[pkgName].push(f);
|
|
573
|
+
}
|
|
574
|
+
return { root: file, nodes: result.length, directCount, files: result, byPackage };
|
|
575
|
+
}
|
|
576
|
+
function importCycles(graph, opts = {}) {
|
|
577
|
+
const { file, package: pkgDir } = opts;
|
|
578
|
+
let index = 0;
|
|
579
|
+
const stack = [];
|
|
580
|
+
const onStack = /* @__PURE__ */ new Set();
|
|
581
|
+
const indices = /* @__PURE__ */ new Map();
|
|
582
|
+
const lowlinks = /* @__PURE__ */ new Map();
|
|
583
|
+
const sccs = [];
|
|
584
|
+
function strongconnect(v) {
|
|
585
|
+
indices.set(v, index);
|
|
586
|
+
lowlinks.set(v, index);
|
|
587
|
+
index++;
|
|
588
|
+
stack.push(v);
|
|
589
|
+
onStack.add(v);
|
|
590
|
+
const edges = graph.forward.get(v) ?? [];
|
|
591
|
+
for (const edge of edges) {
|
|
592
|
+
const w = edge.target;
|
|
593
|
+
if (!graph.files.has(w)) continue;
|
|
594
|
+
if (!indices.has(w)) {
|
|
595
|
+
strongconnect(w);
|
|
596
|
+
lowlinks.set(v, Math.min(lowlinks.get(v), lowlinks.get(w)));
|
|
597
|
+
} else if (onStack.has(w)) {
|
|
598
|
+
lowlinks.set(v, Math.min(lowlinks.get(v), indices.get(w)));
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (lowlinks.get(v) === indices.get(v)) {
|
|
602
|
+
const scc = [];
|
|
603
|
+
let w;
|
|
604
|
+
do {
|
|
605
|
+
w = stack.pop();
|
|
606
|
+
onStack.delete(w);
|
|
607
|
+
scc.push(w);
|
|
608
|
+
} while (w !== v);
|
|
609
|
+
if (scc.length > 1) {
|
|
610
|
+
sccs.push(scc);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
for (const f of graph.files) {
|
|
615
|
+
if (!indices.has(f)) {
|
|
616
|
+
strongconnect(f);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
let cycles = sccs;
|
|
620
|
+
if (file) {
|
|
621
|
+
cycles = cycles.filter((scc) => scc.includes(file));
|
|
622
|
+
}
|
|
623
|
+
if (pkgDir) {
|
|
624
|
+
const absPkgDir = path3.resolve(pkgDir);
|
|
625
|
+
cycles = cycles.filter(
|
|
626
|
+
(scc) => scc.every((f) => f.startsWith(absPkgDir))
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
return { count: cycles.length, cycles };
|
|
630
|
+
}
|
|
631
|
+
function shortestPath(graph, from, to, opts = {}) {
|
|
632
|
+
const { includeTypeOnly = false } = opts;
|
|
633
|
+
if (from === to) {
|
|
634
|
+
return { path: [from], hops: 0, chain: [{ file: from, imports: [] }] };
|
|
635
|
+
}
|
|
636
|
+
const visited = /* @__PURE__ */ new Set();
|
|
637
|
+
const parent = /* @__PURE__ */ new Map();
|
|
638
|
+
visited.add(from);
|
|
639
|
+
let frontier = [from];
|
|
640
|
+
while (frontier.length > 0) {
|
|
641
|
+
const nextFrontier = [];
|
|
642
|
+
for (const f of frontier) {
|
|
643
|
+
const edges = graph.forward.get(f) ?? [];
|
|
644
|
+
for (const edge of edges) {
|
|
645
|
+
if (!shouldIncludeEdge(edge, includeTypeOnly)) continue;
|
|
646
|
+
if (visited.has(edge.target)) continue;
|
|
647
|
+
visited.add(edge.target);
|
|
648
|
+
parent.set(edge.target, { from: f, specifiers: edge.specifiers });
|
|
649
|
+
if (edge.target === to) {
|
|
650
|
+
const filePath = [to];
|
|
651
|
+
let current = to;
|
|
652
|
+
while (parent.has(current)) {
|
|
653
|
+
current = parent.get(current).from;
|
|
654
|
+
filePath.unshift(current);
|
|
655
|
+
}
|
|
656
|
+
const chain = [];
|
|
657
|
+
for (let i = 0; i < filePath.length; i++) {
|
|
658
|
+
const p = parent.get(filePath[i]);
|
|
659
|
+
chain.push({
|
|
660
|
+
file: filePath[i],
|
|
661
|
+
imports: p?.specifiers ?? []
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
return { path: filePath, hops: filePath.length - 1, chain };
|
|
665
|
+
}
|
|
666
|
+
nextFrontier.push(edge.target);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
frontier = nextFrontier;
|
|
670
|
+
}
|
|
671
|
+
return { path: null, hops: -1, chain: [] };
|
|
672
|
+
}
|
|
673
|
+
function subgraph(graph, files, opts = {}) {
|
|
674
|
+
const { depth = 1, direction = "both" } = opts;
|
|
675
|
+
const visited = new Set(files);
|
|
676
|
+
let frontier = [...files];
|
|
677
|
+
for (let d = 0; d < depth && frontier.length > 0; d++) {
|
|
678
|
+
const nextFrontier = [];
|
|
679
|
+
for (const f of frontier) {
|
|
680
|
+
if (direction === "imports" || direction === "both") {
|
|
681
|
+
for (const edge of graph.forward.get(f) ?? []) {
|
|
682
|
+
if (!visited.has(edge.target)) {
|
|
683
|
+
visited.add(edge.target);
|
|
684
|
+
nextFrontier.push(edge.target);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
if (direction === "dependents" || direction === "both") {
|
|
689
|
+
for (const edge of graph.reverse.get(f) ?? []) {
|
|
690
|
+
if (!visited.has(edge.target)) {
|
|
691
|
+
visited.add(edge.target);
|
|
692
|
+
nextFrontier.push(edge.target);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
frontier = nextFrontier;
|
|
698
|
+
}
|
|
699
|
+
const nodes = [...visited];
|
|
700
|
+
const edges = [];
|
|
701
|
+
for (const f of nodes) {
|
|
702
|
+
for (const edge of graph.forward.get(f) ?? []) {
|
|
703
|
+
if (visited.has(edge.target)) {
|
|
704
|
+
edges.push({
|
|
705
|
+
from: f,
|
|
706
|
+
to: edge.target,
|
|
707
|
+
specifiers: edge.specifiers,
|
|
708
|
+
isTypeOnly: edge.isTypeOnly
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return { nodes, edges, stats: { nodeCount: nodes.length, edgeCount: edges.length } };
|
|
714
|
+
}
|
|
715
|
+
function moduleBoundary(graph, files) {
|
|
716
|
+
const fileSet = new Set(files);
|
|
717
|
+
let internalEdges = 0;
|
|
718
|
+
const incomingEdges = [];
|
|
719
|
+
const outgoingEdges = [];
|
|
720
|
+
const outgoingTargets = /* @__PURE__ */ new Set();
|
|
721
|
+
for (const f of files) {
|
|
722
|
+
for (const edge of graph.forward.get(f) ?? []) {
|
|
723
|
+
if (fileSet.has(edge.target)) {
|
|
724
|
+
internalEdges++;
|
|
725
|
+
} else {
|
|
726
|
+
outgoingEdges.push({
|
|
727
|
+
from: f,
|
|
728
|
+
to: edge.target,
|
|
729
|
+
specifiers: edge.specifiers
|
|
730
|
+
});
|
|
731
|
+
outgoingTargets.add(edge.target);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
for (const f of files) {
|
|
736
|
+
for (const edge of graph.reverse.get(f) ?? []) {
|
|
737
|
+
if (!fileSet.has(edge.target)) {
|
|
738
|
+
incomingEdges.push({
|
|
739
|
+
from: edge.target,
|
|
740
|
+
to: f,
|
|
741
|
+
specifiers: edge.specifiers
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const depCounts = /* @__PURE__ */ new Map();
|
|
747
|
+
for (const f of files) {
|
|
748
|
+
for (const edge of graph.forward.get(f) ?? []) {
|
|
749
|
+
if (!fileSet.has(edge.target)) {
|
|
750
|
+
depCounts.set(edge.target, (depCounts.get(edge.target) ?? 0) + 1);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
const sharedDependencies = [...depCounts.entries()].filter(([, count]) => count > 1).map(([dep]) => dep);
|
|
755
|
+
const total = internalEdges + incomingEdges.length + outgoingEdges.length;
|
|
756
|
+
const isolationScore = total === 0 ? 1 : internalEdges / total;
|
|
757
|
+
return {
|
|
758
|
+
internalEdges,
|
|
759
|
+
incomingEdges,
|
|
760
|
+
outgoingEdges,
|
|
761
|
+
sharedDependencies,
|
|
762
|
+
isolationScore
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// config.ts
|
|
767
|
+
import * as path4 from "path";
|
|
768
|
+
function resolveConfig(toolDir) {
|
|
769
|
+
const cwd = process.cwd();
|
|
770
|
+
const projectRoot = process.env["TYPEGRAPH_PROJECT_ROOT"] ? path4.resolve(cwd, process.env["TYPEGRAPH_PROJECT_ROOT"]) : path4.basename(path4.dirname(toolDir)) === "plugins" ? path4.resolve(toolDir, "../..") : cwd;
|
|
771
|
+
const tsconfigPath = process.env["TYPEGRAPH_TSCONFIG"] || "./tsconfig.json";
|
|
772
|
+
const toolIsEmbedded = toolDir.startsWith(projectRoot + path4.sep);
|
|
773
|
+
const toolRelPath = toolIsEmbedded ? path4.relative(projectRoot, toolDir) : toolDir;
|
|
774
|
+
return { projectRoot, tsconfigPath, toolDir, toolIsEmbedded, toolRelPath };
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// smoke-test.ts
|
|
778
|
+
function rel(absPath, projectRoot) {
|
|
779
|
+
return path5.relative(projectRoot, absPath);
|
|
780
|
+
}
|
|
781
|
+
function findInNavBar(items, predicate) {
|
|
782
|
+
for (const item of items) {
|
|
783
|
+
if (predicate(item)) return item;
|
|
784
|
+
if (item.childItems?.length > 0) {
|
|
785
|
+
const found = findInNavBar(item.childItems, predicate);
|
|
786
|
+
if (found) return found;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
var SKIP_DIRS2 = /* @__PURE__ */ new Set([
|
|
792
|
+
"node_modules",
|
|
793
|
+
"dist",
|
|
794
|
+
"build",
|
|
795
|
+
".git",
|
|
796
|
+
".wrangler",
|
|
797
|
+
"coverage",
|
|
798
|
+
"out"
|
|
799
|
+
]);
|
|
800
|
+
function findTestFile(rootDir) {
|
|
801
|
+
const candidates = [];
|
|
802
|
+
function walk(dir, depth) {
|
|
803
|
+
if (depth > 5 || candidates.length >= 30) return;
|
|
804
|
+
let entries;
|
|
805
|
+
try {
|
|
806
|
+
entries = fs4.readdirSync(dir, { withFileTypes: true });
|
|
807
|
+
} catch {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
for (const entry of entries) {
|
|
811
|
+
if (entry.isDirectory()) {
|
|
812
|
+
if (SKIP_DIRS2.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
813
|
+
walk(path5.join(dir, entry.name), depth + 1);
|
|
814
|
+
} else if (entry.isFile()) {
|
|
815
|
+
const name = entry.name;
|
|
816
|
+
if (name.endsWith(".d.ts") || name.endsWith(".test.ts") || name.endsWith(".spec.ts"))
|
|
817
|
+
continue;
|
|
818
|
+
if (!name.endsWith(".ts") && !name.endsWith(".tsx")) continue;
|
|
819
|
+
try {
|
|
820
|
+
const stat = fs4.statSync(path5.join(dir, name));
|
|
821
|
+
if (stat.size > 200 && stat.size < 5e4) {
|
|
822
|
+
candidates.push({ file: path5.join(dir, name), size: stat.size });
|
|
823
|
+
}
|
|
824
|
+
} catch {
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
walk(rootDir, 0);
|
|
830
|
+
const preferred = candidates.find(
|
|
831
|
+
(c) => /service|handler|controller|repository|provider/i.test(path5.basename(c.file))
|
|
832
|
+
);
|
|
833
|
+
const fallback = candidates.sort((a, b) => b.size - a.size)[0];
|
|
834
|
+
return preferred?.file ?? fallback?.file ?? null;
|
|
835
|
+
}
|
|
836
|
+
function findImporter(graph, file) {
|
|
837
|
+
const revEdges = graph.reverse.get(file);
|
|
838
|
+
if (!revEdges || revEdges.length === 0) return null;
|
|
839
|
+
const preferred = revEdges.find(
|
|
840
|
+
(e) => !e.target.includes(".test.") && !e.target.endsWith("index.ts")
|
|
841
|
+
);
|
|
842
|
+
return (preferred ?? revEdges[0]).target;
|
|
843
|
+
}
|
|
844
|
+
async function main(configOverride) {
|
|
845
|
+
const { projectRoot, tsconfigPath } = configOverride ?? resolveConfig(import.meta.dirname);
|
|
846
|
+
let passed = 0;
|
|
847
|
+
let failed = 0;
|
|
848
|
+
let skipped = 0;
|
|
849
|
+
function pass(name, detail, ms) {
|
|
850
|
+
console.log(` \u2713 ${name} [${ms.toFixed(0)}ms]`);
|
|
851
|
+
console.log(` ${detail}`);
|
|
852
|
+
passed++;
|
|
853
|
+
}
|
|
854
|
+
function fail(name, detail, ms) {
|
|
855
|
+
console.log(` \u2717 ${name} [${ms.toFixed(0)}ms]`);
|
|
856
|
+
console.log(` ${detail}`);
|
|
857
|
+
failed++;
|
|
858
|
+
}
|
|
859
|
+
function skip(name, reason) {
|
|
860
|
+
console.log(` - ${name} (skipped: ${reason})`);
|
|
861
|
+
skipped++;
|
|
862
|
+
}
|
|
863
|
+
console.log("");
|
|
864
|
+
console.log("typegraph-mcp Smoke Test");
|
|
865
|
+
console.log("=====================");
|
|
866
|
+
console.log(`Project root: ${projectRoot}`);
|
|
867
|
+
console.log("");
|
|
868
|
+
const testFile = findTestFile(projectRoot);
|
|
869
|
+
if (!testFile) {
|
|
870
|
+
console.log(" No suitable .ts file found in project. Cannot run smoke tests.");
|
|
871
|
+
return { passed, failed: failed + 1, skipped };
|
|
872
|
+
}
|
|
873
|
+
const testFileRel = rel(testFile, projectRoot);
|
|
874
|
+
console.log(`Test subject: ${testFileRel}`);
|
|
875
|
+
console.log("");
|
|
876
|
+
console.log("\u2500\u2500 Module Graph \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
877
|
+
let graph;
|
|
878
|
+
let t0;
|
|
879
|
+
t0 = performance.now();
|
|
880
|
+
try {
|
|
881
|
+
const result = await buildGraph(projectRoot, tsconfigPath);
|
|
882
|
+
graph = result.graph;
|
|
883
|
+
const ms = performance.now() - t0;
|
|
884
|
+
const edgeCount = [...graph.forward.values()].reduce((s, e) => s + e.length, 0);
|
|
885
|
+
if (graph.files.size > 0) {
|
|
886
|
+
pass("graph build", `${graph.files.size} files, ${edgeCount} edges`, ms);
|
|
887
|
+
} else {
|
|
888
|
+
fail("graph build", "0 files discovered", ms);
|
|
889
|
+
console.log("\nCannot continue without module graph.");
|
|
890
|
+
return { passed, failed, skipped };
|
|
891
|
+
}
|
|
892
|
+
} catch (err) {
|
|
893
|
+
fail(
|
|
894
|
+
"graph build",
|
|
895
|
+
`Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
896
|
+
performance.now() - t0
|
|
897
|
+
);
|
|
898
|
+
console.log("\nCannot continue without module graph.");
|
|
899
|
+
return { passed, failed, skipped };
|
|
900
|
+
}
|
|
901
|
+
t0 = performance.now();
|
|
902
|
+
if (graph.files.has(testFile)) {
|
|
903
|
+
const result = dependencyTree(graph, testFile);
|
|
904
|
+
pass(
|
|
905
|
+
"dependency_tree",
|
|
906
|
+
`${result.nodes} transitive deps from ${testFileRel}`,
|
|
907
|
+
performance.now() - t0
|
|
908
|
+
);
|
|
909
|
+
} else {
|
|
910
|
+
skip("dependency_tree", `${testFileRel} not in graph`);
|
|
911
|
+
}
|
|
912
|
+
t0 = performance.now();
|
|
913
|
+
if (graph.files.has(testFile)) {
|
|
914
|
+
const result = dependents(graph, testFile);
|
|
915
|
+
pass(
|
|
916
|
+
"dependents",
|
|
917
|
+
`${result.nodes} dependents (${result.directCount} direct)`,
|
|
918
|
+
performance.now() - t0
|
|
919
|
+
);
|
|
920
|
+
} else {
|
|
921
|
+
skip("dependents", `${testFileRel} not in graph`);
|
|
922
|
+
}
|
|
923
|
+
t0 = performance.now();
|
|
924
|
+
const cycles = importCycles(graph);
|
|
925
|
+
pass("import_cycles", `${cycles.count} cycle(s) detected`, performance.now() - t0);
|
|
926
|
+
t0 = performance.now();
|
|
927
|
+
const importer = findImporter(graph, testFile);
|
|
928
|
+
if (importer && graph.files.has(testFile)) {
|
|
929
|
+
const result = shortestPath(graph, importer, testFile);
|
|
930
|
+
const ms = performance.now() - t0;
|
|
931
|
+
if (result.path) {
|
|
932
|
+
pass("shortest_path", `${result.hops} hops: ${result.path.map((p) => rel(p, projectRoot)).join(" -> ")}`, ms);
|
|
933
|
+
} else {
|
|
934
|
+
pass("shortest_path", `No path from ${rel(importer, projectRoot)} (may be type-only)`, ms);
|
|
935
|
+
}
|
|
936
|
+
} else {
|
|
937
|
+
skip("shortest_path", "No importer found for test file");
|
|
938
|
+
}
|
|
939
|
+
t0 = performance.now();
|
|
940
|
+
if (graph.files.has(testFile)) {
|
|
941
|
+
const result = subgraph(graph, [testFile], { depth: 1, direction: "both" });
|
|
942
|
+
pass(
|
|
943
|
+
"subgraph",
|
|
944
|
+
`${result.stats.nodeCount} nodes, ${result.stats.edgeCount} edges (depth 1)`,
|
|
945
|
+
performance.now() - t0
|
|
946
|
+
);
|
|
947
|
+
} else {
|
|
948
|
+
skip("subgraph", `${testFileRel} not in graph`);
|
|
949
|
+
}
|
|
950
|
+
t0 = performance.now();
|
|
951
|
+
const dir = path5.dirname(testFile);
|
|
952
|
+
const siblings = [...graph.files].filter((f) => path5.dirname(f) === dir);
|
|
953
|
+
if (siblings.length >= 2) {
|
|
954
|
+
const result = moduleBoundary(graph, siblings);
|
|
955
|
+
pass(
|
|
956
|
+
"module_boundary",
|
|
957
|
+
`${siblings.length} files in ${rel(dir, projectRoot)}/: ${result.internalEdges} internal, ${result.incomingEdges.length} in, ${result.outgoingEdges.length} out`,
|
|
958
|
+
performance.now() - t0
|
|
959
|
+
);
|
|
960
|
+
} else {
|
|
961
|
+
skip("module_boundary", `Only ${siblings.length} file(s) in ${rel(dir, projectRoot)}/`);
|
|
962
|
+
}
|
|
963
|
+
console.log("");
|
|
964
|
+
console.log("\u2500\u2500 tsserver \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
965
|
+
const client = new TsServerClient(projectRoot, tsconfigPath);
|
|
966
|
+
t0 = performance.now();
|
|
967
|
+
await client.start();
|
|
968
|
+
console.log(` (started in ${(performance.now() - t0).toFixed(0)}ms)`);
|
|
969
|
+
t0 = performance.now();
|
|
970
|
+
const bar = await client.navbar(testFileRel);
|
|
971
|
+
const navbarMs = performance.now() - t0;
|
|
972
|
+
const symbolKinds = /* @__PURE__ */ new Set([
|
|
973
|
+
"function",
|
|
974
|
+
"const",
|
|
975
|
+
"class",
|
|
976
|
+
"interface",
|
|
977
|
+
"type",
|
|
978
|
+
"enum",
|
|
979
|
+
"var",
|
|
980
|
+
"let",
|
|
981
|
+
"method"
|
|
982
|
+
]);
|
|
983
|
+
const allSymbols = [];
|
|
984
|
+
function collectSymbols(items) {
|
|
985
|
+
for (const item of items) {
|
|
986
|
+
if (symbolKinds.has(item.kind) && item.text !== "<function>" && item.spans.length > 0) {
|
|
987
|
+
allSymbols.push(item);
|
|
988
|
+
}
|
|
989
|
+
if (item.childItems?.length > 0) collectSymbols(item.childItems);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
collectSymbols(bar);
|
|
993
|
+
if (allSymbols.length > 0) {
|
|
994
|
+
pass("navbar", `${allSymbols.length} symbols in ${testFileRel}`, navbarMs);
|
|
995
|
+
} else {
|
|
996
|
+
fail("navbar", `No symbols found in ${testFileRel}`, navbarMs);
|
|
997
|
+
}
|
|
998
|
+
const concreteKinds = /* @__PURE__ */ new Set(["const", "function", "class", "var", "let", "enum"]);
|
|
999
|
+
const sym = allSymbols.find((s) => concreteKinds.has(s.kind)) ?? allSymbols[0];
|
|
1000
|
+
if (!sym) {
|
|
1001
|
+
const toolNames = [
|
|
1002
|
+
"find_symbol",
|
|
1003
|
+
"definition",
|
|
1004
|
+
"references",
|
|
1005
|
+
"type_info",
|
|
1006
|
+
"navigate_to",
|
|
1007
|
+
"blast_radius",
|
|
1008
|
+
"module_exports",
|
|
1009
|
+
"trace_chain"
|
|
1010
|
+
];
|
|
1011
|
+
for (const name of toolNames) skip(name, "No symbol discovered");
|
|
1012
|
+
} else {
|
|
1013
|
+
const span = sym.spans[0];
|
|
1014
|
+
t0 = performance.now();
|
|
1015
|
+
const found = findInNavBar(bar, (item) => item.text === sym.text && item.kind === sym.kind);
|
|
1016
|
+
if (found && found.spans.length > 0) {
|
|
1017
|
+
pass(
|
|
1018
|
+
"find_symbol",
|
|
1019
|
+
`${sym.text} [${sym.kind}] at line ${found.spans[0].start.line}`,
|
|
1020
|
+
performance.now() - t0
|
|
1021
|
+
);
|
|
1022
|
+
} else {
|
|
1023
|
+
fail("find_symbol", `Could not re-find ${sym.text}`, performance.now() - t0);
|
|
1024
|
+
}
|
|
1025
|
+
t0 = performance.now();
|
|
1026
|
+
const defs = await client.definition(testFileRel, span.start.line, span.start.offset);
|
|
1027
|
+
if (defs.length > 0) {
|
|
1028
|
+
const def = defs[0];
|
|
1029
|
+
pass("definition", `${sym.text} -> ${def.file}:${def.start.line}`, performance.now() - t0);
|
|
1030
|
+
} else {
|
|
1031
|
+
pass("definition", `${sym.text} is its own definition`, performance.now() - t0);
|
|
1032
|
+
}
|
|
1033
|
+
t0 = performance.now();
|
|
1034
|
+
const refs = await client.references(testFileRel, span.start.line, span.start.offset);
|
|
1035
|
+
const refFiles = new Set(refs.map((r) => r.file));
|
|
1036
|
+
pass(
|
|
1037
|
+
"references",
|
|
1038
|
+
`${refs.length} ref(s) across ${refFiles.size} file(s)`,
|
|
1039
|
+
performance.now() - t0
|
|
1040
|
+
);
|
|
1041
|
+
t0 = performance.now();
|
|
1042
|
+
const info = await client.quickinfo(testFileRel, span.start.line, span.start.offset);
|
|
1043
|
+
if (info) {
|
|
1044
|
+
const typeStr = info.displayString.length > 80 ? info.displayString.slice(0, 80) + "..." : info.displayString;
|
|
1045
|
+
pass("type_info", typeStr, performance.now() - t0);
|
|
1046
|
+
} else {
|
|
1047
|
+
fail("type_info", `No type info for ${sym.text}`, performance.now() - t0);
|
|
1048
|
+
}
|
|
1049
|
+
t0 = performance.now();
|
|
1050
|
+
const navItems = await client.navto(sym.text, 5);
|
|
1051
|
+
if (navItems.length > 0) {
|
|
1052
|
+
const files = new Set(navItems.map((i) => i.file));
|
|
1053
|
+
pass(
|
|
1054
|
+
"navigate_to",
|
|
1055
|
+
`${navItems.length} match(es) for "${sym.text}" in ${files.size} file(s)`,
|
|
1056
|
+
performance.now() - t0
|
|
1057
|
+
);
|
|
1058
|
+
} else {
|
|
1059
|
+
pass(
|
|
1060
|
+
"navigate_to",
|
|
1061
|
+
`"${sym.text}" not indexed by navto (expected for some kinds)`,
|
|
1062
|
+
performance.now() - t0
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
t0 = performance.now();
|
|
1066
|
+
const callers = refs.filter((r) => !r.isDefinition);
|
|
1067
|
+
const callerFiles = new Set(callers.map((r) => r.file));
|
|
1068
|
+
pass(
|
|
1069
|
+
"blast_radius",
|
|
1070
|
+
`${callers.length} usage(s) across ${callerFiles.size} file(s)`,
|
|
1071
|
+
performance.now() - t0
|
|
1072
|
+
);
|
|
1073
|
+
t0 = performance.now();
|
|
1074
|
+
const moduleItem = bar.find((item) => item.kind === "module");
|
|
1075
|
+
const topItems = moduleItem?.childItems ?? bar;
|
|
1076
|
+
const exportSymbols = topItems.filter((item) => symbolKinds.has(item.kind));
|
|
1077
|
+
pass("module_exports", `${exportSymbols.length} top-level symbol(s)`, performance.now() - t0);
|
|
1078
|
+
t0 = performance.now();
|
|
1079
|
+
const source = fs4.readFileSync(testFile, "utf-8");
|
|
1080
|
+
const importMatch = source.match(/^import\s+\{([^}]+)\}\s+from\s+["']([^"']+)["']/m);
|
|
1081
|
+
if (importMatch) {
|
|
1082
|
+
const firstName = importMatch[1].split(",")[0].replace(/^type\s+/, "").trim();
|
|
1083
|
+
const importSym = findInNavBar(bar, (item) => item.text === firstName);
|
|
1084
|
+
if (importSym && importSym.spans.length > 0) {
|
|
1085
|
+
const chain = [testFileRel];
|
|
1086
|
+
let cur = {
|
|
1087
|
+
file: testFileRel,
|
|
1088
|
+
line: importSym.spans[0].start.line,
|
|
1089
|
+
offset: importSym.spans[0].start.offset
|
|
1090
|
+
};
|
|
1091
|
+
for (let i = 0; i < 5; i++) {
|
|
1092
|
+
const hopDefs = await client.definition(cur.file, cur.line, cur.offset);
|
|
1093
|
+
if (hopDefs.length === 0) break;
|
|
1094
|
+
const hop = hopDefs[0];
|
|
1095
|
+
if (hop.file === cur.file && hop.start.line === cur.line) break;
|
|
1096
|
+
if (hop.file.includes("node_modules")) break;
|
|
1097
|
+
chain.push(`${hop.file}:${hop.start.line}`);
|
|
1098
|
+
cur = { file: hop.file, line: hop.start.line, offset: hop.start.offset };
|
|
1099
|
+
}
|
|
1100
|
+
if (chain.length > 1) {
|
|
1101
|
+
pass(
|
|
1102
|
+
"trace_chain",
|
|
1103
|
+
`${chain.length - 1} hop(s): ${chain.join(" -> ")}`,
|
|
1104
|
+
performance.now() - t0
|
|
1105
|
+
);
|
|
1106
|
+
} else {
|
|
1107
|
+
pass(
|
|
1108
|
+
"trace_chain",
|
|
1109
|
+
`"${firstName}" resolved in-file (0 external hops)`,
|
|
1110
|
+
performance.now() - t0
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
} else {
|
|
1114
|
+
pass(
|
|
1115
|
+
"trace_chain",
|
|
1116
|
+
`"${firstName}" not in navbar (may be type-only)`,
|
|
1117
|
+
performance.now() - t0
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
} else {
|
|
1121
|
+
skip("trace_chain", "No brace imports in test file");
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
client.shutdown();
|
|
1125
|
+
console.log("");
|
|
1126
|
+
const total = passed + failed;
|
|
1127
|
+
if (failed === 0) {
|
|
1128
|
+
console.log(
|
|
1129
|
+
`${passed}/${total} passed` + (skipped > 0 ? ` (${skipped} skipped)` : "") + " -- all tools working"
|
|
1130
|
+
);
|
|
1131
|
+
} else {
|
|
1132
|
+
console.log(
|
|
1133
|
+
`${passed}/${total} passed, ${failed} failed` + (skipped > 0 ? `, ${skipped} skipped` : "") + " -- some tools may not work correctly"
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
console.log("");
|
|
1137
|
+
return { passed, failed, skipped };
|
|
1138
|
+
}
|
|
1139
|
+
var isDirectRun = process.argv[1] && fs4.realpathSync(process.argv[1]) === fs4.realpathSync(new URL(import.meta.url).pathname);
|
|
1140
|
+
if (isDirectRun) {
|
|
1141
|
+
main().then((result) => process.exit(result.failed > 0 ? 1 : 0)).catch((err) => {
|
|
1142
|
+
console.error("Fatal:", err);
|
|
1143
|
+
process.exit(1);
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
export {
|
|
1147
|
+
main
|
|
1148
|
+
};
|