@psnext/lscg 0.1.2 → 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 +6 -3
- package/dist/src/view/templates/interactive.html +79 -9
- package/package.json +1 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { FileRecord, GraphEdge, GraphNode, GraphExtractionResult, Point } from '../types.js';
|
|
2
|
+
export declare const SCANNER_PLUGIN_API_VERSION: 1;
|
|
3
|
+
export type PluginConflictPolicy = 'merge' | 'replace' | 'reject';
|
|
4
|
+
export type ScannerCapability = 'file-scanner' | 'repository-analyzer';
|
|
5
|
+
export interface RepositoryScanFile extends FileRecord {
|
|
6
|
+
absolutePath: string;
|
|
7
|
+
source: string;
|
|
8
|
+
}
|
|
9
|
+
export interface RepositoryScanContext {
|
|
10
|
+
repoPath: string;
|
|
11
|
+
files: readonly RepositoryScanFile[];
|
|
12
|
+
discoveredPaths: readonly string[];
|
|
13
|
+
readFile(relativePath: string): string;
|
|
14
|
+
}
|
|
15
|
+
export interface PluginNodeFact {
|
|
16
|
+
factKey: string;
|
|
17
|
+
/** Internal owner assigned by the host; not part of plugin output. */
|
|
18
|
+
pluginName?: string;
|
|
19
|
+
filePath?: string;
|
|
20
|
+
kind: GraphNode['kind'];
|
|
21
|
+
type: string;
|
|
22
|
+
name?: string | null;
|
|
23
|
+
startByte?: number;
|
|
24
|
+
endByte?: number;
|
|
25
|
+
startPoint?: Point;
|
|
26
|
+
endPoint?: Point;
|
|
27
|
+
sourceHash?: string;
|
|
28
|
+
parser?: string;
|
|
29
|
+
parserVersion?: string;
|
|
30
|
+
metadata?: Record<string, unknown>;
|
|
31
|
+
conflict?: PluginConflictPolicy;
|
|
32
|
+
}
|
|
33
|
+
export interface PluginEdgeFact {
|
|
34
|
+
factKey: string;
|
|
35
|
+
/** Internal owner used to reconcile persisted edges per successful plugin. */
|
|
36
|
+
pluginName?: string;
|
|
37
|
+
sourceFactKey: string;
|
|
38
|
+
targetFactKey: string;
|
|
39
|
+
/** Optional cross-plugin namespace; omitted references use the edge's plugin. */
|
|
40
|
+
sourcePlugin?: string;
|
|
41
|
+
/** Optional cross-plugin namespace; omitted references use the edge's plugin. */
|
|
42
|
+
targetPlugin?: string;
|
|
43
|
+
filePath?: string;
|
|
44
|
+
kind: GraphEdge['kind'];
|
|
45
|
+
confidence?: number;
|
|
46
|
+
metadata?: Record<string, unknown>;
|
|
47
|
+
conflict?: PluginConflictPolicy;
|
|
48
|
+
}
|
|
49
|
+
export interface PluginScanResult {
|
|
50
|
+
nodes: PluginNodeFact[];
|
|
51
|
+
edges: PluginEdgeFact[];
|
|
52
|
+
diagnostics?: string[];
|
|
53
|
+
}
|
|
54
|
+
export interface PluginResourceLimits {
|
|
55
|
+
maxNodes: number;
|
|
56
|
+
maxEdges: number;
|
|
57
|
+
maxDiagnostics: number;
|
|
58
|
+
maxMetadataBytes: number;
|
|
59
|
+
maxSerializedOutputBytes: number;
|
|
60
|
+
maxDiagnosticMessageBytes: number;
|
|
61
|
+
}
|
|
62
|
+
export declare const DEFAULT_PLUGIN_RESOURCE_LIMITS: PluginResourceLimits;
|
|
63
|
+
/** Namespace used by plugins to target nodes produced by the built-in scanner. */
|
|
64
|
+
export declare const BUILTIN_GRAPH_PLUGIN = "__lscg_builtin__";
|
|
65
|
+
export interface ScannerPlugin {
|
|
66
|
+
name: string;
|
|
67
|
+
apiVersion: typeof SCANNER_PLUGIN_API_VERSION;
|
|
68
|
+
capabilities: readonly ScannerCapability[];
|
|
69
|
+
priority?: number;
|
|
70
|
+
initialize?(context: RepositoryScanContext): void | Promise<void>;
|
|
71
|
+
scan(context: RepositoryScanContext): PluginScanResult | Promise<PluginScanResult>;
|
|
72
|
+
finalize?(context: RepositoryScanContext): PluginScanResult | void | Promise<PluginScanResult | void>;
|
|
73
|
+
dispose?(context: RepositoryScanContext): void | Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
export interface PluginRunResult extends GraphExtractionResult {
|
|
76
|
+
diagnostics: string[];
|
|
77
|
+
failedPlugins: string[];
|
|
78
|
+
successfulPlugins?: string[];
|
|
79
|
+
pluginContributions?: Map<string, {
|
|
80
|
+
nodes: GraphNode[];
|
|
81
|
+
edges: GraphEdge[];
|
|
82
|
+
}>;
|
|
83
|
+
}
|
|
84
|
+
export declare function createRepositoryScanContext(repoPath: string): RepositoryScanContext;
|
|
85
|
+
export declare function runScannerPlugins(context: RepositoryScanContext, plugins: readonly ScannerPlugin[], resourceLimits?: Partial<PluginResourceLimits>): Promise<PluginRunResult>;
|
|
86
|
+
//# sourceMappingURL=plugins.d.ts.map
|
|
@@ -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
|