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,111 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { err, ok, type Result } from "../types/result.ts";
4
+
5
+ /**
6
+ * Initializes a Lattice project by creating .lattice/ directory
7
+ * and a starter lattice.toml with detected languages.
8
+ *
9
+ * @param projectRoot - Path to the project root directory
10
+ * @returns Ok on success, Err with a message on failure
11
+ */
12
+ // @lattice:flow init
13
+ function executeInit(projectRoot: string): Result<string, string> {
14
+ try {
15
+ // Create .lattice directory
16
+ const latticeDir = join(projectRoot, ".lattice");
17
+ mkdirSync(latticeDir, { recursive: true });
18
+
19
+ // Generate lattice.toml if it doesn't exist
20
+ const tomlPath = join(projectRoot, "lattice.toml");
21
+ if (!existsSync(tomlPath)) {
22
+ const languages = detectLanguages(projectRoot);
23
+ const root = detectRoot(projectRoot);
24
+ const toml = generateToml(languages, root);
25
+ writeFileSync(tomlPath, toml);
26
+ }
27
+
28
+ return ok("Initialized Lattice project");
29
+ } catch (error) {
30
+ return err(`Init failed: ${error instanceof Error ? error.message : String(error)}`);
31
+ }
32
+ }
33
+
34
+ /** Detects which languages are present in the project by scanning for file extensions. */
35
+ function detectLanguages(projectRoot: string): readonly string[] {
36
+ const languages: string[] = [];
37
+ const glob = new Bun.Glob("**/*.{py,ts,tsx,js,jsx}");
38
+
39
+ let hasPython = false;
40
+ let hasTypeScript = false;
41
+
42
+ for (const path of glob.scanSync({ cwd: projectRoot, dot: false })) {
43
+ if (
44
+ path.includes("node_modules") ||
45
+ path.includes(".git") ||
46
+ path.includes("test") ||
47
+ path.includes("fixture") ||
48
+ path.includes("vendor") ||
49
+ path.includes("dist")
50
+ )
51
+ continue;
52
+ if (path.endsWith(".py")) hasPython = true;
53
+ if (path.endsWith(".ts") || path.endsWith(".tsx")) hasTypeScript = true;
54
+ if (hasPython && hasTypeScript) break;
55
+ }
56
+
57
+ if (hasPython) languages.push("python");
58
+ if (hasTypeScript) languages.push("typescript");
59
+
60
+ return languages;
61
+ }
62
+
63
+ /** Detects the source root — uses "src" if it exists, otherwise ".". */
64
+ function detectRoot(projectRoot: string): string {
65
+ const srcPath = `${projectRoot}/src`;
66
+ try {
67
+ const { statSync } = require("node:fs");
68
+ if (statSync(srcPath).isDirectory()) return "src";
69
+ } catch {
70
+ // src/ doesn't exist
71
+ }
72
+ return ".";
73
+ }
74
+
75
+ /** Generates a starter lattice.toml with detected languages. */
76
+ function generateToml(languages: readonly string[], root: string): string {
77
+ const langArray = languages.map((l) => `"${l}"`).join(", ");
78
+ const lines: string[] = [
79
+ "[project]",
80
+ `languages = [${langArray}]`,
81
+ `root = "${root}"`,
82
+ 'exclude = ["node_modules", "venv", ".git", "dist", "__pycache__", ".lattice"]',
83
+ "",
84
+ ];
85
+
86
+ if (languages.includes("python")) {
87
+ lines.push(
88
+ "[python]",
89
+ `source_roots = ["${root}"]`,
90
+ 'test_paths = ["tests"]',
91
+ "frameworks = []",
92
+ "",
93
+ );
94
+ }
95
+
96
+ if (languages.includes("typescript")) {
97
+ lines.push(
98
+ "[typescript]",
99
+ `source_roots = ["${root}"]`,
100
+ 'test_paths = ["tests"]',
101
+ "frameworks = []",
102
+ "",
103
+ );
104
+ }
105
+
106
+ lines.push("[lint]", "strict = false", "ignore = []", "");
107
+
108
+ return lines.join("\n");
109
+ }
110
+
111
+ export { executeInit };
@@ -0,0 +1,245 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import type { LatticeConfig } from "../types/config.ts";
3
+ import type { LintIssue, LintResult } from "../types/lint.ts";
4
+
5
+ /**
6
+ * Runs all lint checks against the built knowledge graph.
7
+ * Does not modify the database — reports only.
8
+ *
9
+ * @param db - An open Database handle (readonly)
10
+ * @param config - Lattice configuration for boundary package detection
11
+ * @returns Lint result with issues, coverage, and unresolved count
12
+ */
13
+ // @lattice:flow lint
14
+ function executeLint(db: Database, _config: LatticeConfig): LintResult {
15
+ const issues: LintIssue[] = [];
16
+
17
+ checkMissingFlowTags(db, issues);
18
+ checkInvalidTags(db, issues);
19
+ checkTypos(db, issues);
20
+ checkOrphanedEvents(db, issues);
21
+ checkStaleBoundaryTags(db, issues);
22
+
23
+ const coverage = computeCoverage(db);
24
+ const unresolvedCount = countUnresolved(db);
25
+
26
+ return { issues, coverage, unresolvedCount };
27
+ }
28
+
29
+ /** Checks for route handlers without @lattice:flow tags. */
30
+ function checkMissingFlowTags(db: Database, issues: LintIssue[]): void {
31
+ // Nodes with route metadata (detected by framework extractors) but no flow tag
32
+ const rows = db
33
+ .query(
34
+ `SELECT n.id, n.name, n.file, n.line_start FROM nodes n
35
+ WHERE n.metadata IS NOT NULL AND n.metadata LIKE '%"route"%'
36
+ AND NOT EXISTS (SELECT 1 FROM tags t WHERE t.node_id = n.id AND t.kind = 'flow')`,
37
+ )
38
+ .all() as { id: string; name: string; file: string; line_start: number }[];
39
+
40
+ for (const row of rows) {
41
+ issues.push({
42
+ severity: "error",
43
+ file: row.file,
44
+ line: row.line_start,
45
+ symbol: row.name,
46
+ message: `Route handler missing @lattice:flow tag`,
47
+ });
48
+ }
49
+ }
50
+
51
+ /** Checks for tags placed on invalid node kinds (e.g., flow tag on a class). */
52
+ function checkInvalidTags(db: Database, issues: LintIssue[]): void {
53
+ const rows = db
54
+ .query(
55
+ `SELECT t.kind AS tag_kind, t.value, n.id, n.name, n.kind AS node_kind, n.file, n.line_start
56
+ FROM tags t JOIN nodes n ON t.node_id = n.id
57
+ WHERE n.kind NOT IN ('function', 'method')`,
58
+ )
59
+ .all() as {
60
+ tag_kind: string;
61
+ value: string;
62
+ id: string;
63
+ name: string;
64
+ node_kind: string;
65
+ file: string;
66
+ line_start: number;
67
+ }[];
68
+
69
+ for (const row of rows) {
70
+ issues.push({
71
+ severity: "error",
72
+ file: row.file,
73
+ line: row.line_start,
74
+ symbol: row.name,
75
+ message: `@lattice:${row.tag_kind} tag on a ${row.node_kind} — tags should only be on functions or methods`,
76
+ });
77
+ }
78
+ }
79
+
80
+ /** Checks for probable typos by finding tag values used only once when similar values exist. */
81
+ function checkTypos(db: Database, issues: LintIssue[]): void {
82
+ // Group tag values by kind, find singletons
83
+ const tagCounts = db
84
+ .query("SELECT kind, value, COUNT(*) as cnt FROM tags GROUP BY kind, value")
85
+ .all() as { kind: string; value: string; cnt: number }[];
86
+
87
+ const singletons = tagCounts.filter((t) => t.cnt === 1);
88
+ const commons = tagCounts.filter((t) => t.cnt > 1);
89
+
90
+ for (const single of singletons) {
91
+ const similar = commons.filter(
92
+ (c) => c.kind === single.kind && editDistance(single.value, c.value) <= 2,
93
+ );
94
+ if (similar.length > 0) {
95
+ const bestMatch = similar[0];
96
+ const tagNode = db
97
+ .query(
98
+ `SELECT n.name, n.file, n.line_start FROM tags t JOIN nodes n ON t.node_id = n.id
99
+ WHERE t.kind = ? AND t.value = ?`,
100
+ )
101
+ .get(single.kind, single.value) as {
102
+ name: string;
103
+ file: string;
104
+ line_start: number;
105
+ } | null;
106
+
107
+ if (tagNode && bestMatch) {
108
+ issues.push({
109
+ severity: "warning",
110
+ file: tagNode.file,
111
+ line: tagNode.line_start,
112
+ symbol: tagNode.name,
113
+ message: `@lattice:${single.kind} "${single.value}" — did you mean "${bestMatch.value}"? (used ${bestMatch.cnt} times elsewhere)`,
114
+ });
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ /** Checks for events that are emitted but never handled, or handled but never emitted. */
121
+ function checkOrphanedEvents(db: Database, issues: LintIssue[]): void {
122
+ // Emits with no matching handles
123
+ const orphanedEmits = db
124
+ .query(
125
+ `SELECT t.value, n.name, n.file, n.line_start FROM tags t
126
+ JOIN nodes n ON t.node_id = n.id
127
+ WHERE t.kind = 'emits'
128
+ AND NOT EXISTS (SELECT 1 FROM tags h WHERE h.kind = 'handles' AND h.value = t.value)`,
129
+ )
130
+ .all() as { value: string; name: string; file: string; line_start: number }[];
131
+
132
+ for (const row of orphanedEmits) {
133
+ issues.push({
134
+ severity: "warning",
135
+ file: row.file,
136
+ line: row.line_start,
137
+ symbol: row.name,
138
+ message: `@lattice:emits "${row.value}" has no handler — no @lattice:handles "${row.value}" found`,
139
+ });
140
+ }
141
+
142
+ // Handles with no matching emits
143
+ const orphanedHandles = db
144
+ .query(
145
+ `SELECT t.value, n.name, n.file, n.line_start FROM tags t
146
+ JOIN nodes n ON t.node_id = n.id
147
+ WHERE t.kind = 'handles'
148
+ AND NOT EXISTS (SELECT 1 FROM tags e WHERE e.kind = 'emits' AND e.value = t.value)`,
149
+ )
150
+ .all() as { value: string; name: string; file: string; line_start: number }[];
151
+
152
+ for (const row of orphanedHandles) {
153
+ issues.push({
154
+ severity: "warning",
155
+ file: row.file,
156
+ line: row.line_start,
157
+ symbol: row.name,
158
+ message: `@lattice:handles "${row.value}" has no emitter — no @lattice:emits "${row.value}" found`,
159
+ });
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Checks for stale boundary tags.
165
+ * A boundary tag is stale if the tagged function has no uncertain call edges at all
166
+ * (meaning it doesn't call any external code that could be the boundary).
167
+ */
168
+ function checkStaleBoundaryTags(db: Database, issues: LintIssue[]): void {
169
+ const boundaryTags = db
170
+ .query(
171
+ `SELECT t.node_id, t.value, n.name, n.file, n.line_start FROM tags t
172
+ JOIN nodes n ON t.node_id = n.id
173
+ WHERE t.kind = 'boundary'`,
174
+ )
175
+ .all() as { node_id: string; value: string; name: string; file: string; line_start: number }[];
176
+
177
+ for (const tag of boundaryTags) {
178
+ // A boundary function should have at least one uncertain call edge
179
+ // (calls to external packages are marked uncertain during extraction)
180
+ const hasExternalCall = db
181
+ .query("SELECT 1 FROM edges WHERE source_id = ? AND certainty = 'uncertain' LIMIT 1")
182
+ .get(tag.node_id);
183
+
184
+ if (!hasExternalCall) {
185
+ issues.push({
186
+ severity: "warning",
187
+ file: tag.file,
188
+ line: tag.line_start,
189
+ symbol: tag.name,
190
+ message: `@lattice:boundary "${tag.value}" may be stale — no external calls found in this function`,
191
+ });
192
+ }
193
+ }
194
+ }
195
+
196
+ /** Computes tag coverage: how many detected entry points are tagged vs total. */
197
+ function computeCoverage(db: Database): { tagged: number; total: number } {
198
+ const total = db
199
+ .query(
200
+ "SELECT COUNT(*) as c FROM nodes WHERE metadata IS NOT NULL AND metadata LIKE '%\"route\"%'",
201
+ )
202
+ .get() as { c: number };
203
+
204
+ const tagged = db
205
+ .query(
206
+ `SELECT COUNT(*) as c FROM nodes n
207
+ WHERE n.metadata IS NOT NULL AND n.metadata LIKE '%"route"%'
208
+ AND EXISTS (SELECT 1 FROM tags t WHERE t.node_id = n.id AND t.kind = 'flow')`,
209
+ )
210
+ .get() as { c: number };
211
+
212
+ return { tagged: tagged.c, total: total.c };
213
+ }
214
+
215
+ /** Counts unresolved references in the database. */
216
+ function countUnresolved(db: Database): number {
217
+ const row = db.query("SELECT COUNT(*) as c FROM unresolved").get() as { c: number };
218
+ return row.c;
219
+ }
220
+
221
+ /**
222
+ * Computes the Levenshtein edit distance between two strings.
223
+ * Used for typo detection in tag values.
224
+ */
225
+ function editDistance(a: string, b: string): number {
226
+ const m = a.length;
227
+ const n = b.length;
228
+
229
+ // Use two rows instead of a full matrix to avoid index safety issues
230
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
231
+ let curr = new Array<number>(n + 1).fill(0);
232
+
233
+ for (let i = 1; i <= m; i++) {
234
+ curr[0] = i;
235
+ for (let j = 1; j <= n; j++) {
236
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
237
+ curr[j] = Math.min((prev[j] ?? 0) + 1, (curr[j - 1] ?? 0) + 1, (prev[j - 1] ?? 0) + cost);
238
+ }
239
+ [prev, curr] = [curr, prev];
240
+ }
241
+
242
+ return prev[n] ?? 0;
243
+ }
244
+
245
+ export { executeLint };
@@ -0,0 +1,224 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import type { LatticeConfig } from "../types/config.ts";
3
+
4
+ /**
5
+ * Generates a structured workflow that instructs a coding agent to tag the codebase.
6
+ * Includes tag spec, few-shot examples, project context, and a step-by-step process
7
+ * with explicit validation checkpoints.
8
+ *
9
+ * @param db - An open Database handle with a built graph
10
+ * @param _config - Lattice configuration (reserved for future use)
11
+ * @returns A complete instruction string for the coding agent
12
+ */
13
+ // @lattice:flow populate
14
+ function executePopulate(db: Database, _config: LatticeConfig): string {
15
+ const sections: string[] = [];
16
+
17
+ sections.push(tagSpecSection());
18
+ sections.push(examplesSection());
19
+ sections.push(projectSummarySection(db));
20
+ sections.push(workflowSection());
21
+
22
+ return sections.join("\n\n");
23
+ }
24
+
25
+ /** Outputs the tag spec — what each tag means and the syntax rules. */
26
+ function tagSpecSection(): string {
27
+ return `## Lattice Tag Specification
28
+
29
+ Four tags, placed in comments directly above function definitions:
30
+
31
+ - \`@lattice:flow <name>\` — Flow entry point. Where execution begins for a business operation. Route handlers, CLI commands, cron jobs, queue consumers.
32
+ - \`@lattice:boundary <system>\` — External boundary. Where code leaves the codebase. API calls, database queries, cache operations, third-party SDKs.
33
+ - \`@lattice:emits <event>\` — Event emission. Publishes to a queue, event bus, or notification system.
34
+ - \`@lattice:handles <event>\` — Event consumption. Subscribes to or processes events. Must match a corresponding emits tag.
35
+
36
+ Rules:
37
+ - Place the tag comment directly above the function definition, no blank lines between
38
+ - Names are kebab-case: \`checkout\`, \`user-registration\`, \`order.created\`, \`aws-s3\`
39
+ - Multiple values: \`# @lattice:flow checkout, payment\`
40
+ - Do NOT tag intermediate functions — only entry points and boundaries. Everything in between is derived from the call graph automatically.`;
41
+ }
42
+
43
+ /** Few-shot examples showing correct tagging across different scenarios. */
44
+ function examplesSection(): string {
45
+ return `## Examples
46
+
47
+ ### Python — FastAPI route with boundary and events
48
+
49
+ \`\`\`python
50
+ # @lattice:flow checkout
51
+ @app.post("/api/checkout")
52
+ def handle_checkout(req):
53
+ order = create_order(req) # no tag — derived from call graph
54
+ return order
55
+
56
+ def create_order(req): # no tag — derived from call graph
57
+ charge(req.amount, req.token)
58
+ save_order(req)
59
+ emit_order_created(req.order_id)
60
+
61
+ # @lattice:boundary stripe
62
+ def charge(amount, token):
63
+ return stripe.charges.create(amount=amount, source=token)
64
+
65
+ # @lattice:boundary postgres
66
+ def save_order(req):
67
+ db.execute("INSERT INTO orders ...")
68
+
69
+ # @lattice:emits order.created
70
+ def emit_order_created(order_id):
71
+ queue.publish("order.created", {"order_id": order_id})
72
+
73
+ # @lattice:handles order.created
74
+ def send_confirmation(event):
75
+ sendgrid.send(event.order_id)
76
+ \`\`\`
77
+
78
+ ### TypeScript — Express route with database boundary
79
+
80
+ \`\`\`typescript
81
+ // @lattice:flow user-registration
82
+ router.post("/api/users", async (req, res) => {
83
+ const user = await createUser(req.body);
84
+ res.json(user);
85
+ });
86
+
87
+ // @lattice:boundary postgres
88
+ async function createUser(data: CreateUserInput): Promise<User> {
89
+ return db.query("INSERT INTO users ...").run(data);
90
+ }
91
+ \`\`\`
92
+
93
+ ### Python — Celery task as entry point
94
+
95
+ \`\`\`python
96
+ # @lattice:flow invoice-generation
97
+ @shared_task
98
+ def generate_invoice(order_id):
99
+ order = fetch_order(order_id)
100
+ pdf = render_invoice(order)
101
+ send_invoice_email(order, pdf)
102
+
103
+ # @lattice:boundary s3
104
+ def render_invoice(order):
105
+ pdf = create_pdf(order)
106
+ s3.upload(f"invoices/{order.id}.pdf", pdf)
107
+ return pdf
108
+ \`\`\``;
109
+ }
110
+
111
+ /** Brief project summary from the graph — just enough context, not a file listing. */
112
+ function projectSummarySection(db: Database): string {
113
+ const lines: string[] = ["## This Project", ""];
114
+
115
+ const fileCount = (db.query("SELECT COUNT(DISTINCT file) as c FROM nodes").get() as { c: number })
116
+ .c;
117
+ const nodeCount = (db.query("SELECT COUNT(*) as c FROM nodes").get() as { c: number }).c;
118
+ const edgeCount = (db.query("SELECT COUNT(*) as c FROM edges").get() as { c: number }).c;
119
+
120
+ lines.push(`${fileCount} files, ${nodeCount} symbols, ${edgeCount} call edges in the graph.`);
121
+
122
+ const existingTags = db
123
+ .query("SELECT kind, value, node_id FROM tags ORDER BY kind, value")
124
+ .all() as { kind: string; value: string; node_id: string }[];
125
+
126
+ if (existingTags.length > 0) {
127
+ lines.push("");
128
+ lines.push("### Already Tagged");
129
+ lines.push("");
130
+ for (const tag of existingTags) {
131
+ lines.push(`- \`@lattice:${tag.kind} ${tag.value}\` on \`${tag.node_id}\``);
132
+ }
133
+ lines.push("");
134
+ lines.push("Review these existing tags and add any that are missing.");
135
+ } else {
136
+ lines.push("");
137
+ lines.push("No tags exist yet. Read the source files and add tags where appropriate.");
138
+ }
139
+
140
+ return lines.join("\n");
141
+ }
142
+
143
+ /** The complete step-by-step workflow with validation checkpoints. */
144
+ function workflowSection(): string {
145
+ return `## Workflow
146
+
147
+ Follow these steps in order. Do not skip validation steps.
148
+
149
+ ### Step 1: Tag entry points
150
+
151
+ Read the source files and identify all entry points — route handlers, CLI commands, cron jobs, queue consumers, event listeners. Add \`@lattice:flow <name>\` above each one.
152
+
153
+ Use domain names for flows: "checkout", "user-registration", "invoice-generation" — not function names.
154
+
155
+ ### Step 2: Tag boundaries
156
+
157
+ Identify all functions that call external systems — APIs, databases, caches, file storage, third-party SDKs. Add \`@lattice:boundary <system>\` above each one.
158
+
159
+ Use the external system name: "stripe", "postgres", "redis", "s3" — not the function or library name.
160
+
161
+ ### Step 3: Tag events
162
+
163
+ If the codebase uses event-driven patterns, identify publishers and consumers. Add \`@lattice:emits <event>\` and \`@lattice:handles <event>\` where applicable. Event names must match between emitters and handlers.
164
+
165
+ ### Step 4: Rebuild and lint
166
+
167
+ \`\`\`bash
168
+ lattice build && lattice lint
169
+ \`\`\`
170
+
171
+ Fix any errors reported by lint:
172
+ - Missing tags on detected entry points or boundary calls
173
+ - Invalid tags (wrong placement, bad syntax)
174
+ - Typos in tag names
175
+ - Orphaned events (emits without handles, or vice versa)
176
+ - Stale tags (boundary tag on a function that no longer calls that system)
177
+
178
+ Repeat this step until lint reports zero errors.
179
+
180
+ ### Step 5: Verify flows
181
+
182
+ Run \`lattice overview\` and check:
183
+ - Are all business flows listed?
184
+ - Are all external systems represented in boundaries?
185
+ - Are all event connections shown?
186
+
187
+ If anything is missing, go back to steps 1-3 and add the missing tags.
188
+
189
+ ### Step 6: Verify call trees
190
+
191
+ For each flow listed in \`lattice overview\`, run:
192
+
193
+ \`\`\`bash
194
+ lattice flow <name>
195
+ \`\`\`
196
+
197
+ Check each call tree:
198
+ - Does it start at the correct entry point?
199
+ - Does it reach the expected boundaries?
200
+ - Are there functions in the tree that should be boundaries but aren't tagged?
201
+ - Does event propagation cross into the expected handlers?
202
+
203
+ If a call tree is missing expected functions, those functions may not be reachable from the entry point through the call graph. Check if there are missing call edges (dynamic dispatch, dependency injection) and consider whether additional tags are needed.
204
+
205
+ ### Step 7: Verify impact
206
+
207
+ For each boundary, run:
208
+
209
+ \`\`\`bash
210
+ lattice impact <symbol>
211
+ \`\`\`
212
+
213
+ Check that the affected flows make sense. If a boundary is used by flows you didn't expect, investigate whether the flow tagging is correct.
214
+
215
+ ### Done
216
+
217
+ The codebase is tagged when:
218
+ - \`lattice lint\` reports zero errors
219
+ - \`lattice overview\` shows all expected flows, boundaries, and events
220
+ - Each \`lattice flow <name>\` shows a complete, sensible call tree
221
+ - \`lattice impact\` on key functions shows the expected affected flows`;
222
+ }
223
+
224
+ export { executePopulate };