@psnext/lscg 0.1.2 → 0.1.4
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 +168 -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 +13 -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 +17 -11
- package/dist/src/view/templates/interactive.html +236 -52
- package/package.json +1 -1
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { discoverSourceFiles } from './discover.js';
|
|
5
|
+
export const SCANNER_PLUGIN_API_VERSION = 1;
|
|
6
|
+
export const DEFAULT_PLUGIN_RESOURCE_LIMITS = {
|
|
7
|
+
maxNodes: 100_000,
|
|
8
|
+
maxEdges: 200_000,
|
|
9
|
+
maxDiagnostics: 1_000,
|
|
10
|
+
maxMetadataBytes: 1_048_576,
|
|
11
|
+
maxSerializedOutputBytes: 5_242_880,
|
|
12
|
+
maxDiagnosticMessageBytes: 4_096
|
|
13
|
+
};
|
|
14
|
+
const MIN_DIAGNOSTIC_MESSAGE_BYTES = 17;
|
|
15
|
+
const GRAPH_NODE_KINDS = new Set(['file', 'symbol', 'import', 'export', 'call', 'user', 'package']);
|
|
16
|
+
const GRAPH_EDGE_KINDS = new Set(['contains', 'defines', 'imports', 'exports', 'calls', 'attributed_to', 'provides']);
|
|
17
|
+
/** Namespace used by plugins to target nodes produced by the built-in scanner. */
|
|
18
|
+
export const BUILTIN_GRAPH_PLUGIN = '__lscg_builtin__';
|
|
19
|
+
export function createRepositoryScanContext(repoPath) {
|
|
20
|
+
const discoveredPaths = discoverSourceFiles(repoPath);
|
|
21
|
+
const files = discoveredPaths.flatMap((relativePath) => {
|
|
22
|
+
try {
|
|
23
|
+
const absolutePath = path.join(repoPath, relativePath);
|
|
24
|
+
const source = readFileSync(absolutePath, 'utf8');
|
|
25
|
+
const stat = statSync(absolutePath);
|
|
26
|
+
return [{
|
|
27
|
+
id: hashParts(['file', repoPath, relativePath]),
|
|
28
|
+
path: relativePath,
|
|
29
|
+
language: path.extname(relativePath).slice(1),
|
|
30
|
+
hash: sha256(source),
|
|
31
|
+
size: stat.size,
|
|
32
|
+
mtimeMs: Math.round(stat.mtimeMs),
|
|
33
|
+
absolutePath,
|
|
34
|
+
source
|
|
35
|
+
}];
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
return { repoPath, files, discoveredPaths, readFile: (relativePath) => readFileSync(path.join(repoPath, relativePath), 'utf8') };
|
|
42
|
+
}
|
|
43
|
+
export async function runScannerPlugins(context, plugins, resourceLimits = {}) {
|
|
44
|
+
const limits = normalizePluginResourceLimits(resourceLimits);
|
|
45
|
+
const nodes = [];
|
|
46
|
+
const edges = [];
|
|
47
|
+
const diagnostics = [];
|
|
48
|
+
const failedPlugins = [];
|
|
49
|
+
const successfulPlugins = [];
|
|
50
|
+
const owners = new Map();
|
|
51
|
+
for (const plugin of plugins) {
|
|
52
|
+
try {
|
|
53
|
+
validatePlugin(plugin);
|
|
54
|
+
await plugin.initialize?.(context);
|
|
55
|
+
const result = await plugin.scan(context);
|
|
56
|
+
const finalized = await plugin.finalize?.(context);
|
|
57
|
+
validatePluginResult(plugin.name, result, 'scan');
|
|
58
|
+
if (finalized !== undefined)
|
|
59
|
+
validatePluginResult(plugin.name, finalized, 'finalize');
|
|
60
|
+
enforceResourceLimits(plugin, result, finalized, limits);
|
|
61
|
+
collect(plugin, result, nodes, edges, owners, diagnostics, limits);
|
|
62
|
+
if (finalized)
|
|
63
|
+
collect(plugin, finalized, nodes, edges, owners, diagnostics, limits);
|
|
64
|
+
successfulPlugins.push(plugin.name);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
failedPlugins.push(plugin.name);
|
|
68
|
+
if (error instanceof PluginBudgetError) {
|
|
69
|
+
addBudgetDiagnostic(diagnostics, error.pluginName, error.budget, limits);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
addDiagnostic(diagnostics, `${plugin.name}: ${error instanceof Error ? error.message : String(error)}`, limits);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
try {
|
|
77
|
+
await plugin.dispose?.(context);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
addDiagnostic(diagnostics, `${plugin.name}: dispose failed: ${error instanceof Error ? error.message : String(error)}`, limits);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const nodeByIdentity = new Map(nodes.map((node) => {
|
|
85
|
+
const materialized = materializeNode(node, context);
|
|
86
|
+
return [canonicalNodeIdentity(node.pluginName ?? '', node.factKey), materialized];
|
|
87
|
+
}));
|
|
88
|
+
const materializedNodes = [...nodeByIdentity.values()];
|
|
89
|
+
const materializedEdges = [];
|
|
90
|
+
for (const edge of edges) {
|
|
91
|
+
const sourceIdentity = canonicalNodeIdentity(edge.sourcePlugin ?? edge.pluginName ?? '', edge.sourceFactKey);
|
|
92
|
+
const targetPlugin = edge.targetPlugin ?? edge.pluginName ?? '';
|
|
93
|
+
const targetIdentity = canonicalNodeIdentity(targetPlugin, edge.targetFactKey);
|
|
94
|
+
const source = nodeByIdentity.get(sourceIdentity);
|
|
95
|
+
const target = targetPlugin === BUILTIN_GRAPH_PLUGIN
|
|
96
|
+
? { id: edge.targetFactKey }
|
|
97
|
+
: nodeByIdentity.get(targetIdentity);
|
|
98
|
+
if (!source || !target) {
|
|
99
|
+
addDiagnostic(diagnostics, `unresolved edge ${edge.factKey}: ${edge.sourceFactKey} -> ${edge.targetFactKey}`, limits);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
materializedEdges.push({
|
|
103
|
+
id: hashParts(['plugin-edge', edge.pluginName ?? '', edge.factKey, sourceIdentity, targetIdentity, edge.kind]),
|
|
104
|
+
sourceId: source.id,
|
|
105
|
+
targetId: target.id,
|
|
106
|
+
kind: edge.kind,
|
|
107
|
+
confidence: edge.confidence ?? 1,
|
|
108
|
+
metadata: {
|
|
109
|
+
...(edge.metadata ?? {}),
|
|
110
|
+
...(edge.filePath ? { filePath: edge.filePath } : { filePath: nodeFilePath(source, context) }),
|
|
111
|
+
factKey: edge.factKey,
|
|
112
|
+
plugin: edge.pluginName,
|
|
113
|
+
...(edge.sourcePlugin ? { sourcePlugin: edge.sourcePlugin } : {}),
|
|
114
|
+
...(edge.targetPlugin ? { targetPlugin: edge.targetPlugin } : {})
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const pluginContributions = new Map();
|
|
119
|
+
for (const node of materializedNodes) {
|
|
120
|
+
const plugin = typeof node.metadata.plugin === 'string' ? node.metadata.plugin : undefined;
|
|
121
|
+
if (plugin)
|
|
122
|
+
contributionFor(plugin).nodes.push(node);
|
|
123
|
+
}
|
|
124
|
+
for (const edge of materializedEdges) {
|
|
125
|
+
const plugin = typeof edge.metadata.plugin === 'string' ? edge.metadata.plugin : undefined;
|
|
126
|
+
if (plugin)
|
|
127
|
+
contributionFor(plugin).edges.push(edge);
|
|
128
|
+
}
|
|
129
|
+
function contributionFor(plugin) {
|
|
130
|
+
const existing = pluginContributions.get(plugin);
|
|
131
|
+
if (existing)
|
|
132
|
+
return existing;
|
|
133
|
+
const created = { nodes: [], edges: [] };
|
|
134
|
+
pluginContributions.set(plugin, created);
|
|
135
|
+
return created;
|
|
136
|
+
}
|
|
137
|
+
return { nodes: materializedNodes, edges: materializedEdges, diagnostics, failedPlugins, successfulPlugins, pluginContributions };
|
|
138
|
+
function collect(plugin, result, nodeOutput, edgeOutput, ownerMap, messages, contributionLimits) {
|
|
139
|
+
for (const node of result.nodes ?? []) {
|
|
140
|
+
const identity = canonicalNodeIdentity(plugin.name, node.factKey);
|
|
141
|
+
const candidate = { ...node, factKey: node.factKey, pluginName: plugin.name };
|
|
142
|
+
applyFact('node', node.factKey, identity, plugin, node.conflict, nodeOutput, candidate, ownerMap, messages, contributionLimits);
|
|
143
|
+
}
|
|
144
|
+
for (const edge of result.edges ?? []) {
|
|
145
|
+
const identity = canonicalEdgeIdentity(plugin.name, edge.factKey);
|
|
146
|
+
const candidate = { ...edge, factKey: edge.factKey, sourceFactKey: edge.sourceFactKey, targetFactKey: edge.targetFactKey, pluginName: plugin.name };
|
|
147
|
+
applyFact('edge', edge.factKey, identity, plugin, edge.conflict, edgeOutput, candidate, ownerMap, messages, contributionLimits);
|
|
148
|
+
}
|
|
149
|
+
for (const message of result.diagnostics ?? [])
|
|
150
|
+
addDiagnostic(messages, `${plugin.name}: ${message}`, contributionLimits);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function canonicalNodeIdentity(pluginName, factKey) {
|
|
154
|
+
return `${pluginName}\u0000node\u0000${factKey}`;
|
|
155
|
+
}
|
|
156
|
+
function canonicalEdgeIdentity(pluginName, factKey) {
|
|
157
|
+
return `${pluginName}\u0000edge\u0000${factKey}`;
|
|
158
|
+
}
|
|
159
|
+
function applyFact(category, factKey, identity, plugin, policy, output, candidate, owners, diagnostics, limits) {
|
|
160
|
+
const conflictKey = `${category}\u0000${factKey}`;
|
|
161
|
+
const existing = owners.get(conflictKey) ?? [];
|
|
162
|
+
const incumbent = existing[0];
|
|
163
|
+
if (existing.some((owner) => owner.identity === identity)) {
|
|
164
|
+
addDiagnostic(diagnostics, `duplicate ${category} rejected for ${plugin.name}:${factKey}`, limits);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!incumbent) {
|
|
168
|
+
owners.set(conflictKey, [{ priority: plugin.priority ?? 0, plugin: plugin.name, policy: policy ?? 'merge', identity }]);
|
|
169
|
+
output.push(candidate);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const chosen = policy ?? incumbent.policy;
|
|
173
|
+
if (chosen === 'merge') {
|
|
174
|
+
existing.push({ priority: plugin.priority ?? 0, plugin: plugin.name, policy: policy ?? chosen, identity });
|
|
175
|
+
output.push(candidate);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (chosen === 'reject') {
|
|
179
|
+
addDiagnostic(diagnostics, `conflict rejected for ${category}:${factKey} (${incumbent.plugin}, ${plugin.name})`, limits);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const candidatePriority = plugin.priority ?? 0;
|
|
183
|
+
const wins = candidatePriority > incumbent.priority || (candidatePriority === incumbent.priority && plugin.name > incumbent.plugin);
|
|
184
|
+
if (!wins) {
|
|
185
|
+
addDiagnostic(diagnostics, `conflict replaced by higher priority for ${category}:${factKey} (${incumbent.plugin}, ${plugin.name})`, limits);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
for (let index = output.length - 1; index >= 0; index -= 1) {
|
|
189
|
+
const outputIdentity = category === 'node'
|
|
190
|
+
? canonicalNodeIdentity(output[index].pluginName ?? '', output[index].factKey)
|
|
191
|
+
: canonicalEdgeIdentity(output[index].pluginName ?? '', output[index].factKey);
|
|
192
|
+
if (existing.some((owner) => owner.identity === outputIdentity))
|
|
193
|
+
output.splice(index, 1);
|
|
194
|
+
}
|
|
195
|
+
owners.set(conflictKey, [{ priority: candidatePriority, plugin: plugin.name, policy: policy ?? chosen, identity }]);
|
|
196
|
+
output.push(candidate);
|
|
197
|
+
}
|
|
198
|
+
function acceptFact(key, plugin, policy, owners, diagnostics, limits) {
|
|
199
|
+
const priority = plugin.priority ?? 0;
|
|
200
|
+
const existing = owners.get(key);
|
|
201
|
+
if (!existing) {
|
|
202
|
+
owners.set(key, { priority, plugin: plugin.name, policy: policy ?? 'merge' });
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
const chosen = policy ?? existing.policy;
|
|
206
|
+
if (chosen === 'reject') {
|
|
207
|
+
addDiagnostic(diagnostics, `conflict rejected for ${key} (${existing.plugin}, ${plugin.name})`, limits);
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
if (chosen === 'replace' && priority >= existing.priority) {
|
|
211
|
+
owners.set(key, { priority, plugin: plugin.name, policy: chosen });
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
return chosen === 'merge' && priority >= existing.priority;
|
|
215
|
+
}
|
|
216
|
+
function normalizePluginResourceLimits(overrides) {
|
|
217
|
+
const limits = { ...DEFAULT_PLUGIN_RESOURCE_LIMITS, ...overrides };
|
|
218
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
219
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
220
|
+
throw new Error(`invalid plugin resource limit ${name}: ${value}`);
|
|
221
|
+
}
|
|
222
|
+
if (limits.maxDiagnostics < 1)
|
|
223
|
+
throw new Error('maxDiagnostics must be at least 1');
|
|
224
|
+
if (limits.maxDiagnosticMessageBytes < MIN_DIAGNOSTIC_MESSAGE_BYTES) {
|
|
225
|
+
throw new Error(`maxDiagnosticMessageBytes must be at least ${MIN_DIAGNOSTIC_MESSAGE_BYTES}`);
|
|
226
|
+
}
|
|
227
|
+
return limits;
|
|
228
|
+
}
|
|
229
|
+
function validatePluginResult(pluginName, value, phase) {
|
|
230
|
+
if (!isRecord(value))
|
|
231
|
+
throw new Error(`${pluginName}: invalid ${phase} result: expected an object`);
|
|
232
|
+
if (!Array.isArray(value.nodes))
|
|
233
|
+
throw new Error(`${pluginName}: invalid ${phase} result: nodes must be an array`);
|
|
234
|
+
if (!Array.isArray(value.edges))
|
|
235
|
+
throw new Error(`${pluginName}: invalid ${phase} result: edges must be an array`);
|
|
236
|
+
if (value.diagnostics !== undefined && (!Array.isArray(value.diagnostics) || value.diagnostics.some((message) => typeof message !== 'string'))) {
|
|
237
|
+
throw new Error(`${pluginName}: invalid ${phase} result: diagnostics must be an array of strings`);
|
|
238
|
+
}
|
|
239
|
+
value.nodes.forEach((node, index) => validateNodeFact(pluginName, node, `${phase}.nodes[${index}]`));
|
|
240
|
+
value.edges.forEach((edge, index) => validateEdgeFact(pluginName, edge, `${phase}.edges[${index}]`));
|
|
241
|
+
}
|
|
242
|
+
function validateNodeFact(pluginName, value, location) {
|
|
243
|
+
if (!isRecord(value))
|
|
244
|
+
throw new Error(`${pluginName}: invalid plugin fact ${location}: expected an object`);
|
|
245
|
+
requireNonEmptyString(pluginName, location, 'factKey', value.factKey);
|
|
246
|
+
requireNonEmptyString(pluginName, location, 'type', value.type);
|
|
247
|
+
if (!GRAPH_NODE_KINDS.has(value.kind)) {
|
|
248
|
+
throw new Error(`${pluginName}: invalid plugin fact ${location}.kind: ${String(value.kind)}`);
|
|
249
|
+
}
|
|
250
|
+
if (value.filePath !== undefined)
|
|
251
|
+
requireNonEmptyString(pluginName, location, 'filePath', value.filePath);
|
|
252
|
+
if (value.name !== undefined && value.name !== null)
|
|
253
|
+
requireString(pluginName, location, 'name', value.name);
|
|
254
|
+
for (const field of ['sourceHash', 'parser', 'parserVersion']) {
|
|
255
|
+
if (value[field] !== undefined)
|
|
256
|
+
requireString(pluginName, location, field, value[field]);
|
|
257
|
+
}
|
|
258
|
+
for (const field of ['startByte', 'endByte']) {
|
|
259
|
+
if (value[field] !== undefined)
|
|
260
|
+
requireNonNegativeInteger(pluginName, location, field, value[field]);
|
|
261
|
+
}
|
|
262
|
+
for (const field of ['startPoint', 'endPoint']) {
|
|
263
|
+
if (value[field] !== undefined)
|
|
264
|
+
requirePoint(pluginName, location, field, value[field]);
|
|
265
|
+
}
|
|
266
|
+
if (value.conflict !== undefined && !['merge', 'replace', 'reject'].includes(String(value.conflict))) {
|
|
267
|
+
throw new Error(`${pluginName}: invalid plugin fact ${location}.conflict: ${String(value.conflict)}`);
|
|
268
|
+
}
|
|
269
|
+
if (value.metadata !== undefined && !isRecord(value.metadata)) {
|
|
270
|
+
throw new Error(`${pluginName}: invalid plugin fact ${location}.metadata: expected an object`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function validateEdgeFact(pluginName, value, location) {
|
|
274
|
+
if (!isRecord(value))
|
|
275
|
+
throw new Error(`${pluginName}: invalid plugin fact ${location}: expected an object`);
|
|
276
|
+
requireNonEmptyString(pluginName, location, 'factKey', value.factKey);
|
|
277
|
+
requireNonEmptyString(pluginName, location, 'sourceFactKey', value.sourceFactKey);
|
|
278
|
+
requireNonEmptyString(pluginName, location, 'targetFactKey', value.targetFactKey);
|
|
279
|
+
for (const field of ['sourcePlugin', 'targetPlugin']) {
|
|
280
|
+
if (value[field] !== undefined)
|
|
281
|
+
requireNonEmptyString(pluginName, location, field, value[field]);
|
|
282
|
+
}
|
|
283
|
+
if (!GRAPH_EDGE_KINDS.has(value.kind)) {
|
|
284
|
+
throw new Error(`${pluginName}: invalid plugin fact ${location}.kind: ${String(value.kind)}`);
|
|
285
|
+
}
|
|
286
|
+
if (value.filePath !== undefined)
|
|
287
|
+
requireNonEmptyString(pluginName, location, 'filePath', value.filePath);
|
|
288
|
+
if (value.confidence !== undefined && (typeof value.confidence !== 'number' || !Number.isFinite(value.confidence) || value.confidence < 0 || value.confidence > 1)) {
|
|
289
|
+
throw new Error(`${pluginName}: invalid plugin fact ${location}.confidence: ${String(value.confidence)}`);
|
|
290
|
+
}
|
|
291
|
+
if (value.conflict !== undefined && !['merge', 'replace', 'reject'].includes(String(value.conflict))) {
|
|
292
|
+
throw new Error(`${pluginName}: invalid plugin fact ${location}.conflict: ${String(value.conflict)}`);
|
|
293
|
+
}
|
|
294
|
+
if (value.metadata !== undefined && !isRecord(value.metadata)) {
|
|
295
|
+
throw new Error(`${pluginName}: invalid plugin fact ${location}.metadata: expected an object`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function requireNonEmptyString(pluginName, location, field, value) {
|
|
299
|
+
requireString(pluginName, location, field, value);
|
|
300
|
+
if (value.trim().length === 0) {
|
|
301
|
+
throw new Error(`${pluginName}: invalid plugin fact ${location}.${field}: expected a non-empty string`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function requireString(pluginName, location, field, value) {
|
|
305
|
+
if (typeof value !== 'string')
|
|
306
|
+
throw new Error(`${pluginName}: invalid plugin fact ${location}.${field}: expected a string`);
|
|
307
|
+
}
|
|
308
|
+
function requireNonNegativeInteger(pluginName, location, field, value) {
|
|
309
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
|
310
|
+
throw new Error(`${pluginName}: invalid plugin fact ${location}.${field}: expected a non-negative integer`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function requirePoint(pluginName, location, field, value) {
|
|
314
|
+
const point = isRecord(value) ? value : undefined;
|
|
315
|
+
if (!point || typeof point.row !== 'number' || !Number.isSafeInteger(point.row) || point.row < 0 ||
|
|
316
|
+
typeof point.column !== 'number' || !Number.isSafeInteger(point.column) || point.column < 0) {
|
|
317
|
+
throw new Error(`${pluginName}: invalid plugin fact ${location}.${field}: expected non-negative row and column`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function isRecord(value) {
|
|
321
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
322
|
+
}
|
|
323
|
+
function enforceResourceLimits(plugin, result, finalized, limits) {
|
|
324
|
+
let nodeCount = 0;
|
|
325
|
+
let edgeCount = 0;
|
|
326
|
+
let diagnosticCount = 0;
|
|
327
|
+
let metadataBytes = 0;
|
|
328
|
+
let serializedBytes = 0;
|
|
329
|
+
for (const contribution of [result, finalized]) {
|
|
330
|
+
if (!contribution)
|
|
331
|
+
continue;
|
|
332
|
+
nodeCount += contribution.nodes.length;
|
|
333
|
+
edgeCount += contribution.edges.length;
|
|
334
|
+
diagnosticCount += contribution.diagnostics?.length ?? 0;
|
|
335
|
+
if (nodeCount > limits.maxNodes)
|
|
336
|
+
throw new PluginBudgetError(plugin.name, 'maxNodes');
|
|
337
|
+
if (edgeCount > limits.maxEdges)
|
|
338
|
+
throw new PluginBudgetError(plugin.name, 'maxEdges');
|
|
339
|
+
if (diagnosticCount > limits.maxDiagnostics)
|
|
340
|
+
throw new PluginBudgetError(plugin.name, 'maxDiagnostics');
|
|
341
|
+
for (const node of contribution.nodes) {
|
|
342
|
+
if (node.metadata !== undefined)
|
|
343
|
+
metadataBytes += boundedJsonByteLength(node.metadata, limits.maxMetadataBytes - metadataBytes, plugin.name, 'maxMetadataBytes');
|
|
344
|
+
if (metadataBytes > limits.maxMetadataBytes)
|
|
345
|
+
throw new PluginBudgetError(plugin.name, 'maxMetadataBytes');
|
|
346
|
+
}
|
|
347
|
+
for (const edge of contribution.edges) {
|
|
348
|
+
if (edge.metadata !== undefined)
|
|
349
|
+
metadataBytes += boundedJsonByteLength(edge.metadata, limits.maxMetadataBytes - metadataBytes, plugin.name, 'maxMetadataBytes');
|
|
350
|
+
if (metadataBytes > limits.maxMetadataBytes)
|
|
351
|
+
throw new PluginBudgetError(plugin.name, 'maxMetadataBytes');
|
|
352
|
+
}
|
|
353
|
+
for (const message of contribution.diagnostics ?? []) {
|
|
354
|
+
if (Buffer.byteLength(message, 'utf8') > limits.maxDiagnosticMessageBytes)
|
|
355
|
+
throw new PluginBudgetError(plugin.name, 'maxDiagnosticMessageBytes');
|
|
356
|
+
}
|
|
357
|
+
serializedBytes += boundedJsonByteLength(contribution, limits.maxSerializedOutputBytes - serializedBytes, plugin.name, 'maxSerializedOutputBytes');
|
|
358
|
+
if (serializedBytes > limits.maxSerializedOutputBytes)
|
|
359
|
+
throw new PluginBudgetError(plugin.name, 'maxSerializedOutputBytes');
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function boundedJsonByteLength(value, budget, pluginName, budgetName) {
|
|
363
|
+
const seen = new Set();
|
|
364
|
+
let total = 0;
|
|
365
|
+
function add(bytes) {
|
|
366
|
+
total += bytes;
|
|
367
|
+
if (total > budget)
|
|
368
|
+
throw new PluginBudgetError(pluginName, budgetName);
|
|
369
|
+
}
|
|
370
|
+
function visit(candidate) {
|
|
371
|
+
if (candidate === null) {
|
|
372
|
+
add(4);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (typeof candidate === 'string') {
|
|
376
|
+
add(Buffer.byteLength(JSON.stringify(candidate), 'utf8'));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (typeof candidate === 'number' || typeof candidate === 'boolean') {
|
|
380
|
+
add(Buffer.byteLength(String(candidate), 'utf8'));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (typeof candidate !== 'object')
|
|
384
|
+
return;
|
|
385
|
+
if (seen.has(candidate))
|
|
386
|
+
throw new Error('circular plugin output');
|
|
387
|
+
seen.add(candidate);
|
|
388
|
+
if (Array.isArray(candidate)) {
|
|
389
|
+
add(1);
|
|
390
|
+
candidate.forEach((item, index) => { if (index > 0)
|
|
391
|
+
add(1); visit(item); });
|
|
392
|
+
add(1);
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
const entries = Object.entries(candidate);
|
|
396
|
+
add(1);
|
|
397
|
+
entries.forEach(([key, item], index) => {
|
|
398
|
+
if (index > 0)
|
|
399
|
+
add(1);
|
|
400
|
+
add(Buffer.byteLength(JSON.stringify(key), 'utf8'));
|
|
401
|
+
add(1);
|
|
402
|
+
visit(item);
|
|
403
|
+
});
|
|
404
|
+
add(1);
|
|
405
|
+
}
|
|
406
|
+
seen.delete(candidate);
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
visit(value);
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
if (error instanceof PluginBudgetError)
|
|
413
|
+
throw error;
|
|
414
|
+
throw new Error(`invalid plugin output: ${error instanceof Error ? error.message : String(error)}`);
|
|
415
|
+
}
|
|
416
|
+
return total;
|
|
417
|
+
}
|
|
418
|
+
function addDiagnostic(diagnostics, message, limits) {
|
|
419
|
+
if (diagnostics.length >= limits.maxDiagnostics)
|
|
420
|
+
return;
|
|
421
|
+
diagnostics.push(truncateUtf8(message, limits.maxDiagnosticMessageBytes));
|
|
422
|
+
}
|
|
423
|
+
function addBudgetDiagnostic(diagnostics, pluginName, budget, limits) {
|
|
424
|
+
const budgetCode = budgetCodeFor(budget);
|
|
425
|
+
const compactMessage = `limit:${budgetCode}`;
|
|
426
|
+
const message = `${pluginName}: ${compactMessage}`;
|
|
427
|
+
const boundedMessage = Buffer.byteLength(message, 'utf8') <= limits.maxDiagnosticMessageBytes
|
|
428
|
+
? message
|
|
429
|
+
: compactMessage;
|
|
430
|
+
if (diagnostics.length >= limits.maxDiagnostics)
|
|
431
|
+
diagnostics[limits.maxDiagnostics - 1] = boundedMessage;
|
|
432
|
+
else
|
|
433
|
+
diagnostics.push(boundedMessage);
|
|
434
|
+
}
|
|
435
|
+
function budgetCodeFor(budget) {
|
|
436
|
+
return {
|
|
437
|
+
maxNodes: 'nodes',
|
|
438
|
+
maxEdges: 'edges',
|
|
439
|
+
maxDiagnostics: 'diagnostics',
|
|
440
|
+
maxMetadataBytes: 'metadata',
|
|
441
|
+
maxSerializedOutputBytes: 'serialized',
|
|
442
|
+
maxDiagnosticMessageBytes: 'diagnostic'
|
|
443
|
+
}[budget] ?? 'output';
|
|
444
|
+
}
|
|
445
|
+
function truncateUtf8(value, maxBytes) {
|
|
446
|
+
if (Buffer.byteLength(value, 'utf8') <= maxBytes)
|
|
447
|
+
return value;
|
|
448
|
+
let truncated = '';
|
|
449
|
+
for (const character of value) {
|
|
450
|
+
const next = truncated + character;
|
|
451
|
+
if (Buffer.byteLength(next, 'utf8') > maxBytes)
|
|
452
|
+
break;
|
|
453
|
+
truncated = next;
|
|
454
|
+
}
|
|
455
|
+
return truncated;
|
|
456
|
+
}
|
|
457
|
+
class PluginBudgetError extends Error {
|
|
458
|
+
pluginName;
|
|
459
|
+
budget;
|
|
460
|
+
constructor(pluginName, budget) {
|
|
461
|
+
super(`${pluginName}: plugin output exceeded ${budget}`);
|
|
462
|
+
this.pluginName = pluginName;
|
|
463
|
+
this.budget = budget;
|
|
464
|
+
this.name = 'PluginBudgetError';
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
function nodeFilePath(node, _context) {
|
|
468
|
+
return typeof node.metadata.filePath === 'string' ? node.metadata.filePath : undefined;
|
|
469
|
+
}
|
|
470
|
+
function materializeNode(node, context) {
|
|
471
|
+
const filePath = node.filePath;
|
|
472
|
+
const pluginName = node.pluginName ?? '';
|
|
473
|
+
const file = filePath ? context.files.find((candidate) => candidate.path === filePath) : undefined;
|
|
474
|
+
const sourceHash = node.sourceHash ?? file?.hash ?? '';
|
|
475
|
+
const startByte = node.startByte ?? 0;
|
|
476
|
+
const endByte = node.endByte ?? file?.size ?? 0;
|
|
477
|
+
return {
|
|
478
|
+
id: hashParts(['plugin-node', pluginName, node.factKey]),
|
|
479
|
+
kind: node.kind,
|
|
480
|
+
type: node.type,
|
|
481
|
+
name: node.name ?? null,
|
|
482
|
+
startByte,
|
|
483
|
+
endByte,
|
|
484
|
+
startPoint: node.startPoint ?? { row: 0, column: 0 },
|
|
485
|
+
endPoint: node.endPoint ?? { row: 0, column: 0 },
|
|
486
|
+
sourceHash,
|
|
487
|
+
parser: node.parser ?? `plugin:${pluginName}`,
|
|
488
|
+
parserVersion: node.parserVersion ?? `plugin-api-${SCANNER_PLUGIN_API_VERSION}`,
|
|
489
|
+
metadata: { ...(node.metadata ?? {}), ...(filePath ? { filePath } : {}), factKey: node.factKey, plugin: pluginName, identity: canonicalNodeIdentity(pluginName, node.factKey) },
|
|
490
|
+
lastModifiedUserId: null
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
function validatePlugin(plugin) {
|
|
494
|
+
if (!plugin?.name || !Number.isInteger(plugin.apiVersion) || plugin.apiVersion !== SCANNER_PLUGIN_API_VERSION)
|
|
495
|
+
throw new Error(`incompatible scanner plugin: ${plugin?.name ?? 'unknown'}`);
|
|
496
|
+
if (!Array.isArray(plugin.capabilities) || typeof plugin.scan !== 'function')
|
|
497
|
+
throw new Error(`invalid scanner plugin: ${plugin.name}`);
|
|
498
|
+
}
|
|
499
|
+
function hashParts(parts) {
|
|
500
|
+
return createHash('sha256').update(parts.map(String).join('\0')).digest('hex').slice(0, 32);
|
|
501
|
+
}
|
|
502
|
+
function sha256(value) { return createHash('sha256').update(value).digest('hex'); }
|
|
503
|
+
//# sourceMappingURL=plugins.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
export declare function openGraphDatabase(databasePath: string): DatabaseSync;
|
|
3
|
+
export declare function openReadOnlyGraphDatabase(databasePath: string): DatabaseSync;
|
|
4
|
+
export declare function closeDatabase(db: DatabaseSync): void;
|
|
5
|
+
export declare function attachDatabase(db: DatabaseSync, alias: string, databasePath: string): void;
|
|
6
|
+
//# sourceMappingURL=connection.d.ts.map
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { CREATE_NODES_TABLE_SQL, CREATE_SCHEMA_SQL, SCHEMA_VERSION } from './schema.js';
|
|
5
|
+
export function openGraphDatabase(databasePath) {
|
|
6
|
+
mkdirSync(path.dirname(databasePath), { recursive: true });
|
|
7
|
+
const db = new DatabaseSync(databasePath);
|
|
8
|
+
db.exec('PRAGMA foreign_keys = ON;');
|
|
9
|
+
db.exec('PRAGMA journal_mode = WAL;');
|
|
10
|
+
db.exec(CREATE_SCHEMA_SQL);
|
|
11
|
+
ensureSchemaVersion(db);
|
|
12
|
+
return db;
|
|
13
|
+
}
|
|
14
|
+
export function openReadOnlyGraphDatabase(databasePath) {
|
|
15
|
+
const db = new DatabaseSync(databasePath, { readOnly: true });
|
|
16
|
+
db.exec('PRAGMA foreign_keys = ON;');
|
|
17
|
+
return db;
|
|
18
|
+
}
|
|
19
|
+
export function closeDatabase(db) {
|
|
20
|
+
db.close();
|
|
21
|
+
}
|
|
22
|
+
export function attachDatabase(db, alias, databasePath) {
|
|
23
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(alias)) {
|
|
24
|
+
throw new Error(`Unsafe SQLite attachment alias: ${alias}`);
|
|
25
|
+
}
|
|
26
|
+
const escapedPath = databasePath.replaceAll("'", "''");
|
|
27
|
+
db.exec(`ATTACH DATABASE '${escapedPath}' AS ${alias}`);
|
|
28
|
+
}
|
|
29
|
+
function ensureSchemaVersion(db) {
|
|
30
|
+
const currentVersion = getCurrentSchemaVersion(db);
|
|
31
|
+
if (currentVersion === 0) {
|
|
32
|
+
db.prepare('INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)').run(SCHEMA_VERSION);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (currentVersion >= SCHEMA_VERSION)
|
|
36
|
+
return;
|
|
37
|
+
if (currentVersion === 1) {
|
|
38
|
+
migrateSchemaV1ToV2(db);
|
|
39
|
+
db.prepare('INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)').run(2);
|
|
40
|
+
}
|
|
41
|
+
if (currentVersion <= 2) {
|
|
42
|
+
migrateSchemaV2ToV3(db);
|
|
43
|
+
db.prepare('INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)').run(3);
|
|
44
|
+
}
|
|
45
|
+
if (currentVersion <= 3) {
|
|
46
|
+
migrateSchemaV3ToV4(db);
|
|
47
|
+
db.prepare('INSERT OR IGNORE INTO schema_migrations(version) VALUES (?)').run(SCHEMA_VERSION);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`Unsupported schema version: ${currentVersion}`);
|
|
51
|
+
}
|
|
52
|
+
function migrateSchemaV3ToV4(db) {
|
|
53
|
+
db.exec(`
|
|
54
|
+
CREATE TABLE IF NOT EXISTS plugin_contributions (
|
|
55
|
+
repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
|
|
56
|
+
plugin_name TEXT NOT NULL,
|
|
57
|
+
generation INTEGER NOT NULL,
|
|
58
|
+
nodes_json TEXT NOT NULL,
|
|
59
|
+
edges_json TEXT NOT NULL,
|
|
60
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
61
|
+
PRIMARY KEY(repository_id, plugin_name)
|
|
62
|
+
);
|
|
63
|
+
CREATE INDEX IF NOT EXISTS idx_plugin_contributions_repo ON plugin_contributions(repository_id);
|
|
64
|
+
`);
|
|
65
|
+
}
|
|
66
|
+
function getCurrentSchemaVersion(db) {
|
|
67
|
+
const row = db.prepare('SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations').get();
|
|
68
|
+
return Number(row?.version ?? 0);
|
|
69
|
+
}
|
|
70
|
+
function migrateSchemaV1ToV2(db) {
|
|
71
|
+
db.exec('PRAGMA foreign_keys = OFF;');
|
|
72
|
+
db.exec('BEGIN IMMEDIATE');
|
|
73
|
+
try {
|
|
74
|
+
db.exec(`DROP TABLE IF EXISTS nodes_new;`);
|
|
75
|
+
db.exec(CREATE_NODES_TABLE_SQL);
|
|
76
|
+
db.exec(`
|
|
77
|
+
INSERT INTO nodes_new(
|
|
78
|
+
id, repository_id, file_id, kind, type, name, start_byte, end_byte,
|
|
79
|
+
start_point, end_point, source_hash, parser, parser_version, metadata_json, last_modified_user_id
|
|
80
|
+
)
|
|
81
|
+
SELECT
|
|
82
|
+
id, repository_id, file_id, kind, type, name, start_byte, end_byte,
|
|
83
|
+
start_point, end_point, source_hash, parser, parser_version, metadata_json, NULL
|
|
84
|
+
FROM nodes;
|
|
85
|
+
`);
|
|
86
|
+
db.exec('DROP TABLE nodes;');
|
|
87
|
+
db.exec('ALTER TABLE nodes_new RENAME TO nodes;');
|
|
88
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_repo_kind ON nodes(repository_id, kind);');
|
|
89
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_nodes_repo_name ON nodes(repository_id, name);');
|
|
90
|
+
db.exec('COMMIT');
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
db.exec('ROLLBACK');
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
db.exec('PRAGMA foreign_keys = ON;');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function migrateSchemaV2ToV3(db) {
|
|
101
|
+
db.exec('PRAGMA foreign_keys = OFF;');
|
|
102
|
+
db.exec('BEGIN IMMEDIATE');
|
|
103
|
+
try {
|
|
104
|
+
db.exec(`
|
|
105
|
+
CREATE TABLE edges_new (
|
|
106
|
+
id TEXT PRIMARY KEY,
|
|
107
|
+
repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
|
|
108
|
+
file_id TEXT REFERENCES files(id) ON DELETE CASCADE,
|
|
109
|
+
source_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
|
110
|
+
target_id TEXT NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
|
|
111
|
+
kind TEXT NOT NULL,
|
|
112
|
+
confidence REAL NOT NULL DEFAULT 1.0,
|
|
113
|
+
metadata_json TEXT NOT NULL DEFAULT '{}'
|
|
114
|
+
);
|
|
115
|
+
INSERT INTO edges_new(id, repository_id, file_id, source_id, target_id, kind, confidence, metadata_json)
|
|
116
|
+
SELECT id, repository_id, file_id, source_id, target_id, kind, confidence, metadata_json FROM edges;
|
|
117
|
+
DROP TABLE edges;
|
|
118
|
+
ALTER TABLE edges_new RENAME TO edges;
|
|
119
|
+
CREATE INDEX IF NOT EXISTS idx_edges_repo_kind ON edges(repository_id, kind);
|
|
120
|
+
CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id);
|
|
121
|
+
CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id);
|
|
122
|
+
`);
|
|
123
|
+
db.exec('COMMIT');
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
db.exec('ROLLBACK');
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
db.exec('PRAGMA foreign_keys = ON;');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=connection.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import type { ContextCandidate, ContextSourceReference } from '../types.js';
|
|
3
|
+
export interface ContextNodeRow extends ContextSourceReference {
|
|
4
|
+
fileId: string | null;
|
|
5
|
+
}
|
|
6
|
+
export declare function selectContextCandidates(db: DatabaseSync, repositoryId: string, term: string, { kind, file, limit }?: {
|
|
7
|
+
kind?: string | undefined;
|
|
8
|
+
file?: string | undefined;
|
|
9
|
+
limit?: number | undefined;
|
|
10
|
+
}): ContextCandidate[];
|
|
11
|
+
export declare function selectContextRelationships(db: DatabaseSync, repositoryId: string, anchorId: string, limit?: number, maxDepth?: number): Array<{
|
|
12
|
+
edgeId: string;
|
|
13
|
+
edgeKind: 'imports' | 'exports' | 'calls';
|
|
14
|
+
source: ContextNodeRow;
|
|
15
|
+
target: ContextNodeRow;
|
|
16
|
+
confidence: number;
|
|
17
|
+
metadata: Record<string, unknown>;
|
|
18
|
+
depth: number;
|
|
19
|
+
}>;
|
|
20
|
+
//# sourceMappingURL=context-queries.d.ts.map
|