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/smoke-test.ts
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* typegraph-mcp Smoke Test — Verifies all 14 tools work against the target project.
|
|
4
|
+
*
|
|
5
|
+
* Dynamically discovers files and symbols from whatever project it's pointed at.
|
|
6
|
+
*
|
|
7
|
+
* Run from project root:
|
|
8
|
+
* npx tsx plugins/typegraph-mcp/smoke-test.ts
|
|
9
|
+
*
|
|
10
|
+
* Or pointing at a project:
|
|
11
|
+
* TYPEGRAPH_PROJECT_ROOT=/path/to/project npx tsx /path/to/typegraph-mcp/smoke-test.ts
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
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, type TypegraphConfig } from "./config.js";
|
|
27
|
+
|
|
28
|
+
// ─── Result Type ─────────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export interface SmokeTestResult {
|
|
31
|
+
passed: number;
|
|
32
|
+
failed: number;
|
|
33
|
+
skipped: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
function rel(absPath: string, projectRoot: string): string {
|
|
39
|
+
return path.relative(projectRoot, absPath);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Walk navbar tree to find a named symbol */
|
|
43
|
+
function findInNavBar(
|
|
44
|
+
items: NavBarItem[],
|
|
45
|
+
predicate: (item: NavBarItem) => boolean
|
|
46
|
+
): NavBarItem | null {
|
|
47
|
+
for (const item of items) {
|
|
48
|
+
if (predicate(item)) return item;
|
|
49
|
+
if (item.childItems?.length > 0) {
|
|
50
|
+
const found = findInNavBar(item.childItems, predicate);
|
|
51
|
+
if (found) return found;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const SKIP_DIRS = new Set([
|
|
58
|
+
"node_modules",
|
|
59
|
+
"dist",
|
|
60
|
+
"build",
|
|
61
|
+
".git",
|
|
62
|
+
".wrangler",
|
|
63
|
+
"coverage",
|
|
64
|
+
"out",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
/** Find a file with imports and exported symbols (good test candidate) */
|
|
68
|
+
function findTestFile(rootDir: string): string | null {
|
|
69
|
+
const candidates: Array<{ file: string; size: number }> = [];
|
|
70
|
+
|
|
71
|
+
function walk(dir: string, depth: number): void {
|
|
72
|
+
if (depth > 5 || candidates.length >= 30) return;
|
|
73
|
+
let entries: fs.Dirent[];
|
|
74
|
+
try {
|
|
75
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
76
|
+
} catch {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
if (entry.isDirectory()) {
|
|
81
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
82
|
+
walk(path.join(dir, entry.name), depth + 1);
|
|
83
|
+
} else if (entry.isFile()) {
|
|
84
|
+
const name = entry.name;
|
|
85
|
+
if (name.endsWith(".d.ts") || name.endsWith(".test.ts") || name.endsWith(".spec.ts"))
|
|
86
|
+
continue;
|
|
87
|
+
if (!name.endsWith(".ts") && !name.endsWith(".tsx")) continue;
|
|
88
|
+
try {
|
|
89
|
+
const stat = fs.statSync(path.join(dir, name));
|
|
90
|
+
if (stat.size > 200 && stat.size < 50000) {
|
|
91
|
+
candidates.push({ file: path.join(dir, name), size: stat.size });
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// skip
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
walk(rootDir, 0);
|
|
101
|
+
|
|
102
|
+
// Prefer files with rich names — they tend to have imports and exports
|
|
103
|
+
const preferred = candidates.find((c) =>
|
|
104
|
+
/service|handler|controller|repository|provider/i.test(path.basename(c.file))
|
|
105
|
+
);
|
|
106
|
+
const fallback = candidates.sort((a, b) => b.size - a.size)[0];
|
|
107
|
+
return preferred?.file ?? fallback?.file ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Find a file that imports the test file (for cross-file tests) */
|
|
111
|
+
function findImporter(graph: ModuleGraph, file: string): string | null {
|
|
112
|
+
const revEdges = graph.reverse.get(file);
|
|
113
|
+
if (!revEdges || revEdges.length === 0) return null;
|
|
114
|
+
const preferred = revEdges.find(
|
|
115
|
+
(e) => !e.target.includes(".test.") && !e.target.endsWith("index.ts")
|
|
116
|
+
);
|
|
117
|
+
return (preferred ?? revEdges[0])!.target;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestResult> {
|
|
123
|
+
const { projectRoot, tsconfigPath } =
|
|
124
|
+
configOverride ?? resolveConfig(import.meta.dirname);
|
|
125
|
+
|
|
126
|
+
let passed = 0;
|
|
127
|
+
let failed = 0;
|
|
128
|
+
let skipped = 0;
|
|
129
|
+
|
|
130
|
+
function pass(name: string, detail: string, ms: number): void {
|
|
131
|
+
console.log(` \u2713 ${name} [${ms.toFixed(0)}ms]`);
|
|
132
|
+
console.log(` ${detail}`);
|
|
133
|
+
passed++;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function fail(name: string, detail: string, ms: number): void {
|
|
137
|
+
console.log(` \u2717 ${name} [${ms.toFixed(0)}ms]`);
|
|
138
|
+
console.log(` ${detail}`);
|
|
139
|
+
failed++;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function skip(name: string, reason: string): void {
|
|
143
|
+
console.log(` - ${name} (skipped: ${reason})`);
|
|
144
|
+
skipped++;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log("");
|
|
148
|
+
console.log("typegraph-mcp Smoke Test");
|
|
149
|
+
console.log("=====================");
|
|
150
|
+
console.log(`Project root: ${projectRoot}`);
|
|
151
|
+
console.log("");
|
|
152
|
+
|
|
153
|
+
// ─── Discover a test file ───────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
const testFile = findTestFile(projectRoot);
|
|
156
|
+
if (!testFile) {
|
|
157
|
+
console.log(" No suitable .ts file found in project. Cannot run smoke tests.");
|
|
158
|
+
return { passed, failed: failed + 1, skipped };
|
|
159
|
+
}
|
|
160
|
+
const testFileRel = rel(testFile, projectRoot);
|
|
161
|
+
console.log(`Test subject: ${testFileRel}`);
|
|
162
|
+
console.log("");
|
|
163
|
+
|
|
164
|
+
// ─── Module Graph (6 tools) ─────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
console.log("── Module Graph ─────────────────────────────────────────────");
|
|
167
|
+
|
|
168
|
+
let graph: ModuleGraph;
|
|
169
|
+
let t0: number;
|
|
170
|
+
|
|
171
|
+
// Graph build
|
|
172
|
+
t0 = performance.now();
|
|
173
|
+
try {
|
|
174
|
+
const result = await buildGraph(projectRoot, tsconfigPath);
|
|
175
|
+
graph = result.graph;
|
|
176
|
+
const ms = performance.now() - t0;
|
|
177
|
+
const edgeCount = [...graph.forward.values()].reduce((s, e) => s + e.length, 0);
|
|
178
|
+
if (graph.files.size > 0) {
|
|
179
|
+
pass("graph build", `${graph.files.size} files, ${edgeCount} edges`, ms);
|
|
180
|
+
} else {
|
|
181
|
+
fail("graph build", "0 files discovered", ms);
|
|
182
|
+
console.log("\nCannot continue without module graph.");
|
|
183
|
+
return { passed, failed, skipped };
|
|
184
|
+
}
|
|
185
|
+
} catch (err) {
|
|
186
|
+
fail(
|
|
187
|
+
"graph build",
|
|
188
|
+
`Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
189
|
+
performance.now() - t0
|
|
190
|
+
);
|
|
191
|
+
console.log("\nCannot continue without module graph.");
|
|
192
|
+
return { passed, failed, skipped };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// dependency_tree
|
|
196
|
+
t0 = performance.now();
|
|
197
|
+
if (graph.files.has(testFile)) {
|
|
198
|
+
const result = dependencyTree(graph, testFile);
|
|
199
|
+
pass(
|
|
200
|
+
"dependency_tree",
|
|
201
|
+
`${result.nodes} transitive deps from ${testFileRel}`,
|
|
202
|
+
performance.now() - t0
|
|
203
|
+
);
|
|
204
|
+
} else {
|
|
205
|
+
skip("dependency_tree", `${testFileRel} not in graph`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// dependents
|
|
209
|
+
t0 = performance.now();
|
|
210
|
+
if (graph.files.has(testFile)) {
|
|
211
|
+
const result = dependents(graph, testFile);
|
|
212
|
+
pass(
|
|
213
|
+
"dependents",
|
|
214
|
+
`${result.nodes} dependents (${result.directCount} direct)`,
|
|
215
|
+
performance.now() - t0
|
|
216
|
+
);
|
|
217
|
+
} else {
|
|
218
|
+
skip("dependents", `${testFileRel} not in graph`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// import_cycles
|
|
222
|
+
t0 = performance.now();
|
|
223
|
+
const cycles = importCycles(graph);
|
|
224
|
+
pass("import_cycles", `${cycles.count} cycle(s) detected`, performance.now() - t0);
|
|
225
|
+
|
|
226
|
+
// shortest_path
|
|
227
|
+
t0 = performance.now();
|
|
228
|
+
const importer = findImporter(graph, testFile);
|
|
229
|
+
if (importer && graph.files.has(testFile)) {
|
|
230
|
+
const result = shortestPath(graph, importer, testFile);
|
|
231
|
+
const ms = performance.now() - t0;
|
|
232
|
+
if (result.path) {
|
|
233
|
+
pass("shortest_path", `${result.hops} hops: ${result.path.map((p) => rel(p, projectRoot)).join(" -> ")}`, ms);
|
|
234
|
+
} else {
|
|
235
|
+
pass("shortest_path", `No path from ${rel(importer, projectRoot)} (may be type-only)`, ms);
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
skip("shortest_path", "No importer found for test file");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// subgraph
|
|
242
|
+
t0 = performance.now();
|
|
243
|
+
if (graph.files.has(testFile)) {
|
|
244
|
+
const result = subgraph(graph, [testFile], { depth: 1, direction: "both" });
|
|
245
|
+
pass(
|
|
246
|
+
"subgraph",
|
|
247
|
+
`${result.stats.nodeCount} nodes, ${result.stats.edgeCount} edges (depth 1)`,
|
|
248
|
+
performance.now() - t0
|
|
249
|
+
);
|
|
250
|
+
} else {
|
|
251
|
+
skip("subgraph", `${testFileRel} not in graph`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// module_boundary
|
|
255
|
+
t0 = performance.now();
|
|
256
|
+
const dir = path.dirname(testFile);
|
|
257
|
+
const siblings = [...graph.files].filter((f) => path.dirname(f) === dir);
|
|
258
|
+
if (siblings.length >= 2) {
|
|
259
|
+
const result = moduleBoundary(graph, siblings);
|
|
260
|
+
pass(
|
|
261
|
+
"module_boundary",
|
|
262
|
+
`${siblings.length} files in ${rel(dir, projectRoot)}/: ${result.internalEdges} internal, ${result.incomingEdges.length} in, ${result.outgoingEdges.length} out`,
|
|
263
|
+
performance.now() - t0
|
|
264
|
+
);
|
|
265
|
+
} else {
|
|
266
|
+
skip("module_boundary", `Only ${siblings.length} file(s) in ${rel(dir, projectRoot)}/`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── tsserver (8 tools) ─────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
console.log("");
|
|
272
|
+
console.log("── tsserver ─────────────────────────────────────────────────");
|
|
273
|
+
|
|
274
|
+
const client = new TsServerClient(projectRoot, tsconfigPath);
|
|
275
|
+
t0 = performance.now();
|
|
276
|
+
await client.start();
|
|
277
|
+
console.log(` (started in ${(performance.now() - t0).toFixed(0)}ms)`);
|
|
278
|
+
|
|
279
|
+
// navbar — discover symbols
|
|
280
|
+
t0 = performance.now();
|
|
281
|
+
const bar = await client.navbar(testFileRel);
|
|
282
|
+
const navbarMs = performance.now() - t0;
|
|
283
|
+
|
|
284
|
+
const symbolKinds = new Set([
|
|
285
|
+
"function",
|
|
286
|
+
"const",
|
|
287
|
+
"class",
|
|
288
|
+
"interface",
|
|
289
|
+
"type",
|
|
290
|
+
"enum",
|
|
291
|
+
"var",
|
|
292
|
+
"let",
|
|
293
|
+
"method",
|
|
294
|
+
]);
|
|
295
|
+
const allSymbols: NavBarItem[] = [];
|
|
296
|
+
function collectSymbols(items: NavBarItem[]): void {
|
|
297
|
+
for (const item of items) {
|
|
298
|
+
if (symbolKinds.has(item.kind) && item.text !== "<function>" && item.spans.length > 0) {
|
|
299
|
+
allSymbols.push(item);
|
|
300
|
+
}
|
|
301
|
+
if (item.childItems?.length > 0) collectSymbols(item.childItems);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
collectSymbols(bar);
|
|
305
|
+
|
|
306
|
+
if (allSymbols.length > 0) {
|
|
307
|
+
pass("navbar", `${allSymbols.length} symbols in ${testFileRel}`, navbarMs);
|
|
308
|
+
} else {
|
|
309
|
+
fail("navbar", `No symbols found in ${testFileRel}`, navbarMs);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Prefer concrete symbols (const, function, class) — interfaces/types may not
|
|
313
|
+
// return quickinfo at their span start position (points to the keyword, not the name)
|
|
314
|
+
const concreteKinds = new Set(["const", "function", "class", "var", "let", "enum"]);
|
|
315
|
+
const sym = allSymbols.find((s) => concreteKinds.has(s.kind)) ?? allSymbols[0];
|
|
316
|
+
if (!sym) {
|
|
317
|
+
const toolNames = [
|
|
318
|
+
"find_symbol",
|
|
319
|
+
"definition",
|
|
320
|
+
"references",
|
|
321
|
+
"type_info",
|
|
322
|
+
"navigate_to",
|
|
323
|
+
"blast_radius",
|
|
324
|
+
"module_exports",
|
|
325
|
+
"trace_chain",
|
|
326
|
+
];
|
|
327
|
+
for (const name of toolNames) skip(name, "No symbol discovered");
|
|
328
|
+
} else {
|
|
329
|
+
const span = sym.spans[0]!;
|
|
330
|
+
|
|
331
|
+
// find_symbol
|
|
332
|
+
t0 = performance.now();
|
|
333
|
+
const found = findInNavBar(bar, (item) => item.text === sym.text && item.kind === sym.kind);
|
|
334
|
+
if (found && found.spans.length > 0) {
|
|
335
|
+
pass(
|
|
336
|
+
"find_symbol",
|
|
337
|
+
`${sym.text} [${sym.kind}] at line ${found.spans[0]!.start.line}`,
|
|
338
|
+
performance.now() - t0
|
|
339
|
+
);
|
|
340
|
+
} else {
|
|
341
|
+
fail("find_symbol", `Could not re-find ${sym.text}`, performance.now() - t0);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// definition
|
|
345
|
+
t0 = performance.now();
|
|
346
|
+
const defs = await client.definition(testFileRel, span.start.line, span.start.offset);
|
|
347
|
+
if (defs.length > 0) {
|
|
348
|
+
const def = defs[0]!;
|
|
349
|
+
pass("definition", `${sym.text} -> ${def.file}:${def.start.line}`, performance.now() - t0);
|
|
350
|
+
} else {
|
|
351
|
+
pass("definition", `${sym.text} is its own definition`, performance.now() - t0);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// references
|
|
355
|
+
t0 = performance.now();
|
|
356
|
+
const refs = await client.references(testFileRel, span.start.line, span.start.offset);
|
|
357
|
+
const refFiles = new Set(refs.map((r) => r.file));
|
|
358
|
+
pass(
|
|
359
|
+
"references",
|
|
360
|
+
`${refs.length} ref(s) across ${refFiles.size} file(s)`,
|
|
361
|
+
performance.now() - t0
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
// type_info
|
|
365
|
+
t0 = performance.now();
|
|
366
|
+
const info = await client.quickinfo(testFileRel, span.start.line, span.start.offset);
|
|
367
|
+
if (info) {
|
|
368
|
+
const typeStr =
|
|
369
|
+
info.displayString.length > 80
|
|
370
|
+
? info.displayString.slice(0, 80) + "..."
|
|
371
|
+
: info.displayString;
|
|
372
|
+
pass("type_info", typeStr, performance.now() - t0);
|
|
373
|
+
} else {
|
|
374
|
+
fail("type_info", `No type info for ${sym.text}`, performance.now() - t0);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// navigate_to
|
|
378
|
+
t0 = performance.now();
|
|
379
|
+
const navItems = await client.navto(sym.text, 5);
|
|
380
|
+
if (navItems.length > 0) {
|
|
381
|
+
const files = new Set(navItems.map((i) => i.file));
|
|
382
|
+
pass(
|
|
383
|
+
"navigate_to",
|
|
384
|
+
`${navItems.length} match(es) for "${sym.text}" in ${files.size} file(s)`,
|
|
385
|
+
performance.now() - t0
|
|
386
|
+
);
|
|
387
|
+
} else {
|
|
388
|
+
pass(
|
|
389
|
+
"navigate_to",
|
|
390
|
+
`"${sym.text}" not indexed by navto (expected for some kinds)`,
|
|
391
|
+
performance.now() - t0
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// blast_radius
|
|
396
|
+
t0 = performance.now();
|
|
397
|
+
const callers = refs.filter((r) => !r.isDefinition);
|
|
398
|
+
const callerFiles = new Set(callers.map((r) => r.file));
|
|
399
|
+
pass(
|
|
400
|
+
"blast_radius",
|
|
401
|
+
`${callers.length} usage(s) across ${callerFiles.size} file(s)`,
|
|
402
|
+
performance.now() - t0
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
// module_exports
|
|
406
|
+
t0 = performance.now();
|
|
407
|
+
const moduleItem = bar.find((item) => item.kind === "module");
|
|
408
|
+
const topItems = moduleItem?.childItems ?? bar;
|
|
409
|
+
const exportSymbols = topItems.filter((item) => symbolKinds.has(item.kind));
|
|
410
|
+
pass("module_exports", `${exportSymbols.length} top-level symbol(s)`, performance.now() - t0);
|
|
411
|
+
|
|
412
|
+
// trace_chain — follow an import to its source
|
|
413
|
+
t0 = performance.now();
|
|
414
|
+
const source = fs.readFileSync(testFile, "utf-8");
|
|
415
|
+
const importMatch = source.match(/^import\s+\{([^}]+)\}\s+from\s+["']([^"']+)["']/m);
|
|
416
|
+
if (importMatch) {
|
|
417
|
+
const firstName = importMatch[1]!
|
|
418
|
+
.split(",")[0]!
|
|
419
|
+
.replace(/^type\s+/, "")
|
|
420
|
+
.trim();
|
|
421
|
+
const importSym = findInNavBar(bar, (item) => item.text === firstName);
|
|
422
|
+
if (importSym && importSym.spans.length > 0) {
|
|
423
|
+
const chain: string[] = [testFileRel];
|
|
424
|
+
let cur = {
|
|
425
|
+
file: testFileRel,
|
|
426
|
+
line: importSym.spans[0]!.start.line,
|
|
427
|
+
offset: importSym.spans[0]!.start.offset,
|
|
428
|
+
};
|
|
429
|
+
for (let i = 0; i < 5; i++) {
|
|
430
|
+
const hopDefs = await client.definition(cur.file, cur.line, cur.offset);
|
|
431
|
+
if (hopDefs.length === 0) break;
|
|
432
|
+
const hop = hopDefs[0]!;
|
|
433
|
+
if (hop.file === cur.file && hop.start.line === cur.line) break;
|
|
434
|
+
if (hop.file.includes("node_modules")) break;
|
|
435
|
+
chain.push(`${hop.file}:${hop.start.line}`);
|
|
436
|
+
cur = { file: hop.file, line: hop.start.line, offset: hop.start.offset };
|
|
437
|
+
}
|
|
438
|
+
if (chain.length > 1) {
|
|
439
|
+
pass(
|
|
440
|
+
"trace_chain",
|
|
441
|
+
`${chain.length - 1} hop(s): ${chain.join(" -> ")}`,
|
|
442
|
+
performance.now() - t0
|
|
443
|
+
);
|
|
444
|
+
} else {
|
|
445
|
+
pass(
|
|
446
|
+
"trace_chain",
|
|
447
|
+
`"${firstName}" resolved in-file (0 external hops)`,
|
|
448
|
+
performance.now() - t0
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
} else {
|
|
452
|
+
pass(
|
|
453
|
+
"trace_chain",
|
|
454
|
+
`"${firstName}" not in navbar (may be type-only)`,
|
|
455
|
+
performance.now() - t0
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
} else {
|
|
459
|
+
skip("trace_chain", "No brace imports in test file");
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
client.shutdown();
|
|
464
|
+
|
|
465
|
+
// ─── Summary ────────────────────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
console.log("");
|
|
468
|
+
const total = passed + failed;
|
|
469
|
+
if (failed === 0) {
|
|
470
|
+
console.log(
|
|
471
|
+
`${passed}/${total} passed` +
|
|
472
|
+
(skipped > 0 ? ` (${skipped} skipped)` : "") +
|
|
473
|
+
" -- all tools working"
|
|
474
|
+
);
|
|
475
|
+
} else {
|
|
476
|
+
console.log(
|
|
477
|
+
`${passed}/${total} passed, ${failed} failed` +
|
|
478
|
+
(skipped > 0 ? `, ${skipped} skipped` : "") +
|
|
479
|
+
" -- some tools may not work correctly"
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
console.log("");
|
|
483
|
+
|
|
484
|
+
return { passed, failed, skipped };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ─── Self-run guard ──────────────────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
const isDirectRun =
|
|
490
|
+
process.argv[1] &&
|
|
491
|
+
fs.realpathSync(process.argv[1]) === fs.realpathSync(new URL(import.meta.url).pathname);
|
|
492
|
+
|
|
493
|
+
if (isDirectRun) {
|
|
494
|
+
main()
|
|
495
|
+
.then((result) => process.exit(result.failed > 0 ? 1 : 0))
|
|
496
|
+
.catch((err) => {
|
|
497
|
+
console.error("Fatal:", err);
|
|
498
|
+
process.exit(1);
|
|
499
|
+
});
|
|
500
|
+
}
|