typegraph-mcp 0.9.45 → 0.9.47

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/cli.ts CHANGED
@@ -815,6 +815,24 @@ function patchEslintConfig(raw: string): string | null {
815
815
  return raw.replace(exportArrayRe, (match) => `${match} { ignores: ["plugins/**"] },\n`);
816
816
  }
817
817
 
818
+ // Matches common flat-config shape:
819
+ // const config = [
820
+ // ...
821
+ // ];
822
+ // export default config;
823
+ const variableArrayRe = /((?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*\[)\s*\n?/g;
824
+ for (const variableArrayMatch of raw.matchAll(variableArrayRe)) {
825
+ const [, open, variableName] = variableArrayMatch;
826
+ const escapedName = variableName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
827
+ const exportDefaultRe = new RegExp(`export\\s+default\\s+${escapedName}\\s*;?`);
828
+
829
+ if (exportDefaultRe.test(raw)) {
830
+ return `${raw.slice(0, variableArrayMatch.index)}${open}\n { ignores: ["plugins/**"] },\n${raw.slice(
831
+ (variableArrayMatch.index ?? 0) + variableArrayMatch[0].length
832
+ )}`;
833
+ }
834
+ }
835
+
818
836
  return null;
819
837
  }
820
838
 
package/dist/cli.js CHANGED
@@ -1518,7 +1518,8 @@ var init_graph_queries = __esm({
1518
1518
  // smoke-test.ts
1519
1519
  var smoke_test_exports = {};
1520
1520
  __export(smoke_test_exports, {
1521
- main: () => main2
1521
+ main: () => main2,
1522
+ selectQuickInfoSymbol: () => selectQuickInfoSymbol
1522
1523
  });
1523
1524
  import * as fs5 from "fs";
1524
1525
  import * as path6 from "path";
@@ -1535,6 +1536,47 @@ function findInNavBar(items, predicate) {
1535
1536
  }
1536
1537
  return null;
1537
1538
  }
1539
+ function symbolPositions(symbol, sourceLines) {
1540
+ const span = symbol.spans[0];
1541
+ const positions = [];
1542
+ const lineText = sourceLines[span.start.line - 1];
1543
+ if (lineText) {
1544
+ const nameIndex = lineText.indexOf(symbol.text, Math.max(0, span.start.offset - 1));
1545
+ if (nameIndex >= 0) {
1546
+ positions.push({ line: span.start.line, offset: nameIndex + 1 });
1547
+ }
1548
+ }
1549
+ if (!positions.some((position) => position.offset === span.start.offset)) {
1550
+ positions.push(span.start);
1551
+ }
1552
+ return positions;
1553
+ }
1554
+ async function probeQuickInfo(client2, file, symbol, sourceLines) {
1555
+ for (const position of symbolPositions(symbol, sourceLines)) {
1556
+ const info = await client2.quickinfo(file, position.line, position.offset);
1557
+ if (info) return { info, position };
1558
+ }
1559
+ return null;
1560
+ }
1561
+ async function selectQuickInfoSymbol(client2, file, symbols, sourceLines) {
1562
+ const kindPriority = /* @__PURE__ */ new Map([
1563
+ ["const", 0],
1564
+ ["let", 1],
1565
+ ["var", 2],
1566
+ ["class", 3],
1567
+ ["enum", 4],
1568
+ ["function", 5],
1569
+ ["method", 6]
1570
+ ]);
1571
+ const concreteSymbols = symbols.map((symbol, index) => ({ symbol, index })).filter(({ symbol }) => kindPriority.has(symbol.kind)).sort(
1572
+ (a, b) => kindPriority.get(a.symbol.kind) - kindPriority.get(b.symbol.kind) || a.index - b.index
1573
+ ).map(({ symbol }) => symbol);
1574
+ for (const symbol of concreteSymbols) {
1575
+ const probe = await probeQuickInfo(client2, file, symbol, sourceLines);
1576
+ if (probe) return { symbol, ...probe };
1577
+ }
1578
+ return null;
1579
+ }
1538
1580
  function findTestFile(rootDir) {
1539
1581
  const candidates = [];
1540
1582
  function walk(dir, depth) {
@@ -1733,8 +1775,17 @@ async function main2(configOverride) {
1733
1775
  } else {
1734
1776
  fail("navbar", `No symbols found in ${testFileRel}`, navbarMs);
1735
1777
  }
1736
- const concreteKinds = /* @__PURE__ */ new Set(["const", "function", "class", "var", "let", "enum"]);
1737
- const sym = allSymbols.find((s) => concreteKinds.has(s.kind)) ?? allSymbols[0];
1778
+ const source = fs5.readFileSync(testFile, "utf-8");
1779
+ const sourceLines = source.split(/\r?\n/);
1780
+ const quickInfoSelection = await selectQuickInfoSymbol(
1781
+ client2,
1782
+ testFileRel,
1783
+ allSymbols,
1784
+ sourceLines
1785
+ );
1786
+ const sym = quickInfoSelection?.symbol ?? allSymbols[0];
1787
+ const selectedQuickInfo = quickInfoSelection?.info ?? null;
1788
+ const selectedPosition = quickInfoSelection?.position ?? sym?.spans[0]?.start;
1738
1789
  if (!sym) {
1739
1790
  const toolNames = [
1740
1791
  "find_symbol",
@@ -1749,6 +1800,7 @@ async function main2(configOverride) {
1749
1800
  for (const name of toolNames) skip(name, "No symbol discovered");
1750
1801
  } else {
1751
1802
  const span = sym.spans[0];
1803
+ const point = selectedPosition ?? span.start;
1752
1804
  t0 = performance.now();
1753
1805
  const found = findInNavBar(bar, (item) => item.text === sym.text && item.kind === sym.kind);
1754
1806
  if (found && found.spans.length > 0) {
@@ -1761,7 +1813,7 @@ async function main2(configOverride) {
1761
1813
  fail("find_symbol", `Could not re-find ${sym.text}`, performance.now() - t0);
1762
1814
  }
1763
1815
  t0 = performance.now();
1764
- const defs = await client2.definition(testFileRel, span.start.line, span.start.offset);
1816
+ const defs = await client2.definition(testFileRel, point.line, point.offset);
1765
1817
  if (defs.length > 0) {
1766
1818
  const def = defs[0];
1767
1819
  pass("definition", `${sym.text} -> ${def.file}:${def.start.line}`, performance.now() - t0);
@@ -1769,7 +1821,7 @@ async function main2(configOverride) {
1769
1821
  pass("definition", `${sym.text} is its own definition`, performance.now() - t0);
1770
1822
  }
1771
1823
  t0 = performance.now();
1772
- const refs = await client2.references(testFileRel, span.start.line, span.start.offset);
1824
+ const refs = await client2.references(testFileRel, point.line, point.offset);
1773
1825
  const refFiles = new Set(refs.map((r) => r.file));
1774
1826
  pass(
1775
1827
  "references",
@@ -1777,7 +1829,10 @@ async function main2(configOverride) {
1777
1829
  performance.now() - t0
1778
1830
  );
1779
1831
  t0 = performance.now();
1780
- let info = await client2.quickinfo(testFileRel, span.start.line, span.start.offset);
1832
+ let info = selectedQuickInfo;
1833
+ if (!info) {
1834
+ info = await client2.quickinfo(testFileRel, point.line, point.offset);
1835
+ }
1781
1836
  if (!info && defs.length > 0) {
1782
1837
  const def = defs[0];
1783
1838
  info = await client2.quickinfo(testFileRel, def.start.line, def.start.offset);
@@ -1786,7 +1841,7 @@ async function main2(configOverride) {
1786
1841
  const typeStr = info.displayString.length > 80 ? info.displayString.slice(0, 80) + "..." : info.displayString;
1787
1842
  pass("type_info", typeStr, performance.now() - t0);
1788
1843
  } else {
1789
- fail("type_info", `No type info for ${sym.text}`, performance.now() - t0);
1844
+ skip("type_info", `No discovered symbol returned type info`);
1790
1845
  }
1791
1846
  t0 = performance.now();
1792
1847
  const navItems = await client2.navto(sym.text, 5);
@@ -1818,7 +1873,6 @@ async function main2(configOverride) {
1818
1873
  const exportSymbols = topItems.filter((item) => symbolKinds.has(item.kind));
1819
1874
  pass("module_exports", `${exportSymbols.length} top-level symbol(s)`, performance.now() - t0);
1820
1875
  t0 = performance.now();
1821
- const source = fs5.readFileSync(testFile, "utf-8");
1822
1876
  const importMatch = source.match(/^import\s+\{([^}]+)\}\s+from\s+["']([^"']+)["']/m);
1823
1877
  if (importMatch) {
1824
1878
  const firstName = importMatch[1].split(",")[0].replace(/^type\s+/, "").trim();
@@ -3874,6 +3928,19 @@ function patchEslintConfig(raw) {
3874
3928
  return raw.replace(exportArrayRe, (match) => `${match} { ignores: ["plugins/**"] },
3875
3929
  `);
3876
3930
  }
3931
+ const variableArrayRe = /((?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*\[)\s*\n?/g;
3932
+ for (const variableArrayMatch of raw.matchAll(variableArrayRe)) {
3933
+ const [, open, variableName] = variableArrayMatch;
3934
+ const escapedName = variableName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3935
+ const exportDefaultRe = new RegExp(`export\\s+default\\s+${escapedName}\\s*;?`);
3936
+ if (exportDefaultRe.test(raw)) {
3937
+ return `${raw.slice(0, variableArrayMatch.index)}${open}
3938
+ { ignores: ["plugins/**"] },
3939
+ ${raw.slice(
3940
+ (variableArrayMatch.index ?? 0) + variableArrayMatch[0].length
3941
+ )}`;
3942
+ }
3943
+ }
3877
3944
  return null;
3878
3945
  }
3879
3946
  function patchOxlintJsonConfig(raw) {
@@ -827,6 +827,47 @@ function findInNavBar(items, predicate) {
827
827
  }
828
828
  return null;
829
829
  }
830
+ function symbolPositions(symbol, sourceLines) {
831
+ const span = symbol.spans[0];
832
+ const positions = [];
833
+ const lineText = sourceLines[span.start.line - 1];
834
+ if (lineText) {
835
+ const nameIndex = lineText.indexOf(symbol.text, Math.max(0, span.start.offset - 1));
836
+ if (nameIndex >= 0) {
837
+ positions.push({ line: span.start.line, offset: nameIndex + 1 });
838
+ }
839
+ }
840
+ if (!positions.some((position) => position.offset === span.start.offset)) {
841
+ positions.push(span.start);
842
+ }
843
+ return positions;
844
+ }
845
+ async function probeQuickInfo(client, file, symbol, sourceLines) {
846
+ for (const position of symbolPositions(symbol, sourceLines)) {
847
+ const info = await client.quickinfo(file, position.line, position.offset);
848
+ if (info) return { info, position };
849
+ }
850
+ return null;
851
+ }
852
+ async function selectQuickInfoSymbol(client, file, symbols, sourceLines) {
853
+ const kindPriority = /* @__PURE__ */ new Map([
854
+ ["const", 0],
855
+ ["let", 1],
856
+ ["var", 2],
857
+ ["class", 3],
858
+ ["enum", 4],
859
+ ["function", 5],
860
+ ["method", 6]
861
+ ]);
862
+ const concreteSymbols = symbols.map((symbol, index) => ({ symbol, index })).filter(({ symbol }) => kindPriority.has(symbol.kind)).sort(
863
+ (a, b) => kindPriority.get(a.symbol.kind) - kindPriority.get(b.symbol.kind) || a.index - b.index
864
+ ).map(({ symbol }) => symbol);
865
+ for (const symbol of concreteSymbols) {
866
+ const probe = await probeQuickInfo(client, file, symbol, sourceLines);
867
+ if (probe) return { symbol, ...probe };
868
+ }
869
+ return null;
870
+ }
830
871
  var SKIP_DIRS2 = /* @__PURE__ */ new Set([
831
872
  "node_modules",
832
873
  "dist",
@@ -1034,8 +1075,17 @@ async function main(configOverride) {
1034
1075
  } else {
1035
1076
  fail("navbar", `No symbols found in ${testFileRel}`, navbarMs);
1036
1077
  }
1037
- const concreteKinds = /* @__PURE__ */ new Set(["const", "function", "class", "var", "let", "enum"]);
1038
- const sym = allSymbols.find((s) => concreteKinds.has(s.kind)) ?? allSymbols[0];
1078
+ const source = fs4.readFileSync(testFile, "utf-8");
1079
+ const sourceLines = source.split(/\r?\n/);
1080
+ const quickInfoSelection = await selectQuickInfoSymbol(
1081
+ client,
1082
+ testFileRel,
1083
+ allSymbols,
1084
+ sourceLines
1085
+ );
1086
+ const sym = quickInfoSelection?.symbol ?? allSymbols[0];
1087
+ const selectedQuickInfo = quickInfoSelection?.info ?? null;
1088
+ const selectedPosition = quickInfoSelection?.position ?? sym?.spans[0]?.start;
1039
1089
  if (!sym) {
1040
1090
  const toolNames = [
1041
1091
  "find_symbol",
@@ -1050,6 +1100,7 @@ async function main(configOverride) {
1050
1100
  for (const name of toolNames) skip(name, "No symbol discovered");
1051
1101
  } else {
1052
1102
  const span = sym.spans[0];
1103
+ const point = selectedPosition ?? span.start;
1053
1104
  t0 = performance.now();
1054
1105
  const found = findInNavBar(bar, (item) => item.text === sym.text && item.kind === sym.kind);
1055
1106
  if (found && found.spans.length > 0) {
@@ -1062,7 +1113,7 @@ async function main(configOverride) {
1062
1113
  fail("find_symbol", `Could not re-find ${sym.text}`, performance.now() - t0);
1063
1114
  }
1064
1115
  t0 = performance.now();
1065
- const defs = await client.definition(testFileRel, span.start.line, span.start.offset);
1116
+ const defs = await client.definition(testFileRel, point.line, point.offset);
1066
1117
  if (defs.length > 0) {
1067
1118
  const def = defs[0];
1068
1119
  pass("definition", `${sym.text} -> ${def.file}:${def.start.line}`, performance.now() - t0);
@@ -1070,7 +1121,7 @@ async function main(configOverride) {
1070
1121
  pass("definition", `${sym.text} is its own definition`, performance.now() - t0);
1071
1122
  }
1072
1123
  t0 = performance.now();
1073
- const refs = await client.references(testFileRel, span.start.line, span.start.offset);
1124
+ const refs = await client.references(testFileRel, point.line, point.offset);
1074
1125
  const refFiles = new Set(refs.map((r) => r.file));
1075
1126
  pass(
1076
1127
  "references",
@@ -1078,7 +1129,10 @@ async function main(configOverride) {
1078
1129
  performance.now() - t0
1079
1130
  );
1080
1131
  t0 = performance.now();
1081
- let info = await client.quickinfo(testFileRel, span.start.line, span.start.offset);
1132
+ let info = selectedQuickInfo;
1133
+ if (!info) {
1134
+ info = await client.quickinfo(testFileRel, point.line, point.offset);
1135
+ }
1082
1136
  if (!info && defs.length > 0) {
1083
1137
  const def = defs[0];
1084
1138
  info = await client.quickinfo(testFileRel, def.start.line, def.start.offset);
@@ -1087,7 +1141,7 @@ async function main(configOverride) {
1087
1141
  const typeStr = info.displayString.length > 80 ? info.displayString.slice(0, 80) + "..." : info.displayString;
1088
1142
  pass("type_info", typeStr, performance.now() - t0);
1089
1143
  } else {
1090
- fail("type_info", `No type info for ${sym.text}`, performance.now() - t0);
1144
+ skip("type_info", `No discovered symbol returned type info`);
1091
1145
  }
1092
1146
  t0 = performance.now();
1093
1147
  const navItems = await client.navto(sym.text, 5);
@@ -1119,7 +1173,6 @@ async function main(configOverride) {
1119
1173
  const exportSymbols = topItems.filter((item) => symbolKinds.has(item.kind));
1120
1174
  pass("module_exports", `${exportSymbols.length} top-level symbol(s)`, performance.now() - t0);
1121
1175
  t0 = performance.now();
1122
- const source = fs4.readFileSync(testFile, "utf-8");
1123
1176
  const importMatch = source.match(/^import\s+\{([^}]+)\}\s+from\s+["']([^"']+)["']/m);
1124
1177
  if (importMatch) {
1125
1178
  const firstName = importMatch[1].split(",")[0].replace(/^type\s+/, "").trim();
@@ -1180,5 +1233,6 @@ async function main(configOverride) {
1180
1233
  return { passed, failed, skipped };
1181
1234
  }
1182
1235
  export {
1183
- main
1236
+ main,
1237
+ selectQuickInfoSymbol
1184
1238
  };
@@ -68,16 +68,20 @@ async function main(): Promise<void> {
68
68
 
69
69
  const tsconfig = fs.readFileSync(path.join(projectRoot, "tsconfig.json"), "utf-8");
70
70
  const oxlint = fs.readFileSync(path.join(projectRoot, ".oxlintrc.json"), "utf-8");
71
+ const eslint = fs.readFileSync(path.join(projectRoot, "eslint.config.js"), "utf-8");
71
72
 
72
73
  assertIncludes(tsconfig, '"$schema": "http://json.schemastore.org/tsconfig"');
73
74
  assertIncludes(tsconfig, '"exclude": ["plugins/**"]');
74
75
  assertIncludes(oxlint, '"ignorePatterns": [');
75
76
  assertIncludes(oxlint, '"plugins/**"');
77
+ assertIncludes(eslint, 'const config = [\n { ignores: ["plugins/**"] },');
76
78
  assert.ok(fs.existsSync(path.join(pluginRoot, "cli.ts")), "Expected installed plugin CLI");
77
79
 
78
80
  assertIncludes(setupOutput, 'Added "plugins/**" to tsconfig.json exclude');
79
81
  assertIncludes(setupOutput, 'Added "plugins/**" to .oxlintrc.json ignorePatterns');
82
+ assertIncludes(setupOutput, 'Added "plugins/**" to eslint.config.js ignores');
80
83
  assertIncludes(setupOutput, "Oxlint ignores plugins/ (.oxlintrc.json)");
84
+ assertIncludes(setupOutput, "ESLint ignores plugins/ (eslint.config.js)");
81
85
 
82
86
  const checkOutput = runTsx(
83
87
  pluginRoot,
@@ -86,6 +90,7 @@ async function main(): Promise<void> {
86
90
  testEnv
87
91
  );
88
92
  assertIncludes(checkOutput, "Oxlint ignores plugins/ (.oxlintrc.json)");
93
+ assertIncludes(checkOutput, "ESLint ignores plugins/ (eslint.config.js)");
89
94
  assert.ok(
90
95
  !checkOutput.includes("Lint config check (no ESLint or Oxlint config found)"),
91
96
  `Did not expect lint config detection to be skipped:\n${checkOutput}`
@@ -97,7 +102,9 @@ async function main(): Promise<void> {
97
102
  console.log(" ✓ tsconfig schema URL preserved during exclude patch");
98
103
  console.log(" ✓ tsconfig exclude patch ignores unrelated plugins text");
99
104
  console.log(" ✓ .oxlintrc.json patched with plugins ignore");
105
+ console.log(" ✓ eslint.config.js named flat-config array patched with plugins ignore");
100
106
  console.log(" ✓ installed plugin health check recognizes Oxlint config");
107
+ console.log(" ✓ installed plugin health check recognizes ESLint config");
101
108
  } finally {
102
109
  fs.rmSync(tempRoot, { recursive: true, force: true });
103
110
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typegraph-mcp",
3
- "version": "0.9.45",
3
+ "version": "0.9.47",
4
4
  "description": "Type-aware codebase navigation for AI coding agents — 14 MCP tools powered by tsserver + oxc",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,7 +34,7 @@
34
34
  "scripts": {
35
35
  "build": "tsup",
36
36
  "start": "tsx server.ts",
37
- "test": "tsx smoke-test.ts && tsx export-surface-test.ts && tsx engine-sync-test.ts && tsx install-oxlint-test.ts",
37
+ "test": "tsx smoke-test-selection-test.ts && tsx export-surface-test.ts && tsx engine-sync-test.ts && tsx install-oxlint-test.ts",
38
38
  "check": "tsx check.ts"
39
39
  },
40
40
  "dependencies": {
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env npx tsx
2
+
3
+ import * as assert from "node:assert/strict";
4
+ import type { NavBarItem, QuickInfoResult } from "./tsserver-client.js";
5
+ import { selectQuickInfoSymbol } from "./smoke-test.js";
6
+
7
+ const syntheticCallback: NavBarItem = {
8
+ text: "Alpine.data('table') callback",
9
+ kind: "function",
10
+ kindModifiers: "",
11
+ spans: [
12
+ {
13
+ start: { line: 1, offset: 1 },
14
+ end: { line: 1, offset: 21 },
15
+ },
16
+ ],
17
+ childItems: [],
18
+ };
19
+
20
+ const typedConst: NavBarItem = {
21
+ text: "booleanFilterFn",
22
+ kind: "const",
23
+ kindModifiers: "export",
24
+ spans: [
25
+ {
26
+ start: { line: 2, offset: 1 },
27
+ end: { line: 2, offset: 42 },
28
+ },
29
+ ],
30
+ childItems: [],
31
+ };
32
+
33
+ const quickInfo: QuickInfoResult = {
34
+ displayString: "const booleanFilterFn: FilterFn<any, any>",
35
+ documentation: "",
36
+ kind: "const",
37
+ kindModifiers: "export",
38
+ start: { line: 2, offset: 14 },
39
+ end: { line: 2, offset: 29 },
40
+ };
41
+
42
+ const calls: Array<{ line: number; offset: number }> = [];
43
+ const client = {
44
+ async quickinfo(_file: string, line: number, offset: number) {
45
+ calls.push({ line, offset });
46
+ return line === 2 && offset === 14 ? quickInfo : null;
47
+ },
48
+ };
49
+
50
+ const selection = await selectQuickInfoSymbol(
51
+ client,
52
+ "src/main.ts",
53
+ [syntheticCallback, typedConst],
54
+ ["Alpine.data('table', () => ({}))", "export const booleanFilterFn = () => true"]
55
+ );
56
+
57
+ assert.ok(selection);
58
+ assert.equal(selection.symbol.text, "booleanFilterFn");
59
+ assert.deepEqual(selection.position, { line: 2, offset: 14 });
60
+ assert.equal(selection.info, quickInfo);
61
+ assert.deepEqual(calls, [{ line: 2, offset: 14 }]);
62
+
63
+ console.log("");
64
+ console.log("typegraph-mcp Smoke Test Selection Test");
65
+ console.log("=======================================");
66
+ console.log(" ✓ synthetic callbacks are skipped for typed declarations");
67
+ console.log(" ✓ quickinfo probes the declaration name rather than the span keyword");
package/smoke-test.ts CHANGED
@@ -13,7 +13,11 @@
13
13
 
14
14
  import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
- import { TsServerClient, type NavBarItem } from "./tsserver-client.js";
16
+ import {
17
+ TsServerClient,
18
+ type NavBarItem,
19
+ type QuickInfoResult,
20
+ } from "./tsserver-client.js";
17
21
  import { buildGraph, type ModuleGraph } from "./module-graph.js";
18
22
  import {
19
23
  dependencyTree,
@@ -54,6 +58,81 @@ function findInNavBar(
54
58
  return null;
55
59
  }
56
60
 
61
+ function symbolPositions(
62
+ symbol: NavBarItem,
63
+ sourceLines: string[]
64
+ ): Array<{ line: number; offset: number }> {
65
+ const span = symbol.spans[0]!;
66
+ const positions: Array<{ line: number; offset: number }> = [];
67
+ const lineText = sourceLines[span.start.line - 1];
68
+
69
+ if (lineText) {
70
+ const nameIndex = lineText.indexOf(symbol.text, Math.max(0, span.start.offset - 1));
71
+ if (nameIndex >= 0) {
72
+ positions.push({ line: span.start.line, offset: nameIndex + 1 });
73
+ }
74
+ }
75
+
76
+ if (!positions.some((position) => position.offset === span.start.offset)) {
77
+ positions.push(span.start);
78
+ }
79
+
80
+ return positions;
81
+ }
82
+
83
+ async function probeQuickInfo(
84
+ client: Pick<TsServerClient, "quickinfo">,
85
+ file: string,
86
+ symbol: NavBarItem,
87
+ sourceLines: string[]
88
+ ): Promise<{
89
+ info: QuickInfoResult;
90
+ position: { line: number; offset: number };
91
+ } | null> {
92
+ for (const position of symbolPositions(symbol, sourceLines)) {
93
+ const info = await client.quickinfo(file, position.line, position.offset);
94
+ if (info) return { info, position };
95
+ }
96
+ return null;
97
+ }
98
+
99
+ export async function selectQuickInfoSymbol(
100
+ client: Pick<TsServerClient, "quickinfo">,
101
+ file: string,
102
+ symbols: NavBarItem[],
103
+ sourceLines: string[]
104
+ ): Promise<{
105
+ symbol: NavBarItem;
106
+ info: QuickInfoResult;
107
+ position: { line: number; offset: number };
108
+ } | null> {
109
+ const kindPriority = new Map([
110
+ ["const", 0],
111
+ ["let", 1],
112
+ ["var", 2],
113
+ ["class", 3],
114
+ ["enum", 4],
115
+ ["function", 5],
116
+ ["method", 6],
117
+ ]);
118
+ const concreteSymbols = symbols
119
+ .map((symbol, index) => ({ symbol, index }))
120
+ .filter(({ symbol }) => kindPriority.has(symbol.kind))
121
+ .sort(
122
+ (a, b) =>
123
+ kindPriority.get(a.symbol.kind)! - kindPriority.get(b.symbol.kind)! ||
124
+ a.index - b.index
125
+ )
126
+ .map(({ symbol }) => symbol);
127
+
128
+ for (const symbol of concreteSymbols) {
129
+ const probe = await probeQuickInfo(client, file, symbol, sourceLines);
130
+ if (probe) return { symbol, ...probe };
131
+ }
132
+
133
+ return null;
134
+ }
135
+
57
136
  const SKIP_DIRS = new Set([
58
137
  "node_modules",
59
138
  "dist",
@@ -309,10 +388,21 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
309
388
  fail("navbar", `No symbols found in ${testFileRel}`, navbarMs);
310
389
  }
311
390
 
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];
391
+ // Prefer a concrete symbol that quickinfo can actually resolve. Navbar may
392
+ // include synthetic entries such as "Alpine.data(...) callback" that are
393
+ // useful for navigation but do not correspond to a hoverable source token.
394
+ const source = fs.readFileSync(testFile, "utf-8");
395
+ const sourceLines = source.split(/\r?\n/);
396
+ const quickInfoSelection = await selectQuickInfoSymbol(
397
+ client,
398
+ testFileRel,
399
+ allSymbols,
400
+ sourceLines
401
+ );
402
+ const sym = quickInfoSelection?.symbol ?? allSymbols[0];
403
+ const selectedQuickInfo = quickInfoSelection?.info ?? null;
404
+ const selectedPosition = quickInfoSelection?.position ?? sym?.spans[0]?.start;
405
+
316
406
  if (!sym) {
317
407
  const toolNames = [
318
408
  "find_symbol",
@@ -327,6 +417,7 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
327
417
  for (const name of toolNames) skip(name, "No symbol discovered");
328
418
  } else {
329
419
  const span = sym.spans[0]!;
420
+ const point = selectedPosition ?? span.start;
330
421
 
331
422
  // find_symbol
332
423
  t0 = performance.now();
@@ -343,7 +434,7 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
343
434
 
344
435
  // definition
345
436
  t0 = performance.now();
346
- const defs = await client.definition(testFileRel, span.start.line, span.start.offset);
437
+ const defs = await client.definition(testFileRel, point.line, point.offset);
347
438
  if (defs.length > 0) {
348
439
  const def = defs[0]!;
349
440
  pass("definition", `${sym.text} -> ${def.file}:${def.start.line}`, performance.now() - t0);
@@ -353,7 +444,7 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
353
444
 
354
445
  // references
355
446
  t0 = performance.now();
356
- const refs = await client.references(testFileRel, span.start.line, span.start.offset);
447
+ const refs = await client.references(testFileRel, point.line, point.offset);
357
448
  const refFiles = new Set(refs.map((r) => r.file));
358
449
  pass(
359
450
  "references",
@@ -363,7 +454,10 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
363
454
 
364
455
  // type_info
365
456
  t0 = performance.now();
366
- let info = await client.quickinfo(testFileRel, span.start.line, span.start.offset);
457
+ let info = selectedQuickInfo;
458
+ if (!info) {
459
+ info = await client.quickinfo(testFileRel, point.line, point.offset);
460
+ }
367
461
  // Span start may point to a keyword (class, function) — retry at the name position
368
462
  if (!info && defs.length > 0) {
369
463
  const def = defs[0]!;
@@ -376,7 +470,7 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
376
470
  : info.displayString;
377
471
  pass("type_info", typeStr, performance.now() - t0);
378
472
  } else {
379
- fail("type_info", `No type info for ${sym.text}`, performance.now() - t0);
473
+ skip("type_info", `No discovered symbol returned type info`);
380
474
  }
381
475
 
382
476
  // navigate_to
@@ -416,7 +510,6 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
416
510
 
417
511
  // trace_chain — follow an import to its source
418
512
  t0 = performance.now();
419
- const source = fs.readFileSync(testFile, "utf-8");
420
513
  const importMatch = source.match(/^import\s+\{([^}]+)\}\s+from\s+["']([^"']+)["']/m);
421
514
  if (importMatch) {
422
515
  const firstName = importMatch[1]!
@@ -488,4 +581,3 @@ export async function main(configOverride?: TypegraphConfig): Promise<SmokeTestR
488
581
 
489
582
  return { passed, failed, skipped };
490
583
  }
491
-