@psnext/lscg 0.1.1 → 0.1.3
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 -3
- package/dist/src/cli.js +68 -13
- package/dist/src/graph/repository.d.ts +4 -1
- package/dist/src/graph/repository.js +106 -6
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +2 -0
- package/dist/src/mcp/server.js +2 -2
- package/dist/src/scanner/discover.js +0 -2
- package/dist/src/scanner/packagePlugin.d.ts +3 -0
- package/dist/src/scanner/packagePlugin.js +125 -0
- package/dist/src/scanner/plugins.d.ts +86 -0
- package/dist/src/scanner/plugins.js +503 -0
- package/dist/src/storage/connection.d.ts +6 -0
- package/dist/src/storage/connection.js +133 -0
- package/dist/src/storage/context-queries.d.ts +20 -0
- package/dist/src/storage/context-queries.js +137 -0
- package/dist/src/storage/database.d.ts +12 -71
- package/dist/src/storage/database.js +12 -562
- package/dist/src/storage/graph-writes.d.ts +17 -0
- package/dist/src/storage/graph-writes.js +126 -0
- package/dist/src/storage/plugin-contributions.d.ts +15 -0
- package/dist/src/storage/plugin-contributions.js +28 -0
- package/dist/src/storage/plugin-graph.d.ts +6 -0
- package/dist/src/storage/plugin-graph.js +81 -0
- package/dist/src/storage/queries.d.ts +25 -0
- package/dist/src/storage/queries.js +129 -0
- package/dist/src/storage/row-decoders.d.ts +8 -0
- package/dist/src/storage/row-decoders.js +37 -0
- package/dist/src/storage/schema.d.ts +2 -2
- package/dist/src/storage/schema.js +13 -2
- package/dist/src/storage/traversal-queries.d.ts +15 -0
- package/dist/src/storage/traversal-queries.js +87 -0
- package/dist/src/types.d.ts +10 -4
- package/dist/src/view/index.d.ts +1 -1
- package/dist/src/view/index.js +2 -2
- package/dist/src/view/model.d.ts +2 -0
- package/dist/src/view/render.d.ts +5 -2
- package/dist/src/view/render.js +81 -13
- package/dist/src/view/templates/icons/call.svg +11 -0
- package/dist/src/view/templates/icons/export.svg +1 -0
- package/dist/src/view/templates/icons/file.svg +9 -0
- package/dist/src/view/templates/icons/import.svg +1 -0
- package/dist/src/view/templates/icons/package.svg +1 -0
- package/dist/src/view/templates/icons/symbol.svg +7 -0
- package/dist/src/view/templates/icons/user.svg +15 -0
- package/dist/src/view/templates/interactive.css +272 -0
- package/dist/src/view/templates/interactive.html +642 -0
- package/package.json +2 -1
|
@@ -1,563 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
return db;
|
|
14
|
-
}
|
|
15
|
-
export function openReadOnlyGraphDatabase(databasePath) {
|
|
16
|
-
const db = new DatabaseSync(databasePath, { readOnly: true });
|
|
17
|
-
db.exec('PRAGMA foreign_keys = ON;');
|
|
18
|
-
return db;
|
|
19
|
-
}
|
|
20
|
-
export function closeDatabase(db) {
|
|
21
|
-
db.close();
|
|
22
|
-
}
|
|
23
|
-
export function upsertRepository(db, repository) {
|
|
24
|
-
db.prepare(`
|
|
25
|
-
INSERT INTO repositories(id, root, name, updated_at)
|
|
26
|
-
VALUES (?, ?, ?, datetime('now'))
|
|
27
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
28
|
-
root = excluded.root,
|
|
29
|
-
name = excluded.name,
|
|
30
|
-
updated_at = datetime('now')
|
|
31
|
-
`).run(repository.id, repository.root, repository.name);
|
|
32
|
-
}
|
|
33
|
-
export function replaceFileGraph(db, { repository, file, nodes, edges, attribution }) {
|
|
34
|
-
upsertRepository(db, repository);
|
|
35
|
-
db.exec('BEGIN IMMEDIATE');
|
|
36
|
-
try {
|
|
37
|
-
if (attribution) {
|
|
38
|
-
upsertContributorNodes(db, repository, attribution.contributorEmails);
|
|
39
|
-
}
|
|
40
|
-
db.prepare('DELETE FROM edges WHERE file_id = ?').run(file.id);
|
|
41
|
-
db.prepare('DELETE FROM nodes WHERE file_id = ?').run(file.id);
|
|
42
|
-
db.prepare(`
|
|
43
|
-
INSERT INTO files(id, repository_id, path, language, hash, size, mtime_ms, scanned_at)
|
|
44
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
45
|
-
ON CONFLICT(repository_id, path) DO UPDATE SET
|
|
46
|
-
id = excluded.id,
|
|
47
|
-
language = excluded.language,
|
|
48
|
-
hash = excluded.hash,
|
|
49
|
-
size = excluded.size,
|
|
50
|
-
mtime_ms = excluded.mtime_ms,
|
|
51
|
-
scanned_at = datetime('now')
|
|
52
|
-
`).run(file.id, repository.id, file.path, file.language, file.hash, file.size, file.mtimeMs);
|
|
53
|
-
const insertNode = db.prepare(`
|
|
54
|
-
INSERT INTO nodes(
|
|
55
|
-
id, repository_id, file_id, kind, type, name, start_byte, end_byte,
|
|
56
|
-
start_point, end_point, source_hash, parser, parser_version, metadata_json, last_modified_user_id
|
|
57
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
58
|
-
`);
|
|
59
|
-
const attributionByNodeId = new Map(attribution?.nodeAttributions.map((entry) => [entry.nodeId, entry]) ?? []);
|
|
60
|
-
for (const node of nodes) {
|
|
61
|
-
const nodeAttribution = attributionByNodeId.get(node.id);
|
|
62
|
-
insertNode.run(node.id, repository.id, file.id, node.kind, node.type, node.name ?? null, node.startByte, node.endByte, JSON.stringify(node.startPoint), JSON.stringify(node.endPoint), node.sourceHash, node.parser, node.parserVersion, JSON.stringify(node.metadata ?? {}), node.lastModifiedUserId ?? resolveUserId(repository, nodeAttribution?.lastModifiedEmail ?? null));
|
|
63
|
-
}
|
|
64
|
-
const insertEdge = db.prepare(`
|
|
65
|
-
INSERT INTO edges(
|
|
66
|
-
id, repository_id, file_id, source_id, target_id, kind, confidence, metadata_json
|
|
67
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
68
|
-
`);
|
|
69
|
-
for (const edge of edges) {
|
|
70
|
-
insertEdge.run(edge.id, repository.id, file.id, edge.sourceId, edge.targetId, edge.kind, edge.confidence ?? 1, JSON.stringify(edge.metadata ?? {}));
|
|
71
|
-
}
|
|
72
|
-
if (attribution) {
|
|
73
|
-
for (const entry of attribution.nodeAttributions) {
|
|
74
|
-
for (const email of entry.contributorEmails) {
|
|
75
|
-
const contributorId = resolveUserId(repository, email);
|
|
76
|
-
if (!contributorId)
|
|
77
|
-
continue;
|
|
78
|
-
insertEdge.run(hashParts([repository.id, file.id, entry.nodeId, contributorId, 'attributed_to']), repository.id, file.id, entry.nodeId, contributorId, 'attributed_to', 1, JSON.stringify({ email }));
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
db.exec('COMMIT');
|
|
83
|
-
}
|
|
84
|
-
catch (error) {
|
|
85
|
-
db.exec('ROLLBACK');
|
|
86
|
-
throw error;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
export function getStatus(db, repositoryId) {
|
|
90
|
-
const repository = db.prepare('SELECT * FROM repositories WHERE id = ?').get(repositoryId);
|
|
91
|
-
const files = db.prepare('SELECT count(*) AS count FROM files WHERE repository_id = ?').get(repositoryId).count;
|
|
92
|
-
const nodes = db.prepare('SELECT count(*) AS count FROM nodes WHERE repository_id = ?').get(repositoryId).count;
|
|
93
|
-
const edges = db.prepare('SELECT count(*) AS count FROM edges WHERE repository_id = ?').get(repositoryId).count;
|
|
94
|
-
return { repository: repository ?? null, files, nodes, edges };
|
|
95
|
-
}
|
|
96
|
-
export function selectNodes(db, repositoryId, { kind, limit = 50 } = {}) {
|
|
97
|
-
const safeLimit = clampLimit(limit);
|
|
98
|
-
if (kind) {
|
|
99
|
-
return db.prepare(`
|
|
100
|
-
SELECT id, kind, type, name, file_id AS fileId, start_byte AS startByte, end_byte AS endByte, metadata_json AS metadataJson, last_modified_user_id AS lastModifiedUserId
|
|
101
|
-
FROM nodes
|
|
102
|
-
WHERE repository_id = ? AND kind = ?
|
|
103
|
-
ORDER BY name IS NULL, name, id
|
|
104
|
-
LIMIT ?
|
|
105
|
-
`).all(repositoryId, kind, safeLimit);
|
|
106
|
-
}
|
|
107
|
-
return db.prepare(`
|
|
108
|
-
SELECT id, kind, type, name, file_id AS fileId, start_byte AS startByte, end_byte AS endByte, metadata_json AS metadataJson, last_modified_user_id AS lastModifiedUserId
|
|
109
|
-
FROM nodes
|
|
110
|
-
WHERE repository_id = ?
|
|
111
|
-
ORDER BY kind, name IS NULL, name, id
|
|
112
|
-
LIMIT ?
|
|
113
|
-
`).all(repositoryId, safeLimit);
|
|
114
|
-
}
|
|
115
|
-
export function selectNodeTextRows(db, repositoryId, { kind, term, limit = 50 } = {}) {
|
|
116
|
-
const predicates = ['n.repository_id = ?'];
|
|
117
|
-
const params = [repositoryId];
|
|
118
|
-
if (kind) {
|
|
119
|
-
predicates.push('n.kind = ?');
|
|
120
|
-
params.push(kind);
|
|
121
|
-
}
|
|
122
|
-
if (term) {
|
|
123
|
-
predicates.push('lower(COALESCE(n.name, \'\')) LIKE ?');
|
|
124
|
-
params.push(`%${term.toLocaleLowerCase()}%`);
|
|
125
|
-
}
|
|
126
|
-
const rows = db.prepare(`
|
|
127
|
-
SELECT n.id, n.kind, n.type, n.name, n.file_id AS fileId,
|
|
128
|
-
n.start_byte AS startByte, n.end_byte AS endByte,
|
|
129
|
-
n.start_point AS startPoint, n.end_point AS endPoint,
|
|
130
|
-
n.metadata_json AS metadataJson, n.last_modified_user_id AS lastModifiedUserId,
|
|
131
|
-
f.path AS path
|
|
132
|
-
FROM nodes n LEFT JOIN files f ON f.id = n.file_id
|
|
133
|
-
WHERE ${predicates.join(' AND ')}
|
|
134
|
-
ORDER BY n.kind, n.name IS NULL, n.name, n.id
|
|
135
|
-
LIMIT ?
|
|
136
|
-
`).all(...params, clampLimit(limit));
|
|
137
|
-
return rows.map((row) => ({
|
|
138
|
-
...row,
|
|
139
|
-
startPoint: parsePoint(row.startPoint),
|
|
140
|
-
endPoint: parsePoint(row.endPoint)
|
|
141
|
-
}));
|
|
142
|
-
}
|
|
143
|
-
export function selectAllNodes(db, repositoryId, { kind } = {}) {
|
|
144
|
-
if (kind) {
|
|
145
|
-
return db.prepare(`
|
|
146
|
-
SELECT id, kind, type, name, file_id AS fileId, start_byte AS startByte, end_byte AS endByte, metadata_json AS metadataJson, last_modified_user_id AS lastModifiedUserId
|
|
147
|
-
FROM nodes
|
|
148
|
-
WHERE repository_id = ? AND kind = ?
|
|
149
|
-
ORDER BY name IS NULL, name, id
|
|
150
|
-
`).all(repositoryId, kind);
|
|
151
|
-
}
|
|
152
|
-
return db.prepare(`
|
|
153
|
-
SELECT id, kind, type, name, file_id AS fileId, start_byte AS startByte, end_byte AS endByte, metadata_json AS metadataJson, last_modified_user_id AS lastModifiedUserId
|
|
154
|
-
FROM nodes
|
|
155
|
-
WHERE repository_id = ?
|
|
156
|
-
ORDER BY kind, name IS NULL, name, id
|
|
157
|
-
`).all(repositoryId);
|
|
158
|
-
}
|
|
159
|
-
export function selectEdges(db, repositoryId, { kind, limit = 50 } = {}) {
|
|
160
|
-
const safeLimit = clampLimit(limit);
|
|
161
|
-
if (kind) {
|
|
162
|
-
return db.prepare(`
|
|
163
|
-
SELECT id, kind, source_id AS sourceId, target_id AS targetId, file_id AS fileId, confidence, metadata_json AS metadataJson
|
|
164
|
-
FROM edges
|
|
165
|
-
WHERE repository_id = ? AND kind = ?
|
|
166
|
-
ORDER BY kind, id
|
|
167
|
-
LIMIT ?
|
|
168
|
-
`).all(repositoryId, kind, safeLimit);
|
|
169
|
-
}
|
|
170
|
-
return db.prepare(`
|
|
171
|
-
SELECT id, kind, source_id AS sourceId, target_id AS targetId, file_id AS fileId, confidence, metadata_json AS metadataJson
|
|
172
|
-
FROM edges
|
|
173
|
-
WHERE repository_id = ?
|
|
174
|
-
ORDER BY kind, id
|
|
175
|
-
LIMIT ?
|
|
176
|
-
`).all(repositoryId, safeLimit);
|
|
177
|
-
}
|
|
178
|
-
export function selectAllEdges(db, repositoryId, { kind } = {}) {
|
|
179
|
-
if (kind) {
|
|
180
|
-
return db.prepare(`
|
|
181
|
-
SELECT id, kind, source_id AS sourceId, target_id AS targetId, file_id AS fileId, confidence, metadata_json AS metadataJson
|
|
182
|
-
FROM edges
|
|
183
|
-
WHERE repository_id = ? AND kind = ?
|
|
184
|
-
ORDER BY kind, id
|
|
185
|
-
`).all(repositoryId, kind);
|
|
186
|
-
}
|
|
187
|
-
return db.prepare(`
|
|
188
|
-
SELECT id, kind, source_id AS sourceId, target_id AS targetId, file_id AS fileId, confidence, metadata_json AS metadataJson
|
|
189
|
-
FROM edges
|
|
190
|
-
WHERE repository_id = ?
|
|
191
|
-
ORDER BY kind, id
|
|
192
|
-
`).all(repositoryId);
|
|
193
|
-
}
|
|
194
|
-
/** Candidate lookup used by context. Stages are queried independently so a unique
|
|
195
|
-
* higher-priority match never gets merged with lower-priority fuzzy matches. */
|
|
196
|
-
export function selectContextCandidates(db, repositoryId, term, { kind, file, limit = 20 } = {}) {
|
|
197
|
-
const safeLimit = clampLimit(limit, 100);
|
|
198
|
-
const normalized = term.trim().toLocaleLowerCase();
|
|
199
|
-
if (!normalized)
|
|
200
|
-
return [];
|
|
201
|
-
const rows = [];
|
|
202
|
-
const seen = new Set();
|
|
203
|
-
const stages = [
|
|
204
|
-
{ match: 'qualified', condition: "(lower(f.path || ':' || COALESCE(n.name, '')) = ? OR lower(f.path || '::' || COALESCE(n.name, '')) = ?)", value: normalized },
|
|
205
|
-
{ match: 'exact', condition: 'lower(n.name) = ?', value: normalized },
|
|
206
|
-
{ match: 'prefix', condition: 'lower(n.name) LIKE ?', value: `${normalized}%` },
|
|
207
|
-
{ match: 'substring', condition: 'lower(n.name) LIKE ?', value: `%${normalized}%` }
|
|
208
|
-
];
|
|
209
|
-
for (let stage = 0; stage < stages.length; stage += 1) {
|
|
210
|
-
const entry = stages[stage];
|
|
211
|
-
if (!entry)
|
|
212
|
-
continue;
|
|
213
|
-
const predicates = [`n.repository_id = ?`, entry.condition];
|
|
214
|
-
const params = [repositoryId, entry.value, entry.value];
|
|
215
|
-
if (!kind)
|
|
216
|
-
predicates.push("n.kind IN ('symbol', 'file')");
|
|
217
|
-
if (entry.match !== 'qualified')
|
|
218
|
-
params.pop();
|
|
219
|
-
if (kind) {
|
|
220
|
-
predicates.push('n.kind = ?');
|
|
221
|
-
params.push(kind);
|
|
222
|
-
}
|
|
223
|
-
if (file) {
|
|
224
|
-
predicates.push('f.path = ?');
|
|
225
|
-
params.push(file);
|
|
226
|
-
}
|
|
227
|
-
const selected = db.prepare(`
|
|
228
|
-
SELECT n.id, n.kind, n.type, n.name, n.file_id AS fileId,
|
|
229
|
-
n.start_point AS startPoint, n.end_point AS endPoint, n.source_hash AS sourceHash,
|
|
230
|
-
n.parser, n.parser_version AS parserVersion, n.metadata_json AS metadataJson,
|
|
231
|
-
f.path AS path
|
|
232
|
-
FROM nodes n LEFT JOIN files f ON f.id = n.file_id
|
|
233
|
-
WHERE ${predicates.join(' AND ')}
|
|
234
|
-
ORDER BY lower(COALESCE(f.path, '')), n.kind, lower(COALESCE(n.name, '')), n.id
|
|
235
|
-
LIMIT ?
|
|
236
|
-
`).all(...params, safeLimit);
|
|
237
|
-
for (const row of selected) {
|
|
238
|
-
const id = String(row.id);
|
|
239
|
-
if (seen.has(id))
|
|
240
|
-
continue;
|
|
241
|
-
seen.add(id);
|
|
242
|
-
rows.push({
|
|
243
|
-
...contextReferenceFromRow(row),
|
|
244
|
-
rank: stage,
|
|
245
|
-
match: entry.match,
|
|
246
|
-
stage
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
// A unique exact/qualified match is authoritative. Prefix and substring are
|
|
250
|
-
// only consulted when no higher-priority stage produced a result.
|
|
251
|
-
if (rows.length > 0)
|
|
252
|
-
break;
|
|
253
|
-
}
|
|
254
|
-
return rows.map(({ stage: _stage, ...candidate }) => candidate);
|
|
255
|
-
}
|
|
256
|
-
export function selectContextRelationships(db, repositoryId, anchorId, limit = 50, maxDepth = 1) {
|
|
257
|
-
const safeLimit = clampLimit(limit, 500);
|
|
258
|
-
const safeDepth = Math.max(1, Math.min(Number(maxDepth) || 1, 5));
|
|
259
|
-
const rows = [];
|
|
260
|
-
const seenNodes = new Set([anchorId]);
|
|
261
|
-
const seenEdges = new Set();
|
|
262
|
-
let frontier = [anchorId];
|
|
263
|
-
const kinds = ['calls', 'imports', 'exports'];
|
|
264
|
-
for (let depth = 1; depth <= safeDepth && frontier.length > 0; depth += 1) {
|
|
265
|
-
const next = new Set();
|
|
266
|
-
for (const kind of kinds) {
|
|
267
|
-
const remaining = safeLimit - rows.filter((row) => row.edgeKind === kind).length;
|
|
268
|
-
if (remaining <= 0)
|
|
269
|
-
continue;
|
|
270
|
-
const placeholders = frontier.map(() => '?').join(', ');
|
|
271
|
-
const selected = db.prepare(`
|
|
272
|
-
SELECT e.id AS edgeId, e.kind AS edgeKind, e.confidence, e.metadata_json AS edgeMetadata,
|
|
273
|
-
s.id AS sourceId, s.kind AS sourceKind, s.type AS sourceType, s.name AS sourceName,
|
|
274
|
-
s.start_point AS sourceStartPoint, s.end_point AS sourceEndPoint, s.source_hash AS sourceHash,
|
|
275
|
-
s.parser AS sourceParser, s.parser_version AS sourceParserVersion, s.metadata_json AS sourceMetadata,
|
|
276
|
-
sf.path AS sourcePath,
|
|
277
|
-
t.id AS targetId, t.kind AS targetKind, t.type AS targetType, t.name AS targetName,
|
|
278
|
-
t.start_point AS targetStartPoint, t.end_point AS targetEndPoint, t.source_hash AS targetHash,
|
|
279
|
-
t.parser AS targetParser, t.parser_version AS targetParserVersion, t.metadata_json AS targetMetadata,
|
|
280
|
-
tf.path AS targetPath
|
|
281
|
-
FROM edges e
|
|
282
|
-
JOIN nodes s ON s.id = e.source_id AND s.repository_id = e.repository_id
|
|
283
|
-
JOIN nodes t ON t.id = e.target_id AND t.repository_id = e.repository_id
|
|
284
|
-
LEFT JOIN files sf ON sf.id = s.file_id
|
|
285
|
-
LEFT JOIN files tf ON tf.id = t.file_id
|
|
286
|
-
WHERE e.repository_id = ? AND e.kind = ?
|
|
287
|
-
AND (e.source_id IN (${placeholders}) OR e.target_id IN (${placeholders}))
|
|
288
|
-
ORDER BY COALESCE(sf.path, tf.path), e.id
|
|
289
|
-
LIMIT ?
|
|
290
|
-
`).all(repositoryId, kind, ...frontier, ...frontier, remaining);
|
|
291
|
-
for (const row of selected) {
|
|
292
|
-
const edgeId = String(row.edgeId);
|
|
293
|
-
if (seenEdges.has(edgeId))
|
|
294
|
-
continue;
|
|
295
|
-
seenEdges.add(edgeId);
|
|
296
|
-
rows.push({ ...row, depth });
|
|
297
|
-
const sourceId = String(row.sourceId);
|
|
298
|
-
const targetId = String(row.targetId);
|
|
299
|
-
if (seenNodes.has(sourceId))
|
|
300
|
-
next.add(targetId);
|
|
301
|
-
if (seenNodes.has(targetId))
|
|
302
|
-
next.add(sourceId);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
for (const nodeId of next)
|
|
306
|
-
seenNodes.add(nodeId);
|
|
307
|
-
frontier = [...next].filter((nodeId) => nodeId !== anchorId);
|
|
308
|
-
}
|
|
309
|
-
return rows.map((row) => ({
|
|
310
|
-
edgeId: String(row.edgeId),
|
|
311
|
-
edgeKind: row.edgeKind,
|
|
312
|
-
source: contextReferenceFromRow({
|
|
313
|
-
id: row.sourceId, kind: row.sourceKind, type: row.sourceType, name: row.sourceName,
|
|
314
|
-
startPoint: row.sourceStartPoint, endPoint: row.sourceEndPoint, sourceHash: row.sourceHash,
|
|
315
|
-
parser: row.sourceParser, parserVersion: row.sourceParserVersion, metadataJson: row.sourceMetadata,
|
|
316
|
-
path: row.sourcePath
|
|
317
|
-
}),
|
|
318
|
-
target: contextReferenceFromRow({
|
|
319
|
-
id: row.targetId, kind: row.targetKind, type: row.targetType, name: row.targetName,
|
|
320
|
-
startPoint: row.targetStartPoint, endPoint: row.targetEndPoint, sourceHash: row.targetHash,
|
|
321
|
-
parser: row.targetParser, parserVersion: row.targetParserVersion, metadataJson: row.targetMetadata,
|
|
322
|
-
path: row.targetPath
|
|
323
|
-
}),
|
|
324
|
-
confidence: Number(row.confidence ?? 0),
|
|
325
|
-
metadata: parseMetadata(row.edgeMetadata),
|
|
326
|
-
depth: row.depth
|
|
327
|
-
}));
|
|
328
|
-
}
|
|
329
|
-
export function reconcileDeletedFiles(db, repositoryId, discoveredPaths) {
|
|
330
|
-
const existing = db.prepare('SELECT path FROM files WHERE repository_id = ?').all(repositoryId);
|
|
331
|
-
const keep = new Set(discoveredPaths);
|
|
332
|
-
db.exec('BEGIN IMMEDIATE');
|
|
333
|
-
try {
|
|
334
|
-
for (const row of existing) {
|
|
335
|
-
if (!keep.has(row.path))
|
|
336
|
-
db.prepare('DELETE FROM files WHERE repository_id = ? AND path = ?').run(repositoryId, row.path);
|
|
337
|
-
}
|
|
338
|
-
db.exec('COMMIT');
|
|
339
|
-
}
|
|
340
|
-
catch (error) {
|
|
341
|
-
db.exec('ROLLBACK');
|
|
342
|
-
throw error;
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
export function selectFileInventory(db, repositoryId) {
|
|
346
|
-
return db.prepare('SELECT path, size, mtime_ms AS mtimeMs FROM files WHERE repository_id = ? ORDER BY path').all(repositoryId);
|
|
347
|
-
}
|
|
348
|
-
function contextReferenceFromRow(row) {
|
|
349
|
-
return {
|
|
350
|
-
id: String(row.id),
|
|
351
|
-
path: row.path == null ? null : String(row.path),
|
|
352
|
-
kind: row.kind,
|
|
353
|
-
type: String(row.type),
|
|
354
|
-
name: row.name == null ? null : String(row.name),
|
|
355
|
-
span: { start: parsePoint(row.startPoint), end: parsePoint(row.endPoint) },
|
|
356
|
-
sourceHash: row.sourceHash == null ? null : String(row.sourceHash),
|
|
357
|
-
parser: row.parser == null ? null : String(row.parser),
|
|
358
|
-
parserVersion: row.parserVersion == null ? null : String(row.parserVersion),
|
|
359
|
-
metadata: parseMetadata(row.metadataJson),
|
|
360
|
-
fileId: row.fileId == null ? null : String(row.fileId)
|
|
361
|
-
};
|
|
362
|
-
}
|
|
363
|
-
function parsePoint(value) {
|
|
364
|
-
if (typeof value === 'string') {
|
|
365
|
-
try {
|
|
366
|
-
const point = JSON.parse(value);
|
|
367
|
-
return { row: Number(point.row ?? 0), column: Number(point.column ?? 0) };
|
|
368
|
-
}
|
|
369
|
-
catch { /* fall through */ }
|
|
370
|
-
}
|
|
371
|
-
return { row: 0, column: 0 };
|
|
372
|
-
}
|
|
373
|
-
function parseMetadata(value) {
|
|
374
|
-
if (typeof value !== 'string')
|
|
375
|
-
return {};
|
|
376
|
-
try {
|
|
377
|
-
const parsed = JSON.parse(value);
|
|
378
|
-
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
379
|
-
}
|
|
380
|
-
catch {
|
|
381
|
-
return {};
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
export function selectCallGraph(db, repositoryId, term, { kind, depth = 5, limit = 100 } = {}) {
|
|
385
|
-
const safeDepth = Math.max(1, Math.min(Number(depth) || 1, 5));
|
|
386
|
-
const safeLimit = clampLimit(limit, 500);
|
|
387
|
-
const nodes = selectAllNodes(db, repositoryId);
|
|
388
|
-
const edges = selectAllEdges(db, repositoryId);
|
|
389
|
-
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
390
|
-
const needle = term.toLocaleLowerCase();
|
|
391
|
-
const matches = nodes.filter((node) => (!kind || node.kind === kind) && node.name?.toLocaleLowerCase().includes(needle));
|
|
392
|
-
const walk = (startId, direction) => {
|
|
393
|
-
const relations = [];
|
|
394
|
-
const seen = new Set([startId]);
|
|
395
|
-
let frontier = [startId];
|
|
396
|
-
for (let currentDepth = 1; currentDepth <= safeDepth && frontier.length > 0; currentDepth += 1) {
|
|
397
|
-
const next = [];
|
|
398
|
-
for (const nodeId of frontier) {
|
|
399
|
-
for (const edge of edges) {
|
|
400
|
-
const adjacentId = direction === 'upstream'
|
|
401
|
-
? edge.targetId === nodeId ? edge.sourceId : null
|
|
402
|
-
: edge.sourceId === nodeId ? edge.targetId : null;
|
|
403
|
-
if (!adjacentId || seen.has(adjacentId))
|
|
404
|
-
continue;
|
|
405
|
-
const adjacent = nodeById.get(adjacentId);
|
|
406
|
-
if (!adjacent)
|
|
407
|
-
continue;
|
|
408
|
-
seen.add(adjacentId);
|
|
409
|
-
next.push(adjacentId);
|
|
410
|
-
relations.push({
|
|
411
|
-
id: adjacent.id,
|
|
412
|
-
kind: adjacent.kind,
|
|
413
|
-
type: adjacent.type,
|
|
414
|
-
name: adjacent.name,
|
|
415
|
-
viaEdgeId: edge.id,
|
|
416
|
-
edgeKind: edge.kind,
|
|
417
|
-
depth: currentDepth
|
|
418
|
-
});
|
|
419
|
-
if (relations.length >= safeLimit)
|
|
420
|
-
return relations;
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
frontier = next;
|
|
424
|
-
}
|
|
425
|
-
return relations;
|
|
426
|
-
};
|
|
427
|
-
return matches.slice(0, safeLimit).map((node) => ({
|
|
428
|
-
...node,
|
|
429
|
-
upstream: walk(node.id, 'upstream'),
|
|
430
|
-
downstream: walk(node.id, 'downstream')
|
|
431
|
-
}));
|
|
432
|
-
}
|
|
433
|
-
export function selectNeighbors(db, repositoryId, nodeId, { depth = 1, limit = 100 } = {}) {
|
|
434
|
-
const safeDepth = Math.max(1, Math.min(Number(depth) || 1, 5));
|
|
435
|
-
const safeLimit = clampLimit(limit, 500);
|
|
436
|
-
return db.prepare(`
|
|
437
|
-
WITH RECURSIVE walk(node_id, via_edge_id, depth) AS (
|
|
438
|
-
SELECT ?, NULL, 0
|
|
439
|
-
UNION ALL
|
|
440
|
-
SELECT
|
|
441
|
-
CASE WHEN e.source_id = walk.node_id THEN e.target_id ELSE e.source_id END,
|
|
442
|
-
e.id,
|
|
443
|
-
walk.depth + 1
|
|
444
|
-
FROM walk
|
|
445
|
-
JOIN edges e ON e.repository_id = ? AND (e.source_id = walk.node_id OR e.target_id = walk.node_id)
|
|
446
|
-
WHERE walk.depth < ?
|
|
447
|
-
)
|
|
448
|
-
SELECT DISTINCT
|
|
449
|
-
n.id, n.kind, n.type, n.name, w.via_edge_id AS viaEdgeId, w.depth
|
|
450
|
-
FROM walk w
|
|
451
|
-
JOIN nodes n ON n.id = w.node_id
|
|
452
|
-
WHERE n.repository_id = ?
|
|
453
|
-
ORDER BY w.depth, n.kind, n.name
|
|
454
|
-
LIMIT ?
|
|
455
|
-
`).all(nodeId, repositoryId, safeDepth, repositoryId, safeLimit);
|
|
456
|
-
}
|
|
457
|
-
export function runSelect(db, sql, { limit = 200 } = {}) {
|
|
458
|
-
const trimmed = sql.trim();
|
|
459
|
-
if (!/^(select|with|pragma)\b/i.test(trimmed)) {
|
|
460
|
-
throw new Error('Only read-only SELECT, WITH, and PRAGMA queries are allowed');
|
|
461
|
-
}
|
|
462
|
-
const rows = db.prepare(trimmed).all();
|
|
463
|
-
return rows.slice(0, clampLimit(limit, 1000));
|
|
464
|
-
}
|
|
465
|
-
export function attachDatabase(db, alias, databasePath) {
|
|
466
|
-
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(alias)) {
|
|
467
|
-
throw new Error(`Unsafe SQLite attachment alias: ${alias}`);
|
|
468
|
-
}
|
|
469
|
-
const escapedPath = databasePath.replaceAll("'", "''");
|
|
470
|
-
db.exec(`ATTACH DATABASE '${escapedPath}' AS ${alias}`);
|
|
471
|
-
}
|
|
472
|
-
function ensureSchemaVersion(db) {
|
|
473
|
-
const currentVersion = getCurrentSchemaVersion(db);
|
|
474
|
-
if (currentVersion === 0) {
|
|
475
|
-
db.prepare('INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)').run(SCHEMA_VERSION);
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
if (currentVersion >= SCHEMA_VERSION)
|
|
479
|
-
return;
|
|
480
|
-
if (currentVersion === 1) {
|
|
481
|
-
migrateSchemaV1ToV2(db);
|
|
482
|
-
db.prepare('INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)').run(SCHEMA_VERSION);
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
|
-
throw new Error(`Unsupported schema version: ${currentVersion}`);
|
|
486
|
-
}
|
|
487
|
-
function getCurrentSchemaVersion(db) {
|
|
488
|
-
const row = db.prepare('SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations').get();
|
|
489
|
-
return Number(row?.version ?? 0);
|
|
490
|
-
}
|
|
491
|
-
function migrateSchemaV1ToV2(db) {
|
|
492
|
-
db.exec('PRAGMA foreign_keys = OFF;');
|
|
493
|
-
db.exec('BEGIN IMMEDIATE');
|
|
494
|
-
try {
|
|
495
|
-
db.exec(`DROP TABLE IF EXISTS nodes_new;`);
|
|
496
|
-
db.exec(CREATE_NODES_TABLE_SQL);
|
|
497
|
-
db.exec(`
|
|
498
|
-
INSERT INTO nodes_new(
|
|
499
|
-
id, repository_id, file_id, kind, type, name, start_byte, end_byte,
|
|
500
|
-
start_point, end_point, source_hash, parser, parser_version, metadata_json, last_modified_user_id
|
|
501
|
-
)
|
|
502
|
-
SELECT
|
|
503
|
-
id, repository_id, file_id, kind, type, name, start_byte, end_byte,
|
|
504
|
-
start_point, end_point, source_hash, parser, parser_version, metadata_json, NULL
|
|
505
|
-
FROM nodes;
|
|
506
|
-
`);
|
|
507
|
-
db.exec('DROP TABLE nodes;');
|
|
508
|
-
db.exec('ALTER TABLE nodes_new RENAME TO nodes;');
|
|
509
|
-
db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_repo_kind ON nodes(repository_id, kind);');
|
|
510
|
-
db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_repo_name ON nodes(repository_id, name);');
|
|
511
|
-
db.exec('COMMIT');
|
|
512
|
-
}
|
|
513
|
-
catch (error) {
|
|
514
|
-
db.exec('ROLLBACK');
|
|
515
|
-
throw error;
|
|
516
|
-
}
|
|
517
|
-
finally {
|
|
518
|
-
db.exec('PRAGMA foreign_keys = ON;');
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
function upsertContributorNodes(db, repository, contributorEmails) {
|
|
522
|
-
const insertNode = db.prepare(`
|
|
523
|
-
INSERT INTO nodes(
|
|
524
|
-
id, repository_id, file_id, kind, type, name, start_byte, end_byte,
|
|
525
|
-
start_point, end_point, source_hash, parser, parser_version, metadata_json, last_modified_user_id
|
|
526
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
527
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
528
|
-
repository_id = excluded.repository_id,
|
|
529
|
-
file_id = excluded.file_id,
|
|
530
|
-
kind = excluded.kind,
|
|
531
|
-
type = excluded.type,
|
|
532
|
-
name = excluded.name,
|
|
533
|
-
start_byte = excluded.start_byte,
|
|
534
|
-
end_byte = excluded.end_byte,
|
|
535
|
-
start_point = excluded.start_point,
|
|
536
|
-
end_point = excluded.end_point,
|
|
537
|
-
source_hash = excluded.source_hash,
|
|
538
|
-
parser = excluded.parser,
|
|
539
|
-
parser_version = excluded.parser_version,
|
|
540
|
-
metadata_json = excluded.metadata_json,
|
|
541
|
-
last_modified_user_id = excluded.last_modified_user_id
|
|
542
|
-
`);
|
|
543
|
-
for (const contributor of contributorEmails) {
|
|
544
|
-
const normalizedEmail = normalizeEmail(contributor.email);
|
|
545
|
-
if (!normalizedEmail)
|
|
546
|
-
continue;
|
|
547
|
-
insertNode.run(resolveUserId(repository, normalizedEmail), repository.id, null, 'user', 'git_user', normalizedEmail, 0, 0, JSON.stringify({ row: 0, column: 0 }), JSON.stringify({ row: 0, column: 0 }), hashParts([repository.id, 'user', normalizedEmail, 'source']), 'git', 'blame-v1', JSON.stringify({ email: normalizedEmail }), null);
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
function resolveUserId(repository, email) {
|
|
551
|
-
const normalizedEmail = normalizeEmail(email);
|
|
552
|
-
if (!normalizedEmail)
|
|
553
|
-
return null;
|
|
554
|
-
return hashParts([repository.id, 'user', normalizedEmail]);
|
|
555
|
-
}
|
|
556
|
-
function normalizeEmail(value) {
|
|
557
|
-
return value?.trim().replace(/^<|>$/g, '').toLowerCase() ?? '';
|
|
558
|
-
}
|
|
559
|
-
function clampLimit(limit, max = 500) {
|
|
560
|
-
const value = Number(limit) || 50;
|
|
561
|
-
return Math.max(1, Math.min(value, max));
|
|
562
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Compatibility facade for the storage API. Keep consumers importing this module
|
|
3
|
+
* while the implementation stays split into focused modules small enough for
|
|
4
|
+
* Tree-sitter and easier to maintain.
|
|
5
|
+
*/
|
|
6
|
+
export * from './connection.js';
|
|
7
|
+
export * from './plugin-contributions.js';
|
|
8
|
+
export * from './graph-writes.js';
|
|
9
|
+
export * from './plugin-graph.js';
|
|
10
|
+
export * from './queries.js';
|
|
11
|
+
export * from './context-queries.js';
|
|
12
|
+
export * from './traversal-queries.js';
|
|
563
13
|
//# sourceMappingURL=database.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import type { FileAttribution, FileRecord, GraphEdge, GraphNode, RepositoryRecord } from '../types.js';
|
|
3
|
+
export declare function upsertRepository(db: DatabaseSync, repository: RepositoryRecord): void;
|
|
4
|
+
export declare function replaceFileGraph(db: DatabaseSync, { repository, file, nodes, edges, attribution }: {
|
|
5
|
+
repository: RepositoryRecord;
|
|
6
|
+
file: FileRecord;
|
|
7
|
+
nodes: GraphNode[];
|
|
8
|
+
edges: GraphEdge[];
|
|
9
|
+
attribution?: FileAttribution | null;
|
|
10
|
+
}): void;
|
|
11
|
+
export declare function reconcileDeletedFiles(db: DatabaseSync, repositoryId: string, discoveredPaths: string[]): void;
|
|
12
|
+
export declare function selectFileInventory(db: DatabaseSync, repositoryId: string): Array<{
|
|
13
|
+
path: string;
|
|
14
|
+
size: number;
|
|
15
|
+
mtimeMs: number;
|
|
16
|
+
}>;
|
|
17
|
+
//# sourceMappingURL=graph-writes.d.ts.map
|