@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.
Files changed (48) hide show
  1. package/README.md +69 -3
  2. package/dist/src/cli.js +68 -13
  3. package/dist/src/graph/repository.d.ts +4 -1
  4. package/dist/src/graph/repository.js +106 -6
  5. package/dist/src/index.d.ts +3 -0
  6. package/dist/src/index.js +2 -0
  7. package/dist/src/mcp/server.js +2 -2
  8. package/dist/src/scanner/discover.js +0 -2
  9. package/dist/src/scanner/packagePlugin.d.ts +3 -0
  10. package/dist/src/scanner/packagePlugin.js +125 -0
  11. package/dist/src/scanner/plugins.d.ts +86 -0
  12. package/dist/src/scanner/plugins.js +503 -0
  13. package/dist/src/storage/connection.d.ts +6 -0
  14. package/dist/src/storage/connection.js +133 -0
  15. package/dist/src/storage/context-queries.d.ts +20 -0
  16. package/dist/src/storage/context-queries.js +137 -0
  17. package/dist/src/storage/database.d.ts +12 -71
  18. package/dist/src/storage/database.js +12 -562
  19. package/dist/src/storage/graph-writes.d.ts +17 -0
  20. package/dist/src/storage/graph-writes.js +126 -0
  21. package/dist/src/storage/plugin-contributions.d.ts +15 -0
  22. package/dist/src/storage/plugin-contributions.js +28 -0
  23. package/dist/src/storage/plugin-graph.d.ts +6 -0
  24. package/dist/src/storage/plugin-graph.js +81 -0
  25. package/dist/src/storage/queries.d.ts +25 -0
  26. package/dist/src/storage/queries.js +129 -0
  27. package/dist/src/storage/row-decoders.d.ts +8 -0
  28. package/dist/src/storage/row-decoders.js +37 -0
  29. package/dist/src/storage/schema.d.ts +2 -2
  30. package/dist/src/storage/schema.js +13 -2
  31. package/dist/src/storage/traversal-queries.d.ts +15 -0
  32. package/dist/src/storage/traversal-queries.js +87 -0
  33. package/dist/src/types.d.ts +10 -4
  34. package/dist/src/view/index.d.ts +1 -1
  35. package/dist/src/view/index.js +2 -2
  36. package/dist/src/view/model.d.ts +2 -0
  37. package/dist/src/view/render.d.ts +5 -2
  38. package/dist/src/view/render.js +81 -13
  39. package/dist/src/view/templates/icons/call.svg +11 -0
  40. package/dist/src/view/templates/icons/export.svg +1 -0
  41. package/dist/src/view/templates/icons/file.svg +9 -0
  42. package/dist/src/view/templates/icons/import.svg +1 -0
  43. package/dist/src/view/templates/icons/package.svg +1 -0
  44. package/dist/src/view/templates/icons/symbol.svg +7 -0
  45. package/dist/src/view/templates/icons/user.svg +15 -0
  46. package/dist/src/view/templates/interactive.css +272 -0
  47. package/dist/src/view/templates/interactive.html +642 -0
  48. 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
- printJson(listEdges({
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
- return;
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 = [`${match.scope}: ${match.name ?? '<unnamed>'} [${match.kind}/${match.type}] (${match.id})`];
373
- lines.push(' upstream:');
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(' downstream:');
376
- lines.push(...formatCallGraphRelations(match.downstream));
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) => ` - ${relation.name ?? '<unnamed>'} [${relation.kind}/${relation.type}] depth=${relation.depth} edge=${relation.edgeKind} (${relation.id})`);
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
- for (const handle of opened)
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 graph = extractGraph({
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' } = {}) {
@@ -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
@@ -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,
@@ -4,8 +4,6 @@ import path from 'node:path';
4
4
  import { isSupportedSourceFile } from '../parser/treeSitter.js';
5
5
  const DEFAULT_IGNORES = new Set([
6
6
  '.git',
7
- '.sling',
8
- '.pi-subagents',
9
7
  'node_modules',
10
8
  'dist',
11
9
  'coverage',
@@ -0,0 +1,3 @@
1
+ import type { ScannerPlugin } from './plugins.js';
2
+ export declare const PackagePlugin: ScannerPlugin;
3
+ //# sourceMappingURL=packagePlugin.d.ts.map
@@ -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