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/LICENSE +21 -0
- package/README.md +391 -0
- package/package.json +56 -0
- package/src/commands/build.ts +208 -0
- package/src/commands/init.ts +111 -0
- package/src/commands/lint.ts +245 -0
- package/src/commands/populate.ts +224 -0
- package/src/commands/update.ts +175 -0
- package/src/config.ts +93 -0
- package/src/extract/extractor.ts +13 -0
- package/src/extract/parser.ts +117 -0
- package/src/extract/python/calls.ts +121 -0
- package/src/extract/python/extractor.ts +171 -0
- package/src/extract/python/frameworks.ts +142 -0
- package/src/extract/python/imports.ts +115 -0
- package/src/extract/python/symbols.ts +121 -0
- package/src/extract/tags.ts +77 -0
- package/src/extract/typescript/calls.ts +110 -0
- package/src/extract/typescript/extractor.ts +130 -0
- package/src/extract/typescript/imports.ts +71 -0
- package/src/extract/typescript/symbols.ts +252 -0
- package/src/graph/database.ts +95 -0
- package/src/graph/queries.ts +336 -0
- package/src/graph/writer.ts +147 -0
- package/src/main.ts +525 -0
- package/src/output/json.ts +79 -0
- package/src/output/text.ts +265 -0
- package/src/types/config.ts +32 -0
- package/src/types/graph.ts +87 -0
- package/src/types/lint.ts +21 -0
- package/src/types/result.ts +58 -0
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 };
|