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.
- package/README.md +69 -25
- package/package.json +5 -7
- package/src/commands/build.ts +44 -173
- package/src/commands/init.ts +34 -16
- package/src/commands/lint.ts +145 -53
- package/src/commands/populate.ts +14 -3
- package/src/commands/update.ts +75 -137
- package/src/config.ts +0 -2
- package/src/extract/tag-scanner.ts +94 -0
- package/src/files.ts +56 -0
- package/src/graph/database.ts +6 -8
- package/src/graph/writer.ts +17 -21
- package/src/lsp/builder.ts +235 -0
- package/src/lsp/calls.ts +84 -0
- package/src/lsp/client.ts +211 -0
- package/src/lsp/symbols.ts +146 -0
- package/src/lsp/types.ts +73 -0
- package/src/main.ts +2 -18
- package/src/types/config.ts +0 -2
- package/src/types/graph.ts +6 -34
- package/src/types/lint.ts +0 -1
- package/src/extract/extractor.ts +0 -13
- package/src/extract/parser.ts +0 -117
- package/src/extract/python/calls.ts +0 -121
- package/src/extract/python/extractor.ts +0 -171
- package/src/extract/python/frameworks.ts +0 -142
- package/src/extract/python/imports.ts +0 -115
- package/src/extract/python/symbols.ts +0 -121
- package/src/extract/tags.ts +0 -77
- package/src/extract/typescript/calls.ts +0 -110
- package/src/extract/typescript/extractor.ts +0 -130
- package/src/extract/typescript/imports.ts +0 -71
- package/src/extract/typescript/symbols.ts +0 -252
package/src/commands/lint.ts
CHANGED
|
@@ -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
|
|
11
|
-
* @returns Lint result with issues
|
|
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
|
|
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) =>
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
/**
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
/**
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
package/src/commands/populate.ts
CHANGED
|
@@ -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
|
|
161
|
+
### Step 3: Tag async dispatch (queues, Lambda, Celery)
|
|
162
162
|
|
|
163
|
-
If the codebase
|
|
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
|
-
|
|
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
|
package/src/commands/update.ts
CHANGED
|
@@ -1,37 +1,26 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { existsSync, statSync } from "node:fs";
|
|
3
|
-
import { join,
|
|
4
|
-
import
|
|
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
|
|
19
|
-
import {
|
|
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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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 -
|
|
34
|
-
* @param config -
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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 };
|