@psnext/lscg 0.1.2 → 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.
Files changed (48) hide show
  1. package/README.md +69 -3
  2. package/dist/src/cli.js +68 -13
  3. package/dist/src/graph/repository.d.ts +4 -1
  4. package/dist/src/graph/repository.js +106 -6
  5. package/dist/src/index.d.ts +3 -0
  6. package/dist/src/index.js +2 -0
  7. package/dist/src/mcp/server.js +2 -2
  8. package/dist/src/scanner/discover.js +0 -2
  9. package/dist/src/scanner/packagePlugin.d.ts +3 -0
  10. package/dist/src/scanner/packagePlugin.js +168 -0
  11. package/dist/src/scanner/plugins.d.ts +86 -0
  12. package/dist/src/scanner/plugins.js +503 -0
  13. package/dist/src/storage/connection.d.ts +6 -0
  14. package/dist/src/storage/connection.js +133 -0
  15. package/dist/src/storage/context-queries.d.ts +20 -0
  16. package/dist/src/storage/context-queries.js +137 -0
  17. package/dist/src/storage/database.d.ts +12 -71
  18. package/dist/src/storage/database.js +12 -562
  19. package/dist/src/storage/graph-writes.d.ts +17 -0
  20. package/dist/src/storage/graph-writes.js +126 -0
  21. package/dist/src/storage/plugin-contributions.d.ts +15 -0
  22. package/dist/src/storage/plugin-contributions.js +28 -0
  23. package/dist/src/storage/plugin-graph.d.ts +6 -0
  24. package/dist/src/storage/plugin-graph.js +81 -0
  25. package/dist/src/storage/queries.d.ts +25 -0
  26. package/dist/src/storage/queries.js +129 -0
  27. package/dist/src/storage/row-decoders.d.ts +8 -0
  28. package/dist/src/storage/row-decoders.js +37 -0
  29. package/dist/src/storage/schema.d.ts +2 -2
  30. package/dist/src/storage/schema.js +13 -2
  31. package/dist/src/storage/traversal-queries.d.ts +15 -0
  32. package/dist/src/storage/traversal-queries.js +87 -0
  33. package/dist/src/types.d.ts +10 -4
  34. package/dist/src/view/index.d.ts +1 -1
  35. package/dist/src/view/index.js +2 -2
  36. package/dist/src/view/model.d.ts +2 -0
  37. package/dist/src/view/render.d.ts +5 -2
  38. package/dist/src/view/render.js +81 -13
  39. package/dist/src/view/templates/icons/call.svg +13 -0
  40. package/dist/src/view/templates/icons/export.svg +1 -0
  41. package/dist/src/view/templates/icons/file.svg +9 -0
  42. package/dist/src/view/templates/icons/import.svg +1 -0
  43. package/dist/src/view/templates/icons/package.svg +1 -0
  44. package/dist/src/view/templates/icons/symbol.svg +7 -0
  45. package/dist/src/view/templates/icons/user.svg +15 -0
  46. package/dist/src/view/templates/interactive.css +17 -11
  47. package/dist/src/view/templates/interactive.html +236 -52
  48. package/package.json +1 -1
@@ -28,6 +28,28 @@
28
28
  <input id="search" type="search" placeholder="Type to filter by label" autocomplete="off" value="{{SEARCH_TERM}}" />
29
29
  </label>
30
30
  </div>
31
+ <div class="section">
32
+ <label>
33
+ <span class="status">Node display</span>
34
+ <select id="display-select">
35
+ <option value="simple">Simple</option>
36
+ <option value="shape">Shape</option>
37
+ <option value="icon">Icon</option>
38
+ </select>
39
+ </label>
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>
31
53
  <div class="section">
32
54
  <label>
33
55
  <span class="status">View box zoom</span>
@@ -57,11 +79,15 @@
57
79
  import * as d3 from 'https://cdn.jsdelivr.net/npm/d3@7/+esm';
58
80
 
59
81
  const data = {{DATA}};
82
+ let display = data.display ?? 'shape';
83
+ let layoutMode = 'force';
60
84
  const app = document.getElementById('app');
61
85
  const svg = d3.select('#graph');
62
86
  const graphPanel = document.querySelector('.graph-panel');
63
87
  const tooltip = document.getElementById('tooltip');
64
88
  const searchInput = document.getElementById('search');
89
+ const displaySelect = document.getElementById('display-select');
90
+ const layoutSelect = document.getElementById('layout-select');
65
91
  const zoomInput = document.getElementById('zoom');
66
92
  const zoomValue = document.getElementById('zoom-value');
67
93
  const zoomAllButton = document.getElementById('zoom-all');
@@ -114,6 +140,30 @@
114
140
 
115
141
  function appendNodeShape(group, node) {
116
142
  const fill = node.color ?? '#94a3b8';
143
+ if (display === 'simple') {
144
+ group.append('circle')
145
+ .attr('class', 'node-shape node-shape--simple')
146
+ .attr('cx', 0)
147
+ .attr('cy', 0)
148
+ .attr('r', Math.min(node.width, node.height) / 2)
149
+ .attr('fill', fill);
150
+ return;
151
+ }
152
+ if (display === 'icon') {
153
+ const icon = data.icons?.[node.kind];
154
+ if (!icon) throw new Error('Missing icon asset for ' + node.kind);
155
+ group.append('svg')
156
+ .attr('class', 'node-shape node-shape--icon')
157
+ .attr('x', -node.width / 2)
158
+ .attr('y', -node.height / 2)
159
+ .attr('width', node.width)
160
+ .attr('height', node.height)
161
+ .attr('viewBox', icon.viewBox)
162
+ .attr('color', fill)
163
+ .attr('aria-hidden', 'true')
164
+ .html(icon.markup);
165
+ return;
166
+ }
117
167
  switch (node.shape) {
118
168
  case 'rounded-rect':
119
169
  group.append('rect')
@@ -155,6 +205,17 @@
155
205
  : (-node.width / 2 + node.height / 4) + ',' + (-node.height / 2) + ' ' + (node.width / 2 - node.height / 4) + ',' + (-node.height / 2) + ' ' + (node.width / 2) + ',0 ' + (node.width / 2 - node.height / 4) + ',' + (node.height / 2) + ' ' + (-node.width / 2 + node.height / 4) + ',' + (node.height / 2) + ' ' + (-node.width / 2) + ',0')
156
206
  .attr('fill', fill);
157
207
  break;
208
+ case 'package':
209
+ group.append('svg')
210
+ .attr('class', 'node-shape node-shape--package')
211
+ .attr('x', -node.width / 2)
212
+ .attr('y', -node.height / 2)
213
+ .attr('width', node.width)
214
+ .attr('height', node.height)
215
+ .attr('viewBox', '0 0 512 512')
216
+ .attr('aria-hidden', 'true')
217
+ .html('<polygon fill="#7E4F1F" points="511.516,107.105 511.788,107.036 264.065,31.004 0.479,106.898 0,106.759 0,411.849 238.676,480.721 238.676,480.159 238.888,480.996 512,412.125 512,107.036"/><polygon fill="#EDA637" points="264.065,31.004 0,107.036 238.676,175.907 511.788,107.036"/><polygon fill="#C27526" points="238.888,480.996 512,412.125 512,107.036 238.888,175.907"/><path fill="#FFEC99" d="M193.016 51.462 78.638 84.393l230.233 73.866v50.416c0 3.323 1.541 6.458 4.172 8.487 2.631 2.029 6.053 2.725 9.268 1.881l99.058-25.986c4.713-1.237 7.999-5.495 7.999-10.369v-54.816L193.016 51.462Z"/><path fill="#FFDC35" d="M486.158 374.572c0 6.29-4.278 11.775-10.378 13.307l-52.647 13.55c-2.779.698-5.736.663-8.41-.367-5.363-2.066-8.677-7.119-8.677-12.576V332.26c0-6.29 4.278-11.775 10.378-13.307l52.647-13.55c2.779-.698 5.736-.663 8.41.367 5.363 2.066 8.677 7.119 8.677 12.576v56.226Z"/><path fill="#FFDC35" d="M429.367 182.689c0 4.872-3.287 9.132-7.999 10.368l-99.058 25.986c-3.214.843-6.637.148-9.268-1.881-2.631-2.029-4.172-5.164-4.172-8.487v-50.416l120.497-30.386v54.816Z"/><path fill="#DF7400" d="m186.692 292.407-40.804-11.978c-5.273-1.548-8.895-6.385-8.895-11.881v-39.76c0-8.263 7.941-14.208 15.87-11.881l40.804 11.978c5.273 1.548 8.895 6.385 8.895 11.881v39.76c0 8.263-7.941 14.208-15.87 11.881Z"/>');
218
+ break;
158
219
  case 'user':
159
220
  const userIcon = group.append('svg')
160
221
  .attr('class', 'node-shape node-shape--user')
@@ -193,31 +254,68 @@
193
254
  .force('x', d3.forceX(width / 2).strength(0.05))
194
255
  .force('y', d3.forceY(height / 2).strength(0.05));
195
256
 
196
- const links = linkLayer.selectAll('line')
197
- .data(linksData)
198
- .join('line')
199
- .attr('class', (d) => 'edge edge--' + d.kind)
200
- .attr('marker-end', 'url(#arrow)');
201
-
202
- const nodes = nodeLayer.selectAll('g')
203
- .data(nodesData)
204
- .join('g')
205
- .attr('class', (d) => 'node node--' + d.kind)
206
- .call(drag(simulation));
257
+ let links = linkLayer.selectAll('line');
258
+ let nodes = nodeLayer.selectAll('g');
207
259
 
208
- nodeLayer.raise();
209
-
210
- nodes.each(function(d) {
211
- const group = d3.select(this);
212
- appendNodeShape(group, d);
260
+ function renderNode(group, node) {
261
+ appendNodeShape(group, node);
262
+ group.append('title').text(node.label);
213
263
  group.append('text')
214
264
  .attr('class', 'node-label')
215
265
  .attr('text-anchor', 'middle')
216
- .attr('dominant-baseline', d.kind === 'file' ? 'middle' : 'hanging')
217
- .attr('y', d.kind === 'file' ? 0 : (d.height / 2) + 14)
218
- .text(d.label);
219
- group.append('title').text(d.label);
220
- });
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
+ }
303
+
304
+ function updateDisplay() {
305
+ nodes.each(function(d) {
306
+ const group = d3.select(this);
307
+ group.selectAll('.node-shape').remove();
308
+ appendNodeShape(group, d);
309
+ group.select('.node-label')
310
+ .attr('dominant-baseline', display === 'shape' && d.kind === 'file' ? 'middle' : 'hanging')
311
+ .attr('y', display === 'shape' && d.kind === 'file' ? 0 : (d.height / 2) + 14)
312
+ .raise();
313
+ });
314
+ }
315
+
316
+ if (displaySelect instanceof HTMLSelectElement) {
317
+ displaySelect.value = display;
318
+ }
221
319
 
222
320
  const zoom = d3.zoom().scaleExtent([0.2, 4]).on('zoom', (event) => {
223
321
  zoomLayer.attr('transform', event.transform);
@@ -299,6 +397,74 @@
299
397
  return typeof endpoint === 'string' ? endpoint : endpoint?.id;
300
398
  }
301
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
+
302
468
  function updateStatus() {
303
469
  if (anchorId && focusVisibleOnly) {
304
470
  subtitle.textContent = 'Focus on ' + (nodeById.get(anchorId)?.label ?? anchorId);
@@ -354,22 +520,23 @@
354
520
  const visibleEdgeIds = visibleSets.visibleEdgeIds;
355
521
  const activeAnchorVisible = Boolean(anchorId && visibleNodeIds.has(anchorId));
356
522
 
523
+ renderGraph(visibleNodeIds, visibleEdgeIds);
524
+ applyLayout(visibleNodeIds);
525
+
357
526
  nodes
358
- .classed('is-hidden', (node) => !visibleNodeIds.has(node.id))
359
- .classed('is-dimmed', (node) => !focusVisibleOnly && anchorId ? !visibleNodeIds.has(node.id) : false)
527
+ .classed('is-dimmed', () => false)
360
528
  .classed('is-selected', (node) => anchorId === node.id)
361
529
  .classed('is-highlighted', (node) => activeAnchorVisible && visibleNodeIds.has(node.id));
362
530
 
363
531
  links
364
- .classed('is-hidden', (edge) => !visibleEdgeIds.has(edge.id))
365
- .classed('is-dimmed', (edge) => !focusVisibleOnly && anchorId ? !visibleEdgeIds.has(edge.id) : false)
366
- .classed('is-highlighted', (edge) => visibleEdgeIds.has(edge.id));
532
+ .classed('is-dimmed', () => false)
533
+ .classed('is-highlighted', () => true);
367
534
 
368
535
  updateStatus();
369
536
  updateInfo();
370
537
  updateZoomState();
371
538
  updateLegendState();
372
- reframe();
539
+ reframe(visibleNodeIds);
373
540
  }
374
541
 
375
542
  function setAnchor(nodeId) {
@@ -392,7 +559,7 @@
392
559
  }
393
560
  const nextSize = canvasSize();
394
561
  svg.attr('viewBox', [0, 0, nextSize.width, nextSize.height]);
395
- reframe();
562
+ reframe(getVisibleSets().visibleNodeIds);
396
563
  }
397
564
 
398
565
  function zoomAll() {
@@ -412,11 +579,11 @@
412
579
  applyState();
413
580
  }
414
581
 
415
- function reframe() {
582
+ function reframe(visibleNodeIds = allNodeIds) {
416
583
  const selectedNode = anchorId ? nodesData.find((candidate) => candidate.id === anchorId) ?? null : null;
417
584
  const { width: canvasWidth, height: canvasHeight } = canvasSize();
418
585
 
419
- if (focusMode === 'selected' && selectedNode) {
586
+ if (!focusVisibleOnly && focusMode === 'selected' && selectedNode) {
420
587
  const transform = d3.zoomIdentity
421
588
  .translate(canvasWidth / 2, canvasHeight / 2)
422
589
  .scale(focusScale / zoomFactor)
@@ -425,12 +592,13 @@
425
592
  return;
426
593
  }
427
594
 
428
- if (nodesData.length === 0) {
595
+ const visibleNodes = nodesData.filter((node) => visibleNodeIds.has(node.id));
596
+ if (visibleNodes.length === 0) {
429
597
  svg.call(zoom.transform, d3.zoomIdentity);
430
598
  return;
431
599
  }
432
600
 
433
- const bounds = nodesData.reduce((acc, current) => ({
601
+ const bounds = visibleNodes.reduce((acc, current) => ({
434
602
  minX: Math.min(acc.minX, current.x),
435
603
  maxX: Math.max(acc.maxX, current.x),
436
604
  minY: Math.min(acc.minY, current.y),
@@ -491,25 +659,27 @@
491
659
  nodes.attr('transform', (d) => 'translate(' + d.x + ',' + d.y + ')');
492
660
  }
493
661
 
494
- nodes
495
- .on('click', (event, node) => {
496
- event.stopPropagation();
497
- setAnchor(node.id);
498
- })
499
- .on('mouseenter', function(event, node) {
500
- if (!tooltip) return;
501
- tooltip.style.display = 'block';
502
- tooltip.textContent = node.label + ' · ' + node.kind;
503
- })
504
- .on('mousemove', function(event) {
505
- if (!tooltip) return;
506
- tooltip.style.left = event.clientX + 14 + 'px';
507
- tooltip.style.top = event.clientY + 14 + 'px';
508
- })
509
- .on('mouseleave', function() {
510
- if (!tooltip) return;
511
- tooltip.style.display = 'none';
512
- });
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
+ }
513
683
 
514
684
  svg.on('click', (event) => {
515
685
  if (event.target === svg.node()) {
@@ -519,6 +689,20 @@
519
689
  }
520
690
  });
521
691
 
692
+ displaySelect?.addEventListener('change', (event) => {
693
+ const value = event.target instanceof HTMLSelectElement ? event.target.value : 'shape';
694
+ if (value !== 'simple' && value !== 'shape' && value !== 'icon') return;
695
+ display = value;
696
+ updateDisplay();
697
+ });
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
+
522
706
  searchInput.value = filterText;
523
707
  searchInput.addEventListener('input', (event) => {
524
708
  filterText = event.target.value;
@@ -560,7 +744,7 @@
560
744
  if (!graphPanel) return;
561
745
  const nextSize = canvasSize();
562
746
  svg.attr('viewBox', [0, 0, nextSize.width, nextSize.height]);
563
- reframe();
747
+ reframe(getVisibleSets().visibleNodeIds);
564
748
  });
565
749
 
566
750
  simulation.on('tick', ticked);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@psnext/lscg",
3
- "version": "0.1.2",
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": {