lattice-graph 0.1.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.
@@ -0,0 +1,336 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import type { Node } from "../types/graph.ts";
3
+
4
+ /** Raw node row from SQLite with snake_case column names. */
5
+ type NodeRow = {
6
+ id: string;
7
+ kind: string;
8
+ name: string;
9
+ file: string;
10
+ line_start: number;
11
+ line_end: number;
12
+ language: string;
13
+ signature: string | null;
14
+ is_test: number;
15
+ metadata: string | null;
16
+ };
17
+
18
+ /** Converts a raw SQLite row to a typed Node. */
19
+ function rowToNode(row: NodeRow): Node {
20
+ return {
21
+ id: row.id,
22
+ kind: row.kind as Node["kind"],
23
+ name: row.name,
24
+ file: row.file,
25
+ lineStart: row.line_start,
26
+ lineEnd: row.line_end,
27
+ language: row.language,
28
+ signature: row.signature ?? undefined,
29
+ isTest: row.is_test === 1,
30
+ metadata: row.metadata ? (JSON.parse(row.metadata) as Record<string, string>) : undefined,
31
+ };
32
+ }
33
+
34
+ /** Flow entry point with its tag value and associated node. */
35
+ type FlowEntry = {
36
+ readonly value: string;
37
+ readonly node: Node;
38
+ };
39
+
40
+ /** Boundary tag with its value and associated node. */
41
+ type BoundaryEntry = {
42
+ readonly value: string;
43
+ readonly node: Node;
44
+ };
45
+
46
+ /** An event connection between an emitter and a handler. */
47
+ type EventConnection = {
48
+ readonly eventName: string;
49
+ readonly emitterName: string;
50
+ readonly emitterFile: string;
51
+ readonly handlerName: string;
52
+ readonly handlerFile: string;
53
+ };
54
+
55
+ /**
56
+ * Resolves a symbol by full ID or short name.
57
+ * Full ID match takes priority. Falls back to name match.
58
+ *
59
+ * @param db - An open Database handle
60
+ * @param symbol - Full node ID or short symbol name
61
+ * @returns Matching nodes, empty if none found
62
+ */
63
+ function resolveSymbol(db: Database, symbol: string): readonly Node[] {
64
+ // Try exact ID match first
65
+ const exact = db.query("SELECT * FROM nodes WHERE id = ?").get(symbol) as NodeRow | null;
66
+ if (exact) return [rowToNode(exact)];
67
+
68
+ // Fall back to name match
69
+ const byName = db.query("SELECT * FROM nodes WHERE name = ?").all(symbol) as NodeRow[];
70
+ return byName.map(rowToNode);
71
+ }
72
+
73
+ /**
74
+ * Returns all nodes that are members of a flow via recursive call graph traversal.
75
+ * Includes the tagged entry point(s) and all nodes reachable through calls and event edges.
76
+ *
77
+ * @param db - An open Database handle
78
+ * @param flowName - The flow tag value to query
79
+ * @returns All nodes in the flow
80
+ */
81
+ function getFlowMembers(db: Database, flowName: string): readonly Node[] {
82
+ const rows = db
83
+ .query(
84
+ `WITH RECURSIVE flow_members AS (
85
+ SELECT node_id FROM tags WHERE kind = 'flow' AND value = ?
86
+ UNION
87
+ SELECT e.target_id FROM edges e
88
+ JOIN flow_members fm ON e.source_id = fm.node_id
89
+ WHERE e.kind IN ('calls', 'event')
90
+ )
91
+ SELECT n.* FROM nodes n WHERE n.id IN (SELECT node_id FROM flow_members)`,
92
+ )
93
+ .all(flowName) as NodeRow[];
94
+ return rows.map(rowToNode);
95
+ }
96
+
97
+ /**
98
+ * Returns direct callers of a node (reverse call/event edges).
99
+ *
100
+ * @param db - An open Database handle
101
+ * @param nodeId - The full node ID
102
+ * @returns Nodes that directly call or trigger this node
103
+ */
104
+ function getCallers(db: Database, nodeId: string): readonly Node[] {
105
+ const rows = db
106
+ .query(
107
+ `SELECT n.* FROM nodes n
108
+ JOIN edges e ON n.id = e.source_id
109
+ WHERE e.target_id = ? AND e.kind IN ('calls', 'event')`,
110
+ )
111
+ .all(nodeId) as NodeRow[];
112
+ return rows.map(rowToNode);
113
+ }
114
+
115
+ /**
116
+ * Returns direct callees of a node (forward call/event edges).
117
+ *
118
+ * @param db - An open Database handle
119
+ * @param nodeId - The full node ID
120
+ * @returns Nodes that this node directly calls or triggers
121
+ */
122
+ function getCallees(db: Database, nodeId: string): readonly Node[] {
123
+ const rows = db
124
+ .query(
125
+ `SELECT n.* FROM nodes n
126
+ JOIN edges e ON n.id = e.target_id
127
+ WHERE e.source_id = ? AND e.kind IN ('calls', 'event')`,
128
+ )
129
+ .all(nodeId) as NodeRow[];
130
+ return rows.map(rowToNode);
131
+ }
132
+
133
+ /**
134
+ * Returns all transitive callers of a node (upstream traversal).
135
+ * Used for impact analysis — "what is affected if I change this?"
136
+ *
137
+ * @param db - An open Database handle
138
+ * @param nodeId - The full node ID to analyze
139
+ * @returns All nodes that transitively depend on this node
140
+ */
141
+ function getImpact(db: Database, nodeId: string): readonly Node[] {
142
+ const rows = db
143
+ .query(
144
+ `WITH RECURSIVE upstream AS (
145
+ SELECT source_id FROM edges WHERE target_id = ? AND kind IN ('calls', 'event')
146
+ UNION
147
+ SELECT e.source_id FROM edges e
148
+ JOIN upstream u ON e.target_id = u.source_id
149
+ WHERE e.kind IN ('calls', 'event')
150
+ )
151
+ SELECT n.* FROM nodes n WHERE n.id IN (SELECT source_id FROM upstream)`,
152
+ )
153
+ .all(nodeId) as NodeRow[];
154
+ return rows.map(rowToNode);
155
+ }
156
+
157
+ /**
158
+ * Returns which flows a node participates in (derived from graph traversal).
159
+ * Checks if any flow entry point can reach this node through the call graph.
160
+ *
161
+ * @param db - An open Database handle
162
+ * @param nodeId - The full node ID
163
+ * @returns Flow names this node belongs to
164
+ */
165
+ function getFlowsForNode(db: Database, nodeId: string): readonly string[] {
166
+ // First check if the node itself has a flow tag
167
+ const directTags = db
168
+ .query("SELECT value FROM tags WHERE node_id = ? AND kind = 'flow'")
169
+ .all(nodeId) as { value: string }[];
170
+
171
+ // Then check if any flow reaches this node
172
+ const derivedFlows = db
173
+ .query(
174
+ `SELECT DISTINCT t.value FROM tags t
175
+ WHERE t.kind = 'flow'
176
+ AND EXISTS (
177
+ WITH RECURSIVE flow_members AS (
178
+ SELECT t.node_id AS node_id
179
+ UNION
180
+ SELECT e.target_id FROM edges e
181
+ JOIN flow_members fm ON e.source_id = fm.node_id
182
+ WHERE e.kind IN ('calls', 'event')
183
+ )
184
+ SELECT 1 FROM flow_members WHERE node_id = ?
185
+ )`,
186
+ )
187
+ .all(nodeId) as { value: string }[];
188
+
189
+ const all = new Set([...directTags.map((t) => t.value), ...derivedFlows.map((f) => f.value)]);
190
+ return [...all];
191
+ }
192
+
193
+ /**
194
+ * Returns all flow entry points in the graph.
195
+ *
196
+ * @param db - An open Database handle
197
+ * @returns Flow entries with tag value and associated node
198
+ */
199
+ function getAllFlows(db: Database): readonly FlowEntry[] {
200
+ const rows = db
201
+ .query(
202
+ `SELECT t.value, n.* FROM tags t
203
+ JOIN nodes n ON t.node_id = n.id
204
+ WHERE t.kind = 'flow'
205
+ ORDER BY t.value, n.file`,
206
+ )
207
+ .all() as (NodeRow & { value: string })[];
208
+ return rows.map((row) => ({ value: row.value, node: rowToNode(row) }));
209
+ }
210
+
211
+ /**
212
+ * Returns all boundary-tagged nodes in the graph.
213
+ *
214
+ * @param db - An open Database handle
215
+ * @returns Boundary entries with tag value and associated node
216
+ */
217
+ function getAllBoundaries(db: Database): readonly BoundaryEntry[] {
218
+ const rows = db
219
+ .query(
220
+ `SELECT t.value, n.* FROM tags t
221
+ JOIN nodes n ON t.node_id = n.id
222
+ WHERE t.kind = 'boundary'
223
+ ORDER BY t.value, n.file`,
224
+ )
225
+ .all() as (NodeRow & { value: string })[];
226
+ return rows.map((row) => ({ value: row.value, node: rowToNode(row) }));
227
+ }
228
+
229
+ /**
230
+ * Returns all event connections (emits → handles).
231
+ *
232
+ * @param db - An open Database handle
233
+ * @returns Event connections with emitter and handler info
234
+ */
235
+ function getAllEvents(db: Database): readonly EventConnection[] {
236
+ const rows = db
237
+ .query(
238
+ `SELECT e.value AS event_name,
239
+ emitter.name AS emitter_name, emitter.file AS emitter_file,
240
+ handler.name AS handler_name, handler.file AS handler_file
241
+ FROM tags e
242
+ JOIN tags h ON e.value = h.value AND h.kind = 'handles'
243
+ JOIN nodes emitter ON e.node_id = emitter.id
244
+ JOIN nodes handler ON h.node_id = handler.id
245
+ WHERE e.kind = 'emits'
246
+ ORDER BY e.value`,
247
+ )
248
+ .all() as {
249
+ event_name: string;
250
+ emitter_name: string;
251
+ emitter_file: string;
252
+ handler_name: string;
253
+ handler_file: string;
254
+ }[];
255
+ return rows.map((row) => ({
256
+ eventName: row.event_name,
257
+ emitterName: row.emitter_name,
258
+ emitterFile: row.emitter_file,
259
+ handlerName: row.handler_name,
260
+ handlerFile: row.handler_file,
261
+ }));
262
+ }
263
+
264
+ /**
265
+ * Finds all paths from a source node to a target node using DFS with backtracking.
266
+ * Traverses calls and event edges. Cycle-aware via visited set.
267
+ *
268
+ * @param db - An open Database handle
269
+ * @param sourceId - Starting node ID
270
+ * @param targetId - Target node ID
271
+ * @returns All distinct paths as arrays of node IDs
272
+ */
273
+ function findAllPaths(
274
+ db: Database,
275
+ sourceId: string,
276
+ targetId: string,
277
+ ): readonly (readonly string[])[] {
278
+ // Build adjacency list in memory for efficient traversal
279
+ const edges = db
280
+ .query("SELECT source_id, target_id FROM edges WHERE kind IN ('calls', 'event')")
281
+ .all() as { source_id: string; target_id: string }[];
282
+
283
+ const adjacency = new Map<string, string[]>();
284
+ for (const edge of edges) {
285
+ const existing = adjacency.get(edge.source_id);
286
+ if (existing) {
287
+ existing.push(edge.target_id);
288
+ } else {
289
+ adjacency.set(edge.source_id, [edge.target_id]);
290
+ }
291
+ }
292
+
293
+ const results: string[][] = [];
294
+ const visited = new Set<string>();
295
+
296
+ function dfs(current: string, path: string[]): void {
297
+ if (current === targetId) {
298
+ results.push([...path]);
299
+ return;
300
+ }
301
+
302
+ const neighbors = adjacency.get(current);
303
+ if (!neighbors) return;
304
+
305
+ for (const neighbor of neighbors) {
306
+ if (!visited.has(neighbor)) {
307
+ visited.add(neighbor);
308
+ path.push(neighbor);
309
+ dfs(neighbor, path);
310
+ path.pop();
311
+ visited.delete(neighbor);
312
+ }
313
+ }
314
+ }
315
+
316
+ visited.add(sourceId);
317
+ dfs(sourceId, [sourceId]);
318
+
319
+ return results;
320
+ }
321
+
322
+ export {
323
+ type BoundaryEntry,
324
+ type EventConnection,
325
+ type FlowEntry,
326
+ findAllPaths,
327
+ getAllBoundaries,
328
+ getAllEvents,
329
+ getAllFlows,
330
+ getCallees,
331
+ getCallers,
332
+ getFlowMembers,
333
+ getFlowsForNode,
334
+ getImpact,
335
+ resolveSymbol,
336
+ };
@@ -0,0 +1,147 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import type { Edge, Node, Tag, UnresolvedReference } from "../types/graph.ts";
3
+
4
+ /**
5
+ * Inserts nodes into the graph database.
6
+ * Uses INSERT OR REPLACE to handle re-indexing.
7
+ *
8
+ * @param db - An open Database handle
9
+ * @param nodes - Nodes to insert
10
+ */
11
+ function insertNodes(db: Database, nodes: readonly Node[]): void {
12
+ const stmt = db.prepare(
13
+ "INSERT OR REPLACE INTO nodes (id, kind, name, file, line_start, line_end, language, signature, is_test, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
14
+ );
15
+ const tx = db.transaction(() => {
16
+ for (const node of nodes) {
17
+ stmt.run(
18
+ node.id,
19
+ node.kind,
20
+ node.name,
21
+ node.file,
22
+ node.lineStart,
23
+ node.lineEnd,
24
+ node.language,
25
+ node.signature ?? null,
26
+ node.isTest ? 1 : 0,
27
+ node.metadata ? JSON.stringify(node.metadata) : null,
28
+ );
29
+ }
30
+ });
31
+ tx();
32
+ }
33
+
34
+ /**
35
+ * Inserts edges into the graph database.
36
+ * Uses INSERT OR IGNORE to skip duplicates.
37
+ *
38
+ * @param db - An open Database handle
39
+ * @param edges - Edges to insert
40
+ */
41
+ function insertEdges(db: Database, edges: readonly Edge[]): void {
42
+ const stmt = db.prepare(
43
+ "INSERT OR IGNORE INTO edges (source_id, target_id, kind, certainty) VALUES (?, ?, ?, ?)",
44
+ );
45
+ const tx = db.transaction(() => {
46
+ for (const edge of edges) {
47
+ stmt.run(edge.sourceId, edge.targetId, edge.kind, edge.certainty);
48
+ }
49
+ });
50
+ tx();
51
+ }
52
+
53
+ /**
54
+ * Inserts tags into the graph database.
55
+ * Uses INSERT OR IGNORE to skip duplicates.
56
+ *
57
+ * @param db - An open Database handle
58
+ * @param tags - Tags to insert
59
+ */
60
+ function insertTags(db: Database, tags: readonly Tag[]): void {
61
+ const stmt = db.prepare("INSERT OR IGNORE INTO tags (node_id, kind, value) VALUES (?, ?, ?)");
62
+ const tx = db.transaction(() => {
63
+ for (const tag of tags) {
64
+ stmt.run(tag.nodeId, tag.kind, tag.value);
65
+ }
66
+ });
67
+ tx();
68
+ }
69
+
70
+ /**
71
+ * Inserts unresolved references into the database.
72
+ * Uses INSERT OR IGNORE to skip duplicates.
73
+ *
74
+ * @param db - An open Database handle
75
+ * @param refs - Unresolved references to insert
76
+ */
77
+ function insertUnresolved(db: Database, refs: readonly UnresolvedReference[]): void {
78
+ const stmt = db.prepare(
79
+ "INSERT OR IGNORE INTO unresolved (file, line, expression, reason) VALUES (?, ?, ?, ?)",
80
+ );
81
+ const tx = db.transaction(() => {
82
+ for (const ref of refs) {
83
+ stmt.run(ref.file, ref.line, ref.expression, ref.reason);
84
+ }
85
+ });
86
+ tx();
87
+ }
88
+
89
+ /**
90
+ * Deletes all nodes, edges, tags, and unresolved references for a given file.
91
+ * Edges where the file's nodes are either source or target are removed.
92
+ *
93
+ * @param db - An open Database handle
94
+ * @param file - Relative file path to delete data for
95
+ */
96
+ function deleteFileData(db: Database, file: string): void {
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 }[];
100
+ const ids = nodeIds.map((n) => n.id);
101
+
102
+ if (ids.length > 0) {
103
+ const placeholders = ids.map(() => "?").join(",");
104
+ // Delete tags for these nodes
105
+ db.run(`DELETE FROM tags WHERE node_id IN (${placeholders})`, ids);
106
+ // Delete edges where these nodes are source or target
107
+ db.run(`DELETE FROM edges WHERE source_id IN (${placeholders})`, ids);
108
+ db.run(`DELETE FROM edges WHERE target_id IN (${placeholders})`, ids);
109
+ // Delete the nodes themselves
110
+ db.run(`DELETE FROM nodes WHERE id IN (${placeholders})`, ids);
111
+ }
112
+
113
+ // Delete unresolved references for this file
114
+ db.run("DELETE FROM unresolved WHERE file = ?", [file]);
115
+ });
116
+ tx();
117
+ }
118
+
119
+ /**
120
+ * Creates synthetic event edges from @lattice:emits to @lattice:handles.
121
+ * Deletes all existing event edges first, then recreates from current tags.
122
+ * This ensures event edges stay consistent after any tag changes.
123
+ *
124
+ * @param db - An open Database handle
125
+ */
126
+ function synthesizeEventEdges(db: Database): void {
127
+ const tx = db.transaction(() => {
128
+ db.run("DELETE FROM edges WHERE kind = 'event'");
129
+ db.run(`
130
+ INSERT OR IGNORE INTO edges (source_id, target_id, kind, certainty)
131
+ SELECT e.node_id, h.node_id, 'event', 'certain'
132
+ FROM tags e
133
+ JOIN tags h ON e.value = h.value
134
+ WHERE e.kind = 'emits' AND h.kind = 'handles'
135
+ `);
136
+ });
137
+ tx();
138
+ }
139
+
140
+ export {
141
+ deleteFileData,
142
+ insertEdges,
143
+ insertNodes,
144
+ insertTags,
145
+ insertUnresolved,
146
+ synthesizeEventEdges,
147
+ };