lattice-graph 0.3.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lattice-graph",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Knowledge graph CLI for coding agents — navigate code through flows, not grep.",
5
5
  "module": "src/main.ts",
6
6
  "type": "module",
@@ -9,12 +9,10 @@
9
9
  },
10
10
  "files": [
11
11
  "src",
12
- "scripts",
13
12
  "README.md",
14
13
  "LICENSE"
15
14
  ],
16
15
  "scripts": {
17
- "postinstall": "bun scripts/postinstall.ts",
18
16
  "dev": "bun src/main.ts",
19
17
  "test": "bun test",
20
18
  "lint": "bunx biome check src/ tests/",
@@ -73,6 +73,12 @@ function buildLanguageConfigs(config: LatticeConfig): readonly LanguageConfig[]
73
73
  configs.push(buildLanguageConfig("python", [config.root], ["tests"]));
74
74
  }
75
75
 
76
+ if (config.languages.includes("go") && config.go) {
77
+ configs.push(buildLanguageConfig("go", config.go.sourceRoots, config.go.testPaths));
78
+ } else if (config.languages.includes("go")) {
79
+ configs.push(buildLanguageConfig("go", [config.root], []));
80
+ }
81
+
76
82
  return configs;
77
83
  }
78
84
 
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
- import { join, resolve } from "node:path";
2
+ import { join } from "node:path";
3
3
  import { err, ok, type Result } from "../types/result.ts";
4
4
 
5
5
  /**
@@ -102,30 +102,11 @@ function generateToml(languages: readonly string[], root: string): string {
102
102
  return lines.join("\n");
103
103
  }
104
104
 
105
- /** Checks if language server binaries are available (bundled or in PATH). */
105
+ /** Checks prerequisites for language support. */
106
106
  function checkLspAvailability(languages: readonly string[]): readonly string[] {
107
107
  const warnings: string[] = [];
108
- const latticeRoot = resolve(import.meta.dir, "..", "..");
109
-
110
- const checks: Record<string, { bundled: string; system: string; install: string }> = {
111
- typescript: {
112
- bundled: join(latticeRoot, "node_modules", ".bin", "typescript-language-server"),
113
- system: "typescript-language-server",
114
- install: "reinstall lattice-graph",
115
- },
116
- python: {
117
- bundled: join(latticeRoot, "vendor", "venv", "bin", "zubanls"),
118
- system: "zubanls",
119
- install: "reinstall lattice-graph (or pip install zuban)",
120
- },
121
- };
122
-
123
- for (const lang of languages) {
124
- const check = checks[lang];
125
- if (!check) continue;
126
- if (!existsSync(check.bundled) && !Bun.which(check.system)) {
127
- warnings.push(`Warning: ${check.system} not found. ${check.install}`);
128
- }
108
+ if (languages.includes("python") && !Bun.which("python3") && !Bun.which("python")) {
109
+ warnings.push("Warning: Python 3 not found. Install Python 3 to enable Python support.");
129
110
  }
130
111
  return warnings;
131
112
  }
package/src/config.ts CHANGED
@@ -1,8 +1,22 @@
1
1
  import { parse as parseToml } from "smol-toml";
2
- import type { LatticeConfig, LintConfig, PythonConfig, TypeScriptConfig } from "./types/config.ts";
2
+ import type {
3
+ GoConfig,
4
+ LatticeConfig,
5
+ LintConfig,
6
+ PythonConfig,
7
+ TypeScriptConfig,
8
+ } from "./types/config.ts";
3
9
  import { err, ok, type Result } from "./types/result.ts";
4
10
 
5
- const DEFAULT_EXCLUDE = ["node_modules", "venv", ".git", "dist", "__pycache__", ".lattice"];
11
+ const DEFAULT_EXCLUDE = [
12
+ "node_modules",
13
+ "venv",
14
+ "vendor",
15
+ ".git",
16
+ "dist",
17
+ "__pycache__",
18
+ ".lattice",
19
+ ];
6
20
 
7
21
  /**
8
22
  * Parses a TOML string into a validated LatticeConfig.
@@ -38,6 +52,8 @@ function parseConfig(tomlString: string): Result<LatticeConfig, string> {
38
52
  ? parseTypeScriptSection(raw.typescript)
39
53
  : undefined;
40
54
 
55
+ const goConfig = languages.includes("go") ? parseGoSection(raw.go) : undefined;
56
+
41
57
  const lintConfig = parseLintSection(raw.lint);
42
58
 
43
59
  return ok({
@@ -46,6 +62,7 @@ function parseConfig(tomlString: string): Result<LatticeConfig, string> {
46
62
  exclude,
47
63
  python: pythonConfig,
48
64
  typescript: typescriptConfig,
65
+ go: goConfig,
49
66
  lint: lintConfig,
50
67
  });
51
68
  }
@@ -69,6 +86,15 @@ function parseTypeScriptSection(raw: unknown): TypeScriptConfig {
69
86
  };
70
87
  }
71
88
 
89
+ /** Parses the [go] section with defaults for missing fields. */
90
+ function parseGoSection(raw: unknown): GoConfig {
91
+ const section = isRecord(raw) ? raw : {};
92
+ return {
93
+ sourceRoots: isStringArray(section.source_roots) ? section.source_roots : ["."],
94
+ testPaths: isStringArray(section.test_paths) ? section.test_paths : [],
95
+ };
96
+ }
97
+
72
98
  /** Parses the [lint] section with defaults for missing fields. */
73
99
  function parseLintSection(raw: unknown): LintConfig {
74
100
  const section = isRecord(raw) ? raw : {};
@@ -7,6 +7,7 @@ const NAME_PATTERN = /^[a-z][a-z0-9._-]*$/;
7
7
  const COMMENT_PREFIXES: Record<string, RegExp> = {
8
8
  typescript: /^\s*(?:\/\/|\/\*\*?|\*)\s*/,
9
9
  python: /^\s*(?:#|""")\s*/,
10
+ go: /^\s*(?:\/\/)\s*/,
10
11
  };
11
12
  const DEFAULT_COMMENT_PREFIX = /^\s*(?:\/\/|#|--|\/\*\*?|\*)\s*/;
12
13
 
@@ -40,12 +40,19 @@ type BuildStats = {
40
40
  readonly durationMs: number;
41
41
  };
42
42
 
43
- /** Resolves the LSP server binary for a language, checking bundled paths first. */
43
+ const VENDOR_VENV = join(import.meta.dir, "..", "..", "vendor", "venv");
44
+
45
+ const LANGUAGE_EXTENSIONS: Record<string, readonly string[]> = {
46
+ typescript: [".ts", ".tsx"],
47
+ python: [".py"],
48
+ go: [".go"],
49
+ };
50
+
51
+ /** Resolves the LSP server binary for a language. Auto-installs zuban on first use. */
44
52
  function resolveLspServer(
45
53
  language: string,
46
54
  ): { command: string; args: readonly string[]; languageId: string } | undefined {
47
55
  if (language === "typescript") {
48
- // Check node_modules/.bin/ first (bundled with lattice-graph)
49
56
  const bundled = join(
50
57
  import.meta.dir,
51
58
  "..",
@@ -58,13 +65,131 @@ function resolveLspServer(
58
65
  return { command, args: ["--stdio"], languageId: "typescript" };
59
66
  }
60
67
  if (language === "python") {
61
- const bundled = join(import.meta.dir, "..", "..", "vendor", "venv", "bin", "zubanls");
62
- const command = existsSync(bundled) ? bundled : "zubanls";
63
- return { command, args: [], languageId: "python" };
68
+ const zubanls = resolveZuban();
69
+ if (!zubanls) return undefined;
70
+ return { command: zubanls, args: [], languageId: "python" };
71
+ }
72
+ if (language === "go") {
73
+ const goplsBin = resolveGopls();
74
+ if (!goplsBin) return undefined;
75
+ return { command: goplsBin, args: ["serve"], languageId: "go" };
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ /** Finds or installs zubanls. Returns the binary path, or undefined if installation fails. */
81
+ function resolveZuban(): string | undefined {
82
+ const isWindows = process.platform === "win32";
83
+ const binDir = isWindows ? "Scripts" : "bin";
84
+ const binName = isWindows ? "zubanls.exe" : "zubanls";
85
+
86
+ // 1. Check vendored venv (installed by a previous build)
87
+ const vendored = join(VENDOR_VENV, binDir, binName);
88
+ if (existsSync(vendored)) return vendored;
89
+
90
+ // 2. Check system PATH
91
+ const system = Bun.which("zubanls");
92
+ if (system) return system;
93
+
94
+ // 3. Auto-install into vendored venv
95
+ return installZuban();
96
+ }
97
+
98
+ /** Creates a venv and pip-installs zuban. Returns zubanls path or undefined on failure. */
99
+ function installZuban(): string | undefined {
100
+ const isWindows = process.platform === "win32";
101
+ const binDir = isWindows ? "Scripts" : "bin";
102
+ const binName = isWindows ? "zubanls.exe" : "zubanls";
103
+
104
+ const python = Bun.which("python3") ?? Bun.which("python");
105
+ if (!python) {
106
+ console.error("Python not found. Install Python 3 to enable Python support.");
107
+ return undefined;
108
+ }
109
+
110
+ console.log("Installing Python language server (zuban)...");
111
+
112
+ const venvResult = Bun.spawnSync([python, "-m", "venv", VENDOR_VENV], {
113
+ stdout: "ignore",
114
+ stderr: "pipe",
115
+ });
116
+ if (venvResult.exitCode !== 0) {
117
+ console.error(`Failed to create venv: ${venvResult.stderr.toString()}`);
118
+ return undefined;
64
119
  }
120
+
121
+ const pip = join(VENDOR_VENV, binDir, isWindows ? "pip.exe" : "pip");
122
+ const pipResult = Bun.spawnSync([pip, "install", "zuban", "--quiet"], {
123
+ stdout: "ignore",
124
+ stderr: "pipe",
125
+ });
126
+ if (pipResult.exitCode !== 0) {
127
+ console.error(`Failed to install zuban: ${pipResult.stderr.toString()}`);
128
+ return undefined;
129
+ }
130
+
131
+ const zubanls = join(VENDOR_VENV, binDir, binName);
132
+ if (!existsSync(zubanls)) {
133
+ console.error("zubanls not found after installation");
134
+ return undefined;
135
+ }
136
+
137
+ console.log("done");
138
+ return zubanls;
139
+ }
140
+
141
+ /** Resolves gopls binary path from GOBIN or GOPATH/bin. */
142
+ function findGoplsBinary(): string | undefined {
143
+ const gobin = process.env.GOBIN;
144
+ if (gobin) {
145
+ const goplsPath = join(gobin, "gopls");
146
+ if (existsSync(goplsPath)) return goplsPath;
147
+ }
148
+ const gopath = process.env.GOPATH ?? join(process.env.HOME ?? "", "go");
149
+ const gopathBin = join(gopath, "bin", "gopls");
150
+ if (existsSync(gopathBin)) return gopathBin;
65
151
  return undefined;
66
152
  }
67
153
 
154
+ /** Finds or installs gopls. Returns the binary path, or undefined if unavailable. */
155
+ function resolveGopls(): string | undefined {
156
+ const system = Bun.which("gopls");
157
+ if (system) return system;
158
+
159
+ const found = findGoplsBinary();
160
+ if (found) return found;
161
+
162
+ return installGopls();
163
+ }
164
+
165
+ /** Installs gopls via go install. Returns binary path or undefined on failure. */
166
+ function installGopls(): string | undefined {
167
+ const goBin = Bun.which("go");
168
+ if (!goBin) {
169
+ console.error("Go not found. Install Go to enable Go support.");
170
+ return undefined;
171
+ }
172
+
173
+ console.log("Installing Go language server (gopls)...");
174
+
175
+ const result = Bun.spawnSync([goBin, "install", "golang.org/x/tools/gopls@latest"], {
176
+ stdout: "ignore",
177
+ stderr: "pipe",
178
+ });
179
+ if (result.exitCode !== 0) {
180
+ console.error(`Failed to install gopls: ${result.stderr.toString()}`);
181
+ return undefined;
182
+ }
183
+
184
+ const installed = findGoplsBinary();
185
+ if (!installed) {
186
+ console.error("gopls not found after installation");
187
+ return undefined;
188
+ }
189
+ console.log("done");
190
+ return installed;
191
+ }
192
+
68
193
  /**
69
194
  * Builds the knowledge graph by querying LSP servers for symbols and call hierarchy,
70
195
  * scanning for @lattice: tags, and writing everything to SQLite.
@@ -117,7 +242,10 @@ async function buildGraph(opts: BuildGraphOptions): Promise<BuildStats> {
117
242
 
118
243
  for (const filePath of files) {
119
244
  const relativePath = relative(projectRoot, filePath);
120
- const isTest = langConfig.testPaths.some((tp) => relativePath.startsWith(tp));
245
+ const isTest =
246
+ langConfig.language === "go"
247
+ ? relativePath.endsWith("_test.go")
248
+ : langConfig.testPaths.some((tp) => relativePath.startsWith(tp));
121
249
  const source = await Bun.file(filePath).text();
122
250
 
123
251
  const symbols = await client.documentSymbol(filePath);
@@ -136,30 +264,36 @@ async function buildGraph(opts: BuildGraphOptions): Promise<BuildStats> {
136
264
  fileDataList.push({ filePath, relativePath, nodesWithPos });
137
265
  }
138
266
 
139
- // Phase 2a: outgoingCalls — "what does each function call?"
140
- for (const fd of fileDataList) {
141
- for (const nwp of fd.nodesWithPos) {
142
- if (nwp.node.kind !== "function" && nwp.node.kind !== "method") continue;
267
+ // Phase 2a: outgoingCalls — gopls doesn't support this over stdio, so skip for Go
268
+ if (langConfig.language !== "go")
269
+ for (const fd of fileDataList) {
270
+ for (const nwp of fd.nodesWithPos) {
271
+ if (nwp.node.kind !== "function" && nwp.node.kind !== "method") continue;
143
272
 
144
- try {
145
- const items = await client.prepareCallHierarchy(
146
- fd.filePath,
147
- nwp.selectionLine,
148
- nwp.selectionCharacter,
149
- );
150
- if (items.length === 0) continue;
151
- const item = items[0];
152
- if (!item) continue;
153
-
154
- const calls = await client.outgoingCalls(item);
155
- const { edges, externalCalls } = outgoingCallsToEdges(nwp.node.id, calls, projectRoot);
156
- allEdges.push(...edges);
157
- allExternalCalls.push(...externalCalls);
158
- } catch {
159
- // outgoingCalls not supported by this server — skip silently
273
+ try {
274
+ const items = await client.prepareCallHierarchy(
275
+ fd.filePath,
276
+ nwp.selectionLine,
277
+ nwp.selectionCharacter,
278
+ );
279
+ if (items.length === 0) continue;
280
+ const item = items[0];
281
+ if (!item) continue;
282
+
283
+ const calls = await client.outgoingCalls(item);
284
+ const { edges, externalCalls } = outgoingCallsToEdges(
285
+ nwp.node.id,
286
+ calls,
287
+ projectRoot,
288
+ langConfig.language,
289
+ );
290
+ allEdges.push(...edges);
291
+ allExternalCalls.push(...externalCalls);
292
+ } catch {
293
+ // outgoingCalls not supported by this server — skip silently
294
+ }
160
295
  }
161
296
  }
162
- }
163
297
 
164
298
  // Phase 2b: references — "who references each function?"
165
299
  const nodesByFile = new Map<string, readonly Node[]>();
@@ -235,7 +369,7 @@ function buildLanguageConfig(
235
369
  sourceRoots: readonly string[],
236
370
  testPaths: readonly string[],
237
371
  ): LanguageConfig {
238
- const extensions = language === "python" ? [".py"] : [".ts", ".tsx"];
372
+ const extensions = LANGUAGE_EXTENSIONS[language] ?? [];
239
373
  return { language, extensions, sourceRoots, testPaths };
240
374
  }
241
375
 
package/src/lsp/calls.ts CHANGED
@@ -14,12 +14,14 @@ type CallConversionResult = {
14
14
  * @param sourceId - The Lattice node ID of the calling function
15
15
  * @param calls - Outgoing calls from LSP
16
16
  * @param projectRoot - Absolute path to the project root
17
+ * @param language - Language identifier for external call detection
17
18
  * @returns Internal edges and external call records
18
19
  */
19
20
  function outgoingCallsToEdges(
20
21
  sourceId: string,
21
22
  calls: readonly CallHierarchyOutgoingCall[],
22
23
  projectRoot: string,
24
+ language: string,
23
25
  ): CallConversionResult {
24
26
  const edges: Edge[] = [];
25
27
  const externalCalls: ExternalCall[] = [];
@@ -28,11 +30,15 @@ function outgoingCallsToEdges(
28
30
  for (const call of calls) {
29
31
  const uri = call.to.uri;
30
32
 
31
- if (!uri.startsWith(projectFilePrefix) || uri.includes("/node_modules/")) {
32
- // Skip type declarations — not runtime calls
33
- if (isTypeDeclaration(uri)) continue;
33
+ const isExternal =
34
+ !uri.startsWith(projectFilePrefix) ||
35
+ uri.includes("/node_modules/") ||
36
+ uri.includes("/pkg/mod/");
34
37
 
35
- const pkg = extractPackageName(uri);
38
+ if (isExternal) {
39
+ if (language !== "go" && isTypeDeclaration(uri)) continue;
40
+
41
+ const pkg = language === "go" ? extractGoModuleName(uri) : extractPackageName(uri);
36
42
  if (pkg) {
37
43
  externalCalls.push({ nodeId: sourceId, package: pkg, symbol: call.to.name });
38
44
  }
@@ -63,6 +69,33 @@ function extractPackageName(uri: string): string | undefined {
63
69
  return afterNm.split("/")[0];
64
70
  }
65
71
 
72
+ /**
73
+ * Extracts the Go module name from a module cache or stdlib URI.
74
+ * Module cache paths contain /pkg/mod/ with @version.
75
+ * Stdlib paths contain /go/src/ without @version.
76
+ */
77
+ function extractGoModuleName(uri: string): string | undefined {
78
+ const decoded = decodeURIComponent(uri);
79
+
80
+ // Go module cache: contains /pkg/mod/ with @version
81
+ const pkgModIdx = decoded.indexOf("/pkg/mod/");
82
+ if (pkgModIdx !== -1) {
83
+ const afterMod = decoded.slice(pkgModIdx + "/pkg/mod/".length);
84
+ const atIdx = afterMod.indexOf("@");
85
+ if (atIdx !== -1) {
86
+ return afterMod.slice(0, atIdx);
87
+ }
88
+ }
89
+
90
+ // Go stdlib: contains /go/src/ without @version
91
+ const goSrcMatch = decoded.match(/\/go\/src\/(.+?)\/[^/]+$/);
92
+ if (goSrcMatch && !decoded.includes("@")) {
93
+ return goSrcMatch[1];
94
+ }
95
+
96
+ return undefined;
97
+ }
98
+
66
99
  /**
67
100
  * Checks if a URI points to a type-only package (not a runtime dependency).
68
101
  * Type definition packages (@types/*, typescript, bun-types) are filtered out.
@@ -81,4 +114,10 @@ function isTypeDeclaration(uri: string): boolean {
81
114
  return false;
82
115
  }
83
116
 
84
- export { type CallConversionResult, extractPackageName, isTypeDeclaration, outgoingCallsToEdges };
117
+ export {
118
+ type CallConversionResult,
119
+ extractGoModuleName,
120
+ extractPackageName,
121
+ isTypeDeclaration,
122
+ outgoingCallsToEdges,
123
+ };
package/src/lsp/client.ts CHANGED
@@ -80,7 +80,12 @@ async function createLspClient(opts: LspClientOptions): Promise<LspClient> {
80
80
 
81
81
  try {
82
82
  const msg = JSON.parse(body) as JsonRpcMessage;
83
- if (msg.id !== undefined && pending.has(msg.id)) {
83
+ if (msg.method && msg.id !== undefined) {
84
+ // Server-initiated request — reply with null to unblock the server
85
+ const reply = JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: null });
86
+ const replyHeader = `Content-Length: ${Buffer.byteLength(reply)}\r\n\r\n`;
87
+ proc.stdin?.write(replyHeader + reply);
88
+ } else if (msg.id !== undefined && pending.has(msg.id)) {
84
89
  const handler = pending.get(msg.id);
85
90
  pending.delete(msg.id);
86
91
  if (msg.error) {
@@ -95,7 +100,7 @@ async function createLspClient(opts: LspClientOptions): Promise<LspClient> {
95
100
  }
96
101
  });
97
102
 
98
- function sendRequest(method: string, params: unknown): Promise<unknown> {
103
+ function sendRequest(method: string, params: unknown, timeoutMs = 30000): Promise<unknown> {
99
104
  const id = nextId++;
100
105
  const msg: JsonRpcMessage = { jsonrpc: "2.0", id, method, params };
101
106
  const body = JSON.stringify(msg);
@@ -103,7 +108,20 @@ async function createLspClient(opts: LspClientOptions): Promise<LspClient> {
103
108
  proc.stdin?.write(header + body);
104
109
 
105
110
  return new Promise((resolve, reject) => {
106
- pending.set(id, { resolve, reject });
111
+ const timer = setTimeout(() => {
112
+ pending.delete(id);
113
+ reject(new Error(`LSP request ${method} timed out after ${timeoutMs}ms`));
114
+ }, timeoutMs);
115
+ pending.set(id, {
116
+ resolve: (v: unknown) => {
117
+ clearTimeout(timer);
118
+ resolve(v);
119
+ },
120
+ reject: (e: Error) => {
121
+ clearTimeout(timer);
122
+ reject(e);
123
+ },
124
+ });
107
125
  });
108
126
  }
109
127
 
@@ -144,7 +162,7 @@ async function createLspClient(opts: LspClientOptions): Promise<LspClient> {
144
162
  const client: LspClient = {
145
163
  async waitForReady(probePath: string): Promise<void> {
146
164
  openFile(probePath);
147
- const delays = [100, 200, 400, 800, 1600, 3200];
165
+ const delays = [100, 200, 400, 800, 1600, 3200, 5000, 5000];
148
166
  for (const delay of delays) {
149
167
  const result = (await sendRequest("textDocument/documentSymbol", {
150
168
  textDocument: { uri: `file://${probePath}` },
@@ -168,10 +186,14 @@ async function createLspClient(opts: LspClientOptions): Promise<LspClient> {
168
186
  character: number,
169
187
  ): Promise<readonly CallHierarchyItem[]> {
170
188
  openFile(filePath);
171
- const result = await sendRequest("textDocument/prepareCallHierarchy", {
172
- textDocument: { uri: `file://${filePath}` },
173
- position: { line, character },
174
- });
189
+ const result = await sendRequest(
190
+ "textDocument/prepareCallHierarchy",
191
+ {
192
+ textDocument: { uri: `file://${filePath}` },
193
+ position: { line, character },
194
+ },
195
+ 5000,
196
+ );
175
197
  return (result as readonly CallHierarchyItem[]) ?? [];
176
198
  },
177
199
 
@@ -18,7 +18,7 @@ function documentSymbolsToNodes(
18
18
  isTest: boolean,
19
19
  ): readonly Node[] {
20
20
  const nodes: Node[] = [];
21
- flattenSymbols(symbols, filePath, language, isTest, [], nodes);
21
+ flattenSymbols(symbols, filePath, language, isTest, [], nodes, new Map());
22
22
  return nodes;
23
23
  }
24
24
 
@@ -29,19 +29,20 @@ function flattenSymbols(
29
29
  isTest: boolean,
30
30
  parentNames: readonly string[],
31
31
  results: Node[],
32
+ seenIds: Map<string, number>,
32
33
  ): void {
33
34
  for (const sym of symbols) {
34
35
  const kind = symbolKindToNodeKind(sym.kind);
35
36
  if (!kind) {
36
37
  // Still recurse into children for non-matching kinds (e.g., modules)
37
38
  if (sym.children) {
38
- flattenSymbols(sym.children, filePath, language, isTest, parentNames, results);
39
+ flattenSymbols(sym.children, filePath, language, isTest, parentNames, results, seenIds);
39
40
  }
40
41
  continue;
41
42
  }
42
43
 
43
44
  const qualifiedName = [...parentNames, sym.name].join(".");
44
- const id = `${filePath}::${qualifiedName}`;
45
+ const id = deduplicateId(`${filePath}::${qualifiedName}`, seenIds);
45
46
 
46
47
  results.push({
47
48
  id,
@@ -57,17 +58,33 @@ function flattenSymbols(
57
58
  });
58
59
 
59
60
  if (sym.children) {
60
- flattenSymbols(sym.children, filePath, language, isTest, [...parentNames, sym.name], results);
61
+ flattenSymbols(
62
+ sym.children,
63
+ filePath,
64
+ language,
65
+ isTest,
66
+ [...parentNames, sym.name],
67
+ results,
68
+ seenIds,
69
+ );
61
70
  }
62
71
  }
63
72
  }
64
73
 
74
+ /** Returns a unique ID, appending $N for duplicates (e.g., Go's multiple init() per file). */
75
+ function deduplicateId(baseId: string, seenIds: Map<string, number>): string {
76
+ const count = seenIds.get(baseId) ?? 0;
77
+ seenIds.set(baseId, count + 1);
78
+ return count === 0 ? baseId : `${baseId}$${count + 1}`;
79
+ }
80
+
65
81
  function symbolKindToNodeKind(kind: number): NodeKind | undefined {
66
82
  if (kind === SymbolKind.Function) return "function";
67
83
  if (kind === SymbolKind.Method) return "method";
68
84
  if (kind === SymbolKind.Constructor) return "method";
69
85
  if (kind === SymbolKind.Class) return "class";
70
86
  if (kind === SymbolKind.Interface) return "type";
87
+ if (kind === SymbolKind.Struct) return "class";
71
88
  return undefined;
72
89
  }
73
90
 
@@ -89,7 +106,7 @@ function documentSymbolsToNodesWithPositions(
89
106
  isTest: boolean,
90
107
  ): readonly NodeWithPosition[] {
91
108
  const results: NodeWithPosition[] = [];
92
- flattenSymbolsWithPositions(symbols, filePath, language, isTest, [], results);
109
+ flattenSymbolsWithPositions(symbols, filePath, language, isTest, [], results, new Map());
93
110
  return results;
94
111
  }
95
112
 
@@ -100,18 +117,27 @@ function flattenSymbolsWithPositions(
100
117
  isTest: boolean,
101
118
  parentNames: readonly string[],
102
119
  results: NodeWithPosition[],
120
+ seenIds: Map<string, number>,
103
121
  ): void {
104
122
  for (const sym of symbols) {
105
123
  const kind = symbolKindToNodeKind(sym.kind);
106
124
  if (!kind) {
107
125
  if (sym.children) {
108
- flattenSymbolsWithPositions(sym.children, filePath, language, isTest, parentNames, results);
126
+ flattenSymbolsWithPositions(
127
+ sym.children,
128
+ filePath,
129
+ language,
130
+ isTest,
131
+ parentNames,
132
+ results,
133
+ seenIds,
134
+ );
109
135
  }
110
136
  continue;
111
137
  }
112
138
 
113
139
  const qualifiedName = [...parentNames, sym.name].join(".");
114
- const id = `${filePath}::${qualifiedName}`;
140
+ const id = deduplicateId(`${filePath}::${qualifiedName}`, seenIds);
115
141
 
116
142
  results.push({
117
143
  node: {
@@ -138,6 +164,7 @@ function flattenSymbolsWithPositions(
138
164
  isTest,
139
165
  [...parentNames, sym.name],
140
166
  results,
167
+ seenIds,
141
168
  );
142
169
  }
143
170
  }
package/src/lsp/types.ts CHANGED
@@ -29,6 +29,7 @@ const SymbolKind = {
29
29
  Constructor: 9,
30
30
  Variable: 13,
31
31
  Property: 7,
32
+ Struct: 23,
32
33
  } as const;
33
34
 
34
35
  /** A symbol in a document, returned by textDocument/documentSymbol. */
package/src/main.ts CHANGED
@@ -37,7 +37,7 @@ import {
37
37
  import type { Node } from "./types/graph.ts";
38
38
  import { isOk, unwrap } from "./types/result.ts";
39
39
 
40
- const VERSION = "0.3.0";
40
+ const VERSION = "0.4.0";
41
41
 
42
42
  const program = new Command();
43
43
  program.name("lattice").description("Knowledge graph CLI for coding agents").version(VERSION);
@@ -11,6 +11,12 @@ type TypeScriptConfig = {
11
11
  readonly tsconfig: string | undefined;
12
12
  };
13
13
 
14
+ /** Configuration for a Go language section. */
15
+ type GoConfig = {
16
+ readonly sourceRoots: readonly string[];
17
+ readonly testPaths: readonly string[];
18
+ };
19
+
14
20
  /** Lint-specific configuration. */
15
21
  type LintConfig = {
16
22
  readonly strict: boolean;
@@ -24,7 +30,8 @@ type LatticeConfig = {
24
30
  readonly exclude: readonly string[];
25
31
  readonly python: PythonConfig | undefined;
26
32
  readonly typescript: TypeScriptConfig | undefined;
33
+ readonly go: GoConfig | undefined;
27
34
  readonly lint: LintConfig;
28
35
  };
29
36
 
30
- export type { LatticeConfig, LintConfig, PythonConfig, TypeScriptConfig };
37
+ export type { GoConfig, LatticeConfig, LintConfig, PythonConfig, TypeScriptConfig };
@@ -1,69 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Postinstall script — creates a minimal Python venv and installs zuban into it.
4
- * This gives zuban a proper Python environment with typeshed stubs.
5
- */
6
- import { existsSync } from "node:fs";
7
- import { join } from "node:path";
8
-
9
- const VENDOR_DIR = join(import.meta.dir, "..", "vendor");
10
- const VENV_DIR = join(VENDOR_DIR, "venv");
11
-
12
- function findPython(): string | undefined {
13
- for (const cmd of ["python3", "python"]) {
14
- if (Bun.which(cmd)) return cmd;
15
- }
16
- return undefined;
17
- }
18
-
19
- async function main() {
20
- const isWindows = process.platform === "win32";
21
- const zubanBin = join(VENV_DIR, isWindows ? "Scripts" : "bin", isWindows ? "zubanls.exe" : "zubanls");
22
-
23
- if (existsSync(zubanBin)) {
24
- console.log("zuban already installed");
25
- return;
26
- }
27
-
28
- const python = findPython();
29
- if (!python) {
30
- console.warn("Warning: Python not found. Python support requires python3 in PATH.");
31
- return;
32
- }
33
-
34
- console.log("Installing zuban for Python support...");
35
-
36
- try {
37
- // Create venv
38
- const venvResult = Bun.spawnSync([python, "-m", "venv", VENV_DIR], {
39
- stdout: "ignore",
40
- stderr: "pipe",
41
- });
42
- if (venvResult.exitCode !== 0) {
43
- throw new Error(`Failed to create venv: ${venvResult.stderr.toString()}`);
44
- }
45
-
46
- // Install zuban via pip
47
- const pip = join(VENV_DIR, isWindows ? "Scripts" : "bin", "pip");
48
- const pipResult = Bun.spawnSync([pip, "install", "zuban", "--quiet"], {
49
- stdout: "ignore",
50
- stderr: "pipe",
51
- });
52
- if (pipResult.exitCode !== 0) {
53
- throw new Error(`Failed to install zuban: ${pipResult.stderr.toString()}`);
54
- }
55
-
56
- if (!existsSync(zubanBin)) {
57
- throw new Error("zubanls binary not found after installation");
58
- }
59
-
60
- console.log("zuban installed successfully");
61
- } catch (error) {
62
- console.warn(
63
- `Warning: Failed to install zuban: ${error instanceof Error ? error.message : error}. ` +
64
- "Python support requires: pip install zuban",
65
- );
66
- }
67
- }
68
-
69
- main();