@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 +30 -6
- package/dist/bin/lscg.js +0 -0
- package/dist/src/cli.js +54 -9
- package/dist/src/explore/sigma-provider.d.ts +27 -0
- package/dist/src/explore/sigma-provider.js +87 -0
- package/dist/src/explore/sigma-render.d.ts +18 -0
- package/dist/src/explore/sigma-render.js +67 -0
- package/dist/src/graph/explore.d.ts +20 -0
- package/dist/src/graph/explore.js +200 -0
- package/dist/src/graph/repository.d.ts +7 -2
- package/dist/src/graph/repository.js +66 -20
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/mcp/server.js +5 -2
- package/dist/src/scanner/attributionPlugin.d.ts +5 -0
- package/dist/src/scanner/attributionPlugin.js +16 -0
- package/dist/src/scanner/discover.js +89 -16
- package/dist/src/scanner/plugins.d.ts +6 -2
- package/dist/src/scanner/plugins.js +8 -1
- package/dist/src/storage/connection.js +22 -0
- package/dist/src/storage/explore-queries.d.ts +52 -0
- package/dist/src/storage/explore-queries.js +184 -0
- package/dist/src/storage/graph-writes.d.ts +8 -2
- package/dist/src/storage/graph-writes.js +55 -33
- package/dist/src/storage/plugin-graph.js +3 -3
- package/dist/src/storage/queries.d.ts +4 -2
- package/dist/src/storage/queries.js +40 -31
- package/dist/src/storage/schema.d.ts +2 -2
- package/dist/src/storage/schema.js +5 -1
- package/dist/src/types.d.ts +10 -2
- package/dist/src/watch.d.ts +3 -0
- package/dist/src/watch.js +5 -2
- package/package.json +1 -1
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); }
|
|
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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); }
|
|
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
|