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 +18 -0
- package/dist/cli.js +75 -8
- package/dist/smoke-test.js +62 -8
- package/install-oxlint-test.ts +7 -0
- package/package.json +2 -2
- package/smoke-test-selection-test.ts +67 -0
- package/smoke-test.ts +103 -11
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
|
|
1737
|
-
const
|
|
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,
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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) {
|
package/dist/smoke-test.js
CHANGED
|
@@ -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
|
|
1038
|
-
const
|
|
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,
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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
|
};
|
package/install-oxlint-test.ts
CHANGED
|
@@ -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.
|
|
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 {
|
|
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
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
const
|
|
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,
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|