@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
package/README.md
CHANGED
|
@@ -56,12 +56,13 @@ npm start -- nodes greet --kind symbol --limit 20
|
|
|
56
56
|
npm start -- nodes greet --output text
|
|
57
57
|
npm start -- edges --kind calls --limit 20
|
|
58
58
|
npm start -- edges --kind attributed_to --limit 20
|
|
59
|
+
npm start -- edges --output text
|
|
59
60
|
npm start -- neighbors <node-id> --depth 2
|
|
60
61
|
|
|
61
62
|
# Visualize the graph
|
|
62
63
|
npm start -- view --scope repo
|
|
63
64
|
npm start -- view --scope home --anchor greet --search util
|
|
64
|
-
npm start -- view --output /tmp/lscg-view.svg
|
|
65
|
+
npm start -- view --display icon --output /tmp/lscg-view.svg
|
|
65
66
|
|
|
66
67
|
# Run a read-only SQL query
|
|
67
68
|
npm start -- query "select kind, count(*) as n from nodes group by kind"
|
|
@@ -96,13 +97,16 @@ anchor-centered subgraph for the selected scope when no `--anchor` is provided.
|
|
|
96
97
|
The browser view includes a toggle to switch between the subgraph and the full
|
|
97
98
|
graph. Pass `--search` to filter within the active graph mode, or `--output
|
|
98
99
|
<path>` to write a static SVG snapshot of the default subgraph instead of
|
|
99
|
-
opening a browser.
|
|
100
|
+
opening a browser. Use `--display simple`, `--display shape` (the default), or
|
|
101
|
+
`--display icon` to choose how nodes are rendered. Simple mode uses colored
|
|
102
|
+
circles, shape mode uses kind-specific shapes, and icon mode loads SVG icons
|
|
103
|
+
from `src/view/templates/icons`.
|
|
100
104
|
|
|
101
105
|
Examples:
|
|
102
106
|
|
|
103
107
|
```bash
|
|
104
108
|
lscg view --anchor greet --search util
|
|
105
|
-
lscg view --output graph.svg
|
|
109
|
+
lscg view --display simple --output graph.svg
|
|
106
110
|
```
|
|
107
111
|
|
|
108
112
|
`lscg context <symbol>` is the agent-oriented retrieval workflow. It returns a
|
|
@@ -135,6 +139,68 @@ When the repository has git history, `lscg scan` also records contributor user
|
|
|
135
139
|
nodes keyed by email and tracks the most recent contributor for each discovered
|
|
136
140
|
node.
|
|
137
141
|
|
|
142
|
+
## Scanner plugins
|
|
143
|
+
|
|
144
|
+
The library accepts trusted, versioned scanner plugins through the `plugins`
|
|
145
|
+
option on `scanRepository`. A plugin receives a read-only repository context
|
|
146
|
+
with the resolved `repoPath`, discovered files, file contents, and a `readFile`
|
|
147
|
+
helper, then returns semantic node and edge facts. Edges refer to node `factKey` values within their emitting plugin namespace and
|
|
148
|
+
may connect facts from different files. Use the additive `sourcePlugin` and
|
|
149
|
+
`targetPlugin` fields when an endpoint belongs to another plugin; raw fact keys
|
|
150
|
+
are never parsed as namespaces.
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
import { scanRepository, type ScannerPlugin } from '@psnext/lscg';
|
|
154
|
+
|
|
155
|
+
const plugin: ScannerPlugin = {
|
|
156
|
+
name: 'my-analyzer',
|
|
157
|
+
apiVersion: 1,
|
|
158
|
+
capabilities: ['repository-analyzer'],
|
|
159
|
+
async scan({ repoPath, files }) {
|
|
160
|
+
return {
|
|
161
|
+
nodes: files.map((file) => ({
|
|
162
|
+
factKey: `artifact:${file.path}`,
|
|
163
|
+
filePath: file.path,
|
|
164
|
+
kind: 'symbol',
|
|
165
|
+
type: 'artifact',
|
|
166
|
+
name: file.path,
|
|
167
|
+
metadata: { repoPath }
|
|
168
|
+
})),
|
|
169
|
+
edges: []
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
await scanRepository({ root: '/path/to/repo', plugins: [plugin] });
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Plugins may implement `initialize`, `scan`, `finalize`, and `dispose` hooks.
|
|
178
|
+
The core assigns deterministic IDs, validates API compatibility, persists
|
|
179
|
+
plugin facts, and reports plugin diagnostics in the scan summary. Plugins run
|
|
180
|
+
in-process and must be treated as trusted code.
|
|
181
|
+
|
|
182
|
+
Plugin output is bounded by conservative host-selected resource limits. The
|
|
183
|
+
limits cover node and edge fact counts, diagnostics, metadata bytes, and total
|
|
184
|
+
serialized output bytes. Callers can override them with `pluginLimits` on
|
|
185
|
+
`scanRepository`; an oversized plugin contribution is rejected atomically and
|
|
186
|
+
reported in the scan summary, while later plugins can still contribute normally.
|
|
187
|
+
|
|
188
|
+
The host keeps semantic `factKey` values separate from graph identity. Node
|
|
189
|
+
facts conflict by `(node, factKey)` and edge facts by `(edge, factKey)` across
|
|
190
|
+
plugins. `merge` retains both under distinct plugin-qualified identities,
|
|
191
|
+
`replace` uses priority (then plugin-name order for ties), and `reject` keeps
|
|
192
|
+
the incumbent and reports a diagnostic. The host assigns deterministic IDs;
|
|
193
|
+
plugins do not provide database IDs.
|
|
194
|
+
|
|
195
|
+
Every scan also runs the bundled `PackagePlugin`. It reads the repository-root
|
|
196
|
+
`package.json` dependency sections (`dependencies`, `devDependencies`,
|
|
197
|
+
`peerDependencies`, and `optionalDependencies`) and creates repository-scoped
|
|
198
|
+
`package` nodes only for package roots used by source imports. A `provides` edge
|
|
199
|
+
connects each package node to its matching `import` node. Undeclared bare
|
|
200
|
+
external imports create `implied` package nodes; relative, absolute, URL, and
|
|
201
|
+
Node built-in imports are excluded. The plugin is de-duplicated if explicitly
|
|
202
|
+
listed in `plugins`.
|
|
203
|
+
|
|
138
204
|
## Current parser support
|
|
139
205
|
|
|
140
206
|
The starter scanner supports JavaScript, JSX, TypeScript, and TSX via
|
package/dist/src/cli.js
CHANGED
|
@@ -53,14 +53,24 @@ export async function runCli(argv) {
|
|
|
53
53
|
}
|
|
54
54
|
throw new Error(`nodes output must be json or text, got: ${output}`);
|
|
55
55
|
}
|
|
56
|
-
case 'edges':
|
|
57
|
-
|
|
56
|
+
case 'edges': {
|
|
57
|
+
const edges = listEdges({
|
|
58
58
|
root: options.root,
|
|
59
59
|
scope: options.scope ?? 'repo',
|
|
60
60
|
kind: options.kind,
|
|
61
61
|
limit: options.limit
|
|
62
|
-
})
|
|
63
|
-
|
|
62
|
+
});
|
|
63
|
+
const output = options.output ?? 'json';
|
|
64
|
+
if (output === 'json') {
|
|
65
|
+
printJson(edges);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (output === 'text') {
|
|
69
|
+
console.log(formatEdgesText(edges));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`edges output must be json or text, got: ${output}`);
|
|
73
|
+
}
|
|
64
74
|
case 'neighbors': {
|
|
65
75
|
const nodeId = options._[0] ?? options.nodeId;
|
|
66
76
|
printJson(neighbors({
|
|
@@ -140,15 +150,21 @@ export async function runCli(argv) {
|
|
|
140
150
|
}));
|
|
141
151
|
return;
|
|
142
152
|
}
|
|
143
|
-
case 'view':
|
|
153
|
+
case 'view': {
|
|
154
|
+
const display = options.display ?? 'shape';
|
|
155
|
+
if (display !== 'simple' && display !== 'shape' && display !== 'icon') {
|
|
156
|
+
throw new Error(`view display must be simple, shape, or icon, got: ${display}`);
|
|
157
|
+
}
|
|
144
158
|
printJson(await viewGraph({
|
|
145
159
|
root: options.root,
|
|
146
160
|
scope: options.scope ?? 'repo',
|
|
147
161
|
anchor: options.anchor,
|
|
148
162
|
search: options.search,
|
|
163
|
+
display,
|
|
149
164
|
output: options.output
|
|
150
165
|
}));
|
|
151
166
|
return;
|
|
167
|
+
}
|
|
152
168
|
case 'mcp':
|
|
153
169
|
await startMcpServer({ root: options.root });
|
|
154
170
|
return;
|
|
@@ -274,12 +290,12 @@ function commandHelpText(command) {
|
|
|
274
290
|
watch: `Usage: lscg watch [options]\n\nKeep the repository current by rescanning on file changes.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo Repository scope to watch (default: repo).\n\nExamples:\n lscg watch`,
|
|
275
291
|
status: `Usage: lscg status [options]\n\nShow graph counts and database paths.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to inspect (default: repo).\n\nExamples:\n lscg status --scope repo`,
|
|
276
292
|
nodes: `Usage: lscg nodes [term] [options]\n\nList graph nodes, optionally filtered by a case-insensitive name term.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to inspect (default: repo).\n --kind node-kind Filter by node kind.\n --limit n Maximum number of nodes (default: 50).\n --output json|text Output format (default: json).\n -o value Alias for --output.\n\nExamples:\n lscg nodes --kind user --limit 20\n lscg nodes --output text`,
|
|
277
|
-
edges: `Usage: lscg edges [options]\n\nList graph edges.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to inspect (default: repo).\n --kind edge-kind Filter by edge kind.\n --limit n Maximum number of edges (default: 50).\n\nExamples:\n lscg edges --kind calls --limit 20`,
|
|
293
|
+
edges: `Usage: lscg edges [options]\n\nList graph edges.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to inspect (default: repo).\n --kind edge-kind Filter by edge kind.\n --limit n Maximum number of edges (default: 50).\n --output json|text Output format (default: json).\n -o value Alias for --output.\n\nExamples:\n lscg edges --kind calls --limit 20\n lscg edges --output text`,
|
|
278
294
|
neighbors: `Usage: lscg neighbors <node-id> [options]\n\nShow nearby nodes around a graph node id.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to inspect (default: repo).\n --depth n Traversal depth (default: 1).\n --limit n Maximum number of neighbors (default: 100).\n --node-id id Node ID alternative to the positional argument.\n\nExamples:\n lscg neighbors <node-id> --depth 2`,
|
|
279
295
|
callgraph: `Usage: lscg callgraph <term> [options]\n\nFind nodes whose names contain a term and show upstream/downstream nodes.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to inspect (default: repo).\n --kind node-kind Filter matching nodes by kind.\n --depth n Upstream/downstream traversal depth (default: 5).\n --limit n Maximum matches and relations (default: 100).\n --output json|text|svg Output format (default: text; svg requires repo or home scope).\n -o value Alias for --output.\n\nExamples:\n lscg callgraph greet --depth 2\n lscg callgraph greet --output text`,
|
|
280
296
|
context: `Usage: lscg context <symbol> [options]\n\nRetrieve bounded definition, impact, and dependency context.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to inspect (default: repo).\n --kind node-kind Constrain anchor candidates by node kind.\n --file relative/path Constrain anchor candidates by repository-relative path.\n --depth 0..5 Relationship depth (default: 2).\n --limit 1..500 Relationships per section (default: 50).\n --candidate-limit 1..100 Maximum ranked candidates (default: 20).\n --excerpt-lines 1..500 Opt-in excerpt line budget (default: 80).\n --excerpt-bytes 1..100000 Opt-in excerpt byte budget (default: 12000).\n --excerpts Include bounded source excerpts.\n --output json|text|svg Output format (default: text; svg requires repo or home scope).\n -o value Alias for --output.\n\nExamples:\n lscg context greet --kind symbol\n lscg context greet --output text`,
|
|
281
297
|
query: `Usage: lscg query <sql> [options]\n\nRun a read-only SQLite query against the graph database.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to query (default: repo).\n --sql \"select ...\" SQL alternative to the positional argument.\n --attach-home Attach the home database when querying repo scope.\n --limit n Maximum number of rows (default: 200).\n\nExamples:\n lscg query \"select kind, count(*) as n from nodes group by kind\"`,
|
|
282
|
-
view: `Usage: lscg view [options]\n\nOpen an interactive graph view or export a static SVG.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home Storage scope to view (default: repo).\n --anchor name Focus the view around a node name.\n --search term Filter visible nodes by name.\n --output path Write an SVG snapshot instead of opening a browser.\n -o path Alias for --output.\n\nExamples:\n lscg view --anchor greet --search util\n lscg view --output graph.svg`,
|
|
298
|
+
view: `Usage: lscg view [options]\n\nOpen an interactive graph view or export a static SVG.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home Storage scope to view (default: repo).\n --anchor name Focus the view around a node name.\n --search term Filter visible nodes by name.\n --display simple|shape|icon Display style (default: shape).\n --output path Write an SVG snapshot instead of opening a browser.\n -o path Alias for --output.\n\nExamples:\n lscg view --anchor greet --search util\n lscg view --output graph.svg`,
|
|
283
299
|
mcp: `Usage: lscg mcp [options]\n\nStart the MCP server over stdio.\nOptions:\n --root path Repository root for the MCP server (default: current directory).\n\nExamples:\n lscg mcp`
|
|
284
300
|
};
|
|
285
301
|
const commandHelp = help[command];
|
|
@@ -302,6 +318,18 @@ function coerce(value) {
|
|
|
302
318
|
function printJson(value) {
|
|
303
319
|
console.log(JSON.stringify(value, null, 2));
|
|
304
320
|
}
|
|
321
|
+
function formatEdgesText(edges) {
|
|
322
|
+
if (edges.length === 0)
|
|
323
|
+
return 'No matching edges.';
|
|
324
|
+
return edges.map((edge) => [
|
|
325
|
+
`${edge.scope}: ${edge.kind} (${edge.id})`,
|
|
326
|
+
`from: ${edge.sourceId}`,
|
|
327
|
+
`to: ${edge.targetId}`,
|
|
328
|
+
`file: ${edge.fileId ?? '<repository>'}`,
|
|
329
|
+
`confidence: ${edge.confidence}`,
|
|
330
|
+
`metadata: ${edge.metadataJson}`
|
|
331
|
+
].join('\n')).join('\n\n');
|
|
332
|
+
}
|
|
305
333
|
function formatNodesText(nodes) {
|
|
306
334
|
if (nodes.length === 0)
|
|
307
335
|
return 'No matching nodes.';
|
|
@@ -369,18 +397,43 @@ function formatCallGraphText(results) {
|
|
|
369
397
|
if (results.length === 0)
|
|
370
398
|
return 'No matching nodes.';
|
|
371
399
|
return results.map((match) => {
|
|
372
|
-
const lines = [
|
|
373
|
-
lines.push('
|
|
400
|
+
const lines = [`[${match.kind}/${match.type}]: ${match.name ?? '<unnamed>'}${formatCallGraphLocation(match)}`];
|
|
401
|
+
lines.push(' referenced from:');
|
|
374
402
|
lines.push(...formatCallGraphRelations(match.upstream));
|
|
375
|
-
lines.push('
|
|
376
|
-
lines.push(...
|
|
403
|
+
lines.push(' depends on:');
|
|
404
|
+
lines.push(...formatCallGraphRelationsHierarchical(match.id, match.downstream));
|
|
377
405
|
return lines.join('\n');
|
|
378
|
-
}).join('\n\n');
|
|
406
|
+
}).join('\n\n----\n\n');
|
|
407
|
+
}
|
|
408
|
+
function formatCallGraphLocation(node) {
|
|
409
|
+
return node.path ? ` in file ${node.path}:${node.startPoint.row + 1}` : '';
|
|
410
|
+
}
|
|
411
|
+
function formatCallGraphRelationLine(relation, indent) {
|
|
412
|
+
return `${indent}- ${relation.name ?? '<unnamed>'} [${relation.kind}/${relation.type}]${formatCallGraphLocation(relation)}`;
|
|
379
413
|
}
|
|
380
414
|
function formatCallGraphRelations(relations) {
|
|
381
415
|
if (relations.length === 0)
|
|
382
416
|
return [' (none)'];
|
|
383
|
-
return relations.map((relation) =>
|
|
417
|
+
return relations.map((relation) => formatCallGraphRelationLine(relation, ' '));
|
|
418
|
+
}
|
|
419
|
+
function formatCallGraphRelationsHierarchical(rootId, relations) {
|
|
420
|
+
if (relations.length === 0)
|
|
421
|
+
return [' (none)'];
|
|
422
|
+
const children = new Map();
|
|
423
|
+
for (const relation of relations) {
|
|
424
|
+
const siblings = children.get(relation.parentId) ?? [];
|
|
425
|
+
siblings.push(relation);
|
|
426
|
+
children.set(relation.parentId, siblings);
|
|
427
|
+
}
|
|
428
|
+
const lines = [];
|
|
429
|
+
const append = (parentId, indent) => {
|
|
430
|
+
for (const relation of children.get(parentId) ?? []) {
|
|
431
|
+
lines.push(formatCallGraphRelationLine(relation, indent));
|
|
432
|
+
append(relation.id, `${indent} `);
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
append(rootId, ' ');
|
|
436
|
+
return lines.length > 0 ? lines : [' (none)'];
|
|
384
437
|
}
|
|
385
438
|
function formatCallGraphSvg(results, { root, scope }) {
|
|
386
439
|
if (scope === 'both')
|
|
@@ -444,6 +497,7 @@ Command options:
|
|
|
444
497
|
--scope repo|home|both Storage scope to inspect (default: repo).
|
|
445
498
|
--kind edge-kind Filter by edge kind.
|
|
446
499
|
--limit n Maximum number of edges (default: 50).
|
|
500
|
+
--output json|text Output format (default: json).
|
|
447
501
|
neighbors <node-id>:
|
|
448
502
|
--root path Repository root (default: current directory).
|
|
449
503
|
--scope repo|home|both Storage scope to inspect (default: repo).
|
|
@@ -480,6 +534,7 @@ Command options:
|
|
|
480
534
|
--scope repo|home Storage scope to view (default: repo).
|
|
481
535
|
--anchor name Focus the view around a node name.
|
|
482
536
|
--search term Filter visible nodes by name.
|
|
537
|
+
--display simple|shape|icon Display style (default: shape).
|
|
483
538
|
--output path Write an SVG snapshot instead of opening a browser.
|
|
484
539
|
mcp:
|
|
485
540
|
--root path Repository root for the MCP server (default: current directory).
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { GraphEdgeRow, GraphNodeRow, GraphNodeTextRow, NeighborRow, CallGraphMatch, RepositoryRecord, ScanSummary, StatusResult, StorageScope, ContextGraphResult } from '../types.js';
|
|
2
|
+
import { type PluginResourceLimits, type ScannerPlugin } from '../scanner/plugins.js';
|
|
2
3
|
export declare function repositoryForRoot(root?: string | undefined): RepositoryRecord;
|
|
3
4
|
export declare function initGraph({ root, scope }?: {
|
|
4
5
|
root?: string | undefined;
|
|
@@ -10,9 +11,11 @@ export declare function initGraph({ root, scope }?: {
|
|
|
10
11
|
databasePath: string;
|
|
11
12
|
}>;
|
|
12
13
|
};
|
|
13
|
-
export declare function scanRepository({ root, scope }?: {
|
|
14
|
+
export declare function scanRepository({ root, scope, plugins, pluginLimits }?: {
|
|
14
15
|
root?: string | undefined;
|
|
15
16
|
scope?: StorageScope | 'both' | undefined;
|
|
17
|
+
plugins?: readonly ScannerPlugin[] | undefined;
|
|
18
|
+
pluginLimits?: Partial<PluginResourceLimits> | undefined;
|
|
16
19
|
}): Promise<ScanSummary>;
|
|
17
20
|
export declare function graphStatus({ root, scope }?: {
|
|
18
21
|
root?: string | undefined;
|
|
@@ -1,13 +1,53 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { databasePathForScope, homeDatabasePath, normalizeScope, repoDatabasePath, resolveProjectRoot } from '../config/paths.js';
|
|
5
5
|
import { parseSource } from '../parser/treeSitter.js';
|
|
6
6
|
import { discoverSourceFiles } from '../scanner/discover.js';
|
|
7
7
|
import { collectRepositoryFingerprint } from '../scanner/fingerprint.js';
|
|
8
|
-
import { attachDatabase, closeDatabase, getStatus, openGraphDatabase, replaceFileGraph, runSelect, selectCallGraph, selectEdges, selectNeighbors, selectNodes, selectNodeTextRows, upsertRepository, reconcileDeletedFiles, selectFileInventory, selectContextCandidates, selectContextRelationships } from '../storage/database.js';
|
|
8
|
+
import { attachDatabase, closeDatabase, getStatus, openGraphDatabase, replaceFileGraph, runSelect, selectCallGraph, selectEdges, selectNeighbors, selectNodes, selectNodeTextRows, upsertRepository, reconcileDeletedFiles, reconcilePluginEdges, reconcileRepositoryPluginNodes, insertRepositoryPluginNodes, loadPluginContributions, persistPluginContributions, nextPluginGeneration, selectFileInventory, selectContextCandidates, selectContextRelationships } from '../storage/database.js';
|
|
9
9
|
import { extractGraph, hashParts } from './extract.js';
|
|
10
10
|
import { buildFileAttribution } from './attribution.js';
|
|
11
|
+
import { createRepositoryScanContext, runScannerPlugins, DEFAULT_PLUGIN_RESOURCE_LIMITS } from '../scanner/plugins.js';
|
|
12
|
+
import { PackagePlugin } from '../scanner/packagePlugin.js';
|
|
13
|
+
function normalizePluginFilePath(value) {
|
|
14
|
+
if (typeof value !== 'string' || value.trim().length === 0)
|
|
15
|
+
return undefined;
|
|
16
|
+
const normalized = path.posix.normalize(value.replaceAll('\\', '/'));
|
|
17
|
+
if (path.posix.isAbsolute(normalized) || normalized === '..' || normalized.startsWith('../'))
|
|
18
|
+
return undefined;
|
|
19
|
+
return normalized.replace(/^\.\//, '');
|
|
20
|
+
}
|
|
21
|
+
function truncateDiagnostic(message, maxBytes) {
|
|
22
|
+
if (Buffer.byteLength(message, 'utf8') <= maxBytes)
|
|
23
|
+
return message;
|
|
24
|
+
let truncated = message;
|
|
25
|
+
while (Buffer.byteLength(truncated, 'utf8') > maxBytes)
|
|
26
|
+
truncated = truncated.slice(0, -1);
|
|
27
|
+
return truncated;
|
|
28
|
+
}
|
|
29
|
+
function diagnoseUnresolvedPluginOwnership(nodes, edges, persistedFileIds) {
|
|
30
|
+
const diagnostics = [];
|
|
31
|
+
for (const node of nodes) {
|
|
32
|
+
const filePath = node.metadata.filePath;
|
|
33
|
+
if (typeof filePath !== 'string')
|
|
34
|
+
continue;
|
|
35
|
+
const normalizedPath = normalizePluginFilePath(filePath);
|
|
36
|
+
if (!normalizedPath || !persistedFileIds.has(normalizedPath)) {
|
|
37
|
+
diagnostics.push(`unresolved plugin node ownership: ${filePath}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const edge of edges) {
|
|
41
|
+
const filePath = edge.metadata.filePath;
|
|
42
|
+
if (typeof filePath !== 'string')
|
|
43
|
+
continue;
|
|
44
|
+
const normalizedPath = normalizePluginFilePath(filePath);
|
|
45
|
+
if (!normalizedPath || !persistedFileIds.has(normalizedPath)) {
|
|
46
|
+
diagnostics.push(`unresolved plugin edge ownership: ${filePath}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return diagnostics;
|
|
50
|
+
}
|
|
11
51
|
export function repositoryForRoot(root = process.cwd()) {
|
|
12
52
|
const resolvedRoot = resolveProjectRoot(root);
|
|
13
53
|
return {
|
|
@@ -33,10 +73,15 @@ export function initGraph({ root = process.cwd(), scope = 'repo' } = {}) {
|
|
|
33
73
|
}
|
|
34
74
|
return { repository, initialized };
|
|
35
75
|
}
|
|
36
|
-
export async function scanRepository({ root = process.cwd(), scope = 'repo' } = {}) {
|
|
76
|
+
export async function scanRepository({ root = process.cwd(), scope = 'repo', plugins = [], pluginLimits } = {}) {
|
|
37
77
|
const repository = repositoryForRoot(root);
|
|
38
78
|
const scopes = normalizeScope(scope);
|
|
39
79
|
const files = discoverSourceFiles(repository.root);
|
|
80
|
+
const configuredPlugins = [
|
|
81
|
+
...(existsSync(path.join(repository.root, 'package.json')) ? [PackagePlugin] : []),
|
|
82
|
+
...plugins.filter((plugin) => plugin.name !== PackagePlugin.name)
|
|
83
|
+
];
|
|
84
|
+
const pluginRun = await runScannerPlugins(createRepositoryScanContext(repository.root), configuredPlugins, pluginLimits);
|
|
40
85
|
const opened = scopes.map((graphScope) => {
|
|
41
86
|
const databasePath = databasePathForScope(graphScope, repository.root);
|
|
42
87
|
return { scope: graphScope, databasePath, db: openGraphDatabase(databasePath) };
|
|
@@ -45,9 +90,42 @@ export async function scanRepository({ root = process.cwd(), scope = 'repo' } =
|
|
|
45
90
|
let nodesWritten = 0;
|
|
46
91
|
let edgesWritten = 0;
|
|
47
92
|
const skipped = [];
|
|
93
|
+
const stalePlugins = [];
|
|
48
94
|
try {
|
|
49
|
-
|
|
95
|
+
const primary = opened[0];
|
|
96
|
+
const contributions = new Map(pluginRun.pluginContributions ?? new Map());
|
|
97
|
+
for (const pluginName of pluginRun.successfulPlugins ?? []) {
|
|
98
|
+
if (!contributions.has(pluginName))
|
|
99
|
+
contributions.set(pluginName, { nodes: [], edges: [] });
|
|
100
|
+
}
|
|
101
|
+
if (primary) {
|
|
102
|
+
upsertRepository(primary.db, repository);
|
|
103
|
+
const generation = nextPluginGeneration(primary.db, repository);
|
|
104
|
+
persistPluginContributions(primary.db, repository, contributions, generation);
|
|
105
|
+
const previous = loadPluginContributions(primary.db, repository, pluginRun.failedPlugins ?? []);
|
|
106
|
+
for (const pluginName of pluginRun.failedPlugins ?? []) {
|
|
107
|
+
const contribution = previous.get(pluginName);
|
|
108
|
+
if (!contribution)
|
|
109
|
+
continue;
|
|
110
|
+
stalePlugins.push(pluginName);
|
|
111
|
+
pluginRun.nodes.push(...contribution.nodes);
|
|
112
|
+
pluginRun.edges.push(...contribution.edges);
|
|
113
|
+
const diagnosticLimit = pluginLimits?.maxDiagnostics ?? DEFAULT_PLUGIN_RESOURCE_LIMITS.maxDiagnostics;
|
|
114
|
+
const messageLimit = pluginLimits?.maxDiagnosticMessageBytes ?? DEFAULT_PLUGIN_RESOURCE_LIMITS.maxDiagnosticMessageBytes;
|
|
115
|
+
if (pluginRun.diagnostics.length < diagnosticLimit) {
|
|
116
|
+
pluginRun.diagnostics.push(truncateDiagnostic(`${pluginName}: reused last successful contribution (stale)`, messageLimit));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const handle of opened.slice(1)) {
|
|
121
|
+
persistPluginContributions(handle.db, repository, contributions, nextPluginGeneration(handle.db, repository));
|
|
122
|
+
}
|
|
123
|
+
const activePlugins = [...(pluginRun.successfulPlugins ?? []), ...stalePlugins];
|
|
124
|
+
for (const handle of opened) {
|
|
50
125
|
upsertRepository(handle.db, repository);
|
|
126
|
+
reconcileRepositoryPluginNodes(handle.db, repository, activePlugins);
|
|
127
|
+
insertRepositoryPluginNodes(handle.db, repository, pluginRun.nodes);
|
|
128
|
+
}
|
|
51
129
|
for (const relativePath of files) {
|
|
52
130
|
const absolutePath = path.join(repository.root, relativePath);
|
|
53
131
|
const source = readFileSync(absolutePath, 'utf8');
|
|
@@ -67,7 +145,7 @@ export async function scanRepository({ root = process.cwd(), scope = 'repo' } =
|
|
|
67
145
|
size: stat.size,
|
|
68
146
|
mtimeMs: Math.round(stat.mtimeMs)
|
|
69
147
|
};
|
|
70
|
-
const
|
|
148
|
+
const builtInGraph = extractGraph({
|
|
71
149
|
repositoryId: repository.id,
|
|
72
150
|
fileId,
|
|
73
151
|
relativePath,
|
|
@@ -75,6 +153,12 @@ export async function scanRepository({ root = process.cwd(), scope = 'repo' } =
|
|
|
75
153
|
sourceHash,
|
|
76
154
|
parseResult
|
|
77
155
|
});
|
|
156
|
+
const normalizedRelativePath = normalizePluginFilePath(relativePath);
|
|
157
|
+
const pluginNodes = pluginRun.nodes.filter((node) => normalizePluginFilePath(node.metadata.filePath) === normalizedRelativePath);
|
|
158
|
+
const graph = {
|
|
159
|
+
nodes: [...builtInGraph.nodes, ...pluginNodes],
|
|
160
|
+
edges: builtInGraph.edges
|
|
161
|
+
};
|
|
78
162
|
const attribution = buildFileAttribution({
|
|
79
163
|
root: repository.root,
|
|
80
164
|
relativePath,
|
|
@@ -88,6 +172,19 @@ export async function scanRepository({ root = process.cwd(), scope = 'repo' } =
|
|
|
88
172
|
nodesWritten += graph.nodes.length + (attribution?.contributorEmails.length ?? 0);
|
|
89
173
|
edgesWritten += graph.edges.length + (attribution?.nodeAttributions.reduce((sum, entry) => sum + entry.contributorEmails.length, 0) ?? 0);
|
|
90
174
|
}
|
|
175
|
+
const persistedFileIds = new Map(files
|
|
176
|
+
.filter((relativePath) => !skipped.includes(relativePath))
|
|
177
|
+
.map((relativePath) => [normalizePluginFilePath(relativePath) ?? relativePath, hashParts([repository.id, relativePath])]));
|
|
178
|
+
const unresolvedOwnership = diagnoseUnresolvedPluginOwnership(pluginRun.nodes, pluginRun.edges, persistedFileIds);
|
|
179
|
+
const diagnosticLimit = pluginLimits?.maxDiagnostics ?? DEFAULT_PLUGIN_RESOURCE_LIMITS.maxDiagnostics;
|
|
180
|
+
const messageLimit = pluginLimits?.maxDiagnosticMessageBytes ?? DEFAULT_PLUGIN_RESOURCE_LIMITS.maxDiagnosticMessageBytes;
|
|
181
|
+
for (const diagnostic of unresolvedOwnership.slice(0, Math.max(0, diagnosticLimit - pluginRun.diagnostics.length))) {
|
|
182
|
+
pluginRun.diagnostics.push(truncateDiagnostic(diagnostic, messageLimit));
|
|
183
|
+
}
|
|
184
|
+
if (opened[0])
|
|
185
|
+
edgesWritten += reconcilePluginEdges(opened[0].db, repository, pluginRun.edges, persistedFileIds, activePlugins);
|
|
186
|
+
for (const handle of opened.slice(1))
|
|
187
|
+
reconcilePluginEdges(handle.db, repository, pluginRun.edges, persistedFileIds, activePlugins);
|
|
91
188
|
}
|
|
92
189
|
finally {
|
|
93
190
|
for (const handle of opened)
|
|
@@ -100,7 +197,10 @@ export async function scanRepository({ root = process.cwd(), scope = 'repo' } =
|
|
|
100
197
|
filesScanned,
|
|
101
198
|
nodesWritten,
|
|
102
199
|
edgesWritten,
|
|
103
|
-
skipped
|
|
200
|
+
skipped,
|
|
201
|
+
pluginDiagnostics: pluginRun.diagnostics,
|
|
202
|
+
failedPlugins: pluginRun.failedPlugins,
|
|
203
|
+
stalePlugins
|
|
104
204
|
};
|
|
105
205
|
}
|
|
106
206
|
export function graphStatus({ root = process.cwd(), scope = 'repo' } = {}) {
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export { initGraph, listEdges, listNodes, graphStatus, callGraph, neighbors, contextGraph, runReadOnlySql, scanRepository } from './graph/repository.js';
|
|
2
2
|
export { watchRepository } from './watch.js';
|
|
3
|
+
export { SCANNER_PLUGIN_API_VERSION, DEFAULT_PLUGIN_RESOURCE_LIMITS, createRepositoryScanContext, runScannerPlugins, BUILTIN_GRAPH_PLUGIN } from './scanner/plugins.js';
|
|
4
|
+
export type { ScannerPlugin, ScannerCapability, PluginConflictPolicy, PluginNodeFact, PluginEdgeFact, PluginScanResult, PluginRunResult, PluginResourceLimits, RepositoryScanContext, RepositoryScanFile } from './scanner/plugins.js';
|
|
5
|
+
export { PackagePlugin } from './scanner/packagePlugin.js';
|
|
3
6
|
export { viewGraph, loadViewSnapshot, buildViewModel, renderInteractiveHtml, renderSvgMarkup, openInDefaultBrowser, openHtmlArtifactInBrowser, writeTemporaryHtmlArtifact } from './view/index.js';
|
|
4
7
|
export { repoDatabasePath, homeDatabasePath, resolveProjectRoot } from './config/paths.js';
|
|
5
8
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/src/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { initGraph, listEdges, listNodes, graphStatus, callGraph, neighbors, contextGraph, runReadOnlySql, scanRepository } from './graph/repository.js';
|
|
2
2
|
export { watchRepository } from './watch.js';
|
|
3
|
+
export { SCANNER_PLUGIN_API_VERSION, DEFAULT_PLUGIN_RESOURCE_LIMITS, createRepositoryScanContext, runScannerPlugins, BUILTIN_GRAPH_PLUGIN } from './scanner/plugins.js';
|
|
4
|
+
export { PackagePlugin } from './scanner/packagePlugin.js';
|
|
3
5
|
export { viewGraph, loadViewSnapshot, buildViewModel, renderInteractiveHtml, renderSvgMarkup, openInDefaultBrowser, openHtmlArtifactInBrowser, writeTemporaryHtmlArtifact } from './view/index.js';
|
|
4
6
|
export { repoDatabasePath, homeDatabasePath, resolveProjectRoot } from './config/paths.js';
|
|
5
7
|
//# sourceMappingURL=index.js.map
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -20,7 +20,7 @@ export async function startMcpServer({ root = process.cwd() } = {}) {
|
|
|
20
20
|
tool('context_graph_nodes', 'List graph nodes discovered in the repository.', {
|
|
21
21
|
root: z.string().optional(),
|
|
22
22
|
scope: scopeSchema,
|
|
23
|
-
kind: z.enum(['file', 'symbol', 'import', 'export', 'call', 'user']).optional(),
|
|
23
|
+
kind: z.enum(['file', 'symbol', 'import', 'export', 'call', 'user', 'package']).optional(),
|
|
24
24
|
limit: z.number().int().positive().max(500).default(50)
|
|
25
25
|
}, async (input) => jsonResponse(listNodes({
|
|
26
26
|
root: input.root ?? root,
|
|
@@ -31,7 +31,7 @@ export async function startMcpServer({ root = process.cwd() } = {}) {
|
|
|
31
31
|
tool('context_graph_edges', 'List graph edges discovered in the repository.', {
|
|
32
32
|
root: z.string().optional(),
|
|
33
33
|
scope: scopeSchema,
|
|
34
|
-
kind: z.enum(['contains', 'defines', 'imports', 'exports', 'calls', 'attributed_to']).optional(),
|
|
34
|
+
kind: z.enum(['contains', 'defines', 'imports', 'exports', 'calls', 'attributed_to', 'provides']).optional(),
|
|
35
35
|
limit: z.number().int().positive().max(500).default(50)
|
|
36
36
|
}, async (input) => jsonResponse(listEdges({
|
|
37
37
|
root: input.root ?? root,
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { builtinModules } from 'node:module';
|
|
2
|
+
import { extractGraph, hashParts } from '../graph/extract.js';
|
|
3
|
+
import { parseSource } from '../parser/treeSitter.js';
|
|
4
|
+
import { BUILTIN_GRAPH_PLUGIN } from './plugins.js';
|
|
5
|
+
const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
|
6
|
+
const BUILTINS = new Set(builtinModules.flatMap((name) => [name, name.replace(/^node:/, ''), `node:${name.replace(/^node:/, '')}`]));
|
|
7
|
+
function packageRoot(specifier) {
|
|
8
|
+
if (!specifier || specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#') || /^[a-z][a-z\d+.-]*:/i.test(specifier))
|
|
9
|
+
return undefined;
|
|
10
|
+
if (BUILTINS.has(specifier))
|
|
11
|
+
return undefined;
|
|
12
|
+
if (specifier.startsWith('@')) {
|
|
13
|
+
const parts = specifier.split('/');
|
|
14
|
+
return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined;
|
|
15
|
+
}
|
|
16
|
+
return specifier.split('/')[0];
|
|
17
|
+
}
|
|
18
|
+
function isManifest(value) {
|
|
19
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
export const PackagePlugin = {
|
|
22
|
+
name: 'package-plugin',
|
|
23
|
+
apiVersion: 1,
|
|
24
|
+
capabilities: ['repository-analyzer'],
|
|
25
|
+
scan: (context) => {
|
|
26
|
+
let manifest;
|
|
27
|
+
try {
|
|
28
|
+
manifest = JSON.parse(context.readFile('package.json'));
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
32
|
+
return { nodes: [], edges: [] };
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`invalid package.json: ${error instanceof Error ? error.message : String(error)}`);
|
|
35
|
+
}
|
|
36
|
+
if (!isManifest(manifest))
|
|
37
|
+
throw new Error('invalid package.json: expected an object');
|
|
38
|
+
const diagnostics = [];
|
|
39
|
+
const declarations = new Map();
|
|
40
|
+
for (const section of DEPENDENCY_SECTIONS) {
|
|
41
|
+
const entries = manifest[section];
|
|
42
|
+
if (entries === undefined)
|
|
43
|
+
continue;
|
|
44
|
+
if (!isManifest(entries)) {
|
|
45
|
+
diagnostics.push(`${section} must be an object`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
for (const [name, version] of Object.entries(entries)) {
|
|
49
|
+
if (typeof version !== 'string') {
|
|
50
|
+
diagnostics.push(`${section}.${name} must be a string`);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const list = declarations.get(name) ?? [];
|
|
54
|
+
list.push({ section, version });
|
|
55
|
+
declarations.set(name, list);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const imports = [];
|
|
59
|
+
const repositoryId = hashParts(['repository', context.repoPath]);
|
|
60
|
+
for (const file of context.files) {
|
|
61
|
+
const parsed = parseSource(file.absolutePath, file.source);
|
|
62
|
+
if (!parsed)
|
|
63
|
+
continue;
|
|
64
|
+
const extracted = extractGraph({
|
|
65
|
+
repositoryId,
|
|
66
|
+
fileId: hashParts([repositoryId, file.path]),
|
|
67
|
+
relativePath: file.path,
|
|
68
|
+
source: file.source,
|
|
69
|
+
sourceHash: file.hash,
|
|
70
|
+
parseResult: parsed
|
|
71
|
+
});
|
|
72
|
+
for (const node of extracted.nodes) {
|
|
73
|
+
if (node.kind !== 'import')
|
|
74
|
+
continue;
|
|
75
|
+
const module = typeof node.metadata.module === 'string' ? node.metadata.module : node.name;
|
|
76
|
+
if (module)
|
|
77
|
+
imports.push({ path: file.path, id: node.id, module });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const usedPackages = new Map();
|
|
81
|
+
for (const entry of imports) {
|
|
82
|
+
const root = packageRoot(entry.module);
|
|
83
|
+
if (!root)
|
|
84
|
+
continue;
|
|
85
|
+
if (!usedPackages.has(root))
|
|
86
|
+
usedPackages.set(root, { implied: !declarations.has(root) });
|
|
87
|
+
}
|
|
88
|
+
const packageNames = new Set(usedPackages.keys());
|
|
89
|
+
const nodes = [...packageNames].sort().map((name) => {
|
|
90
|
+
const packageDeclarations = declarations.get(name) ?? [];
|
|
91
|
+
const implied = !declarations.has(name);
|
|
92
|
+
const versions = new Set(packageDeclarations.map((declaration) => declaration.version));
|
|
93
|
+
if (versions.size > 1)
|
|
94
|
+
diagnostics.push(`${name} has conflicting declared versions`);
|
|
95
|
+
return {
|
|
96
|
+
factKey: `package:${name}`,
|
|
97
|
+
kind: 'package',
|
|
98
|
+
type: 'package',
|
|
99
|
+
name,
|
|
100
|
+
metadata: {
|
|
101
|
+
manifest: 'package.json',
|
|
102
|
+
packageName: name,
|
|
103
|
+
implied,
|
|
104
|
+
declarations: packageDeclarations
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
const edges = imports.flatMap((entry) => {
|
|
109
|
+
const name = packageRoot(entry.module);
|
|
110
|
+
if (!name || !packageNames.has(name))
|
|
111
|
+
return [];
|
|
112
|
+
return [{
|
|
113
|
+
factKey: `provides:${name}:${entry.path}:${entry.id}`,
|
|
114
|
+
sourceFactKey: `package:${name}`,
|
|
115
|
+
targetFactKey: entry.id,
|
|
116
|
+
targetPlugin: BUILTIN_GRAPH_PLUGIN,
|
|
117
|
+
filePath: entry.path,
|
|
118
|
+
kind: 'provides',
|
|
119
|
+
metadata: { module: entry.module, package: name, implied: !declarations.has(name) }
|
|
120
|
+
}];
|
|
121
|
+
});
|
|
122
|
+
return { nodes, edges, diagnostics };
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
//# sourceMappingURL=packagePlugin.js.map
|