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.
package/src/main.ts ADDED
@@ -0,0 +1,525 @@
1
+ #!/usr/bin/env bun
2
+ import { Database } from "bun:sqlite";
3
+ import { readFileSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { Command } from "commander";
6
+ import { executeBuild } from "./commands/build.ts";
7
+ import { executeInit } from "./commands/init.ts";
8
+ import { executeLint } from "./commands/lint.ts";
9
+ import { executePopulate } from "./commands/populate.ts";
10
+ import { executeUpdate } from "./commands/update.ts";
11
+ import { parseConfig } from "./config.ts";
12
+ import { checkSchemaVersion } from "./graph/database.ts";
13
+ import {
14
+ findAllPaths,
15
+ getAllBoundaries,
16
+ getAllEvents,
17
+ getAllFlows,
18
+ getCallees,
19
+ getCallers,
20
+ getFlowMembers,
21
+ getFlowsForNode,
22
+ getImpact,
23
+ resolveSymbol,
24
+ } from "./graph/queries.ts";
25
+ import { formatContextJson, formatOverviewJson } from "./output/json.ts";
26
+ import {
27
+ type FlowTreeNode,
28
+ formatBoundaries,
29
+ formatCallees,
30
+ formatCallers,
31
+ formatContext,
32
+ formatEvents,
33
+ formatFlowTree,
34
+ formatImpact,
35
+ formatOverview,
36
+ } from "./output/text.ts";
37
+ import type { Node } from "./types/graph.ts";
38
+ import { isOk, unwrap } from "./types/result.ts";
39
+
40
+ const VERSION = "0.1.0";
41
+
42
+ const program = new Command();
43
+ program.name("lattice").description("Knowledge graph CLI for coding agents").version(VERSION);
44
+
45
+ /** Loads config from lattice.toml in the given directory. */
46
+ function loadConfig(dir: string): ReturnType<typeof parseConfig> {
47
+ const tomlPath = join(dir, "lattice.toml");
48
+ try {
49
+ const toml = readFileSync(tomlPath, "utf-8");
50
+ return parseConfig(toml);
51
+ } catch {
52
+ return { ok: false, error: `Cannot read ${tomlPath}` };
53
+ }
54
+ }
55
+
56
+ /** Opens the graph database for query commands. */
57
+ function openDb(dir: string): Database {
58
+ const dbPath = join(dir, ".lattice", "graph.db");
59
+ const db = new Database(dbPath, { readonly: true });
60
+ const check = checkSchemaVersion(db);
61
+ if (!isOk(check)) {
62
+ db.close();
63
+ throw new Error(unwrap(check));
64
+ }
65
+ return db;
66
+ }
67
+
68
+ // --- init ---
69
+ program
70
+ .command("init")
71
+ .description("Initialize .lattice/ in a project, detect languages")
72
+ .action(() => {
73
+ const cwd = process.cwd();
74
+ const result = executeInit(cwd);
75
+ if (isOk(result)) {
76
+ console.log(unwrap(result));
77
+ } else {
78
+ console.error(result.error);
79
+ process.exit(1);
80
+ }
81
+ });
82
+
83
+ // --- build ---
84
+ program
85
+ .command("build")
86
+ .description("Full index: parse all files, resolve imports, build graph")
87
+ .action(async () => {
88
+ const cwd = process.cwd();
89
+ const configResult = loadConfig(cwd);
90
+ if (!isOk(configResult)) {
91
+ console.error(configResult.error);
92
+ process.exit(1);
93
+ }
94
+ const config = unwrap(configResult);
95
+ const result = await executeBuild(cwd, config);
96
+ if (isOk(result)) {
97
+ const stats = unwrap(result);
98
+ console.log(
99
+ `Built graph: ${stats.fileCount} files, ${stats.nodeCount} nodes, ${stats.edgeCount} edges, ${stats.tagCount} tags (${stats.durationMs}ms)`,
100
+ );
101
+ } else {
102
+ console.error(result.error);
103
+ process.exit(1);
104
+ }
105
+ });
106
+
107
+ // --- lint ---
108
+ program
109
+ .command("lint")
110
+ .description("Validate tags: syntax, typos, orphans, missing tags")
111
+ .option("--strict", "Treat warnings as errors")
112
+ .option("--unresolved", "Show detailed unresolved references")
113
+ .action((opts: { strict?: boolean; unresolved?: boolean }) => {
114
+ const cwd = process.cwd();
115
+ const configResult = loadConfig(cwd);
116
+ if (!isOk(configResult)) {
117
+ console.error(configResult.error);
118
+ process.exit(1);
119
+ }
120
+ const config = unwrap(configResult);
121
+ const db = openDb(cwd);
122
+ const result = executeLint(db, config);
123
+
124
+ // Print issues
125
+ const errors = result.issues.filter((i) => i.severity === "error");
126
+ const warnings = result.issues.filter((i) => i.severity === "warning");
127
+
128
+ if (errors.length > 0) {
129
+ console.log("Errors (must fix):");
130
+ for (const issue of errors) {
131
+ console.log(` ${issue.file}:${issue.line} ${issue.symbol}`);
132
+ console.log(` ${issue.message}`);
133
+ console.log();
134
+ }
135
+ }
136
+
137
+ if (warnings.length > 0) {
138
+ console.log("Warnings (should fix):");
139
+ for (const issue of warnings) {
140
+ console.log(` ${issue.file}:${issue.line} ${issue.symbol}`);
141
+ console.log(` ${issue.message}`);
142
+ console.log();
143
+ }
144
+ }
145
+
146
+ // Coverage info
147
+ console.log(
148
+ `Info:\n Coverage: ${result.coverage.tagged}/${result.coverage.total} entry points tagged (${result.coverage.total > 0 ? Math.round((result.coverage.tagged / result.coverage.total) * 100) : 0}%)`,
149
+ );
150
+ console.log(` Unresolved references: ${result.unresolvedCount}`);
151
+
152
+ // Unresolved details
153
+ if (opts.unresolved) {
154
+ const refs = db
155
+ .query("SELECT file, line, expression, reason FROM unresolved ORDER BY file, line")
156
+ .all() as { file: string; line: number; expression: string; reason: string }[];
157
+ if (refs.length > 0) {
158
+ console.log("\nUnresolved references:");
159
+ for (const ref of refs) {
160
+ console.log(` ${ref.file}:${ref.line} ${ref.expression} (${ref.reason})`);
161
+ }
162
+ }
163
+ }
164
+
165
+ db.close();
166
+
167
+ // Exit code
168
+ const hasErrors = errors.length > 0;
169
+ const hasWarnings = warnings.length > 0;
170
+ if (hasErrors || (opts.strict && hasWarnings)) {
171
+ process.exit(1);
172
+ }
173
+ });
174
+
175
+ // --- update ---
176
+ program
177
+ .command("update")
178
+ .description("Incremental: re-index only files changed since last build")
179
+ .action(async () => {
180
+ const cwd = process.cwd();
181
+ const configResult = loadConfig(cwd);
182
+ if (!isOk(configResult)) {
183
+ console.error(configResult.error);
184
+ process.exit(1);
185
+ }
186
+ const config = unwrap(configResult);
187
+ const result = await executeUpdate(cwd, config);
188
+ if (isOk(result)) {
189
+ const stats = unwrap(result);
190
+ console.log(
191
+ `Updated: ${stats.filesReindexed}/${stats.totalFiles} files re-indexed (${stats.durationMs}ms)`,
192
+ );
193
+ } else {
194
+ console.error(result.error);
195
+ process.exit(1);
196
+ }
197
+ });
198
+
199
+ // --- populate ---
200
+ program
201
+ .command("populate")
202
+ .description("Output agent instructions for tagging the codebase")
203
+ .action(() => {
204
+ const cwd = process.cwd();
205
+ const configResult = loadConfig(cwd);
206
+ if (!isOk(configResult)) {
207
+ console.error(configResult.error);
208
+ process.exit(1);
209
+ }
210
+ const config = unwrap(configResult);
211
+ const db = openDb(cwd);
212
+ const output = executePopulate(db, config);
213
+ db.close();
214
+ console.log(output);
215
+ });
216
+
217
+ // --- overview ---
218
+ program
219
+ .command("overview")
220
+ .description("Project landscape: flows, boundaries, event connections")
221
+ .option("--json", "Output as JSON")
222
+ .action((opts: { json?: boolean }) => {
223
+ const db = openDb(process.cwd());
224
+ const flows = getAllFlows(db);
225
+ const boundaries = getAllBoundaries(db);
226
+ const events = getAllEvents(db);
227
+ db.close();
228
+
229
+ if (opts.json) {
230
+ console.log(formatOverviewJson(flows, boundaries, events));
231
+ } else {
232
+ console.log(formatOverview(flows, boundaries, events));
233
+ }
234
+ });
235
+
236
+ // --- flows ---
237
+ program
238
+ .command("flows")
239
+ .description("List all flows with their entry points")
240
+ .action(() => {
241
+ const db = openDb(process.cwd());
242
+ const flows = getAllFlows(db);
243
+ db.close();
244
+
245
+ for (const flow of flows) {
246
+ const route = flow.node.metadata?.route;
247
+ const routeStr = route ? `→ ${route} ` : "→ ";
248
+ console.log(`${flow.value.padEnd(20)} ${routeStr}(${flow.node.file}:${flow.node.lineStart})`);
249
+ }
250
+ });
251
+
252
+ // --- flow <name> ---
253
+ program
254
+ .command("flow <name>")
255
+ .description("Full call tree from a flow's entry point to its boundaries")
256
+ .action((name: string) => {
257
+ const db = openDb(process.cwd());
258
+ const members = getFlowMembers(db, name);
259
+ if (members.length === 0) {
260
+ db.close();
261
+ console.log(`Unknown flow: ${name}`);
262
+ process.exit(1);
263
+ }
264
+
265
+ // Build call tree from entry points
266
+ const flows = getAllFlows(db);
267
+ const entryPoints = flows.filter((f) => f.value === name).map((f) => f.node);
268
+ const boundaries = getAllBoundaries(db);
269
+ const boundaryMap = new Map(boundaries.map((b) => [b.node.id, b.value]));
270
+
271
+ for (const entry of entryPoints) {
272
+ const tree = buildFlowTree(db, entry, boundaryMap, new Set());
273
+ console.log(formatFlowTree(tree));
274
+ }
275
+ db.close();
276
+ });
277
+
278
+ // --- context <symbol> ---
279
+ program
280
+ .command("context <symbol>")
281
+ .description("Symbol neighborhood: callers, callees, flows, boundaries")
282
+ .option("--json", "Output as JSON")
283
+ .action((symbol: string, opts: { json?: boolean }) => {
284
+ const db = openDb(process.cwd());
285
+ const nodes = resolveSymbol(db, symbol);
286
+
287
+ if (nodes.length === 0) {
288
+ db.close();
289
+ console.log(`Unknown symbol: ${symbol}`);
290
+ process.exit(1);
291
+ }
292
+
293
+ if (nodes.length > 1) {
294
+ db.close();
295
+ console.log("Ambiguous symbol. Matches:");
296
+ for (const n of nodes) {
297
+ console.log(` ${n.id}`);
298
+ }
299
+ process.exit(1);
300
+ }
301
+
302
+ const node = nodes[0];
303
+ if (!node) {
304
+ db.close();
305
+ console.error("Symbol resolution failed");
306
+ process.exit(1);
307
+ }
308
+ const callers = getCallers(db, node.id);
309
+ const callees = getCallees(db, node.id);
310
+ const flowNames = getFlowsForNode(db, node.id);
311
+ const allBoundaries = getAllBoundaries(db);
312
+ const boundary = allBoundaries.find((b) => b.node.id === node.id)?.value;
313
+ db.close();
314
+
315
+ const data = { node, flows: flowNames, callers: [...callers], callees: [...callees], boundary };
316
+ if (opts.json) {
317
+ console.log(formatContextJson(data));
318
+ } else {
319
+ console.log(formatContext(data));
320
+ }
321
+ });
322
+
323
+ // --- callers <symbol> ---
324
+ program
325
+ .command("callers <symbol>")
326
+ .description("What calls this symbol (reverse edges)")
327
+ .action((symbol: string) => {
328
+ const db = openDb(process.cwd());
329
+ const node = resolveOne(db, symbol);
330
+ const callers = getCallers(db, node.id);
331
+ db.close();
332
+ console.log(formatCallers([...callers]));
333
+ });
334
+
335
+ // --- callees <symbol> ---
336
+ program
337
+ .command("callees <symbol>")
338
+ .description("What this symbol calls (forward edges)")
339
+ .action((symbol: string) => {
340
+ const db = openDb(process.cwd());
341
+ const node = resolveOne(db, symbol);
342
+ const callees = getCallees(db, node.id);
343
+ db.close();
344
+ console.log(formatCallees([...callees]));
345
+ });
346
+
347
+ // --- trace <flow> --to <boundary> ---
348
+ program
349
+ .command("trace <flow>")
350
+ .description("Call chain from flow entry to a specific boundary")
351
+ .requiredOption("--to <boundary>", "Target boundary system name")
352
+ .action((flowName: string, opts: { to: string }) => {
353
+ const db = openDb(process.cwd());
354
+ const flows = getAllFlows(db);
355
+ const entries = flows.filter((f) => f.value === flowName);
356
+ if (entries.length === 0) {
357
+ db.close();
358
+ console.log(`Unknown flow: ${flowName}`);
359
+ process.exit(1);
360
+ }
361
+
362
+ const boundaries = getAllBoundaries(db);
363
+ const targets = boundaries.filter((b) => b.value === opts.to);
364
+ if (targets.length === 0) {
365
+ db.close();
366
+ console.log(`Unknown boundary: ${opts.to}`);
367
+ process.exit(1);
368
+ }
369
+
370
+ let found = false;
371
+ for (const entry of entries) {
372
+ for (const target of targets) {
373
+ const paths = findAllPaths(db, entry.node.id, target.node.id);
374
+ for (const path of paths) {
375
+ found = true;
376
+ const pathNodes = path
377
+ .map((id) => resolveSymbol(db, id))
378
+ .filter((n) => n.length > 0)
379
+ .map((n) => n[0])
380
+ .filter((n): n is Node => n !== undefined);
381
+ for (let i = 0; i < pathNodes.length; i++) {
382
+ const indent = i === 0 ? "" : `${" ".repeat(i)}→ `;
383
+ const boundaryTag = boundaries.find((b) => b.node.id === pathNodes[i]?.id);
384
+ const suffix = boundaryTag ? ` [${boundaryTag.value}]` : "";
385
+ console.log(
386
+ `${indent}${pathNodes[i]?.name} (${pathNodes[i]?.file}:${pathNodes[i]?.lineStart})${suffix}`,
387
+ );
388
+ }
389
+ }
390
+ }
391
+ }
392
+
393
+ if (!found) {
394
+ console.log(`No path from ${flowName} to ${opts.to}`);
395
+ }
396
+ db.close();
397
+ });
398
+
399
+ // --- impact <symbol> ---
400
+ program
401
+ .command("impact <symbol>")
402
+ .description("Everything affected if this symbol changes")
403
+ .action((symbol: string) => {
404
+ const db = openDb(process.cwd());
405
+ const node = resolveOne(db, symbol);
406
+ const directCallers = [...getCallers(db, node.id)];
407
+ const allUpstream = [...getImpact(db, node.id)];
408
+ const transitiveCallers = allUpstream.filter((n) => !directCallers.some((d) => d.id === n.id));
409
+ const affectedFlows = [...new Set(allUpstream.flatMap((n) => [...getFlowsForNode(db, n.id)]))];
410
+ const affectedTests = allUpstream.filter((n) => n.isTest);
411
+ db.close();
412
+
413
+ console.log(formatImpact({ directCallers, transitiveCallers, affectedFlows, affectedTests }));
414
+ });
415
+
416
+ // --- boundaries ---
417
+ program
418
+ .command("boundaries")
419
+ .description("All external system boundaries")
420
+ .action(() => {
421
+ const db = openDb(process.cwd());
422
+ const boundaries = getAllBoundaries(db);
423
+ db.close();
424
+ console.log(formatBoundaries([...boundaries]));
425
+ });
426
+
427
+ // --- events ---
428
+ program
429
+ .command("events")
430
+ .description("All event connections (emits → handles)")
431
+ .action(() => {
432
+ const db = openDb(process.cwd());
433
+ const events = getAllEvents(db);
434
+ db.close();
435
+ console.log(formatEvents([...events]));
436
+ });
437
+
438
+ // --- code <symbol> ---
439
+ program
440
+ .command("code <symbol>")
441
+ .description("Source code of a specific function/method")
442
+ .action((symbol: string) => {
443
+ const db = openDb(process.cwd());
444
+ const node = resolveOne(db, symbol);
445
+ db.close();
446
+
447
+ const fullPath = resolve(process.cwd(), node.file);
448
+ const source = readFileSync(fullPath, "utf-8");
449
+ const lines = source.split("\n");
450
+
451
+ // Expand upward to include lattice tags and decorators
452
+ let start = node.lineStart - 1; // 0-based
453
+ while (start > 0) {
454
+ const line = lines[start - 1]?.trim();
455
+ if (!line) break;
456
+ if (
457
+ line.startsWith("#") ||
458
+ line.startsWith("//") ||
459
+ line.startsWith("@") ||
460
+ line.startsWith("/*")
461
+ ) {
462
+ start--;
463
+ } else {
464
+ break;
465
+ }
466
+ }
467
+
468
+ const codeLines = lines.slice(start, node.lineEnd);
469
+ console.log(`# ${node.file}:${start + 1}-${node.lineEnd}\n`);
470
+ console.log(codeLines.join("\n"));
471
+ });
472
+
473
+ /** Resolves a symbol to exactly one node. Exits on ambiguity or not found. */
474
+ function resolveOne(db: Database, symbol: string): Node {
475
+ const nodes = resolveSymbol(db, symbol);
476
+ if (nodes.length === 0) {
477
+ db.close();
478
+ console.error(`Unknown symbol: ${symbol}`);
479
+ process.exit(1);
480
+ }
481
+ if (nodes.length > 1) {
482
+ db.close();
483
+ console.error("Ambiguous symbol. Matches:");
484
+ for (const n of nodes) {
485
+ console.error(` ${n.id}`);
486
+ }
487
+ process.exit(1);
488
+ }
489
+ const node = nodes[0];
490
+ if (!node) {
491
+ db.close();
492
+ console.error("Symbol resolution failed");
493
+ process.exit(1);
494
+ }
495
+ return node;
496
+ }
497
+
498
+ /** Builds a flow tree by recursively following call edges from a root node. */
499
+ function buildFlowTree(
500
+ db: Database,
501
+ node: Node,
502
+ boundaryMap: Map<string, string>,
503
+ visited: Set<string>,
504
+ ): FlowTreeNode {
505
+ visited.add(node.id);
506
+ const boundary = boundaryMap.get(node.id);
507
+ const callees = getCallees(db, node.id);
508
+
509
+ // Check for emits tags
510
+ const emitRows = db
511
+ .query("SELECT value FROM tags WHERE node_id = ? AND kind = 'emits'")
512
+ .all(node.id) as { value: string }[];
513
+ const emits = emitRows.length > 0 ? emitRows.map((r) => r.value).join(", ") : undefined;
514
+
515
+ const children: FlowTreeNode[] = [];
516
+ for (const callee of callees) {
517
+ if (!visited.has(callee.id)) {
518
+ children.push(buildFlowTree(db, callee, boundaryMap, visited));
519
+ }
520
+ }
521
+
522
+ return { node, boundary, emits, children };
523
+ }
524
+
525
+ program.parse();
@@ -0,0 +1,79 @@
1
+ import type { BoundaryEntry, EventConnection, FlowEntry } from "../graph/queries.ts";
2
+ import type { ContextData } from "./text.ts";
3
+
4
+ /**
5
+ * Formats the overview as JSON.
6
+ *
7
+ * @param flows - All flow entry points
8
+ * @param boundaries - All boundary-tagged nodes
9
+ * @param events - All event connections
10
+ * @returns JSON string
11
+ */
12
+ function formatOverviewJson(
13
+ flows: readonly FlowEntry[],
14
+ boundaries: readonly BoundaryEntry[],
15
+ events: readonly EventConnection[],
16
+ ): string {
17
+ return JSON.stringify(
18
+ {
19
+ flows: flows.map((f) => ({
20
+ name: f.value,
21
+ entryPoint: f.node.name,
22
+ file: f.node.file,
23
+ line: f.node.lineStart,
24
+ route: f.node.metadata?.route,
25
+ })),
26
+ boundaries: boundaries.map((b) => ({
27
+ system: b.value,
28
+ function: b.node.name,
29
+ file: b.node.file,
30
+ line: b.node.lineStart,
31
+ })),
32
+ events: events.map((e) => ({
33
+ event: e.eventName,
34
+ emitter: e.emitterName,
35
+ emitterFile: e.emitterFile,
36
+ handler: e.handlerName,
37
+ handlerFile: e.handlerFile,
38
+ })),
39
+ },
40
+ undefined,
41
+ 2,
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Formats a symbol's context as JSON.
47
+ *
48
+ * @param data - Context data for the symbol
49
+ * @returns JSON string
50
+ */
51
+ function formatContextJson(data: ContextData): string {
52
+ return JSON.stringify(
53
+ {
54
+ id: data.node.id,
55
+ name: data.node.name,
56
+ file: data.node.file,
57
+ line: data.node.lineStart,
58
+ signature: data.node.signature,
59
+ flows: data.flows,
60
+ callers: data.callers.map((c) => ({
61
+ id: c.id,
62
+ name: c.name,
63
+ file: c.file,
64
+ line: c.lineStart,
65
+ })),
66
+ callees: data.callees.map((c) => ({
67
+ id: c.id,
68
+ name: c.name,
69
+ file: c.file,
70
+ line: c.lineStart,
71
+ })),
72
+ boundary: data.boundary,
73
+ },
74
+ undefined,
75
+ 2,
76
+ );
77
+ }
78
+
79
+ export { formatContextJson, formatOverviewJson };