lattice-graph 0.1.0 → 0.2.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.
@@ -7,45 +7,24 @@ import type { LintIssue, LintResult } from "../types/lint.ts";
7
7
  * Does not modify the database — reports only.
8
8
  *
9
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
10
+ * @param config - Lattice configuration
11
+ * @returns Lint result with issues and coverage
12
12
  */
13
13
  // @lattice:flow lint
14
14
  function executeLint(db: Database, _config: LatticeConfig): LintResult {
15
15
  const issues: LintIssue[] = [];
16
16
 
17
- checkMissingFlowTags(db, issues);
18
17
  checkInvalidTags(db, issues);
19
18
  checkTypos(db, issues);
20
19
  checkOrphanedEvents(db, issues);
21
20
  checkStaleBoundaryTags(db, issues);
21
+ checkMissingBoundaryTags(db, issues);
22
+ checkDeadEndFlows(db, issues);
23
+ checkDisconnectedFunctions(db, issues);
22
24
 
23
25
  const coverage = computeCoverage(db);
24
- const unresolvedCount = countUnresolved(db);
25
26
 
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
- }
27
+ return { issues, coverage };
49
28
  }
50
29
 
51
30
  /** Checks for tags placed on invalid node kinds (e.g., flow tag on a class). */
@@ -79,7 +58,6 @@ function checkInvalidTags(db: Database, issues: LintIssue[]): void {
79
58
 
80
59
  /** Checks for probable typos by finding tag values used only once when similar values exist. */
81
60
  function checkTypos(db: Database, issues: LintIssue[]): void {
82
- // Group tag values by kind, find singletons
83
61
  const tagCounts = db
84
62
  .query("SELECT kind, value, COUNT(*) as cnt FROM tags GROUP BY kind, value")
85
63
  .all() as { kind: string; value: string; cnt: number }[];
@@ -88,8 +66,15 @@ function checkTypos(db: Database, issues: LintIssue[]): void {
88
66
  const commons = tagCounts.filter((t) => t.cnt > 1);
89
67
 
90
68
  for (const single of singletons) {
69
+ // Require longer names for typo detection — short names like "s3" and "sqs" are distinct
70
+ const minLength = 4;
71
+ if (single.value.length < minLength) continue;
72
+
91
73
  const similar = commons.filter(
92
- (c) => c.kind === single.kind && editDistance(single.value, c.value) <= 2,
74
+ (c) =>
75
+ c.kind === single.kind &&
76
+ c.value.length >= minLength &&
77
+ editDistance(single.value, c.value) <= 2,
93
78
  );
94
79
  if (similar.length > 0) {
95
80
  const bestMatch = similar[0];
@@ -119,7 +104,6 @@ function checkTypos(db: Database, issues: LintIssue[]): void {
119
104
 
120
105
  /** Checks for events that are emitted but never handled, or handled but never emitted. */
121
106
  function checkOrphanedEvents(db: Database, issues: LintIssue[]): void {
122
- // Emits with no matching handles
123
107
  const orphanedEmits = db
124
108
  .query(
125
109
  `SELECT t.value, n.name, n.file, n.line_start FROM tags t
@@ -139,7 +123,6 @@ function checkOrphanedEvents(db: Database, issues: LintIssue[]): void {
139
123
  });
140
124
  }
141
125
 
142
- // Handles with no matching emits
143
126
  const orphanedHandles = db
144
127
  .query(
145
128
  `SELECT t.value, n.name, n.file, n.line_start FROM tags t
@@ -162,23 +145,31 @@ function checkOrphanedEvents(db: Database, issues: LintIssue[]): void {
162
145
 
163
146
  /**
164
147
  * 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).
148
+ * A boundary tag is stale if the function has no external calls recorded in the external_calls table.
167
149
  */
168
150
  function checkStaleBoundaryTags(db: Database, issues: LintIssue[]): void {
151
+ // Skip stale boundary check if no external call data is available
152
+ // (e.g., when the LSP server doesn't support outgoingCalls)
153
+ const hasAnyExternalCalls = db.query("SELECT 1 FROM external_calls LIMIT 1").get();
154
+ if (!hasAnyExternalCalls) return;
155
+
169
156
  const boundaryTags = db
170
157
  .query(
171
158
  `SELECT t.node_id, t.value, n.name, n.file, n.line_start FROM tags t
172
159
  JOIN nodes n ON t.node_id = n.id
173
160
  WHERE t.kind = 'boundary'`,
174
161
  )
175
- .all() as { node_id: string; value: string; name: string; file: string; line_start: number }[];
162
+ .all() as {
163
+ node_id: string;
164
+ value: string;
165
+ name: string;
166
+ file: string;
167
+ line_start: number;
168
+ }[];
176
169
 
177
170
  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
171
  const hasExternalCall = db
181
- .query("SELECT 1 FROM edges WHERE source_id = ? AND certainty = 'uncertain' LIMIT 1")
172
+ .query("SELECT 1 FROM external_calls WHERE node_id = ? LIMIT 1")
182
173
  .get(tag.node_id);
183
174
 
184
175
  if (!hasExternalCall) {
@@ -193,29 +184,131 @@ function checkStaleBoundaryTags(db: Database, issues: LintIssue[]): void {
193
184
  }
194
185
  }
195
186
 
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
187
+ /**
188
+ * Checks for functions that call external packages but have no @lattice:boundary tag.
189
+ */
190
+ function checkMissingBoundaryTags(db: Database, issues: LintIssue[]): void {
191
+ const rows = db
199
192
  .query(
200
- "SELECT COUNT(*) as c FROM nodes WHERE metadata IS NOT NULL AND metadata LIKE '%\"route\"%'",
193
+ `SELECT DISTINCT ec.node_id, ec.package, n.name, n.file, n.line_start
194
+ FROM external_calls ec
195
+ JOIN nodes n ON ec.node_id = n.id
196
+ WHERE NOT EXISTS (
197
+ SELECT 1 FROM tags t WHERE t.node_id = ec.node_id AND t.kind = 'boundary'
198
+ )`,
201
199
  )
202
- .get() as { c: number };
200
+ .all() as {
201
+ node_id: string;
202
+ package: string;
203
+ name: string;
204
+ file: string;
205
+ line_start: number;
206
+ }[];
203
207
 
204
- const tagged = db
208
+ for (const row of rows) {
209
+ issues.push({
210
+ severity: "warning",
211
+ file: row.file,
212
+ line: row.line_start,
213
+ symbol: row.name,
214
+ message: `Function calls external package '${row.package}' but has no @lattice:boundary tag`,
215
+ });
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Checks for flow entry points with zero callees — the flow tree is just the root node.
221
+ * This typically indicates dynamic dispatch, decorated functions, or missing event connections.
222
+ */
223
+ function checkDeadEndFlows(db: Database, issues: LintIssue[]): void {
224
+ const flowEntries = db
205
225
  .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')`,
226
+ `SELECT t.value AS flow_name, n.id, n.name, n.file, n.line_start
227
+ FROM tags t JOIN nodes n ON t.node_id = n.id
228
+ WHERE t.kind = 'flow'`,
209
229
  )
210
- .get() as { c: number };
230
+ .all() as {
231
+ flow_name: string;
232
+ id: string;
233
+ name: string;
234
+ file: string;
235
+ line_start: number;
236
+ }[];
237
+
238
+ for (const entry of flowEntries) {
239
+ const hasCallees = db
240
+ .query("SELECT 1 FROM edges WHERE source_id = ? AND kind IN ('calls', 'event') LIMIT 1")
241
+ .get(entry.id);
211
242
 
212
- return { tagged: tagged.c, total: total.c };
243
+ if (!hasCallees) {
244
+ issues.push({
245
+ severity: "warning",
246
+ file: entry.file,
247
+ line: entry.line_start,
248
+ symbol: entry.name,
249
+ message: `Flow "${entry.flow_name}" entry point has no callees — the call tree may be incomplete. If this function dispatches through a queue or dynamic dispatch, add @lattice:emits/@lattice:handles tags.`,
250
+ });
251
+ }
252
+ }
213
253
  }
214
254
 
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;
255
+ /**
256
+ * Checks for functions that have callees but are unreachable from any flow.
257
+ * These are likely worker handlers or event consumers that need flow/handles tags.
258
+ */
259
+ function checkDisconnectedFunctions(db: Database, issues: LintIssue[]): void {
260
+ // Find functions with callees (they do work) that no flow can reach
261
+ const disconnected = db
262
+ .query(
263
+ `SELECT n.id, n.name, n.file, n.line_start,
264
+ (SELECT COUNT(*) FROM edges WHERE source_id = n.id AND kind IN ('calls', 'event')) as callee_count
265
+ FROM nodes n
266
+ WHERE n.kind IN ('function', 'method')
267
+ AND n.is_test = 0
268
+ AND NOT EXISTS (SELECT 1 FROM tags WHERE node_id = n.id)
269
+ AND EXISTS (SELECT 1 FROM edges WHERE source_id = n.id AND kind IN ('calls', 'event'))
270
+ AND NOT EXISTS (
271
+ WITH RECURSIVE flow_reachable AS (
272
+ SELECT node_id AS id FROM tags WHERE kind = 'flow'
273
+ UNION
274
+ SELECT e.target_id FROM edges e
275
+ JOIN flow_reachable fr ON e.source_id = fr.id
276
+ WHERE e.kind IN ('calls', 'event')
277
+ )
278
+ SELECT 1 FROM flow_reachable WHERE id = n.id
279
+ )`,
280
+ )
281
+ .all() as {
282
+ id: string;
283
+ name: string;
284
+ file: string;
285
+ line_start: number;
286
+ callee_count: number;
287
+ }[];
288
+
289
+ // Only report functions with 3+ callees to reduce noise
290
+ for (const fn of disconnected) {
291
+ if (fn.callee_count < 3) continue;
292
+ issues.push({
293
+ severity: "info",
294
+ file: fn.file,
295
+ line: fn.line_start,
296
+ symbol: fn.name,
297
+ message: `Function has ${fn.callee_count} callees but is unreachable from any flow. Consider adding @lattice:flow or @lattice:handles tag.`,
298
+ });
299
+ }
300
+ }
301
+
302
+ /** Computes tag coverage: how many tags exist vs total functions. */
303
+ function computeCoverage(db: Database): { tagged: number; total: number } {
304
+ const total = (
305
+ db.query("SELECT COUNT(*) as c FROM nodes WHERE kind IN ('function', 'method')").get() as {
306
+ c: number;
307
+ }
308
+ ).c;
309
+ const tagged = (db.query("SELECT COUNT(DISTINCT node_id) as c FROM tags").get() as { c: number })
310
+ .c;
311
+ return { tagged, total };
219
312
  }
220
313
 
221
314
  /**
@@ -226,7 +319,6 @@ function editDistance(a: string, b: string): number {
226
319
  const m = a.length;
227
320
  const n = b.length;
228
321
 
229
- // Use two rows instead of a full matrix to avoid index safety issues
230
322
  let prev = Array.from({ length: n + 1 }, (_, j) => j);
231
323
  let curr = new Array<number>(n + 1).fill(0);
232
324
 
@@ -158,11 +158,22 @@ Identify all functions that call external systems — APIs, databases, caches, f
158
158
 
159
159
  Use the external system name: "stripe", "postgres", "redis", "s3" — not the function or library name.
160
160
 
161
- ### Step 3: Tag events
161
+ ### Step 3: Tag async dispatch (queues, Lambda, Celery)
162
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.
163
+ If the codebase submits work to a queue (SQS, RabbitMQ), invokes Lambda functions, or dispatches Celery tasks, these create invisible connections between the submitter and the handler. Tag both sides:
164
164
 
165
- ### Step 4: Rebuild and lint
165
+ - Add \`@lattice:emits job.<name>\` on the function that submits/invokes the async work
166
+ - Add \`@lattice:handles job.<name>\` on the function that processes the work on the other side
167
+
168
+ Important: Worker handlers and Lambda consumers are NOT separate flows — they are the receiving side of an async dispatch. Tag them with \`@lattice:handles\`, not \`@lattice:flow\`.
169
+
170
+ Place \`emits\` tags on the function the flow actually passes through, not on a concrete implementation behind a protocol or interface.
171
+
172
+ ### Step 4: Tag events
173
+
174
+ If the codebase uses event-driven patterns (pub/sub, event bus, signals), identify publishers and consumers. Add \`@lattice:emits <event>\` and \`@lattice:handles <event>\` where applicable. Event names must match between emitters and handlers.
175
+
176
+ ### Step 5: Rebuild and lint
166
177
 
167
178
  \`\`\`bash
168
179
  lattice build && lattice lint
@@ -1,37 +1,26 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { existsSync, statSync } from "node:fs";
3
- import { join, relative } from "node:path";
4
- import type { Extractor } from "../extract/extractor.ts";
5
- import { initTreeSitter } from "../extract/parser.ts";
6
- import { createPythonExtractor } from "../extract/python/extractor.ts";
7
- import { createTypeScriptExtractor } from "../extract/typescript/extractor.ts";
3
+ import { join, resolve } from "node:path";
4
+ import { discoverFiles } from "../files.ts";
8
5
  import { checkSchemaVersion } from "../graph/database.ts";
9
- import {
10
- deleteFileData,
11
- insertEdges,
12
- insertNodes,
13
- insertTags,
14
- insertUnresolved,
15
- synthesizeEventEdges,
16
- } from "../graph/writer.ts";
17
6
  import type { LatticeConfig } from "../types/config.ts";
18
- import type { ExtractionResult } from "../types/graph.ts";
19
- import { err, isOk, ok, type Result } from "../types/result.ts";
7
+ import { isOk, ok, type Result } from "../types/result.ts";
8
+ import { executeBuild } from "./build.ts";
20
9
 
21
10
  /** Statistics from an incremental update. */
22
11
  type UpdateStats = {
23
- readonly totalFiles: number;
24
12
  readonly filesReindexed: number;
13
+ readonly totalFiles: number;
25
14
  readonly durationMs: number;
26
15
  };
27
16
 
28
17
  /**
29
- * Performs an incremental update of the knowledge graph.
30
- * Only re-indexes files that have changed since the last build.
31
- * Falls back to a full rebuild if >30% of files are dirty.
18
+ * Performs an incremental update: re-indexes only files changed since the last build.
19
+ * Falls back to a full rebuild if the database is missing, schema mismatches,
20
+ * or more than 30% of files have changed.
32
21
  *
33
- * @param projectRoot - Path to the project root
34
- * @param config - Lattice configuration
22
+ * @param projectRoot - Absolute path to the project root
23
+ * @param config - Parsed lattice.toml configuration
35
24
  * @returns Update statistics or an error message
36
25
  */
37
26
  // @lattice:flow update
@@ -39,137 +28,86 @@ async function executeUpdate(
39
28
  projectRoot: string,
40
29
  config: LatticeConfig,
41
30
  ): Promise<Result<UpdateStats, string>> {
42
- const startTime = Date.now();
43
- const dbPath = join(projectRoot, ".lattice", "graph.db");
31
+ const start = performance.now();
44
32
 
33
+ const dbPath = join(projectRoot, ".lattice", "graph.db");
45
34
  if (!existsSync(dbPath)) {
46
- return err("No existing graph found. Run 'lattice build' first.");
35
+ const buildResult = await executeBuild(projectRoot, config);
36
+ if (!isOk(buildResult)) return buildResult;
37
+ return ok({ filesReindexed: 0, totalFiles: 0, durationMs: 0 });
47
38
  }
48
39
 
49
- try {
50
- const db = new Database(dbPath);
51
- const schemaCheck = checkSchemaVersion(db);
52
- if (!isOk(schemaCheck)) {
53
- db.close();
54
- return err(schemaCheck.error);
55
- }
56
-
57
- // Get last build time
58
- const lastBuildRow = db.query("SELECT value FROM meta WHERE key = 'last_build'").get() as {
59
- value: string;
60
- } | null;
61
- if (!lastBuildRow) {
62
- db.close();
63
- return err("No last_build timestamp found. Run 'lattice build' first.");
64
- }
65
- const lastBuild = new Date(lastBuildRow.value);
66
-
67
- // Initialize extractors
68
- await initTreeSitter();
69
- const extractors = await createExtractors(config);
70
- const extByExt = new Map<string, Extractor>();
71
- for (const ext of extractors) {
72
- for (const fileExt of ext.fileExtensions) {
73
- extByExt.set(fileExt, ext);
74
- }
75
- }
76
-
77
- // Scan all files, find changed ones
78
- const sourceRoot = join(projectRoot, config.root);
79
- const glob = new Bun.Glob("**/*");
80
- const allFiles: string[] = [];
81
- const changedFiles: string[] = [];
82
-
83
- for await (const path of glob.scan({ cwd: sourceRoot, dot: false })) {
84
- const ext = `.${path.split(".").pop()}`;
85
- if (!extByExt.has(ext)) continue;
86
- if (isExcluded(path, config.exclude)) continue;
87
- allFiles.push(path);
88
-
89
- const fullPath = join(sourceRoot, path);
90
- const stat = statSync(fullPath);
91
- if (stat.mtime > lastBuild) {
92
- changedFiles.push(path);
93
- }
94
- }
95
-
96
- // Fall back to full rebuild if >30% changed
97
- if (changedFiles.length > allFiles.length * 0.3) {
98
- db.close();
99
- const { executeBuild } = await import("./build.ts");
100
- return executeBuild(projectRoot, config).then((result) => {
101
- if (isOk(result)) {
102
- return ok({
103
- totalFiles: allFiles.length,
104
- filesReindexed: allFiles.length,
105
- durationMs: Date.now() - startTime,
106
- });
107
- }
108
- return err(result.error);
109
- });
110
- }
111
-
112
- // Re-extract changed files
113
- for (const file of changedFiles) {
114
- const ext = `.${file.split(".").pop()}`;
115
- const extractor = extByExt.get(ext);
116
- if (!extractor) continue;
117
-
118
- const fullPath = join(sourceRoot, file);
119
- const source = await Bun.file(fullPath).text();
120
- const relativePath = relative(projectRoot, fullPath);
40
+ const db = new Database(dbPath);
41
+ const schemaCheck = checkSchemaVersion(db);
42
+ if (!isOk(schemaCheck)) {
43
+ db.close();
44
+ const buildResult = await executeBuild(projectRoot, config);
45
+ if (!isOk(buildResult)) return buildResult;
46
+ return ok({ filesReindexed: 0, totalFiles: 0, durationMs: 0 });
47
+ }
121
48
 
122
- // Delete old data for this file
123
- deleteFileData(db, relativePath);
49
+ // Get last build timestamp
50
+ const metaRow = db.query("SELECT value FROM meta WHERE key = 'last_build'").get() as {
51
+ value: string;
52
+ } | null;
53
+ if (!metaRow) {
54
+ db.close();
55
+ const buildResult = await executeBuild(projectRoot, config);
56
+ if (!isOk(buildResult)) return buildResult;
57
+ return ok({ filesReindexed: 0, totalFiles: 0, durationMs: 0 });
58
+ }
59
+ const lastBuild = Number.parseInt(metaRow.value, 10);
60
+
61
+ // Discover files
62
+ const extensions = [".ts", ".tsx"];
63
+ const sourceRoots = config.typescript?.sourceRoots ?? [config.root];
64
+ const allFiles: string[] = [];
65
+ for (const srcRoot of sourceRoots) {
66
+ const absRoot = resolve(projectRoot, srcRoot);
67
+ allFiles.push(...discoverFiles(absRoot, extensions, config.exclude));
68
+ }
124
69
 
125
- // Re-extract
126
- const result: ExtractionResult = await extractor.extract(relativePath, source);
127
- insertNodes(db, result.nodes);
128
- insertEdges(db, result.edges);
129
- insertTags(db, result.tags);
130
- insertUnresolved(db, result.unresolved);
70
+ // Find changed files
71
+ const changedFiles = allFiles.filter((f) => {
72
+ try {
73
+ return statSync(f).mtimeMs > lastBuild;
74
+ } catch {
75
+ return true;
131
76
  }
77
+ });
132
78
 
133
- // Rebuild event edges
134
- synthesizeEventEdges(db);
135
-
136
- // Update timestamp
137
- db.run("INSERT OR REPLACE INTO meta (key, value) VALUES ('last_build', ?)", [
138
- new Date().toISOString(),
139
- ]);
140
-
79
+ if (changedFiles.length === 0) {
141
80
  db.close();
142
-
143
81
  return ok({
82
+ filesReindexed: 0,
144
83
  totalFiles: allFiles.length,
145
- filesReindexed: changedFiles.length,
146
- durationMs: Date.now() - startTime,
84
+ durationMs: Math.round(performance.now() - start),
147
85
  });
148
- } catch (error) {
149
- return err(`Update failed: ${error instanceof Error ? error.message : String(error)}`);
150
86
  }
151
- }
152
87
 
153
- /** Creates extractors for configured languages. */
154
- async function createExtractors(config: LatticeConfig): Promise<readonly Extractor[]> {
155
- const extractors: Extractor[] = [];
156
- for (const lang of config.languages) {
157
- if (lang === "python") {
158
- extractors.push(await createPythonExtractor());
159
- }
160
- if (lang === "typescript") {
161
- extractors.push(await createTypeScriptExtractor());
162
- }
88
+ // Fall back to full rebuild if too many files changed
89
+ if (changedFiles.length > allFiles.length * 0.3) {
90
+ db.close();
91
+ const buildResult = await executeBuild(projectRoot, config);
92
+ if (!isOk(buildResult)) return buildResult;
93
+ return ok({
94
+ filesReindexed: changedFiles.length,
95
+ totalFiles: allFiles.length,
96
+ durationMs: Math.round(performance.now() - start),
97
+ });
163
98
  }
164
- return extractors;
165
- }
166
99
 
167
- /** Checks if a file path matches any exclude pattern. */
168
- function isExcluded(filePath: string, excludePatterns: readonly string[]): boolean {
169
- for (const pattern of excludePatterns) {
170
- if (filePath.includes(pattern.replace("**", "").replace("*", ""))) return true;
171
- }
172
- return false;
100
+ // Any changed files full rebuild via executeBuild
101
+ // True incremental (per-file LSP re-extraction) is a future optimization
102
+ db.close();
103
+ const buildResult = await executeBuild(projectRoot, config);
104
+ if (!isOk(buildResult)) return buildResult;
105
+
106
+ return ok({
107
+ filesReindexed: changedFiles.length,
108
+ totalFiles: allFiles.length,
109
+ durationMs: Math.round(performance.now() - start),
110
+ });
173
111
  }
174
112
 
175
- export { executeUpdate, type UpdateStats };
113
+ export { executeUpdate };
package/src/config.ts CHANGED
@@ -56,7 +56,6 @@ function parsePythonSection(raw: unknown): PythonConfig {
56
56
  return {
57
57
  sourceRoots: isStringArray(section.source_roots) ? section.source_roots : ["."],
58
58
  testPaths: isStringArray(section.test_paths) ? section.test_paths : ["tests"],
59
- frameworks: isStringArray(section.frameworks) ? section.frameworks : [],
60
59
  };
61
60
  }
62
61
 
@@ -67,7 +66,6 @@ function parseTypeScriptSection(raw: unknown): TypeScriptConfig {
67
66
  sourceRoots: isStringArray(section.source_roots) ? section.source_roots : ["."],
68
67
  testPaths: isStringArray(section.test_paths) ? section.test_paths : ["__tests__"],
69
68
  tsconfig: typeof section.tsconfig === "string" ? section.tsconfig : undefined,
70
- frameworks: isStringArray(section.frameworks) ? section.frameworks : [],
71
69
  };
72
70
  }
73
71
 
@@ -0,0 +1,94 @@
1
+ import type { Node, Tag, TagKind } from "../types/graph.ts";
2
+ import { TAG_KINDS } from "../types/graph.ts";
3
+
4
+ const TAG_PATTERN = /^@lattice:(\S+)\s+(.+)/;
5
+ const NAME_PATTERN = /^[a-z][a-z0-9._-]*$/;
6
+
7
+ const COMMENT_PREFIXES: Record<string, RegExp> = {
8
+ typescript: /^\s*(?:\/\/|\/\*\*?|\*)\s*/,
9
+ python: /^\s*(?:#|""")\s*/,
10
+ };
11
+ const DEFAULT_COMMENT_PREFIX = /^\s*(?:\/\/|#|--|\/\*\*?|\*)\s*/;
12
+
13
+ /** Result of scanning a file for @lattice: tags. */
14
+ type TagScanResult = {
15
+ readonly tags: readonly Tag[];
16
+ readonly errors: readonly string[];
17
+ };
18
+
19
+ /**
20
+ * Scans source code for @lattice: tags and associates them with LSP-provided symbols.
21
+ * Validates tag syntax and names. Returns tags and any validation errors.
22
+ *
23
+ * @param source - File source code
24
+ * @param nodes - Nodes from LSP documentSymbol for this file
25
+ * @param language - Language identifier for comment prefix detection
26
+ * @returns Parsed tags and validation errors
27
+ */
28
+ function scanTags(source: string, nodes: readonly Node[], language?: string): TagScanResult {
29
+ const lines = source.split("\n");
30
+ const tags: Tag[] = [];
31
+ const errors: string[] = [];
32
+ const commentPrefix =
33
+ (language ? COMMENT_PREFIXES[language] : undefined) ?? DEFAULT_COMMENT_PREFIX;
34
+
35
+ const candidateNodes = [...nodes]
36
+ .filter((n) => n.kind === "function" || n.kind === "method" || n.kind === "class")
37
+ .sort((a, b) => a.lineStart - b.lineStart);
38
+
39
+ for (let i = 0; i < lines.length; i++) {
40
+ const line = lines[i];
41
+ if (!line) continue;
42
+ const tagLine = i + 1;
43
+
44
+ // Only process lines that start with a comment prefix for this language
45
+ if (!commentPrefix.test(line)) continue;
46
+
47
+ // Skip tags that are inside a function body and point to the SAME function
48
+ // (these are @lattice: mentions in string literals, not real tags).
49
+ // Tags between functions (e.g., above a decorated function whose predecessor's
50
+ // range overlaps) are fine — the target will be a different, later function.
51
+ const containingNode = candidateNodes.find(
52
+ (n) => tagLine > n.lineStart && tagLine <= n.lineEnd,
53
+ );
54
+ // If the tag is inside a function and the next function IS that same function, skip it
55
+ const nextNode = candidateNodes.find((n) => n.lineStart >= tagLine);
56
+ if (containingNode && nextNode && containingNode.id === nextNode.id) continue;
57
+
58
+ const stripped = line.replace(commentPrefix, "");
59
+ const match = stripped.match(TAG_PATTERN);
60
+ if (!match) continue;
61
+
62
+ const kind = match[1] as string;
63
+ const rawValue = match[2] as string;
64
+
65
+ if (!TAG_KINDS.includes(kind as TagKind)) {
66
+ errors.push(`Line ${tagLine}: unknown tag kind '${kind}'`);
67
+ continue;
68
+ }
69
+
70
+ const values = rawValue
71
+ .split(",")
72
+ .map((v) => v.trim())
73
+ .filter(Boolean);
74
+
75
+ for (const value of values) {
76
+ if (!NAME_PATTERN.test(value)) {
77
+ errors.push(`Line ${tagLine}: invalid tag name '${value}' — must be lowercase kebab-case`);
78
+ continue;
79
+ }
80
+
81
+ const targetNode = candidateNodes.find((n) => n.lineStart >= tagLine);
82
+ if (!targetNode) {
83
+ errors.push(`Line ${tagLine}: @lattice:${kind} ${value} has no function below it`);
84
+ continue;
85
+ }
86
+
87
+ tags.push({ nodeId: targetNode.id, kind: kind as TagKind, value });
88
+ }
89
+ }
90
+
91
+ return { tags, errors };
92
+ }
93
+
94
+ export { scanTags, type TagScanResult };