@psnext/lscg 0.1.5 → 0.1.6

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 CHANGED
@@ -198,9 +198,13 @@ The report also retains deterministic work counters and informational p50/p95
198
198
  timings for the isolated representative fixture's initial full scan, no-change
199
199
  incremental scan, and one-file Watch Session update.
200
200
 
201
- When the repository has git history, `lscg scan` also records contributor user
202
- nodes keyed by email and tracks the most recent contributor for each discovered
203
- node.
201
+ Git user attribution is optional. Enable it with `--attribution` on `scan`,
202
+ `watch`, or `scan --watch`; normal scans return structural data with attribution
203
+ pending, while `--full` and active watch sessions drain the selected enrichment.
204
+ Without the flag, scans report attribution as `disabled` and do not invoke Git
205
+ or create user nodes. Existing attribution remains readable if a later scan omits
206
+ the flag. When enabled, contributor user nodes are keyed by normalized email and
207
+ the most recent contributor is tracked for each discovered node.
204
208
 
205
209
  ## Scanner plugins
206
210
 
@@ -210,10 +214,11 @@ with the resolved `repoPath`, discovered files, file contents, and a `readFile`
210
214
  helper, then returns semantic node and edge facts. Edges refer to node `factKey` values within their emitting plugin namespace and
211
215
  may connect facts from different files. Use the additive `sourcePlugin` and
212
216
  `targetPlugin` fields when an endpoint belongs to another plugin; raw fact keys
213
- are never parsed as namespaces.
217
+ are never parsed as namespaces. Nodes and edges may also carry plugin-defined
218
+ `type` values; edge `type` defaults to its structural `kind` when omitted.
214
219
 
215
220
  ```ts
216
- import { scanRepository, type ScannerPlugin } from '@psnext/lscg';
221
+ import { AttributionPlugin, scanRepository, type ScannerPlugin } from '@psnext/lscg';
217
222
 
218
223
  const plugin: ScannerPlugin = {
219
224
  name: 'my-analyzer',
@@ -235,8 +240,26 @@ const plugin: ScannerPlugin = {
235
240
  };
236
241
 
237
242
  await scanRepository({ root: '/path/to/repo', plugins: [plugin] });
243
+
244
+ // Or opt into the bundled Git user attribution enrichment:
245
+ await scanRepository({ root: '/path/to/repo', plugins: [AttributionPlugin] });
246
+ // The same bundled plugin can also be selected as a convenience option:
247
+ await scanRepository({ root: '/path/to/repo', attribution: true });
248
+ // The CLI equivalent is: lscg scan --attribution
249
+ ```
250
+
251
+ A separately packaged plugin can be passed to a host application as an imported `ScannerPlugin`. The copyable [external scanner plugin example](examples/external-scanner-plugin/) demonstrates this with a TODO/FIXME analyzer and a host script.
252
+
253
+ The CLI can load the default export of an external module with the repeatable `--plugin` option:
254
+
255
+ ```sh
256
+ lscg scan --root /path/to/repo --plugin @acme/my-scanner-plugin
257
+ lscg scan --root /path/to/repo --plugin ./my-scanner-plugin.mjs
258
+ lscg watch --root /path/to/repo --plugin ./my-scanner-plugin.mjs
238
259
  ```
239
260
 
261
+ Relative module specifiers are resolved from the current working directory. The module must default-export one `ScannerPlugin` (an array is also accepted). External plugins are trusted code and run in-process; only load modules you trust. The supplied plugins are used for one-shot scans, `watch`, and `scan --watch`.
262
+
240
263
  Plugins may implement `initialize`, `scan`, `finalize`, and `dispose` hooks.
241
264
  The core assigns deterministic IDs, validates API compatibility, persists
242
265
  plugin facts, and reports plugin diagnostics in the scan summary. Plugins run
@@ -253,7 +276,8 @@ facts conflict by `(node, factKey)` and edge facts by `(edge, factKey)` across
253
276
  plugins. `merge` retains both under distinct plugin-qualified identities,
254
277
  `replace` uses priority (then plugin-name order for ties), and `reject` keeps
255
278
  the incumbent and reports a diagnostic. The host assigns deterministic IDs;
256
- plugins do not provide database IDs.
279
+ plugins do not provide database IDs. Use `lscg edges --type <edge-type>` to filter
280
+ persisted edge types.
257
281
 
258
282
  Every scan also runs the bundled `PackagePlugin`. It reads the repository-root
259
283
  `package.json` dependency sections (`dependencies`, `devDependencies`,
package/dist/bin/lscg.js CHANGED
File without changes
package/dist/src/cli.js CHANGED
@@ -1,3 +1,5 @@
1
+ import path from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
1
3
  import { startMcpServer } from './mcp/server.js';
2
4
  import { buildViewModel, loadViewSnapshot, renderSvgMarkup, viewGraph } from './view/index.js';
3
5
  import { watchRepository } from './watch.js';
@@ -19,18 +21,22 @@ export async function runCli(argv) {
19
21
  case 'init':
20
22
  printJson(initGraph({ root: options.root, scope: options.scope ?? 'repo' }));
21
23
  return;
22
- case 'scan':
24
+ case 'scan': {
23
25
  if (options.watch && options.full)
24
26
  throw new Error('scan --full cannot be combined with --watch');
27
+ const plugins = await loadExternalPlugins(options.plugin ?? []);
25
28
  if (options.watch) {
26
- await watchRepository({ root: options.root, scope: options.scope ?? 'repo' }, { mode: 'scan --watch', progress });
29
+ await watchRepository({ root: options.root, scope: options.scope ?? 'repo', attribution: options.attribution === true, plugins }, { mode: 'scan --watch', progress });
27
30
  return;
28
31
  }
29
- printJson(await scanRepository({ root: options.root, scope: options.scope ?? 'repo', full: options.full === true, progress }));
32
+ printJson(await scanRepository({ root: options.root, scope: options.scope ?? 'repo', plugins, attribution: options.attribution === true, full: options.full === true, progress }));
30
33
  return;
31
- case 'watch':
32
- await watchRepository({ root: options.root, scope: options.scope ?? 'repo' }, { mode: 'watch', progress });
34
+ }
35
+ case 'watch': {
36
+ const plugins = await loadExternalPlugins(options.plugin ?? []);
37
+ await watchRepository({ root: options.root, scope: options.scope ?? 'repo', attribution: options.attribution === true, plugins }, { mode: 'watch', progress });
33
38
  return;
39
+ }
34
40
  case 'status':
35
41
  printJson(graphStatus({ root: options.root, scope: options.scope ?? 'repo' }));
36
42
  return;
@@ -62,6 +68,7 @@ export async function runCli(argv) {
62
68
  root: options.root,
63
69
  scope: options.scope ?? 'repo',
64
70
  kind: options.kind,
71
+ type: options.type,
65
72
  limit: options.limit
66
73
  });
67
74
  const output = options.output ?? 'json';
@@ -237,12 +244,21 @@ function parseOptions(args) {
237
244
  if (!rawKey)
238
245
  continue;
239
246
  const key = toCamelCase(rawKey);
247
+ if (key === 'plugin') {
248
+ const value = inlineValue ?? args[index + 1];
249
+ if (!value || value.startsWith('-'))
250
+ throw new Error('--plugin requires a module specifier');
251
+ options.plugin = [...(options.plugin ?? []), value];
252
+ if (inlineValue === undefined)
253
+ index += 1;
254
+ continue;
255
+ }
240
256
  if (inlineValue !== undefined) {
241
257
  options[key] = coerce(inlineValue);
242
258
  continue;
243
259
  }
244
260
  const next = args[index + 1];
245
- const booleanOption = key === 'watch' || key === 'full' || key === 'attachHome' || key === 'excerpts' || key === 'help' || key === 'progress';
261
+ const booleanOption = key === 'watch' || key === 'full' || key === 'attachHome' || key === 'excerpts' || key === 'help' || key === 'progress' || key === 'attribution';
246
262
  if (!booleanOption && next && !next.startsWith('--')) {
247
263
  options[key] = coerce(next);
248
264
  index += 1;
@@ -290,11 +306,11 @@ function validateContextOptions(options, args) {
290
306
  function commandHelpText(command) {
291
307
  const help = {
292
308
  init: `Usage: lscg init [options]\n\nCreate graph storage for the selected scope.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to initialize (default: repo).\n\nExamples:\n lscg init --scope both`,
293
- scan: `Usage: lscg scan [options]\n\nParse repository files and write discovered graph data.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to scan (default: repo).\n --full Rebuild and verify every discovered source file.\n --watch Watch and rescan after the initial scan.\n --progress Write human-readable lifecycle progress to stderr.\n\nExamples:\n lscg scan --scope both\n lscg scan --full\n lscg scan --watch`,
294
- 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 --progress Write human-readable lifecycle progress to stderr.\n\nExamples:\n lscg watch`,
309
+ scan: `Usage: lscg scan [options]\n\nParse repository files and write discovered graph data.\nOptions:\n --root path Repository root (default: current directory).\n --scope repo|home|both Storage scope to scan (default: repo).\n --full Rebuild and verify every discovered source file.\n --watch Watch and rescan after the initial scan.\n --progress Write human-readable lifecycle progress to stderr.\n --attribution Enable optional Git user attribution enrichment.\n --plugin module Load an external ScannerPlugin module (repeatable).\n\nExamples:\n lscg scan --scope both\n lscg scan --full\n lscg scan --watch`,
310
+ 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 --progress Write human-readable lifecycle progress to stderr.\n --attribution Enable optional Git user attribution enrichment.\n --plugin module Load an external ScannerPlugin module (repeatable).\n\nExamples:\n lscg watch`,
295
311
  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`,
296
312
  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`,
297
- 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`,
313
+ 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 --type edge-type Filter by edge type.\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`,
298
314
  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`,
299
315
  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`,
300
316
  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`,
@@ -307,6 +323,30 @@ function commandHelpText(command) {
307
323
  return helpText();
308
324
  return `${commandHelp}\n\nShort aliases (where supported): -r --root, -s --scope, -k --kind, -d --depth, -l --limit, -f --file, -o --output.`;
309
325
  }
326
+ async function loadExternalPlugins(specifiers) {
327
+ const plugins = [];
328
+ for (const specifier of specifiers) {
329
+ const importTarget = specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('file:')
330
+ ? (specifier.startsWith('file:') ? specifier : pathToFileURL(path.resolve(process.cwd(), specifier)).href)
331
+ : specifier;
332
+ let loaded;
333
+ try {
334
+ loaded = await import(importTarget);
335
+ }
336
+ catch (error) {
337
+ throw new Error(`failed to load external plugin ${specifier}: ${error instanceof Error ? error.message : String(error)}`);
338
+ }
339
+ const candidate = loaded.default ?? loaded;
340
+ const values = Array.isArray(candidate) ? candidate : [candidate];
341
+ for (const plugin of values) {
342
+ if (!plugin || typeof plugin !== 'object' || typeof plugin.name !== 'string' || typeof plugin.scan !== 'function') {
343
+ throw new Error(`external plugin ${specifier} must default-export a ScannerPlugin or ScannerPlugin[]`);
344
+ }
345
+ plugins.push(plugin);
346
+ }
347
+ }
348
+ return plugins;
349
+ }
310
350
  function toCamelCase(value) {
311
351
  return value.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
312
352
  }
@@ -486,10 +526,14 @@ Command options:
486
526
  --full Rebuild and verify every discovered source file.
487
527
  --watch Watch and rescan after the initial scan.
488
528
  --progress Write human-readable lifecycle progress to stderr.
529
+ --attribution Enable optional Git user attribution enrichment.
530
+ --plugin module Load an external ScannerPlugin module (repeatable).
489
531
  watch:
490
532
  --root path Repository root (default: current directory).
491
533
  --scope repo Repository scope to watch (default: repo).
492
534
  --progress Write human-readable lifecycle progress to stderr.
535
+ --attribution Enable optional Git user attribution enrichment.
536
+ --plugin module Load an external ScannerPlugin module (repeatable).
493
537
  status:
494
538
  --root path Repository root (default: current directory).
495
539
  --scope repo|home|both Storage scope to inspect (default: repo).
@@ -503,6 +547,7 @@ Command options:
503
547
  --root path Repository root (default: current directory).
504
548
  --scope repo|home|both Storage scope to inspect (default: repo).
505
549
  --kind edge-kind Filter by edge kind.
550
+ --type edge-type Filter by edge type.
506
551
  --limit n Maximum number of edges (default: 50).
507
552
  --output json|text Output format (default: json).
508
553
  neighbors <node-id>:
@@ -0,0 +1,27 @@
1
+ import type { ExploreGraphResult } from '../types.js';
2
+ export interface SigmaGraph {
3
+ order: number;
4
+ size: number;
5
+ hasNode(nodeId: string): boolean;
6
+ hasEdge(edgeId: string): boolean;
7
+ addNode(nodeId: string, attributes: Record<string, unknown>): void;
8
+ addDirectedEdgeWithKey(edgeId: string, sourceId: string, targetId: string, attributes: Record<string, unknown>): void;
9
+ source(edgeId: string): string;
10
+ target(edgeId: string): string;
11
+ }
12
+ export interface SigmaGraphStats {
13
+ nodes: number;
14
+ edges: number;
15
+ aggregateNodes: number;
16
+ sourceNodes: number;
17
+ relationshipKinds: string[];
18
+ }
19
+ /**
20
+ * Convert the provider-neutral Explore result into Sigma's Graphology model.
21
+ * This adapter owns no layout or browser lifecycle; those remain provider work.
22
+ */
23
+ export declare function toSigmaGraph(result: ExploreGraphResult): {
24
+ graph: SigmaGraph;
25
+ stats: SigmaGraphStats;
26
+ };
27
+ //# sourceMappingURL=sigma-provider.d.ts.map
@@ -0,0 +1,87 @@
1
+ import Graphology from 'graphology';
2
+ /**
3
+ * Convert the provider-neutral Explore result into Sigma's Graphology model.
4
+ * This adapter owns no layout or browser lifecycle; those remain provider work.
5
+ */
6
+ export function toSigmaGraph(result) {
7
+ const GraphConstructor = Graphology;
8
+ const graph = new GraphConstructor({ type: 'directed', multi: true, allowSelfLoops: true });
9
+ const aggregateNodes = result.scopes.flatMap((scope) => scope.nodes.filter((node) => node.category === 'aggregate'));
10
+ const sourceNodes = result.scopes.flatMap((scope) => scope.nodes.filter((node) => node.category === 'source'));
11
+ const edges = result.scopes.flatMap((scope) => scope.edges);
12
+ const allNodes = [...aggregateNodes, ...sourceNodes];
13
+ const columns = Math.max(1, Math.ceil(Math.sqrt(allNodes.length)));
14
+ allNodes.forEach((node, index) => {
15
+ if (graph.hasNode(node.id))
16
+ return;
17
+ graph.addNode(node.id, {
18
+ ...sigmaNodeAttributes(node),
19
+ x: (index % columns) * 2,
20
+ y: Math.floor(index / columns) * 2
21
+ });
22
+ });
23
+ for (const edge of edges) {
24
+ if (!graph.hasNode(edge.sourceId) || !graph.hasNode(edge.targetId))
25
+ continue;
26
+ if (graph.hasEdge(edge.id))
27
+ continue;
28
+ graph.addDirectedEdgeWithKey(edge.id, edge.sourceId, edge.targetId, {
29
+ label: edge.kind,
30
+ type: edge.kind,
31
+ size: edge.count ? Math.max(1, Math.log2(edge.count + 1)) : 1,
32
+ color: edgeColor(edge.kind),
33
+ confidence: edge.confidence,
34
+ metadata: edge.metadata
35
+ });
36
+ }
37
+ return {
38
+ graph,
39
+ stats: {
40
+ nodes: graph.order,
41
+ edges: graph.size,
42
+ aggregateNodes: aggregateNodes.length,
43
+ sourceNodes: sourceNodes.length,
44
+ relationshipKinds: [...new Set(edges.map((edge) => edge.kind))].sort()
45
+ }
46
+ };
47
+ }
48
+ function sigmaNodeAttributes(node) {
49
+ if (node.category === 'aggregate') {
50
+ return {
51
+ label: node.name,
52
+ x: 0,
53
+ y: 0,
54
+ size: Math.max(4, Math.min(18, 4 + Math.log2(node.childCount + 1))),
55
+ color: '#2563eb',
56
+ category: node.category,
57
+ kind: node.kind,
58
+ path: node.path,
59
+ expandable: node.expandable,
60
+ expansionKey: node.expansionKey
61
+ };
62
+ }
63
+ return {
64
+ label: node.name ?? node.id,
65
+ x: 0,
66
+ y: 0,
67
+ size: 5,
68
+ color: '#64748b',
69
+ category: node.category,
70
+ kind: node.kind,
71
+ type: node.type,
72
+ path: node.path,
73
+ metadata: node.metadata,
74
+ expansionKey: node.expansionKey
75
+ };
76
+ }
77
+ function edgeColor(kind) {
78
+ const colors = {
79
+ calls: '#dc2626',
80
+ imports: '#7c3aed',
81
+ exports: '#0891b2',
82
+ contains: '#64748b',
83
+ defines: '#16a34a'
84
+ };
85
+ return colors[kind] ?? '#94a3b8';
86
+ }
87
+ //# sourceMappingURL=sigma-provider.js.map
@@ -0,0 +1,18 @@
1
+ import { type BrowserOpenResult } from '../view/open.js';
2
+ import type { ExploreGraphResult } from '../types.js';
3
+ export interface SigmaExploreRunResult {
4
+ mode: 'interactive';
5
+ artifactPath: string;
6
+ opened: boolean;
7
+ command?: string;
8
+ error?: string;
9
+ nodeCount: number;
10
+ edgeCount: number;
11
+ }
12
+ export interface SigmaExploreDependencies {
13
+ opener?: (targetPath: string) => BrowserOpenResult;
14
+ }
15
+ /** Render the provider-neutral result into a standalone Sigma.js HTML page. */
16
+ export declare function renderSigmaExploreHtml(result: ExploreGraphResult): string;
17
+ export declare function openSigmaExplore(result: ExploreGraphResult, dependencies?: SigmaExploreDependencies): SigmaExploreRunResult;
18
+ //# sourceMappingURL=sigma-render.d.ts.map
@@ -0,0 +1,67 @@
1
+ import { openHtmlArtifactInBrowser } from '../view/open.js';
2
+ /** Render the provider-neutral result into a standalone Sigma.js HTML page. */
3
+ export function renderSigmaExploreHtml(result) {
4
+ const serialized = JSON.stringify(result).replaceAll('<', '\\u003c');
5
+ const title = `${result.repository.name} Explore`;
6
+ return `<!doctype html>
7
+ <html lang="en">
8
+ <head>
9
+ <meta charset="utf-8">
10
+ <meta name="viewport" content="width=device-width, initial-scale=1">
11
+ <title>${escapeHtml(title)}</title>
12
+ <style>
13
+ :root { color-scheme: dark; font-family: ui-sans-serif, system-ui, sans-serif; }
14
+ html, body, #app { width: 100%; height: 100%; margin: 0; background: #020617; color: #e2e8f0; }
15
+ #graph { position: absolute; inset: 0; }
16
+ #panel { position: absolute; top: 16px; left: 16px; width: min(360px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; padding: 16px; border: 1px solid #334155; border-radius: 12px; background: rgba(15, 23, 42, .94); box-shadow: 0 12px 32px rgba(0,0,0,.35); }
17
+ h1 { margin: 0 0 6px; font-size: 18px; } h2 { margin: 16px 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
18
+ #status { color: #94a3b8; font-size: 12px; white-space: pre-wrap; }
19
+ input { width: 100%; box-sizing: border-box; margin-top: 12px; padding: 8px 10px; border: 1px solid #475569; border-radius: 7px; background: #0f172a; color: inherit; }
20
+ label { display: block; margin: 6px 0; font-size: 12px; color: #cbd5e1; } label input { width: auto; margin: 0 6px 0 0; }
21
+ #details { font-size: 12px; line-height: 1.5; color: #cbd5e1; }
22
+ .warning { color: #fbbf24; } code { color: #93c5fd; }
23
+ </style>
24
+ </head>
25
+ <body><div id="app"><div id="graph"></div><aside id="panel">
26
+ <h1>${escapeHtml(title)}</h1><div id="status"></div>
27
+ <input id="search" type="search" placeholder="Search nodes">
28
+ <h2>Relationship filters</h2><div id="filters"></div>
29
+ <h2>Selected node</h2><div id="details">Click a node to inspect it.</div>
30
+ </aside></div>
31
+ <script type="module">
32
+ import Graph from 'https://cdn.jsdelivr.net/npm/graphology@0.26.0/+esm';
33
+ import Sigma from 'https://cdn.jsdelivr.net/npm/sigma@3.0.3/+esm';
34
+ const result = ${serialized};
35
+ const scope = result.scopes[0] || { nodes: [], edges: [], warnings: [], filters: { relationshipKinds: [] }, freshness: { state: 'missing' } };
36
+ const graph = new Graph({ type: 'directed', multi: true, allowSelfLoops: true });
37
+ const nodes = scope.nodes || [], edges = scope.edges || [];
38
+ const columns = Math.max(1, Math.ceil(Math.sqrt(nodes.length)));
39
+ for (const [index, node] of nodes.entries()) {
40
+ graph.addNode(node.id, { label: node.name || node.id, x: (index % columns) * 2, y: Math.floor(index / columns) * 2, size: node.category === 'aggregate' ? Math.max(5, Math.min(18, 5 + Math.log2((node.childCount || 0) + 1))) : 5, color: node.category === 'aggregate' ? '#2563eb' : '#64748b', kind: node.kind, category: node.category, hidden: false });
41
+ }
42
+ for (const edge of edges) {
43
+ if (graph.hasNode(edge.sourceId) && graph.hasNode(edge.targetId) && !graph.hasEdge(edge.id)) graph.addDirectedEdgeWithKey(edge.id, edge.sourceId, edge.targetId, { type: 'arrow', label: edge.kind, color: edgeColor(edge.kind), size: edge.count ? Math.max(1, Math.log2(edge.count + 1)) : 1, relationshipKind: edge.kind });
44
+ }
45
+ const visibleKinds = new Set(scope.filters?.relationshipKinds || []);
46
+ const search = document.getElementById('search');
47
+ const details = document.getElementById('details');
48
+ const status = document.getElementById('status');
49
+ const filters = document.getElementById('filters');
50
+ status.textContent = 'Mode: ' + scope.mode + '\\nScope: ' + scope.scope + '\\nNodes: ' + graph.order + ' · Edges: ' + graph.size + '\\nFreshness: ' + scope.freshness.state + (scope.truncation?.bounded ? '\\nResult bounded by visibility caps.' : '');
51
+ if (scope.warnings?.length) status.textContent += '\\n' + scope.warnings.join('\\n');
52
+ for (const kind of ['contains', 'defines', 'calls', 'imports', 'exports']) { const label = document.createElement('label'); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = visibleKinds.has(kind); checkbox.addEventListener('change', () => { checkbox.checked ? visibleKinds.add(kind) : visibleKinds.delete(kind); renderer.refresh(); }); label.append(checkbox, kind); filters.append(label); }
53
+ const renderer = new Sigma(graph, document.getElementById('graph'), { renderEdgeLabels: false, labelColor: { color: '#e2e8f0' }, nodeReducer: (node, attrs) => { const next = { ...attrs }; const term = search.value.trim().toLocaleLowerCase(); if (term && !String(attrs.label || '').toLocaleLowerCase().includes(term)) next.hidden = true; return next; }, edgeReducer: (edge, attrs) => ({ ...attrs, hidden: !visibleKinds.has(String(attrs.relationshipKind)) }) });
54
+ search.addEventListener('input', () => renderer.refresh());
55
+ renderer.on('clickNode', ({ node }) => { const attrs = graph.getNodeAttributes(node); details.innerHTML = '<code>' + escapeHtml(node) + '</code><br>kind: ' + escapeHtml(String(attrs.kind || '')) + '<br>label: ' + escapeHtml(String(attrs.label || '')); });
56
+ function edgeColor(kind) { return ({ calls: '#dc2626', imports: '#7c3aed', exports: '#0891b2', contains: '#64748b', defines: '#16a34a' })[kind] || '#94a3b8'; }
57
+ function escapeHtml(value) { return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;'); }
58
+ </script></body></html>`;
59
+ }
60
+ export function openSigmaExplore(result, dependencies = {}) {
61
+ const opened = openHtmlArtifactInBrowser(renderSigmaExploreHtml(result), dependencies.opener);
62
+ const scope = result.scopes.reduce((total, item) => total + item.nodes.length, 0);
63
+ const edges = result.scopes.reduce((total, item) => total + item.edges.length, 0);
64
+ return { mode: 'interactive', artifactPath: opened.htmlPath, opened: opened.opened, ...(opened.command ? { command: opened.command } : {}), ...(opened.error ? { error: opened.error } : {}), nodeCount: scope, edgeCount: edges };
65
+ }
66
+ function escapeHtml(value) { return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;'); }
67
+ //# sourceMappingURL=sigma-render.js.map
@@ -0,0 +1,20 @@
1
+ import type { ExploreDirection, ExploreEdgeKind, ExploreGraphResult, GraphScope } from '../types.js';
2
+ export declare const EXPLORE_DEFAULT_DEPTH = 4;
3
+ export declare const EXPLORE_MAX_NODES = 2000;
4
+ export declare const EXPLORE_MAX_EDGES = 10000;
5
+ export declare const EXPLORE_EDGE_KINDS: readonly ExploreEdgeKind[];
6
+ export interface ExploreGraphOptions {
7
+ root?: string | undefined;
8
+ scope?: GraphScope;
9
+ anchor?: string | undefined;
10
+ file?: string | undefined;
11
+ search?: string | undefined;
12
+ direction?: ExploreDirection | undefined;
13
+ relationshipKinds?: readonly ExploreEdgeKind[] | undefined;
14
+ depth?: number | undefined;
15
+ nodeCap?: number | undefined;
16
+ edgeCap?: number | undefined;
17
+ candidateLimit?: number | undefined;
18
+ }
19
+ export declare function exploreGraph(options?: ExploreGraphOptions): ExploreGraphResult;
20
+ //# sourceMappingURL=explore.d.ts.map
@@ -0,0 +1,200 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { databasePathForScope, normalizeScope } from '../config/paths.js';
3
+ import { aggregateId, closeDatabase, openReadOnlyGraphDatabase, selectExploreAggregateEdges, selectExploreAggregates, selectExploreCandidates, selectExploreNode, selectExploreNodeRows, selectExploreSourcesForSearch, selectExploreTraversalEdges, selectFreshnessReport } from '../storage/database.js';
4
+ import { repositoryForRoot } from './repository.js';
5
+ export const EXPLORE_DEFAULT_DEPTH = 4;
6
+ export const EXPLORE_MAX_NODES = 2_000;
7
+ export const EXPLORE_MAX_EDGES = 10_000;
8
+ export const EXPLORE_EDGE_KINDS = ['contains', 'defines', 'calls', 'imports', 'exports'];
9
+ export function exploreGraph(options = {}) {
10
+ const repository = repositoryForRoot(options.root);
11
+ const scope = options.scope ?? 'repo';
12
+ const mode = options.anchor || options.file ? 'anchor' : 'full';
13
+ const request = {
14
+ root: repository.root,
15
+ scope,
16
+ mode,
17
+ ...(options.anchor ? { anchor: options.anchor.trim() } : {}),
18
+ ...(options.file ? { file: options.file.trim() } : {}),
19
+ ...(options.search ? { search: options.search.trim() } : {}),
20
+ direction: options.direction ?? 'both',
21
+ relationshipKinds: normalizeKinds(options.relationshipKinds),
22
+ depth: clamp(options.depth, 0, EXPLORE_DEFAULT_DEPTH, EXPLORE_DEFAULT_DEPTH),
23
+ nodeCap: clamp(options.nodeCap, 1, EXPLORE_MAX_NODES, EXPLORE_MAX_NODES),
24
+ edgeCap: clamp(options.edgeCap, 1, EXPLORE_MAX_EDGES, EXPLORE_MAX_EDGES),
25
+ candidateLimit: clamp(options.candidateLimit, 1, 100, 20)
26
+ };
27
+ const scopes = normalizeScope(scope);
28
+ return {
29
+ contract_version: 1,
30
+ repository,
31
+ request,
32
+ scopes: scopes.map((graphScope) => exploreScope(repository, graphScope, request))
33
+ };
34
+ }
35
+ function exploreScope(repository, graphScope, request) {
36
+ const databasePath = databasePathForScope(graphScope, repository.root);
37
+ if (!existsSync(databasePath))
38
+ return emptyScope(graphScope, databasePath, request, missingFreshness(databasePath));
39
+ let db;
40
+ try {
41
+ db = openReadOnlyGraphDatabase(databasePath);
42
+ }
43
+ catch (error) {
44
+ return emptyScope(graphScope, databasePath, request, {
45
+ state: 'unavailable', reason: 'unavailable_storage', message: `Explore storage is unavailable: ${diagnostic(error)}`,
46
+ recovery: 'Run lscg scan explicitly, then retry Explore.'
47
+ });
48
+ }
49
+ try {
50
+ const persistedFreshness = selectFreshnessReport(db, repository.id);
51
+ const freshness = freshnessFor(persistedFreshness);
52
+ if (request.mode === 'full')
53
+ return fullScope(db, repository, graphScope, databasePath, request, freshness);
54
+ return anchorScope(db, repository, graphScope, databasePath, request, freshness);
55
+ }
56
+ finally {
57
+ closeDatabase(db);
58
+ }
59
+ }
60
+ function fullScope(db, repository, scope, databasePath, request, freshness) {
61
+ const allAggregates = selectExploreAggregates(db, repository.id);
62
+ let searchState = { term: request.search ?? null, matchedNodeIds: [], containingAggregateIds: [] };
63
+ let aggregates = allAggregates;
64
+ let matchingSources = [];
65
+ if (request.search?.trim()) {
66
+ const normalized = request.search.trim().toLocaleLowerCase();
67
+ matchingSources = selectExploreSourcesForSearch(db, repository.id, request.search, 200);
68
+ const matchedNodeIds = matchingSources.map((row) => row.id);
69
+ const containingAggregateIds = [...new Set(matchingSources.flatMap((row) => row.fileId ? [aggregateId(repository.id, row.fileId)] : []))];
70
+ const matchingFiles = new Set(containingAggregateIds);
71
+ aggregates = allAggregates.filter((row) => matchingFiles.has(row.id) || row.path.toLocaleLowerCase().includes(normalized));
72
+ searchState = { term: request.search.trim(), matchedNodeIds, containingAggregateIds };
73
+ }
74
+ const aggregateNodes = aggregates.map((row) => aggregateNode(row, repository.id, scope));
75
+ const nodes = [...aggregateNodes, ...matchingSources.map((row) => sourceNode(row, repository.id, scope))];
76
+ const aggregateIds = new Set(aggregateNodes.map((node) => node.id));
77
+ const edges = selectExploreAggregateEdges(db, repository.id, request.relationshipKinds)
78
+ .filter((edge) => aggregateIds.has(edge.sourceAggregateId) && aggregateIds.has(edge.targetAggregateId))
79
+ .map((edge) => ({ id: edge.id, sourceId: edge.sourceAggregateId, targetId: edge.targetAggregateId, kind: edge.kind, confidence: edge.confidence, direction: 'forward', metadata: edge.metadata, count: edge.count, expansionKey: `aggregate-expand:${edge.sourceAggregateId}:${edge.targetAggregateId}:${edge.kind}` }));
80
+ const state = nodes.length > 0 ? 'ok' : 'empty';
81
+ return {
82
+ scope, databasePath, freshness, state, mode: 'full', nodes, edges,
83
+ search: searchState, filters: { direction: request.direction, relationshipKinds: [...request.relationshipKinds] },
84
+ truncation: noTruncation(request.nodeCap, request.edgeCap), expansionTargets: nodes.filter((node) => node.category === 'aggregate' && node.expandable).map((node) => node.expansionKey), warnings: freshnessWarnings(freshness)
85
+ };
86
+ }
87
+ function anchorScope(db, repository, scope, databasePath, request, freshness) {
88
+ const term = request.file ?? request.anchor ?? '';
89
+ const candidates = selectExploreCandidates(db, repository.id, term, { fileAnchor: Boolean(request.file), limit: request.candidateLimit });
90
+ const candidateContract = candidates.map(candidateContractFromRow);
91
+ const base = {
92
+ scope, databasePath, freshness, mode: 'anchor',
93
+ search: { term: null, matchedNodeIds: [], containingAggregateIds: [] },
94
+ filters: { direction: request.direction, relationshipKinds: [...request.relationshipKinds] },
95
+ expansionTargets: [], warnings: freshnessWarnings(freshness)
96
+ };
97
+ if (candidates.length === 0)
98
+ return { ...base, state: 'not_found', nodes: [], edges: [], candidates: [], truncation: noTruncation(request.nodeCap, request.edgeCap) };
99
+ if (candidates.length !== 1)
100
+ return { ...base, state: 'ambiguous', nodes: [], edges: [], candidates: candidateContract, truncation: noTruncation(request.nodeCap, request.edgeCap), warnings: [...base.warnings, 'Select one ranked candidate before traversal.'] };
101
+ const selected = candidates[0];
102
+ if (selected.kind === 'file' && selected.aggregateId) {
103
+ const aggregate = selectExploreAggregates(db, repository.id).find((row) => row.id === selected.aggregateId);
104
+ const node = aggregate ? aggregateNode(aggregate, repository.id, scope) : null;
105
+ return { ...base, state: node ? 'ok' : 'not_found', nodes: node ? [node] : [], edges: [], ...(node ? { anchor: node } : {}), candidates: candidateContract, expansionTargets: node ? [node.expansionKey] : [], truncation: noTruncation(request.nodeCap, request.edgeCap) };
106
+ }
107
+ const anchor = selectExploreNode(db, repository.id, selected.id);
108
+ if (!anchor)
109
+ return { ...base, state: 'not_found', nodes: [], edges: [], candidates: candidateContract, truncation: noTruncation(request.nodeCap, request.edgeCap) };
110
+ const traversal = traverse(db, repository.id, anchor, request);
111
+ const nodes = traversal.nodes.map((row) => sourceNode(row, repository.id, scope));
112
+ const anchorNode = nodes.find((node) => node.category === 'source' && node.id === anchor.id) ?? sourceNode(anchor, repository.id, scope);
113
+ return {
114
+ ...base, state: 'ok', nodes, edges: traversal.edges.map((edge) => edgeContract(edge, traversal.nodeIds)), anchor: anchorNode, candidates: candidateContract,
115
+ truncation: traversal.truncation, expansionTargets: [anchorNode.expansionKey ?? `node-expand:${anchorNode.id}`]
116
+ };
117
+ }
118
+ function traverse(db, repositoryId, anchor, request) {
119
+ const nodeIds = new Set([anchor.id]);
120
+ const allEdges = new Map();
121
+ let frontier = [anchor.id];
122
+ let omittedNodes = 0;
123
+ let omittedEdges = 0;
124
+ let deepestDepth = 0;
125
+ for (let depth = 1; depth <= request.depth && frontier.length > 0; depth += 1) {
126
+ const rows = selectExploreTraversalEdges(db, repositoryId, frontier, request.direction, request.relationshipKinds, request.edgeCap - allEdges.size + 1);
127
+ if (rows.length > request.edgeCap - allEdges.size)
128
+ omittedEdges += rows.length - Math.max(0, request.edgeCap - allEdges.size);
129
+ const uniqueRows = rows.filter((row) => !allEdges.has(row.id));
130
+ const adjacentIds = [...new Set(uniqueRows.flatMap((row) => adjacentFor(row, frontier, request.direction)))].filter((id) => !nodeIds.has(id));
131
+ const adjacentRows = selectExploreNodeRowsBounded(db, repositoryId, adjacentIds);
132
+ adjacentRows.sort(nodeOrder);
133
+ const available = Math.max(0, request.nodeCap - nodeIds.size);
134
+ const retained = adjacentRows.slice(0, available);
135
+ omittedNodes += Math.max(0, adjacentRows.length - retained.length);
136
+ for (const row of retained)
137
+ nodeIds.add(row.id);
138
+ const retainedIds = new Set(retained.map((row) => row.id));
139
+ for (const row of uniqueRows) {
140
+ if (allEdges.size >= request.edgeCap) {
141
+ omittedEdges += 1;
142
+ continue;
143
+ }
144
+ if (nodeIds.has(row.sourceId) && nodeIds.has(row.targetId))
145
+ allEdges.set(row.id, row);
146
+ else if (retainedIds.has(row.sourceId) || retainedIds.has(row.targetId))
147
+ omittedEdges += 1;
148
+ }
149
+ const next = retained.map((row) => row.id);
150
+ if (next.length === 0)
151
+ break;
152
+ frontier = next;
153
+ deepestDepth = depth;
154
+ }
155
+ const rows = selectExploreNodeRowsBounded(db, repositoryId, [...nodeIds]);
156
+ rows.sort(nodeOrder);
157
+ return { nodes: rows, nodeIds, edges: [...allEdges.values()], truncation: { bounded: omittedNodes > 0 || omittedEdges > 0, nodeCap: request.nodeCap, edgeCap: request.edgeCap, omittedNodes, omittedEdges, deepestDepth } };
158
+ }
159
+ function adjacentFor(row, frontier, direction) {
160
+ const hasSource = frontier.includes(row.sourceId);
161
+ const hasTarget = frontier.includes(row.targetId);
162
+ if (direction === 'incoming')
163
+ return hasTarget ? [row.sourceId] : [];
164
+ if (direction === 'outgoing')
165
+ return hasSource ? [row.targetId] : [];
166
+ return [...(hasSource ? [row.targetId] : []), ...(hasTarget ? [row.sourceId] : [])];
167
+ }
168
+ function edgeContract(edge, nodeIds) {
169
+ return { id: edge.id, sourceId: edge.sourceId, targetId: edge.targetId, kind: edge.kind, confidence: edge.confidence, direction: 'forward', metadata: edge.metadata };
170
+ }
171
+ function selectExploreNodeRowsBounded(db, repositoryId, ids) {
172
+ const rows = [];
173
+ for (let index = 0; index < ids.length; index += 500)
174
+ rows.push(...selectExploreNodeRows(db, repositoryId, ids.slice(index, index + 500)));
175
+ return rows;
176
+ }
177
+ function aggregateNode(row, repositoryId, scope) {
178
+ return { id: row.id, category: 'aggregate', kind: 'file', name: row.path, identity: { repositoryId, scope, path: row.path, qualifiedName: row.path }, path: row.path, language: row.language, expandable: row.childCount > 0, expansionKey: `aggregate-expand:${row.id}`, childCount: row.childCount };
179
+ }
180
+ function sourceNode(row, repositoryId, scope) {
181
+ return { id: row.id, category: 'source', kind: row.kind, type: row.type, name: row.name, identity: { repositoryId, scope, path: row.path, qualifiedName: row.name && row.path ? `${row.path}:${row.name}` : row.name }, path: row.path, span: { start: row.startPoint, end: row.endPoint }, metadata: row.metadata, ...(row.fileId ? { expansionKey: `aggregate-expand:${aggregateId(repositoryId, row.fileId)}` } : {}) };
182
+ }
183
+ function candidateContractFromRow(row) {
184
+ return { id: row.id, rank: row.rank, match: row.match, category: row.aggregateId && row.id === row.aggregateId ? 'aggregate' : 'source', kind: row.kind, name: row.name, qualifiedName: row.qualifiedName, path: row.path, ...(row.kind === 'file' ? {} : { span: { start: row.startPoint, end: row.endPoint } }) };
185
+ }
186
+ function freshnessFor(report) {
187
+ const message = report.state === 'fresh' ? 'Stored graph is fresh.' : report.state === 'degraded' ? 'Stored graph is degraded; some scan facts were retained.' : 'Stored graph is stale; Explore does not scan implicitly.';
188
+ return report.state === 'fresh'
189
+ ? { state: report.state, ...(report.reason ? { reason: report.reason } : {}), message }
190
+ : { state: report.state, ...(report.reason ? { reason: report.reason } : {}), message, recovery: 'Run lscg scan explicitly to refresh the stored graph.' };
191
+ }
192
+ function missingFreshness(databasePath) { return { state: 'missing', reason: 'missing_database', message: `No stored graph was found at ${databasePath}.`, recovery: 'Run lscg scan explicitly, then retry Explore.' }; }
193
+ function freshnessWarnings(freshness) { return freshness.state === 'fresh' ? [] : [freshness.message, ...(freshness.recovery ? [freshness.recovery] : [])]; }
194
+ function noTruncation(nodeCap, edgeCap) { return { bounded: false, nodeCap, edgeCap, omittedNodes: 0, omittedEdges: 0, deepestDepth: 0 }; }
195
+ function emptyScope(scope, databasePath, request, freshness) { return { scope, databasePath, freshness, state: 'empty', mode: request.mode, nodes: [], edges: [], search: { term: request.search ?? null, matchedNodeIds: [], containingAggregateIds: [] }, filters: { direction: request.direction, relationshipKinds: [...request.relationshipKinds] }, truncation: noTruncation(request.nodeCap, request.edgeCap), expansionTargets: [], warnings: freshnessWarnings(freshness) }; }
196
+ function normalizeKinds(kinds) { return [...new Set((kinds ?? EXPLORE_EDGE_KINDS).filter((kind) => EXPLORE_EDGE_KINDS.includes(kind)))]; }
197
+ function clamp(value, min, max, fallback) { return value === undefined || !Number.isFinite(value) ? fallback : Math.max(min, Math.min(max, Math.trunc(value))); }
198
+ function nodeOrder(a, b) { return `${a.path ?? ''}:${a.name ?? ''}:${a.id}`.localeCompare(`${b.path ?? ''}:${b.name ?? ''}:${b.id}`); }
199
+ function diagnostic(error) { return error instanceof Error ? error.message : String(error); }
200
+ //# sourceMappingURL=explore.js.map