@psnext/lscg 0.1.4 → 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.
Files changed (62) hide show
  1. package/README.md +117 -15
  2. package/dist/bin/lscg.js +0 -0
  3. package/dist/src/cli-progress.d.ts +7 -0
  4. package/dist/src/cli-progress.js +59 -0
  5. package/dist/src/cli.js +62 -9
  6. package/dist/src/explore/sigma-provider.d.ts +27 -0
  7. package/dist/src/explore/sigma-provider.js +87 -0
  8. package/dist/src/explore/sigma-render.d.ts +18 -0
  9. package/dist/src/explore/sigma-render.js +67 -0
  10. package/dist/src/graph/attribution.d.ts +2 -2
  11. package/dist/src/graph/attribution.js +36 -13
  12. package/dist/src/graph/explore.d.ts +20 -0
  13. package/dist/src/graph/explore.js +200 -0
  14. package/dist/src/graph/repository.d.ts +36 -4
  15. package/dist/src/graph/repository.js +443 -159
  16. package/dist/src/graph/repositoryScanWorker.d.ts +21 -0
  17. package/dist/src/graph/repositoryScanWorker.js +45 -0
  18. package/dist/src/index.d.ts +6 -0
  19. package/dist/src/index.js +5 -0
  20. package/dist/src/mcp/server.js +21 -3
  21. package/dist/src/parser/treeSitter.js +20 -1
  22. package/dist/src/scanner/artifactInventory.d.ts +35 -0
  23. package/dist/src/scanner/artifactInventory.js +139 -0
  24. package/dist/src/scanner/attributionPlugin.d.ts +5 -0
  25. package/dist/src/scanner/attributionPlugin.js +16 -0
  26. package/dist/src/scanner/discover.js +89 -16
  27. package/dist/src/scanner/fingerprint.js +5 -0
  28. package/dist/src/scanner/javaDependencyPlugin.d.ts +5 -0
  29. package/dist/src/scanner/javaDependencyPlugin.js +107 -0
  30. package/dist/src/scanner/javaPlugin.d.ts +5 -0
  31. package/dist/src/scanner/javaPlugin.js +199 -0
  32. package/dist/src/scanner/javaScanWorker.d.ts +2 -0
  33. package/dist/src/scanner/javaScanWorker.js +8 -0
  34. package/dist/src/scanner/packageParseWorker.d.ts +17 -0
  35. package/dist/src/scanner/packageParseWorker.js +30 -0
  36. package/dist/src/scanner/packagePlugin.js +83 -24
  37. package/dist/src/scanner/parallelScan.d.ts +2 -0
  38. package/dist/src/scanner/parallelScan.js +32 -0
  39. package/dist/src/scanner/plugins.d.ts +38 -4
  40. package/dist/src/scanner/plugins.js +58 -5
  41. package/dist/src/scanner/pythonPlugin.d.ts +5 -0
  42. package/dist/src/scanner/pythonPlugin.js +198 -0
  43. package/dist/src/scanner/pythonScanWorker.d.ts +2 -0
  44. package/dist/src/scanner/pythonScanWorker.js +8 -0
  45. package/dist/src/storage/connection.js +85 -0
  46. package/dist/src/storage/database.d.ts +1 -0
  47. package/dist/src/storage/database.js +1 -0
  48. package/dist/src/storage/explore-queries.d.ts +52 -0
  49. package/dist/src/storage/explore-queries.js +184 -0
  50. package/dist/src/storage/graph-writes.d.ts +22 -3
  51. package/dist/src/storage/graph-writes.js +167 -20
  52. package/dist/src/storage/manifest-inventory.d.ts +23 -0
  53. package/dist/src/storage/manifest-inventory.js +82 -0
  54. package/dist/src/storage/plugin-graph.js +3 -3
  55. package/dist/src/storage/queries.d.ts +10 -3
  56. package/dist/src/storage/queries.js +99 -24
  57. package/dist/src/storage/schema.d.ts +2 -2
  58. package/dist/src/storage/schema.js +48 -1
  59. package/dist/src/types.d.ts +110 -6
  60. package/dist/src/watch.d.ts +20 -2
  61. package/dist/src/watch.js +211 -46
  62. package/package.json +9 -3
package/README.md CHANGED
@@ -119,9 +119,12 @@ prefix and substring matches; an ambiguous match returns ranked candidates
119
119
  instead of merging unrelated symbols. Use `--kind` and repository-relative
120
120
  `--file` to disambiguate. Results are references by default; `--excerpts` opts
121
121
  into bounded source text with `--excerpt-lines` and `--excerpt-bytes` limits.
122
- The command compares a cheap path/size/mtime fingerprint with indexed files and
123
- reports whether the graph was reused or refreshed. This metadata check is not a
124
- content-verification guarantee for same-size edits with restored mtimes.
122
+ Graph-reading commands never start a scan. Their Freshness Report reflects the
123
+ stored graph state: `fresh` after a successful explicit scan, `stale` when no
124
+ compatible graph exists or a known invalidation remains, and `degraded` when a
125
+ retained failed-file or plugin contribution is being served. Run `scan` or keep
126
+ `watch` active after source changes; a stored `fresh` result is not a live
127
+ filesystem content-verification guarantee.
125
128
 
126
129
  Impact contains only syntax-grounded `calls` edges (with caller/callee
127
130
  direction), and dependencies contain `imports`/`exports`; parser confidence
@@ -130,14 +133,78 @@ Substring fallback is intentionally reported as a bounded capability warning.
130
133
  Use `rg` or `grep` for literal discovery when a term is not indexed—`context`
131
134
  does not replace arbitrary text search or claim complete semantic resolution.
132
135
 
136
+ `lscg scan` performs an Explicit Scan. After the first scan, its default mode
137
+ is incremental: it retains unchanged file graphs and reprocesses files whose
138
+ path, size, or mtime metadata changed. Use `lscg scan --full` when you need a
139
+ complete rebuild and content verification (including same-size edits with
140
+ restored mtimes).
141
+
133
142
  `lscg watch` keeps the selected repository current in the foreground. It runs
134
143
  one scan immediately, then watches scanner-relevant files and prints compact
135
- status updates between rescans. Use `lscg scan` when you want the full one-shot
136
- scan output.
144
+ status updates between rescans. `lscg scan --watch` provides the same Watch
145
+ Session from the scan command. Use `lscg scan` when you want the one-shot scan
146
+ output.
147
+
148
+ Pass `--progress` to `scan`, `watch`, or `scan --watch` for line-oriented,
149
+ human-readable lifecycle messages on stderr while the existing JSON summary or
150
+ Watch Session events remain on stdout. For example:
151
+
152
+ ```bash
153
+ npm start -- scan --progress >summary.json
154
+ npm start -- watch --progress >watch-events.jsonl
155
+ npm start -- scan --watch --progress >watch-events.jsonl
156
+ ```
137
157
 
138
- When the repository has git history, `lscg scan` also records contributor user
139
- nodes keyed by email and tracks the most recent contributor for each discovered
140
- node.
158
+ Progress labels discovery/planning, plugin work, file processing,
159
+ reconciliation, and optional enrichment. A normal scan may report structural
160
+ completion with enrichment deferred or degraded; a Full Scan reports retries
161
+ and only reports stabilized completion after its enrichment loop succeeds.
162
+ Progress is informational rather than a stable machine-readable protocol, and
163
+ it does not provide cancellation or animated progress bars.
164
+
165
+ ## Scan/watch benchmark
166
+
167
+ Run the non-blocking benchmark with:
168
+
169
+ ```bash
170
+ npm run benchmark:scan-watch
171
+ ```
172
+
173
+ The versioned JSON report separates two cold timings for this lscg checkout:
174
+
175
+ - `coldNormalStructuralReady`: a normal scan's return after its structural
176
+ graph is persisted, before optional enrichment completes.
177
+ - `coldFullEnrichmentComplete`: a cold `scan --full` completion, including its
178
+ required enrichment drain.
179
+
180
+ Every cold sample uses an isolated copy with no pre-existing graph database;
181
+ source and Git metadata are copied so the normal and full scans exercise the
182
+ same source graph and optional attribution path without changing this checkout.
183
+ The report records SHA-256 fingerprints plus structural node/edge counts before
184
+ normal-scan enrichment and after that scan's pending enrichment work drains.
185
+ `structuralEquivalence` must be `true`: user nodes, `attributed_to` edges, and
186
+ per-node attribution are
187
+ intentionally excluded because they are optional enrichment rather than source
188
+ structure.
189
+
190
+ `coldNormalStructuralReadyTarget` reports the 3.43-second baseline, the
191
+ 2.06-second median target (the required 40% reduction), the measured p50, and
192
+ whether that run met the target. Timing values and target status are benchmark
193
+ results for the documented machine/run, not unit-test thresholds. Use multiple
194
+ cold samples for review (the default is 10); pass `-- --repetitions 5` to change
195
+ the sample count.
196
+
197
+ The report also retains deterministic work counters and informational p50/p95
198
+ timings for the isolated representative fixture's initial full scan, no-change
199
+ incremental scan, and one-file Watch Session update.
200
+
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.
141
208
 
142
209
  ## Scanner plugins
143
210
 
@@ -147,10 +214,11 @@ with the resolved `repoPath`, discovered files, file contents, and a `readFile`
147
214
  helper, then returns semantic node and edge facts. Edges refer to node `factKey` values within their emitting plugin namespace and
148
215
  may connect facts from different files. Use the additive `sourcePlugin` and
149
216
  `targetPlugin` fields when an endpoint belongs to another plugin; raw fact keys
150
- 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.
151
219
 
152
220
  ```ts
153
- import { scanRepository, type ScannerPlugin } from '@psnext/lscg';
221
+ import { AttributionPlugin, scanRepository, type ScannerPlugin } from '@psnext/lscg';
154
222
 
155
223
  const plugin: ScannerPlugin = {
156
224
  name: 'my-analyzer',
@@ -172,8 +240,26 @@ const plugin: ScannerPlugin = {
172
240
  };
173
241
 
174
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
175
259
  ```
176
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
+
177
263
  Plugins may implement `initialize`, `scan`, `finalize`, and `dispose` hooks.
178
264
  The core assigns deterministic IDs, validates API compatibility, persists
179
265
  plugin facts, and reports plugin diagnostics in the scan summary. Plugins run
@@ -190,7 +276,8 @@ facts conflict by `(node, factKey)` and edge facts by `(edge, factKey)` across
190
276
  plugins. `merge` retains both under distinct plugin-qualified identities,
191
277
  `replace` uses priority (then plugin-name order for ties), and `reject` keeps
192
278
  the incumbent and reports a diagnostic. The host assigns deterministic IDs;
193
- 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.
194
281
 
195
282
  Every scan also runs the bundled `PackagePlugin`. It reads the repository-root
196
283
  `package.json` dependency sections (`dependencies`, `devDependencies`,
@@ -203,7 +290,22 @@ listed in `plugins`.
203
290
 
204
291
  ## Current parser support
205
292
 
206
- The starter scanner supports JavaScript, JSX, TypeScript, and TSX via
207
- Tree-sitter. The extraction layer records syntax-grounded edges such as
208
- `defines`, `contains`, `imports`, `exports`, and `calls`. Cross-file symbol
209
- resolution should be added as language-specific semantic enrichers.
293
+ The scanner supports JavaScript, JSX, TypeScript, TSX, Python, and Java via
294
+ Tree-sitter. Python is handled by the bundled `PythonScannerPlugin`, and Java
295
+ by `JavaScannerPlugin` using the pinned native `tree-sitter-java@0.21.0`
296
+ grammar. Java records syntax-grounded classes, interfaces, enums, records,
297
+ annotation types, methods, constructors, fields, imports, method invocations,
298
+ containment, and explicit-public export approximations. Java calls/imports are
299
+ occurrences (no type, overload, constructor, or cross-file resolution), and
300
+ inherited/module APIs are not modeled. A Java parse error retains the prior
301
+ file contribution and marks freshness degraded until the file recovers.
302
+
303
+ Repositories with root or immediate-module `pom.xml`, `build.gradle`, or
304
+ `build.gradle.kts` files also receive `java-dependency` `package` nodes from
305
+ `JavaDependencyPlugin`. Manifest reading is bounded and non-executing; only
306
+ literal Maven coordinates/properties and literal Gradle dependency calls are
307
+ modeled. Dependencies remain independent from Java imports: no heuristic
308
+ coordinate matching or dependency-to-import edges are emitted. Native
309
+ Tree-sitter install scripts must remain allowlisted for clean installs.
310
+ Cross-file symbol resolution remains a language-specific semantic-enricher
311
+ concern.
package/dist/bin/lscg.js CHANGED
File without changes
@@ -0,0 +1,7 @@
1
+ import type { ProgressReporter } from './types.js';
2
+ export interface ProgressWriter {
3
+ (text: string): void;
4
+ }
5
+ /** Create a deterministic, line-oriented renderer for the stderr channel. */
6
+ export declare function createProgressReporter(write?: ProgressWriter): ProgressReporter;
7
+ //# sourceMappingURL=cli-progress.d.ts.map
@@ -0,0 +1,59 @@
1
+ /** Create a deterministic, line-oriented renderer for the stderr channel. */
2
+ export function createProgressReporter(write = (text) => process.stderr.write(`${text}\n`)) {
3
+ const skippedByAttempt = new Map();
4
+ return (event) => {
5
+ const line = event.kind === 'scan'
6
+ ? renderScan(event, skippedByAttempt)
7
+ : renderWatch(event);
8
+ if (line)
9
+ write(line);
10
+ };
11
+ }
12
+ function renderScan(event, skippedByAttempt) {
13
+ const prefix = `[${event.operation} scan scope=${event.scope}]`;
14
+ if (event.phase === 'discovery' && event.status === 'started')
15
+ return `${prefix} discovery/planning: discovering files`;
16
+ if (event.phase === 'discovery' && event.status === 'completed') {
17
+ const c = event.counts ?? {};
18
+ return `${prefix} discovery/planning: discovered=${c.discovered ?? 0} selected=${c.selected ?? 0} unchanged=${c.unchanged ?? 0} deleted=${c.deleted ?? 0}`;
19
+ }
20
+ if (event.phase === 'plugins' && event.status === 'started')
21
+ return `${prefix} plugin work: starting plugins=${event.counts?.plugins ?? 0}`;
22
+ if (event.phase === 'plugins') {
23
+ const c = event.counts ?? {};
24
+ const suffix = event.detail ? ` (${event.detail})` : '';
25
+ return `${prefix} plugin work: plugins=${c.plugins ?? 0} failed=${c.failedPlugins ?? 0}${suffix}`;
26
+ }
27
+ if (event.phase === 'files' && event.status === 'started')
28
+ return `${prefix} file processing: selected=${event.counts?.selected ?? 0}`;
29
+ if (event.phase === 'files' && event.status === 'processed')
30
+ return `${prefix} file processing: processed=${event.counts?.processed ?? 0} skipped=${event.counts?.skipped ?? 0}`;
31
+ if (event.phase === 'files' && event.status === 'skipped') {
32
+ const key = `${event.operation}:${event.attempt ?? 1}`;
33
+ const count = (skippedByAttempt.get(key) ?? 0) + 1;
34
+ skippedByAttempt.set(key, count);
35
+ if (count <= 3)
36
+ return `${prefix} warning: skipped ${event.path ?? '<unknown>'}${event.detail ? ` (${event.detail})` : ''}`;
37
+ if (count === 4)
38
+ return `${prefix} warning: additional skipped files omitted (showing first 3)`;
39
+ return undefined;
40
+ }
41
+ if (event.phase === 'reconciliation' && event.status === 'started')
42
+ return `${prefix} reconciliation: starting deleted=${event.counts?.deleted ?? 0}`;
43
+ if (event.phase === 'reconciliation' && event.status === 'completed')
44
+ return `${prefix} reconciliation: complete processed=${event.counts?.processed ?? 0} skipped=${event.counts?.skipped ?? 0}`;
45
+ if (event.phase === 'enrichment' && event.status === 'started')
46
+ return `${prefix} enrichment: starting`;
47
+ if (event.phase === 'enrichment') {
48
+ const c = event.counts ?? {};
49
+ return `${prefix} enrichment: ${event.status} pending=${c.pending ?? 0} complete=${c.complete ?? 0} failed=${c.failed ?? 0}${event.detail ? ` (${event.detail})` : ''}`;
50
+ }
51
+ if (event.phase === 'terminal')
52
+ return `${prefix} ${event.status === 'failed' ? 'ERROR' : event.status === 'degraded' ? 'warning' : 'complete'}${event.detail ? `: ${event.detail}` : ''}`;
53
+ return undefined;
54
+ }
55
+ function renderWatch(event) {
56
+ const prefix = `[watch mode=${event.mode} scope=${event.scope}]`;
57
+ return `${prefix} ${event.event}${event.detail ? `: ${event.detail}` : ''}`;
58
+ }
59
+ //# sourceMappingURL=cli-progress.js.map
package/dist/src/cli.js CHANGED
@@ -1,6 +1,9 @@
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';
6
+ import { createProgressReporter } from './cli-progress.js';
4
7
  import { graphStatus, initGraph, callGraph, contextGraph, listEdges, listNodes, listNodeText, neighbors, runReadOnlySql, scanRepository } from './graph/repository.js';
5
8
  const operationalCommands = new Set([
6
9
  'init', 'scan', 'watch', 'status', 'nodes', 'edges', 'neighbors',
@@ -13,20 +16,27 @@ export async function runCli(argv) {
13
16
  return;
14
17
  }
15
18
  const options = parseOptions(rest);
19
+ const progress = options.progress === true ? createProgressReporter() : undefined;
16
20
  switch (command) {
17
21
  case 'init':
18
22
  printJson(initGraph({ root: options.root, scope: options.scope ?? 'repo' }));
19
23
  return;
20
- case 'scan':
24
+ case 'scan': {
25
+ if (options.watch && options.full)
26
+ throw new Error('scan --full cannot be combined with --watch');
27
+ const plugins = await loadExternalPlugins(options.plugin ?? []);
21
28
  if (options.watch) {
22
- await watchRepository({ root: options.root, scope: options.scope ?? 'repo' }, { mode: 'scan --watch' });
29
+ await watchRepository({ root: options.root, scope: options.scope ?? 'repo', attribution: options.attribution === true, plugins }, { mode: 'scan --watch', progress });
23
30
  return;
24
31
  }
25
- printJson(await scanRepository({ root: options.root, scope: options.scope ?? 'repo' }));
32
+ printJson(await scanRepository({ root: options.root, scope: options.scope ?? 'repo', plugins, attribution: options.attribution === true, full: options.full === true, progress }));
26
33
  return;
27
- case 'watch':
28
- await watchRepository({ root: options.root, scope: options.scope ?? 'repo' }, { mode: 'watch' });
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 });
29
38
  return;
39
+ }
30
40
  case 'status':
31
41
  printJson(graphStatus({ root: options.root, scope: options.scope ?? 'repo' }));
32
42
  return;
@@ -58,6 +68,7 @@ export async function runCli(argv) {
58
68
  root: options.root,
59
69
  scope: options.scope ?? 'repo',
60
70
  kind: options.kind,
71
+ type: options.type,
61
72
  limit: options.limit
62
73
  });
63
74
  const output = options.output ?? 'json';
@@ -233,12 +244,21 @@ function parseOptions(args) {
233
244
  if (!rawKey)
234
245
  continue;
235
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
+ }
236
256
  if (inlineValue !== undefined) {
237
257
  options[key] = coerce(inlineValue);
238
258
  continue;
239
259
  }
240
260
  const next = args[index + 1];
241
- const booleanOption = key === 'watch' || key === 'attachHome' || key === 'excerpts' || key === 'help';
261
+ const booleanOption = key === 'watch' || key === 'full' || key === 'attachHome' || key === 'excerpts' || key === 'help' || key === 'progress' || key === 'attribution';
242
262
  if (!booleanOption && next && !next.startsWith('--')) {
243
263
  options[key] = coerce(next);
244
264
  index += 1;
@@ -286,11 +306,11 @@ function validateContextOptions(options, args) {
286
306
  function commandHelpText(command) {
287
307
  const help = {
288
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`,
289
- 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 --watch Watch and rescan after the initial scan.\n\nExamples:\n lscg scan --scope both\n lscg scan --watch`,
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`,
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`,
291
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`,
292
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`,
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`,
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`,
294
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`,
295
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`,
296
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`,
@@ -303,6 +323,30 @@ function commandHelpText(command) {
303
323
  return helpText();
304
324
  return `${commandHelp}\n\nShort aliases (where supported): -r --root, -s --scope, -k --kind, -d --depth, -l --limit, -f --file, -o --output.`;
305
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
+ }
306
350
  function toCamelCase(value) {
307
351
  return value.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
308
352
  }
@@ -479,10 +523,17 @@ Command options:
479
523
  scan:
480
524
  --root path Repository root (default: current directory).
481
525
  --scope repo|home|both Storage scope to scan (default: repo).
526
+ --full Rebuild and verify every discovered source file.
482
527
  --watch Watch and rescan after the initial scan.
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).
483
531
  watch:
484
532
  --root path Repository root (default: current directory).
485
533
  --scope repo Repository scope to watch (default: repo).
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).
486
537
  status:
487
538
  --root path Repository root (default: current directory).
488
539
  --scope repo|home|both Storage scope to inspect (default: repo).
@@ -496,6 +547,7 @@ Command options:
496
547
  --root path Repository root (default: current directory).
497
548
  --scope repo|home|both Storage scope to inspect (default: repo).
498
549
  --kind edge-kind Filter by edge kind.
550
+ --type edge-type Filter by edge type.
499
551
  --limit n Maximum number of edges (default: 50).
500
552
  --output json|text Output format (default: json).
501
553
  neighbors <node-id>:
@@ -570,6 +622,7 @@ Notes:
570
622
  - lscg view opens an interactive HTML page by default.
571
623
  - Pass --output to write a static SVG snapshot instead of opening the browser.
572
624
  - lscg watch supports repo scope only.
625
+ - --progress is opt-in human-readable diagnostics on stderr; stdout JSON is unchanged.
573
626
 
574
627
  Storage:
575
628
  repo: <root>/.sling/graph.sqlite
@@ -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
@@ -1,8 +1,8 @@
1
- import type { GraphNode, FileAttribution } from '../types.js';
1
+ import type { AttributionOutcome, GraphNode } from '../types.js';
2
2
  export declare function buildFileAttribution({ root, relativePath, source, nodes }: {
3
3
  root: string;
4
4
  relativePath: string;
5
5
  source: string;
6
6
  nodes: GraphNode[];
7
- }): FileAttribution | null;
7
+ }): AttributionOutcome;
8
8
  //# sourceMappingURL=attribution.d.ts.map