lattice-graph 0.1.0 → 0.3.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/src/files.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Checks if a file or directory name matches any exclusion pattern.
6
+ *
7
+ * @param name - File or directory name to check
8
+ * @param excludePatterns - Patterns to exclude
9
+ * @returns True if the name matches any pattern
10
+ */
11
+ function isExcluded(name: string, excludePatterns: readonly string[]): boolean {
12
+ return excludePatterns.some((pattern) => name.includes(pattern));
13
+ }
14
+
15
+ /**
16
+ * Recursively discovers files matching given extensions, excluding directories by pattern.
17
+ *
18
+ * @param root - Root directory to search
19
+ * @param extensions - File extensions to include (e.g., [".ts", ".tsx"])
20
+ * @param exclude - Directory name patterns to skip
21
+ * @returns Array of absolute file paths
22
+ */
23
+ function discoverFiles(
24
+ root: string,
25
+ extensions: readonly string[],
26
+ exclude: readonly string[],
27
+ ): readonly string[] {
28
+ const files: string[] = [];
29
+
30
+ function walk(dir: string): void {
31
+ let names: string[];
32
+ try {
33
+ names = readdirSync(dir) as string[];
34
+ } catch {
35
+ return;
36
+ }
37
+ for (const name of names) {
38
+ const fullPath = join(dir, name);
39
+ try {
40
+ const stat = statSync(fullPath);
41
+ if (stat.isDirectory()) {
42
+ if (!isExcluded(name, exclude)) walk(fullPath);
43
+ } else if (extensions.some((ext) => name.endsWith(ext))) {
44
+ files.push(fullPath);
45
+ }
46
+ } catch {
47
+ // skip inaccessible files
48
+ }
49
+ }
50
+ }
51
+
52
+ walk(root);
53
+ return files;
54
+ }
55
+
56
+ export { discoverFiles, isExcluded };
@@ -1,7 +1,7 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { err, ok, type Result } from "../types/result.ts";
3
3
 
4
- const SCHEMA_VERSION = "1";
4
+ const SCHEMA_VERSION = "2";
5
5
 
6
6
  const SCHEMA_SQL = `
7
7
  CREATE TABLE IF NOT EXISTS nodes (
@@ -21,7 +21,6 @@ CREATE TABLE IF NOT EXISTS edges (
21
21
  source_id TEXT NOT NULL,
22
22
  target_id TEXT NOT NULL,
23
23
  kind TEXT NOT NULL,
24
- certainty TEXT DEFAULT 'certain',
25
24
  PRIMARY KEY (source_id, target_id, kind)
26
25
  );
27
26
 
@@ -32,12 +31,11 @@ CREATE TABLE IF NOT EXISTS tags (
32
31
  PRIMARY KEY (node_id, kind, value)
33
32
  );
34
33
 
35
- CREATE TABLE IF NOT EXISTS unresolved (
36
- file TEXT NOT NULL,
37
- line INTEGER NOT NULL,
38
- expression TEXT NOT NULL,
39
- reason TEXT NOT NULL,
40
- PRIMARY KEY (file, line, expression)
34
+ CREATE TABLE IF NOT EXISTS external_calls (
35
+ node_id TEXT NOT NULL,
36
+ package TEXT NOT NULL,
37
+ symbol TEXT NOT NULL,
38
+ PRIMARY KEY (node_id, package, symbol)
41
39
  );
42
40
 
43
41
  CREATE TABLE IF NOT EXISTS meta (
@@ -1,5 +1,5 @@
1
1
  import type { Database } from "bun:sqlite";
2
- import type { Edge, Node, Tag, UnresolvedReference } from "../types/graph.ts";
2
+ import type { Edge, ExternalCall, Node, Tag } from "../types/graph.ts";
3
3
 
4
4
  /**
5
5
  * Inserts nodes into the graph database.
@@ -40,11 +40,11 @@ function insertNodes(db: Database, nodes: readonly Node[]): void {
40
40
  */
41
41
  function insertEdges(db: Database, edges: readonly Edge[]): void {
42
42
  const stmt = db.prepare(
43
- "INSERT OR IGNORE INTO edges (source_id, target_id, kind, certainty) VALUES (?, ?, ?, ?)",
43
+ "INSERT OR IGNORE INTO edges (source_id, target_id, kind) VALUES (?, ?, ?)",
44
44
  );
45
45
  const tx = db.transaction(() => {
46
46
  for (const edge of edges) {
47
- stmt.run(edge.sourceId, edge.targetId, edge.kind, edge.certainty);
47
+ stmt.run(edge.sourceId, edge.targetId, edge.kind);
48
48
  }
49
49
  });
50
50
  tx();
@@ -68,26 +68,26 @@ function insertTags(db: Database, tags: readonly Tag[]): void {
68
68
  }
69
69
 
70
70
  /**
71
- * Inserts unresolved references into the database.
71
+ * Inserts external call records for lint boundary detection.
72
72
  * Uses INSERT OR IGNORE to skip duplicates.
73
73
  *
74
74
  * @param db - An open Database handle
75
- * @param refs - Unresolved references to insert
75
+ * @param calls - External calls to insert
76
76
  */
77
- function insertUnresolved(db: Database, refs: readonly UnresolvedReference[]): void {
77
+ function insertExternalCalls(db: Database, calls: readonly ExternalCall[]): void {
78
78
  const stmt = db.prepare(
79
- "INSERT OR IGNORE INTO unresolved (file, line, expression, reason) VALUES (?, ?, ?, ?)",
79
+ "INSERT OR IGNORE INTO external_calls (node_id, package, symbol) VALUES (?, ?, ?)",
80
80
  );
81
81
  const tx = db.transaction(() => {
82
- for (const ref of refs) {
83
- stmt.run(ref.file, ref.line, ref.expression, ref.reason);
82
+ for (const call of calls) {
83
+ stmt.run(call.nodeId, call.package, call.symbol);
84
84
  }
85
85
  });
86
86
  tx();
87
87
  }
88
88
 
89
89
  /**
90
- * Deletes all nodes, edges, tags, and unresolved references for a given file.
90
+ * Deletes all nodes, edges, tags, and external calls for a given file.
91
91
  * Edges where the file's nodes are either source or target are removed.
92
92
  *
93
93
  * @param db - An open Database handle
@@ -95,23 +95,19 @@ function insertUnresolved(db: Database, refs: readonly UnresolvedReference[]): v
95
95
  */
96
96
  function deleteFileData(db: Database, file: string): void {
97
97
  const tx = db.transaction(() => {
98
- // Get node IDs for this file
99
- const nodeIds = db.query("SELECT id FROM nodes WHERE file = ?").all(file) as { id: string }[];
98
+ const nodeIds = db.query("SELECT id FROM nodes WHERE file = ?").all(file) as {
99
+ id: string;
100
+ }[];
100
101
  const ids = nodeIds.map((n) => n.id);
101
102
 
102
103
  if (ids.length > 0) {
103
104
  const placeholders = ids.map(() => "?").join(",");
104
- // Delete tags for these nodes
105
105
  db.run(`DELETE FROM tags WHERE node_id IN (${placeholders})`, ids);
106
- // Delete edges where these nodes are source or target
107
106
  db.run(`DELETE FROM edges WHERE source_id IN (${placeholders})`, ids);
108
107
  db.run(`DELETE FROM edges WHERE target_id IN (${placeholders})`, ids);
109
- // Delete the nodes themselves
108
+ db.run(`DELETE FROM external_calls WHERE node_id IN (${placeholders})`, ids);
110
109
  db.run(`DELETE FROM nodes WHERE id IN (${placeholders})`, ids);
111
110
  }
112
-
113
- // Delete unresolved references for this file
114
- db.run("DELETE FROM unresolved WHERE file = ?", [file]);
115
111
  });
116
112
  tx();
117
113
  }
@@ -127,8 +123,8 @@ function synthesizeEventEdges(db: Database): void {
127
123
  const tx = db.transaction(() => {
128
124
  db.run("DELETE FROM edges WHERE kind = 'event'");
129
125
  db.run(`
130
- INSERT OR IGNORE INTO edges (source_id, target_id, kind, certainty)
131
- SELECT e.node_id, h.node_id, 'event', 'certain'
126
+ INSERT OR IGNORE INTO edges (source_id, target_id, kind)
127
+ SELECT e.node_id, h.node_id, 'event'
132
128
  FROM tags e
133
129
  JOIN tags h ON e.value = h.value
134
130
  WHERE e.kind = 'emits' AND h.kind = 'handles'
@@ -140,8 +136,8 @@ function synthesizeEventEdges(db: Database): void {
140
136
  export {
141
137
  deleteFileData,
142
138
  insertEdges,
139
+ insertExternalCalls,
143
140
  insertNodes,
144
141
  insertTags,
145
- insertUnresolved,
146
142
  synthesizeEventEdges,
147
143
  };
@@ -0,0 +1,248 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { existsSync } from "node:fs";
3
+ import { join, relative, resolve } from "node:path";
4
+ import { scanTags } from "../extract/tag-scanner.ts";
5
+ import { discoverFiles } from "../files.ts";
6
+ import {
7
+ insertEdges,
8
+ insertExternalCalls,
9
+ insertNodes,
10
+ insertTags,
11
+ synthesizeEventEdges,
12
+ } from "../graph/writer.ts";
13
+ import type { Edge, ExternalCall, Node, Tag } from "../types/graph.ts";
14
+ import { outgoingCallsToEdges } from "./calls.ts";
15
+ import { createLspClient } from "./client.ts";
16
+ import { documentSymbolsToNodesWithPositions, type NodeWithPosition } from "./symbols.ts";
17
+
18
+ /** Per-language extraction configuration. */
19
+ type LanguageConfig = {
20
+ readonly language: string;
21
+ readonly extensions: readonly string[];
22
+ readonly sourceRoots: readonly string[];
23
+ readonly testPaths: readonly string[];
24
+ };
25
+
26
+ /** Options for building the graph. */
27
+ type BuildGraphOptions = {
28
+ readonly projectRoot: string;
29
+ readonly db: Database;
30
+ readonly languageConfigs: readonly LanguageConfig[];
31
+ readonly exclude: readonly string[];
32
+ };
33
+
34
+ /** Statistics from a graph build. */
35
+ type BuildStats = {
36
+ readonly fileCount: number;
37
+ readonly nodeCount: number;
38
+ readonly edgeCount: number;
39
+ readonly tagCount: number;
40
+ readonly durationMs: number;
41
+ };
42
+
43
+ /** Resolves the LSP server binary for a language, checking bundled paths first. */
44
+ function resolveLspServer(
45
+ language: string,
46
+ ): { command: string; args: readonly string[]; languageId: string } | undefined {
47
+ if (language === "typescript") {
48
+ // Check node_modules/.bin/ first (bundled with lattice-graph)
49
+ const bundled = join(
50
+ import.meta.dir,
51
+ "..",
52
+ "..",
53
+ "node_modules",
54
+ ".bin",
55
+ "typescript-language-server",
56
+ );
57
+ const command = existsSync(bundled) ? bundled : "typescript-language-server";
58
+ return { command, args: ["--stdio"], languageId: "typescript" };
59
+ }
60
+ 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" };
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ /**
69
+ * Builds the knowledge graph by querying LSP servers for symbols and call hierarchy,
70
+ * scanning for @lattice: tags, and writing everything to SQLite.
71
+ * Spawns one LSP server per language. Uses both outgoingCalls and references
72
+ * strategies to maximize edge coverage.
73
+ *
74
+ * @param opts - Build configuration
75
+ * @returns Build statistics
76
+ */
77
+ async function buildGraph(opts: BuildGraphOptions): Promise<BuildStats> {
78
+ const start = performance.now();
79
+ const { projectRoot, db } = opts;
80
+
81
+ let totalFiles = 0;
82
+ const allNodes: Node[] = [];
83
+ const allEdges: Edge[] = [];
84
+ const allTags: Tag[] = [];
85
+ const allExternalCalls: ExternalCall[] = [];
86
+
87
+ for (const langConfig of opts.languageConfigs) {
88
+ const files: string[] = [];
89
+ for (const srcRoot of langConfig.sourceRoots) {
90
+ const absRoot = resolve(projectRoot, srcRoot);
91
+ files.push(...discoverFiles(absRoot, langConfig.extensions, opts.exclude));
92
+ }
93
+
94
+ if (files.length === 0) continue;
95
+ totalFiles += files.length;
96
+
97
+ const lsp = resolveLspServer(langConfig.language);
98
+ if (!lsp) continue;
99
+
100
+ const client = await createLspClient({
101
+ command: lsp.command,
102
+ args: [...lsp.args],
103
+ rootUri: `file://${projectRoot}`,
104
+ languageId: lsp.languageId,
105
+ });
106
+
107
+ try {
108
+ await client.waitForReady(files[0] as string);
109
+
110
+ // Phase 1: extract symbols and tags from all files
111
+ type FileData = {
112
+ filePath: string;
113
+ relativePath: string;
114
+ nodesWithPos: readonly NodeWithPosition[];
115
+ };
116
+ const fileDataList: FileData[] = [];
117
+
118
+ for (const filePath of files) {
119
+ const relativePath = relative(projectRoot, filePath);
120
+ const isTest = langConfig.testPaths.some((tp) => relativePath.startsWith(tp));
121
+ const source = await Bun.file(filePath).text();
122
+
123
+ const symbols = await client.documentSymbol(filePath);
124
+ const nodesWithPos = documentSymbolsToNodesWithPositions(
125
+ symbols,
126
+ relativePath,
127
+ langConfig.language,
128
+ isTest,
129
+ );
130
+ const nodes = nodesWithPos.map((nwp) => nwp.node);
131
+ allNodes.push(...nodes);
132
+
133
+ const { tags } = scanTags(source, nodes, langConfig.language);
134
+ allTags.push(...tags);
135
+
136
+ fileDataList.push({ filePath, relativePath, nodesWithPos });
137
+ }
138
+
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;
143
+
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
160
+ }
161
+ }
162
+ }
163
+
164
+ // Phase 2b: references — "who references each function?"
165
+ const nodesByFile = new Map<string, readonly Node[]>();
166
+ for (const fd of fileDataList) {
167
+ nodesByFile.set(
168
+ fd.relativePath,
169
+ fd.nodesWithPos
170
+ .filter((nwp) => nwp.node.kind === "function" || nwp.node.kind === "method")
171
+ .map((nwp) => nwp.node),
172
+ );
173
+ }
174
+
175
+ for (const fd of fileDataList) {
176
+ for (const nwp of fd.nodesWithPos) {
177
+ if (nwp.node.kind !== "function" && nwp.node.kind !== "method") continue;
178
+
179
+ try {
180
+ const refs = await client.references(
181
+ fd.filePath,
182
+ nwp.selectionLine,
183
+ nwp.selectionCharacter,
184
+ );
185
+
186
+ for (const ref of refs) {
187
+ const refFile = ref.uri.startsWith(`file://${projectRoot}/`)
188
+ ? ref.uri.slice(`file://${projectRoot}/`.length)
189
+ : undefined;
190
+ if (!refFile) continue;
191
+
192
+ const fileFunctions = nodesByFile.get(refFile);
193
+ if (!fileFunctions) continue;
194
+
195
+ const refLine = ref.range.start.line + 1;
196
+ const caller = fileFunctions.find(
197
+ (n) => refLine >= n.lineStart && refLine <= n.lineEnd,
198
+ );
199
+ if (!caller || caller.id === nwp.node.id) continue;
200
+
201
+ allEdges.push({ sourceId: caller.id, targetId: nwp.node.id, kind: "calls" });
202
+ }
203
+ } catch {
204
+ // references not supported by this server — skip silently
205
+ }
206
+ }
207
+ }
208
+ } finally {
209
+ await client.shutdown();
210
+ }
211
+ }
212
+
213
+ insertNodes(db, allNodes);
214
+ insertEdges(db, allEdges);
215
+ insertTags(db, allTags);
216
+ insertExternalCalls(db, allExternalCalls);
217
+ synthesizeEventEdges(db);
218
+
219
+ const durationMs = Math.round(performance.now() - start);
220
+ return {
221
+ fileCount: totalFiles,
222
+ nodeCount: allNodes.length,
223
+ edgeCount: allEdges.length,
224
+ tagCount: allTags.length,
225
+ durationMs,
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Builds a LanguageConfig from a language name and config sections.
231
+ * Uses sensible defaults for LSP commands.
232
+ */
233
+ function buildLanguageConfig(
234
+ language: string,
235
+ sourceRoots: readonly string[],
236
+ testPaths: readonly string[],
237
+ ): LanguageConfig {
238
+ const extensions = language === "python" ? [".py"] : [".ts", ".tsx"];
239
+ return { language, extensions, sourceRoots, testPaths };
240
+ }
241
+
242
+ export {
243
+ type BuildGraphOptions,
244
+ type BuildStats,
245
+ buildGraph,
246
+ buildLanguageConfig,
247
+ type LanguageConfig,
248
+ };
@@ -0,0 +1,84 @@
1
+ import type { Edge, ExternalCall } from "../types/graph.ts";
2
+ import type { CallHierarchyOutgoingCall } from "./types.ts";
3
+
4
+ /** Result of converting outgoing calls to edges. */
5
+ type CallConversionResult = {
6
+ readonly edges: readonly Edge[];
7
+ readonly externalCalls: readonly ExternalCall[];
8
+ };
9
+
10
+ /**
11
+ * Converts LSP CallHierarchyOutgoingCall responses into Lattice Edges.
12
+ * Calls to symbols outside the project root are collected as external calls.
13
+ *
14
+ * @param sourceId - The Lattice node ID of the calling function
15
+ * @param calls - Outgoing calls from LSP
16
+ * @param projectRoot - Absolute path to the project root
17
+ * @returns Internal edges and external call records
18
+ */
19
+ function outgoingCallsToEdges(
20
+ sourceId: string,
21
+ calls: readonly CallHierarchyOutgoingCall[],
22
+ projectRoot: string,
23
+ ): CallConversionResult {
24
+ const edges: Edge[] = [];
25
+ const externalCalls: ExternalCall[] = [];
26
+ const projectFilePrefix = `file://${projectRoot}/`;
27
+
28
+ for (const call of calls) {
29
+ const uri = call.to.uri;
30
+
31
+ if (!uri.startsWith(projectFilePrefix) || uri.includes("/node_modules/")) {
32
+ // Skip type declarations — not runtime calls
33
+ if (isTypeDeclaration(uri)) continue;
34
+
35
+ const pkg = extractPackageName(uri);
36
+ if (pkg) {
37
+ externalCalls.push({ nodeId: sourceId, package: pkg, symbol: call.to.name });
38
+ }
39
+ continue;
40
+ }
41
+
42
+ const relativePath = uri.slice(projectFilePrefix.length);
43
+ const targetId = `${relativePath}::${call.to.name}`;
44
+ edges.push({ sourceId, targetId, kind: "calls" });
45
+ }
46
+
47
+ return { edges, externalCalls };
48
+ }
49
+
50
+ /**
51
+ * Extracts the package name from a node_modules URI.
52
+ * Handles scoped packages (@scope/package).
53
+ */
54
+ function extractPackageName(uri: string): string | undefined {
55
+ const decoded = decodeURIComponent(uri);
56
+ const nodeModulesIdx = decoded.indexOf("/node_modules/");
57
+ if (nodeModulesIdx === -1) return undefined;
58
+ const afterNm = decoded.slice(nodeModulesIdx + "/node_modules/".length);
59
+ if (afterNm.startsWith("@")) {
60
+ const parts = afterNm.split("/");
61
+ return `${parts[0]}/${parts[1]}`;
62
+ }
63
+ return afterNm.split("/")[0];
64
+ }
65
+
66
+ /**
67
+ * Checks if a URI points to a type-only package (not a runtime dependency).
68
+ * Type definition packages (@types/*, typescript, bun-types) are filtered out.
69
+ * Actual library .d.ts stubs (e.g., stripe/index.d.ts) are kept — they represent runtime deps.
70
+ */
71
+ function isTypeDeclaration(uri: string): boolean {
72
+ // TypeScript type-only packages
73
+ if (uri.includes("/node_modules/@types/")) return true;
74
+ if (uri.includes("/node_modules/%40types/")) return true;
75
+ if (uri.includes("/node_modules/typescript/")) return true;
76
+ if (uri.includes("/node_modules/bun-types/")) return true;
77
+ // Python type stubs
78
+ if (uri.includes("/typeshed/")) return true;
79
+ if (uri.includes("/typestubs/")) return true;
80
+ if (uri.includes("-stubs/")) return true;
81
+ return false;
82
+ }
83
+
84
+ export { type CallConversionResult, extractPackageName, isTypeDeclaration, outgoingCallsToEdges };