@psnext/lscg 0.1.3 → 0.1.4

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.
@@ -2,8 +2,31 @@ import { builtinModules } from 'node:module';
2
2
  import { extractGraph, hashParts } from '../graph/extract.js';
3
3
  import { parseSource } from '../parser/treeSitter.js';
4
4
  import { BUILTIN_GRAPH_PLUGIN } from './plugins.js';
5
+ // This plugin adds package-level relationships on top of the built-in source
6
+ // graph. Built-in extraction already creates file/import/symbol nodes; this
7
+ // plugin creates package nodes and connects them to the existing import nodes.
8
+ //
9
+ // The important identity rule for edges is:
10
+ // - package nodes use this plugin's fact-key namespace (`package:<name>`), and
11
+ // - built-in nodes are referenced by their final graph ID with
12
+ // `targetPlugin: BUILTIN_GRAPH_PLUGIN`.
13
+ //
14
+ // Keep that distinction in mind when repointing an edge to another existing
15
+ // node. Do not use the package node's eventual database ID as its fact key.
5
16
  const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
6
17
  const BUILTINS = new Set(builtinModules.flatMap((name) => [name, name.replace(/^node:/, ''), `node:${name.replace(/^node:/, '')}`]));
18
+ /**
19
+ * Converts an import specifier into its package root.
20
+ *
21
+ * Examples:
22
+ * react -> react
23
+ * lodash/map -> lodash
24
+ * @scope/library/tool -> @scope/library
25
+ * ./local-file -> undefined
26
+ * node:fs -> undefined
27
+ *
28
+ * Returning the root is what lets several imports share one package node.
29
+ */
7
30
  function packageRoot(specifier) {
8
31
  if (!specifier || specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#') || /^[a-z][a-z\d+.-]*:/i.test(specifier))
9
32
  return undefined;
@@ -55,6 +78,9 @@ export const PackagePlugin = {
55
78
  declarations.set(name, list);
56
79
  }
57
80
  }
81
+ // Re-run the same extraction used by the built-in scanner. The resulting
82
+ // import IDs are stable graph node IDs, so they can be used directly as
83
+ // targets when targetPlugin is BUILTIN_GRAPH_PLUGIN.
58
84
  const imports = [];
59
85
  const repositoryId = hashParts(['repository', context.repoPath]);
60
86
  for (const file of context.files) {
@@ -77,6 +103,9 @@ export const PackagePlugin = {
77
103
  imports.push({ path: file.path, id: node.id, module });
78
104
  }
79
105
  }
106
+ // A package is represented once even when it is imported by many files or
107
+ // through many subpaths. `implied` records imports that are not declared in
108
+ // any dependency section of package.json.
80
109
  const usedPackages = new Map();
81
110
  for (const entry of imports) {
82
111
  const root = packageRoot(entry.module);
@@ -87,6 +116,8 @@ export const PackagePlugin = {
87
116
  }
88
117
  const packageNames = new Set(usedPackages.keys());
89
118
  const nodes = [...packageNames].sort().map((name) => {
119
+ // This fact key is local to PackagePlugin. The scanner host later
120
+ // materializes it as hashParts(['plugin-node', 'package-plugin', factKey]).
90
121
  const packageDeclarations = declarations.get(name) ?? [];
91
122
  const implied = !declarations.has(name);
92
123
  const versions = new Set(packageDeclarations.map((declaration) => declaration.version));
@@ -109,6 +140,18 @@ export const PackagePlugin = {
109
140
  const name = packageRoot(entry.module);
110
141
  if (!name || !packageNames.has(name))
111
142
  return [];
143
+ // The source is a node emitted by this plugin, so sourceFactKey uses
144
+ // PackagePlugin's namespace and can be resolved as `package:<name>`.
145
+ //
146
+ // The target is an existing node emitted by the built-in graph scanner.
147
+ // `entry.id` is that node's final graph ID, not a plugin fact key. The
148
+ // explicit built-in namespace tells the scanner host to use the ID
149
+ // directly instead of looking for a PackagePlugin node with that key.
150
+ //
151
+ // To repoint this edge to another existing built-in node, replace
152
+ // `targetFactKey` with that node's ID and keep
153
+ // `targetPlugin: BUILTIN_GRAPH_PLUGIN`. For a node from another plugin,
154
+ // use that plugin's name in targetPlugin and its factKey in targetFactKey.
112
155
  return [{
113
156
  factKey: `provides:${name}:${entry.path}:${entry.id}`,
114
157
  sourceFactKey: `package:${name}`,
@@ -1,11 +1,13 @@
1
- <svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
2
- <g id="call_icon">
3
- <circle cx="24" cy="24" r="21" fill="#ff6d2e" stroke="inherit" stroke-width="4"></circle>
4
- <path d="M37 22.0001L34 25.0001L23 14.0001L26 11.0001C27.5 9.50002 33 7.00005 37 11.0001C41 15.0001 38.5 20.5 37 22.0001Z" fill="#b71111" stroke="#3a1304" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
5
- <path d="M42 6L37 11" stroke="#3a1304" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
6
- <path d="M11 25.9999L14 22.9999L25 33.9999L22 36.9999C20.5 38.5 15 41 11 36.9999C7 32.9999 9.5 27.5 11 25.9999Z" fill="#b71111" stroke="#3a1304" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
7
- <path d="M23 32L27 28" stroke="#3a1304" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
8
- <path d="M6 42L11 37" stroke="#3a1304" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
9
- <path d="M16 25L20 21" stroke="#3a1304" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
1
+ <svg viewBox="0 0 48 48" fill="none" stroke="#3a3a3a" xmlns="http://www.w3.org/2000/svg">
2
+ <g id="call_icon" >
3
+ <circle cx="24" cy="24" r="21" fill="#fb7185" stroke="inherit" stroke-width="2"></circle>
4
+ <path d="M37 22.0001L34 25.0001L23 14.0001L26 11.0001C27.5 9.50002 33 7.00005 37 11.0001C41 15.0001 38.5 20.5 37 22.0001Z"
5
+ fill="#fb7185" stroke="inherit" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
6
+ <path d="M42 6L37 11" stroke="inherit" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
7
+ <path d="M11 25.9999L14 22.9999L25 33.9999L22 36.9999C20.5 38.5 15 41 11 36.9999C7 32.9999 9.5 27.5 11 25.9999Z"
8
+ fill="#fb7185" stroke="inherit" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
9
+ <path d="M23 32L27 28" stroke="inherit" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
10
+ <path d="M6 42L11 37" stroke="inherit" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
11
+ <path d="M16 25L20 21" stroke="inherit" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
10
12
  </g>
11
13
  </svg>
@@ -193,20 +193,23 @@ html, body {
193
193
  font-weight: 200;
194
194
  paint-order: stroke;
195
195
  stroke: #020617;
196
- stroke-width: 3px;
196
+ stroke-width: 2px;
197
197
  stroke-linejoin: round;
198
198
  -webkit-user-select: none;
199
199
  user-select: none;
200
200
  pointer-events: none;
201
201
  }
202
- .node.is-highlighted .node-shape,
203
202
  .node.is-selected .node-shape {
204
- stroke: white;
205
- stroke-width: 2.5;
206
- }
207
- .node.is-hidden,
208
- .edge.is-hidden {
209
- display: none;
203
+ stroke: rgb(255, 255, 255);
204
+ stroke-width: 1;
205
+ filter:
206
+ drop-shadow(0px 0px 12px #00a2ff)
207
+ drop-shadow(0px 0px 24px #00a2ff)
208
+ drop-shadow(0px 0px 36px #00a2ff);
209
+ }
210
+ .node.is-highlighted .node.is-selected .node-shape {
211
+ stroke: rgb(193, 193, 193);
212
+ stroke-width: 2;
210
213
  }
211
214
  .node.is-dimmed {
212
215
  opacity: 0.18;
@@ -38,6 +38,18 @@
38
38
  </select>
39
39
  </label>
40
40
  </div>
41
+ <div class="section">
42
+ <label>
43
+ <span class="status">Layout</span>
44
+ <select id="layout-select">
45
+ <option value="force">Force-directed</option>
46
+ <option value="radial">Radial</option>
47
+ <option value="grid">Grid</option>
48
+ <option value="kind">By kind</option>
49
+ <option value="treemap">Treemap</option>
50
+ </select>
51
+ </label>
52
+ </div>
41
53
  <div class="section">
42
54
  <label>
43
55
  <span class="status">View box zoom</span>
@@ -68,12 +80,14 @@
68
80
 
69
81
  const data = {{DATA}};
70
82
  let display = data.display ?? 'shape';
83
+ let layoutMode = 'force';
71
84
  const app = document.getElementById('app');
72
85
  const svg = d3.select('#graph');
73
86
  const graphPanel = document.querySelector('.graph-panel');
74
87
  const tooltip = document.getElementById('tooltip');
75
88
  const searchInput = document.getElementById('search');
76
89
  const displaySelect = document.getElementById('display-select');
90
+ const layoutSelect = document.getElementById('layout-select');
77
91
  const zoomInput = document.getElementById('zoom');
78
92
  const zoomValue = document.getElementById('zoom-value');
79
93
  const zoomAllButton = document.getElementById('zoom-all');
@@ -240,31 +254,52 @@
240
254
  .force('x', d3.forceX(width / 2).strength(0.05))
241
255
  .force('y', d3.forceY(height / 2).strength(0.05));
242
256
 
243
- const links = linkLayer.selectAll('line')
244
- .data(linksData)
245
- .join('line')
246
- .attr('class', (d) => 'edge edge--' + d.kind)
247
- .attr('marker-end', 'url(#arrow)');
248
-
249
- const nodes = nodeLayer.selectAll('g')
250
- .data(nodesData)
251
- .join('g')
252
- .attr('class', (d) => 'node node--' + d.kind)
253
- .call(drag(simulation));
257
+ let links = linkLayer.selectAll('line');
258
+ let nodes = nodeLayer.selectAll('g');
254
259
 
255
- nodeLayer.raise();
256
-
257
- nodes.each(function(d) {
258
- const group = d3.select(this);
259
- appendNodeShape(group, d);
260
+ function renderNode(group, node) {
261
+ appendNodeShape(group, node);
262
+ group.append('title').text(node.label);
260
263
  group.append('text')
261
264
  .attr('class', 'node-label')
262
265
  .attr('text-anchor', 'middle')
263
- .attr('dominant-baseline', display === 'shape' && d.kind === 'file' ? 'middle' : 'hanging')
264
- .attr('y', display === 'shape' && d.kind === 'file' ? 0 : (d.height / 2) + 14)
265
- .text(d.label);
266
- group.append('title').text(d.label);
267
- });
266
+ .attr('dominant-baseline', display === 'shape' && node.kind === 'file' ? 'middle' : 'hanging')
267
+ .attr('y', display === 'shape' && node.kind === 'file' ? 0 : (node.height / 2) + 14)
268
+ .text(node.label);
269
+ }
270
+
271
+ function renderGraph(visibleNodeIds, visibleEdgeIds) {
272
+ links = linkLayer.selectAll('line')
273
+ .data(linksData.filter((edge) => visibleEdgeIds.has(edge.id)), (edge) => edge.id)
274
+ .join(
275
+ (enter) => enter.append('line'),
276
+ (update) => update,
277
+ (exit) => exit.remove()
278
+ )
279
+ .attr('class', (d) => 'edge edge--' + d.kind)
280
+ .attr('marker-end', 'url(#arrow)');
281
+
282
+ nodes = nodeLayer.selectAll('g')
283
+ .data(nodesData.filter((node) => visibleNodeIds.has(node.id)), (node) => node.id)
284
+ .join(
285
+ (enter) => {
286
+ const selection = enter.append('g')
287
+ .attr('class', (d) => 'node node--' + d.kind)
288
+ if (layoutMode === 'force')
289
+ selection.call(drag(simulation));
290
+
291
+ selection.each(function(node) {
292
+ renderNode(d3.select(this), node);
293
+ });
294
+ return selection;
295
+ },
296
+ (update) => update,
297
+ (exit) => exit.remove()
298
+ );
299
+
300
+ nodeLayer.raise();
301
+ bindNodeInteractions();
302
+ }
268
303
 
269
304
  function updateDisplay() {
270
305
  nodes.each(function(d) {
@@ -273,7 +308,8 @@
273
308
  appendNodeShape(group, d);
274
309
  group.select('.node-label')
275
310
  .attr('dominant-baseline', display === 'shape' && d.kind === 'file' ? 'middle' : 'hanging')
276
- .attr('y', display === 'shape' && d.kind === 'file' ? 0 : (d.height / 2) + 14);
311
+ .attr('y', display === 'shape' && d.kind === 'file' ? 0 : (d.height / 2) + 14)
312
+ .raise();
277
313
  });
278
314
  }
279
315
 
@@ -361,6 +397,74 @@
361
397
  return typeof endpoint === 'string' ? endpoint : endpoint?.id;
362
398
  }
363
399
 
400
+ function applyLayout(visibleNodeIds) {
401
+ if (layoutMode === 'force') {
402
+ for (const node of nodesData) {
403
+ node.fx = null;
404
+ node.fy = null;
405
+ }
406
+ simulation.alpha(0.8).restart();
407
+ return;
408
+ }
409
+
410
+ simulation.stop();
411
+ const visibleNodes = nodesData.filter((node) => visibleNodeIds.has(node.id));
412
+ const { width: canvasWidth, height: canvasHeight } = canvasSize();
413
+ const centerX = canvasWidth / 2;
414
+ const centerY = canvasHeight / 2;
415
+ const padding = 80;
416
+ const radius = Math.max(40, Math.min(canvasWidth, canvasHeight) / 2 - padding);
417
+ const columns = Math.max(1, Math.ceil(Math.sqrt(visibleNodes.length)));
418
+ const rows = Math.max(1, Math.ceil(visibleNodes.length / columns));
419
+
420
+ const orderedNodes = layoutMode === 'kind'
421
+ ? [...visibleNodes].sort((left, right) => left.kind.localeCompare(right.kind) || left.label.localeCompare(right.label))
422
+ : visibleNodes;
423
+ const treemapRoot = layoutMode === 'treemap'
424
+ ? d3.treemap().size([canvasWidth - padding * 2, canvasHeight - padding * 2]).paddingInner(12)(
425
+ d3.hierarchy({ children: visibleNodes }).sum((node) => node.degree ? Math.max(1, node.degree + 1) : 1)
426
+ )
427
+ : null;
428
+
429
+ orderedNodes.forEach((node, index) => {
430
+ let x = centerX;
431
+ let y = centerY;
432
+ if (layoutMode === 'treemap') {
433
+ const tile = treemapRoot?.leaves().find((leaf) => leaf.data === node);
434
+ x = padding + ((tile?.x0 ?? 0) + (tile?.x1 ?? 0)) / 2;
435
+ y = padding + ((tile?.y0 ?? 0) + (tile?.y1 ?? 0)) / 2;
436
+ } else if (layoutMode === 'radial') {
437
+ const angle = (index * Math.PI * 2) / Math.max(visibleNodes.length, 1) - Math.PI / 2;
438
+ x = centerX + Math.cos(angle) * radius;
439
+ y = centerY + Math.sin(angle) * radius;
440
+ } else {
441
+ const kindIndex = layoutMode === 'kind'
442
+ ? orderedNodes.slice(0, index).filter((candidate) => candidate.kind === node.kind).length
443
+ : index;
444
+ const kindCount = layoutMode === 'kind'
445
+ ? orderedNodes.filter((candidate) => candidate.kind === node.kind).length
446
+ : visibleNodes.length;
447
+ const kindColumn = layoutMode === 'kind'
448
+ ? [...new Set(orderedNodes.map((candidate) => candidate.kind))].indexOf(node.kind)
449
+ : 0;
450
+ const kindColumns = layoutMode === 'kind'
451
+ ? Math.max(1, new Set(orderedNodes.map((candidate) => candidate.kind)).size)
452
+ : columns;
453
+ const localColumns = layoutMode === 'kind' ? Math.max(1, Math.ceil(Math.sqrt(kindCount))) : columns;
454
+ const localRows = layoutMode === 'kind' ? Math.max(1, Math.ceil(kindCount / localColumns)) : rows;
455
+ const column = layoutMode === 'kind' ? kindIndex % localColumns : index % columns;
456
+ const row = layoutMode === 'kind' ? Math.floor(kindIndex / localColumns) : Math.floor(index / columns);
457
+ x = layoutMode === 'kind'
458
+ ? ((kindColumn + 0.5) * canvasWidth) / kindColumns
459
+ : ((column + 1) * canvasWidth) / (columns + 1);
460
+ y = ((row + 1) * canvasHeight) / (localRows + 1);
461
+ }
462
+ node.x = node.fx = x;
463
+ node.y = node.fy = y;
464
+ });
465
+ ticked();
466
+ }
467
+
364
468
  function updateStatus() {
365
469
  if (anchorId && focusVisibleOnly) {
366
470
  subtitle.textContent = 'Focus on ' + (nodeById.get(anchorId)?.label ?? anchorId);
@@ -416,16 +520,17 @@
416
520
  const visibleEdgeIds = visibleSets.visibleEdgeIds;
417
521
  const activeAnchorVisible = Boolean(anchorId && visibleNodeIds.has(anchorId));
418
522
 
523
+ renderGraph(visibleNodeIds, visibleEdgeIds);
524
+ applyLayout(visibleNodeIds);
525
+
419
526
  nodes
420
- .classed('is-hidden', (node) => !visibleNodeIds.has(node.id))
421
- .classed('is-dimmed', (node) => !focusVisibleOnly && anchorId ? !visibleNodeIds.has(node.id) : false)
527
+ .classed('is-dimmed', () => false)
422
528
  .classed('is-selected', (node) => anchorId === node.id)
423
529
  .classed('is-highlighted', (node) => activeAnchorVisible && visibleNodeIds.has(node.id));
424
530
 
425
531
  links
426
- .classed('is-hidden', (edge) => !visibleEdgeIds.has(edge.id))
427
- .classed('is-dimmed', (edge) => !focusVisibleOnly && anchorId ? !visibleEdgeIds.has(edge.id) : false)
428
- .classed('is-highlighted', (edge) => visibleEdgeIds.has(edge.id));
532
+ .classed('is-dimmed', () => false)
533
+ .classed('is-highlighted', () => true);
429
534
 
430
535
  updateStatus();
431
536
  updateInfo();
@@ -554,25 +659,27 @@
554
659
  nodes.attr('transform', (d) => 'translate(' + d.x + ',' + d.y + ')');
555
660
  }
556
661
 
557
- nodes
558
- .on('click', (event, node) => {
559
- event.stopPropagation();
560
- setAnchor(node.id);
561
- })
562
- .on('mouseenter', function(event, node) {
563
- if (!tooltip) return;
564
- tooltip.style.display = 'block';
565
- tooltip.textContent = node.label + ' · ' + node.kind;
566
- })
567
- .on('mousemove', function(event) {
568
- if (!tooltip) return;
569
- tooltip.style.left = event.clientX + 14 + 'px';
570
- tooltip.style.top = event.clientY + 14 + 'px';
571
- })
572
- .on('mouseleave', function() {
573
- if (!tooltip) return;
574
- tooltip.style.display = 'none';
575
- });
662
+ function bindNodeInteractions() {
663
+ nodes
664
+ .on('click', (event, node) => {
665
+ event.stopPropagation();
666
+ setAnchor(node.id);
667
+ })
668
+ .on('mouseenter', function(event, node) {
669
+ if (!tooltip) return;
670
+ tooltip.style.display = 'block';
671
+ tooltip.textContent = node.label + ' · ' + node.kind;
672
+ })
673
+ .on('mousemove', function(event) {
674
+ if (!tooltip) return;
675
+ tooltip.style.left = event.clientX + 14 + 'px';
676
+ tooltip.style.top = event.clientY + 14 + 'px';
677
+ })
678
+ .on('mouseleave', function() {
679
+ if (!tooltip) return;
680
+ tooltip.style.display = 'none';
681
+ });
682
+ }
576
683
 
577
684
  svg.on('click', (event) => {
578
685
  if (event.target === svg.node()) {
@@ -589,6 +696,13 @@
589
696
  updateDisplay();
590
697
  });
591
698
 
699
+ layoutSelect?.addEventListener('change', (event) => {
700
+ const value = event.target instanceof HTMLSelectElement ? event.target.value : 'force';
701
+ if (value !== 'force' && value !== 'radial' && value !== 'grid' && value !== 'kind' && value !== 'treemap') return;
702
+ layoutMode = value;
703
+ applyState();
704
+ });
705
+
592
706
  searchInput.value = filterText;
593
707
  searchInput.addEventListener('input', (event) => {
594
708
  filterText = event.target.value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@psnext/lscg",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Local source context graphs for repositories, exposed as a CLI and MCP server.",
5
5
  "type": "module",
6
6
  "bin": {