gitnexus 1.2.8 → 1.2.9

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 (38) hide show
  1. package/README.md +194 -186
  2. package/dist/cli/ai-context.js +71 -71
  3. package/dist/cli/analyze.js +1 -1
  4. package/dist/cli/setup.js +8 -1
  5. package/dist/cli/view.d.ts +13 -0
  6. package/dist/cli/view.js +59 -0
  7. package/dist/core/augmentation/engine.js +20 -20
  8. package/dist/core/embeddings/embedding-pipeline.js +26 -26
  9. package/dist/core/graph/html-graph-viewer.d.ts +15 -0
  10. package/dist/core/graph/html-graph-viewer.js +542 -0
  11. package/dist/core/graph/html-graph-viewer.test.d.ts +1 -0
  12. package/dist/core/graph/html-graph-viewer.test.js +67 -0
  13. package/dist/core/ingestion/cluster-enricher.js +16 -16
  14. package/dist/core/kuzu/kuzu-adapter.js +9 -9
  15. package/dist/core/kuzu/schema.js +256 -256
  16. package/dist/core/search/bm25-index.js +5 -5
  17. package/dist/core/search/hybrid-search.js +3 -3
  18. package/dist/core/wiki/graph-queries.js +52 -52
  19. package/dist/core/wiki/html-viewer.js +192 -192
  20. package/dist/core/wiki/prompts.js +82 -82
  21. package/dist/mcp/core/embedder.js +8 -4
  22. package/dist/mcp/local/local-backend.d.ts +6 -0
  23. package/dist/mcp/local/local-backend.js +224 -117
  24. package/dist/mcp/resources.js +42 -42
  25. package/dist/mcp/server.js +16 -16
  26. package/dist/mcp/tools.js +86 -77
  27. package/dist/server/api.d.ts +4 -2
  28. package/dist/server/api.js +253 -83
  29. package/hooks/claude/gitnexus-hook.cjs +135 -135
  30. package/hooks/claude/pre-tool-use.sh +78 -78
  31. package/hooks/claude/session-start.sh +42 -42
  32. package/package.json +82 -82
  33. package/skills/debugging.md +85 -85
  34. package/skills/exploring.md +75 -75
  35. package/skills/impact-analysis.md +94 -94
  36. package/skills/refactoring.md +113 -113
  37. package/vendor/leiden/index.cjs +355 -355
  38. package/vendor/leiden/utils.cjs +392 -392
@@ -0,0 +1,542 @@
1
+ /**
2
+ * HTML Graph Viewer Generator
3
+ *
4
+ * Produces a self-contained graph.html that renders the knowledge graph
5
+ * using Sigma.js v2 + graphology (both from CDN).
6
+ *
7
+ * Critical: node `content` fields are stripped before embedding to prevent
8
+ * </script> injection from source code breaking the HTML parser.
9
+ */
10
+ // ─── CDN URLs ───────────────────────────────────────────────────────────
11
+ const CDN_GRAPHOLOGY = 'https://cdn.jsdelivr.net/npm/graphology@0.25/dist/graphology.umd.min.js';
12
+ const CDN_SIGMA = 'https://cdn.jsdelivr.net/npm/sigma@2/build/sigma.min.js';
13
+ const CDN_FA2 = 'https://cdn.jsdelivr.net/npm/graphology-layout-forceatlas2@0.10/dist/graphology-layout-forceatlas2.min.js';
14
+ // ─── Public API ─────────────────────────────────────────────────────────
15
+ /**
16
+ * Generate a self-contained HTML file that renders the knowledge graph.
17
+ * Strips large/unsafe fields from nodes before embedding.
18
+ */
19
+ export function generateHTMLGraphViewer(nodes, relationships, projectName) {
20
+ // Strip content + other large string fields to:
21
+ // 1) Prevent </script> injection (source code breaks HTML parsing)
22
+ // 2) Dramatically reduce output file size
23
+ const liteNodes = nodes.map(n => ({
24
+ id: n.id,
25
+ label: n.label,
26
+ properties: {
27
+ name: n.properties.name,
28
+ filePath: n.properties.filePath,
29
+ startLine: n.properties.startLine,
30
+ endLine: n.properties.endLine,
31
+ heuristicLabel: n.properties.heuristicLabel,
32
+ cohesion: n.properties.cohesion,
33
+ symbolCount: n.properties.symbolCount,
34
+ entryPointId: n.properties.entryPointId,
35
+ terminalId: n.properties.terminalId,
36
+ },
37
+ }));
38
+ const liteRelationships = relationships.map(r => ({
39
+ id: r.id,
40
+ type: r.type,
41
+ sourceId: r.sourceId,
42
+ targetId: r.targetId,
43
+ confidence: r.confidence,
44
+ step: r.step,
45
+ }));
46
+ // Escape </script> as a belt-and-suspenders safety measure
47
+ const graphData = JSON.stringify({ nodes: liteNodes, relationships: liteRelationships })
48
+ .replace(/<\/script>/gi, '<\\/script>');
49
+ const parts = [];
50
+ // ── Head ──
51
+ parts.push('<!DOCTYPE html>');
52
+ parts.push('<html lang="en">');
53
+ parts.push('<head>');
54
+ parts.push('<meta charset="UTF-8">');
55
+ parts.push('<meta name="viewport" content="width=device-width, initial-scale=1.0">');
56
+ parts.push('<title>' + esc(projectName) + ' \u2014 Knowledge Graph</title>');
57
+ parts.push('<style>');
58
+ parts.push(VIEWER_CSS);
59
+ parts.push('</style>');
60
+ parts.push('</head>');
61
+ // ── Body ──
62
+ parts.push('<body>');
63
+ parts.push('<div id="header">');
64
+ parts.push('<div id="header-left">');
65
+ parts.push('<span id="title">' + esc(projectName) + '</span>');
66
+ parts.push('<span id="stats"></span>');
67
+ parts.push('</div>');
68
+ parts.push('<div id="search-wrap">');
69
+ parts.push('<input id="search-input" type="search" placeholder="Search nodes\u2026" autocomplete="off">');
70
+ parts.push('<span id="search-count"></span>');
71
+ parts.push('</div>');
72
+ parts.push('<div id="legend"></div>');
73
+ parts.push('</div>');
74
+ parts.push('<div id="main">');
75
+ parts.push('<div id="sigma-container"></div>');
76
+ parts.push('<div id="info-panel" class="hidden">');
77
+ parts.push('<div id="info-header"><span id="info-name"></span><button id="info-close">\u00d7</button></div>');
78
+ parts.push('<div id="info-body"></div>');
79
+ parts.push('</div>');
80
+ parts.push('</div>');
81
+ parts.push('<div id="tooltip"></div>');
82
+ // ── Scripts ──
83
+ parts.push('<script src="' + CDN_GRAPHOLOGY + '"><\/script>');
84
+ parts.push('<script src="' + CDN_SIGMA + '"><\/script>');
85
+ parts.push('<script src="' + CDN_FA2 + '"><\/script>');
86
+ parts.push('<script>');
87
+ parts.push('window.GRAPH_DATA = ' + graphData + ';');
88
+ parts.push('window.PROJECT_NAME = ' + JSON.stringify(projectName) + ';');
89
+ parts.push(VIEWER_JS);
90
+ parts.push('<\/script>');
91
+ parts.push('</body>');
92
+ parts.push('</html>');
93
+ return parts.join('\n');
94
+ }
95
+ // ─── Helpers ────────────────────────────────────────────────────────────
96
+ function esc(text) {
97
+ return text
98
+ .replace(/&/g, '&amp;')
99
+ .replace(/</g, '&lt;')
100
+ .replace(/>/g, '&gt;')
101
+ .replace(/"/g, '&quot;');
102
+ }
103
+ // ─── Static Assets ──────────────────────────────────────────────────────
104
+ const VIEWER_CSS = `
105
+ *{margin:0;padding:0;box-sizing:border-box}
106
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0d0e17;color:#e2e8f0;display:flex;flex-direction:column;height:100vh;overflow:hidden}
107
+ #header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;background:#12131f;border-bottom:1px solid #1e2035;flex-shrink:0;gap:12px}
108
+ #header-left{display:flex;align-items:center;gap:12px;min-width:0}
109
+ #title{font-size:13px;font-weight:700;color:#a5b4fc;white-space:nowrap}
110
+ #stats{font-size:11px;color:#475569;white-space:nowrap}
111
+ #legend{display:flex;gap:8px;flex-wrap:wrap;flex:1;justify-content:flex-end}
112
+ .legend-item{display:flex;align-items:center;gap:4px;font-size:10px;color:#64748b;white-space:nowrap}
113
+ .legend-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
114
+ #main{flex:1;position:relative;overflow:hidden;display:flex}
115
+ #sigma-container{flex:1;position:relative}
116
+ #sigma-container canvas{display:block}
117
+ #info-panel{position:absolute;top:12px;right:12px;width:260px;background:#12131f;border:1px solid #1e2035;border-radius:8px;z-index:10;font-size:12px}
118
+ #info-panel.hidden{display:none}
119
+ #info-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-bottom:1px solid #1e2035}
120
+ #info-name{font-weight:600;color:#c4b5fd;font-size:13px;word-break:break-all}
121
+ #info-close{background:none;border:none;color:#475569;cursor:pointer;font-size:16px;line-height:1;padding:0 2px}
122
+ #info-close:hover{color:#e2e8f0}
123
+ #info-body{padding:12px;display:flex;flex-direction:column;gap:6px}
124
+ .info-row{display:flex;flex-direction:column;gap:2px}
125
+ .info-label{font-size:10px;color:#475569;text-transform:uppercase;letter-spacing:.4px}
126
+ .info-value{color:#94a3b8;word-break:break-all;font-size:11px}
127
+ .info-badge{display:inline-block;padding:2px 6px;border-radius:4px;font-size:10px;font-weight:600;color:#0d0e17}
128
+ #tooltip{position:fixed;pointer-events:none;background:#12131f;border:1px solid #1e2035;border-radius:6px;padding:6px 10px;font-size:11px;color:#94a3b8;z-index:100;display:none;max-width:260px;line-height:1.6}
129
+ #tooltip strong{color:#c4b5fd;font-size:12px;display:block}
130
+ #search-wrap{display:flex;align-items:center;gap:6px;flex:0 0 auto}
131
+ #search-input{background:#0d0e17;border:1px solid #1e2035;border-radius:6px;color:#e2e8f0;font-size:11px;font-family:inherit;padding:4px 8px;width:200px;outline:none;transition:border-color .15s}
132
+ #search-input:focus{border-color:#6366f1}
133
+ #search-input::-webkit-search-cancel-button{cursor:pointer}
134
+ #search-count{font-size:10px;color:#6366f1;white-space:nowrap;min-width:60px}
135
+ `;
136
+ // The client-side JS is kept as a plain string to avoid template literal conflicts
137
+ const VIEWER_JS = `
138
+ (function() {
139
+
140
+ // ── Color + size tables (mirrored from gitnexus-web) ──────────────────────
141
+ var NODE_COLORS = {
142
+ Project:'#a855f7', Package:'#8b5cf6', Module:'#7c3aed', Folder:'#6366f1',
143
+ File:'#3b82f6', Class:'#f59e0b', Function:'#10b981', Method:'#14b8a6',
144
+ Variable:'#64748b', Interface:'#ec4899', Enum:'#f97316', Decorator:'#eab308',
145
+ Import:'#475569', Type:'#a78bfa', CodeElement:'#64748b',
146
+ Community:'#818cf8', Process:'#f43f5e',
147
+ };
148
+ var NODE_SIZES = {
149
+ Project:20, Package:16, Module:13, Folder:10, File:6,
150
+ Class:8, Function:4, Method:3, Variable:2, Interface:7, Enum:5,
151
+ Decorator:2, Import:1.5, Type:3, CodeElement:2, Community:0, Process:0,
152
+ };
153
+ var EDGE_COLORS = {
154
+ CONTAINS:'#2d5a3d', DEFINES:'#0e7490', IMPORTS:'#1d4ed8',
155
+ CALLS:'#7c3aed', EXTENDS:'#c2410c', IMPLEMENTS:'#be185d',
156
+ };
157
+ var COMMUNITY_COLORS = [
158
+ '#ef4444','#f97316','#eab308','#22c55e','#06b6d4','#3b82f6',
159
+ '#8b5cf6','#d946ef','#ec4899','#f43f5e','#14b8a6','#84cc16',
160
+ ];
161
+
162
+ // Types visible by default (hide noisy Import/Variable/Decorator/CodeElement)
163
+ var VISIBLE_LABELS = {
164
+ Project:1, Package:1, Module:1, Folder:1, File:1,
165
+ Class:1, Function:1, Method:1, Interface:1, Enum:1, Type:1,
166
+ };
167
+
168
+ function getCommunityColor(idx) {
169
+ return COMMUNITY_COLORS[idx % COMMUNITY_COLORS.length];
170
+ }
171
+
172
+ // Scale node sizes for large graphs
173
+ function getScaledSize(base, nodeCount) {
174
+ if (nodeCount > 20000) return Math.max(1, base * 0.5);
175
+ if (nodeCount > 5000) return Math.max(1.5, base * 0.65);
176
+ if (nodeCount > 1000) return Math.max(2, base * 0.8);
177
+ return base;
178
+ }
179
+
180
+ // ── Graph building ────────────────────────────────────────────────────────
181
+
182
+ document.addEventListener('DOMContentLoaded', function() {
183
+ var data = window.GRAPH_DATA;
184
+ var container = document.getElementById('sigma-container');
185
+
186
+ // Stats header
187
+ var visible = data.nodes.filter(function(n) { return VISIBLE_LABELS[n.label]; });
188
+ document.getElementById('stats').textContent =
189
+ data.nodes.length + ' nodes \u00b7 ' + data.relationships.length + ' edges';
190
+
191
+ // Build legend
192
+ var shownTypes = {};
193
+ data.nodes.forEach(function(n) { if (VISIBLE_LABELS[n.label]) shownTypes[n.label] = true; });
194
+ var legendEl = document.getElementById('legend');
195
+ Object.keys(NODE_COLORS).forEach(function(label) {
196
+ if (!shownTypes[label]) return;
197
+ var item = document.createElement('div');
198
+ item.className = 'legend-item';
199
+ var dot = document.createElement('div');
200
+ dot.className = 'legend-dot';
201
+ dot.style.background = NODE_COLORS[label];
202
+ item.appendChild(dot);
203
+ item.appendChild(document.createTextNode(label));
204
+ legendEl.appendChild(item);
205
+ });
206
+
207
+ // ── Build graphology graph ────────────────────────────────────────────
208
+
209
+ var Graph = graphology.Graph || graphology;
210
+ var graph = new Graph({ type: 'directed', multi: false });
211
+ var nodeCount = data.nodes.length;
212
+
213
+ // Build parent maps from structural relationships
214
+ var parentOf = {}; // childId -> parentId
215
+ var childrenOf = {}; // parentId -> [childId]
216
+ var HIERARCHY = { CONTAINS:1, DEFINES:1, IMPORTS:1 };
217
+ data.relationships.forEach(function(r) {
218
+ if (HIERARCHY[r.type]) {
219
+ parentOf[r.targetId] = r.sourceId;
220
+ if (!childrenOf[r.sourceId]) childrenOf[r.sourceId] = [];
221
+ childrenOf[r.sourceId].push(r.targetId);
222
+ }
223
+ });
224
+
225
+ // Build community membership from MEMBER_OF relationships
226
+ var communityOf = {}; // nodeId -> communityIndex
227
+ var communityNodes = {};
228
+ data.nodes.forEach(function(n) {
229
+ if (n.label === 'Community') communityNodes[n.id] = true;
230
+ });
231
+ var memberIdx = {};
232
+ var communityCounter = 0;
233
+ data.relationships.forEach(function(r) {
234
+ if (r.type === 'MEMBER_OF' && communityNodes[r.targetId]) {
235
+ if (memberIdx[r.targetId] === undefined) {
236
+ memberIdx[r.targetId] = communityCounter++;
237
+ }
238
+ communityOf[r.sourceId] = memberIdx[r.targetId];
239
+ }
240
+ });
241
+
242
+ // Spread multiplier
243
+ var spread = Math.sqrt(nodeCount) * 40;
244
+ var childJitter = Math.sqrt(nodeCount) * 3;
245
+ var clusterJitter = Math.sqrt(nodeCount) * 1.5;
246
+ var GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
247
+
248
+ // Cluster centers
249
+ var clusterCenters = {};
250
+ var uniqueCommunities = {};
251
+ Object.values(communityOf).forEach(function(ci) { uniqueCommunities[ci] = true; });
252
+ var communityList = Object.keys(uniqueCommunities).map(Number);
253
+ communityList.forEach(function(ci, idx) {
254
+ var angle = idx * GOLDEN_ANGLE;
255
+ var radius = spread * 0.8 * Math.sqrt((idx + 1) / Math.max(communityList.length, 1));
256
+ clusterCenters[ci] = { x: radius * Math.cos(angle), y: radius * Math.sin(angle) };
257
+ });
258
+
259
+ // Node positions cache
260
+ var posMap = {};
261
+
262
+ // Structural types positioned first, wide radial
263
+ var STRUCTURAL = { Project:1, Package:1, Module:1, Folder:1 };
264
+ var structural = data.nodes.filter(function(n) { return STRUCTURAL[n.label]; });
265
+
266
+ structural.forEach(function(node, idx) {
267
+ var angle = idx * GOLDEN_ANGLE;
268
+ var radius = spread * Math.sqrt((idx + 1) / Math.max(structural.length, 1));
269
+ var jitter = spread * 0.15;
270
+ var x = radius * Math.cos(angle) + (Math.random() - 0.5) * jitter;
271
+ var y = radius * Math.sin(angle) + (Math.random() - 0.5) * jitter;
272
+ posMap[node.id] = { x: x, y: y };
273
+ });
274
+
275
+ function addNode(node) {
276
+ if (graph.hasNode(node.id)) return;
277
+ if (!VISIBLE_LABELS[node.label]) return; // skip Community, Process, Import by default
278
+
279
+ var x, y;
280
+ var ci = communityOf[node.id];
281
+ var SYMBOLS = { Function:1, Class:1, Method:1, Interface:1 };
282
+
283
+ if (ci !== undefined && SYMBOLS[node.label] && clusterCenters[ci]) {
284
+ x = clusterCenters[ci].x + (Math.random() - 0.5) * clusterJitter;
285
+ y = clusterCenters[ci].y + (Math.random() - 0.5) * clusterJitter;
286
+ } else {
287
+ var parentPos = posMap[parentOf[node.id]];
288
+ if (parentPos) {
289
+ x = parentPos.x + (Math.random() - 0.5) * childJitter;
290
+ y = parentPos.y + (Math.random() - 0.5) * childJitter;
291
+ } else {
292
+ x = (Math.random() - 0.5) * spread * 0.5;
293
+ y = (Math.random() - 0.5) * spread * 0.5;
294
+ }
295
+ }
296
+
297
+ posMap[node.id] = { x: x, y: y };
298
+
299
+ var baseSize = NODE_SIZES[node.label] || 4;
300
+ var size = getScaledSize(baseSize, nodeCount);
301
+ var color = (ci !== undefined) ? getCommunityColor(ci) : (NODE_COLORS[node.label] || '#94a3b8');
302
+
303
+ graph.addNode(node.id, {
304
+ x: x, y: y, size: size, color: color,
305
+ label: node.properties.name || node.id,
306
+ nodeType: node.label,
307
+ filePath: node.properties.filePath,
308
+ startLine: node.properties.startLine,
309
+ endLine: node.properties.endLine,
310
+ communityColor: ci !== undefined ? getCommunityColor(ci) : null,
311
+ });
312
+ }
313
+
314
+ // BFS from structural nodes (parents before children)
315
+ var queue = structural.map(function(n) { return n.id; });
316
+ var visited = {};
317
+ queue.forEach(function(id) { visited[id] = true; addNode(data.nodes.find(function(n) { return n.id === id; })); });
318
+
319
+ while (queue.length) {
320
+ var cur = queue.shift();
321
+ var kids = childrenOf[cur] || [];
322
+ kids.forEach(function(kidId) {
323
+ if (!visited[kidId]) {
324
+ visited[kidId] = true;
325
+ var kidNode = data.nodes.find(function(n) { return n.id === kidId; });
326
+ if (kidNode) { addNode(kidNode); queue.push(kidId); }
327
+ }
328
+ });
329
+ }
330
+
331
+ // Orphans
332
+ data.nodes.forEach(function(n) {
333
+ if (!visited[n.id]) { visited[n.id] = true; addNode(n); }
334
+ });
335
+
336
+ // Add edges
337
+ var edgeBaseSize = nodeCount > 20000 ? 0.4 : nodeCount > 5000 ? 0.6 : 0.8;
338
+ data.relationships.forEach(function(r) {
339
+ if (!graph.hasNode(r.sourceId) || !graph.hasNode(r.targetId)) return;
340
+ if (graph.hasEdge(r.sourceId, r.targetId)) return;
341
+ var color = EDGE_COLORS[r.type] || '#2a2a3a';
342
+ var sizeM = (r.type === 'CALLS') ? 0.8 : (r.type === 'EXTENDS' || r.type === 'IMPLEMENTS') ? 1.0 : 0.5;
343
+ try {
344
+ graph.addEdge(r.sourceId, r.targetId, {
345
+ size: edgeBaseSize * sizeM,
346
+ color: color,
347
+ relationType: r.type,
348
+ });
349
+ } catch(e) {}
350
+ });
351
+
352
+ // ── Degree-based node sizing ──────────────────────────────────────────
353
+ graph.forEachNode(function(node) {
354
+ var deg = graph.degree(node);
355
+ var cur = graph.getNodeAttribute(node, 'size');
356
+ var bonus = Math.min(deg * 0.15, cur * 1.5);
357
+ graph.setNodeAttribute(node, 'size', cur + bonus);
358
+ });
359
+
360
+ // ── ForceAtlas2 layout ────────────────────────────────────────────────
361
+ var FA2Layout = typeof graphologyLayoutForceatlas2 !== 'undefined' ? graphologyLayoutForceatlas2 : null;
362
+ if (FA2Layout && FA2Layout.assign) {
363
+ try {
364
+ FA2Layout.assign(graph, {
365
+ iterations: nodeCount > 1000 ? 100 : 200,
366
+ settings: {
367
+ gravity: 1,
368
+ scalingRatio: 2,
369
+ barnesHutOptimize: nodeCount > 500,
370
+ },
371
+ });
372
+ } catch(e) { console.warn('FA2 layout failed:', e); }
373
+ }
374
+
375
+ // ── Mount Sigma ────────────────────────────────────────────────────────
376
+
377
+ var SigmaClass = typeof Sigma !== 'undefined' ? Sigma : window.Sigma;
378
+ if (!SigmaClass) {
379
+ container.innerHTML = '<div style="color:#ef4444;padding:40px;text-align:center">Sigma.js failed to load from CDN.<br>Check your internet connection.</div>';
380
+ return;
381
+ }
382
+
383
+ var sigma = new SigmaClass(graph, container, {
384
+ renderLabels: true,
385
+ labelFont: 'monospace',
386
+ labelSize: 10,
387
+ labelWeight: '400',
388
+ labelColor: { color: '#94a3b8' },
389
+ labelRenderedSizeThreshold: 6,
390
+ labelDensity: 0.07,
391
+ labelGridCellSize: 80,
392
+ defaultNodeColor: '#6b7280',
393
+ defaultEdgeColor: '#1e2035',
394
+ minCameraRatio: 0.005,
395
+ maxCameraRatio: 50,
396
+ hideEdgesOnMove: true,
397
+ zIndex: true,
398
+ });
399
+
400
+ // ── Search index ───────────────────────────────────────────────────────
401
+ // Build search index from graphology graph (visible nodes only)
402
+ var searchIndex = {};
403
+ graph.forEachNode(function(nodeId, attrs) {
404
+ var terms = (attrs.label || '').toLowerCase();
405
+ if (attrs.filePath) terms += ' ' + attrs.filePath.toLowerCase();
406
+ searchIndex[nodeId] = terms;
407
+ });
408
+
409
+ // ── Hover tooltip ──────────────────────────────────────────────────────
410
+
411
+ var tooltip = document.getElementById('tooltip');
412
+
413
+ sigma.on('enterNode', function(e) {
414
+ var attrs = graph.getNodeAttributes(e.node);
415
+ var html = '<strong>' + (attrs.label || e.node).replace(/</g, '&lt;') + '</strong>';
416
+ if (attrs.nodeType) html += '<span style="color:#475569">' + attrs.nodeType + '</span>';
417
+ if (attrs.filePath) html += '<span style="color:#64748b;font-size:10px;display:block">' + attrs.filePath + '</span>';
418
+ tooltip.innerHTML = html;
419
+ tooltip.style.display = 'block';
420
+ });
421
+
422
+ sigma.on('leaveNode', function() { tooltip.style.display = 'none'; });
423
+
424
+ container.addEventListener('mousemove', function(e) {
425
+ tooltip.style.left = (e.clientX + 14) + 'px';
426
+ tooltip.style.top = (e.clientY - 8) + 'px';
427
+ });
428
+
429
+ // ── Node info panel + neighbor highlighting ────────────────────────────
430
+
431
+ var panel = document.getElementById('info-panel');
432
+ var infoName = document.getElementById('info-name');
433
+ var infoBody = document.getElementById('info-body');
434
+ var highlightedNode = null;
435
+ var highlightedNeighbors = {};
436
+
437
+ function row(label, value) {
438
+ if (!value && value !== 0) return '';
439
+ return '<div class="info-row"><div class="info-label">' + label + '</div>' +
440
+ '<div class="info-value">' + String(value).replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</div></div>';
441
+ }
442
+
443
+ function clearHighlight() {
444
+ highlightedNode = null;
445
+ highlightedNeighbors = {};
446
+ sigma.setSetting('nodeReducer', null);
447
+ sigma.setSetting('edgeReducer', null);
448
+ }
449
+
450
+ // ── Search ─────────────────────────────────────────────────────────────
451
+ var searchActive = false;
452
+ var searchMatches = {};
453
+
454
+ function applySearch(query) {
455
+ var q = query.trim().toLowerCase();
456
+ if (!q) {
457
+ searchActive = false;
458
+ searchMatches = {};
459
+ sigma.setSetting('nodeReducer', null);
460
+ sigma.setSetting('edgeReducer', null);
461
+ document.getElementById('search-count').textContent = '';
462
+ return;
463
+ }
464
+ searchActive = true;
465
+ searchMatches = {};
466
+ graph.forEachNode(function(nodeId) {
467
+ if (searchIndex[nodeId] && searchIndex[nodeId].indexOf(q) !== -1) {
468
+ searchMatches[nodeId] = true;
469
+ }
470
+ });
471
+ var n = Object.keys(searchMatches).length;
472
+ document.getElementById('search-count').textContent =
473
+ n === 0 ? 'no matches' : n === 1 ? '1 match' : n + ' matches';
474
+ sigma.setSetting('nodeReducer', function(node, data) {
475
+ if (searchMatches[node]) return Object.assign({}, data, { zIndex: 1 });
476
+ return Object.assign({}, data, { color: '#1a1b2e', label: null, zIndex: 0 });
477
+ });
478
+ sigma.setSetting('edgeReducer', function(edge, data) {
479
+ if (searchMatches[graph.source(edge)] || searchMatches[graph.target(edge)]) return data;
480
+ return Object.assign({}, data, { color: '#151520' });
481
+ });
482
+ }
483
+
484
+ document.getElementById('search-input').addEventListener('input', function(e) {
485
+ if (e.target.value.trim()) {
486
+ // Clear click-highlight when search activates
487
+ highlightedNode = null;
488
+ highlightedNeighbors = {};
489
+ panel.classList.add('hidden');
490
+ }
491
+ applySearch(e.target.value);
492
+ });
493
+
494
+ sigma.on('clickNode', function(e) {
495
+ // Clear search when click-highlight activates
496
+ if (searchActive) {
497
+ searchActive = false;
498
+ searchMatches = {};
499
+ document.getElementById('search-input').value = '';
500
+ document.getElementById('search-count').textContent = '';
501
+ }
502
+ highlightedNode = e.node;
503
+ highlightedNeighbors = {};
504
+ graph.neighbors(e.node).forEach(function(n) { highlightedNeighbors[n] = true; });
505
+
506
+ sigma.setSetting('nodeReducer', function(node, data) {
507
+ if (node === highlightedNode || highlightedNeighbors[node]) return data;
508
+ return Object.assign({}, data, { color: '#1a1b2e', label: null, zIndex: 0 });
509
+ });
510
+
511
+ sigma.setSetting('edgeReducer', function(edge, data) {
512
+ var src = graph.source(edge);
513
+ var tgt = graph.target(edge);
514
+ if (src === highlightedNode || tgt === highlightedNode) return data;
515
+ return Object.assign({}, data, { color: '#151520' });
516
+ });
517
+
518
+ var attrs = graph.getNodeAttributes(e.node);
519
+ infoName.innerHTML = '<span class="info-badge" style="background:' + (attrs.color||'#6366f1') + '">' +
520
+ (attrs.nodeType || '') + '</span> ' +
521
+ (attrs.label || e.node).replace(/</g,'&lt;');
522
+ var html = '';
523
+ html += row('File', attrs.filePath);
524
+ if (attrs.startLine) html += row('Lines', attrs.startLine + (attrs.endLine ? '\u2013' + attrs.endLine : ''));
525
+ html += row('Connections', graph.degree(e.node));
526
+ panel.classList.remove('hidden');
527
+ infoBody.innerHTML = html;
528
+ });
529
+
530
+ sigma.on('clickStage', function() {
531
+ clearHighlight();
532
+ panel.classList.add('hidden');
533
+ });
534
+
535
+ document.getElementById('info-close').addEventListener('click', function() {
536
+ clearHighlight();
537
+ panel.classList.add('hidden');
538
+ });
539
+ });
540
+
541
+ })();
542
+ `;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateHTMLGraphViewer } from './html-graph-viewer.js';
3
+ const nodes = [
4
+ { id: 'fn_a', label: 'Function', properties: { name: 'doSomething', filePath: 'src/a.ts' } },
5
+ { id: 'fn_b', label: 'Function', properties: { name: 'doOther', filePath: 'src/b.ts' } },
6
+ ];
7
+ const relationships = [
8
+ { id: 'fn_a_CALLS_fn_b', type: 'CALLS', sourceId: 'fn_a', targetId: 'fn_b' }
9
+ ];
10
+ describe('generateHTMLGraphViewer', () => {
11
+ it('returns a non-empty HTML string', () => {
12
+ const html = generateHTMLGraphViewer(nodes, relationships, 'TestProject');
13
+ expect(typeof html).toBe('string');
14
+ expect(html.length).toBeGreaterThan(100);
15
+ expect(html).toContain('<!DOCTYPE html>');
16
+ });
17
+ it('embeds all node ids in GRAPH_DATA', () => {
18
+ const html = generateHTMLGraphViewer(nodes, relationships, 'TestProject');
19
+ expect(html).toContain('fn_a');
20
+ expect(html).toContain('fn_b');
21
+ });
22
+ it('embeds relationship data', () => {
23
+ const html = generateHTMLGraphViewer(nodes, relationships, 'TestProject');
24
+ expect(html).toContain('CALLS');
25
+ });
26
+ it('includes the project name in the title', () => {
27
+ const html = generateHTMLGraphViewer(nodes, relationships, 'MyRepo');
28
+ expect(html).toContain('MyRepo');
29
+ });
30
+ describe('search feature', () => {
31
+ it('includes search input, wrap, and count elements', () => {
32
+ const html = generateHTMLGraphViewer(nodes, relationships, 'TestProject');
33
+ expect(html).toContain('id="search-input"');
34
+ expect(html).toContain('id="search-wrap"');
35
+ expect(html).toContain('id="search-count"');
36
+ });
37
+ it('places search-wrap between header-left and legend', () => {
38
+ const html = generateHTMLGraphViewer(nodes, relationships, 'TestProject');
39
+ const wrapPos = html.indexOf('id="search-wrap"');
40
+ const legendPos = html.indexOf('id="legend"');
41
+ const headerLeftPos = html.indexOf('id="header-left"');
42
+ expect(wrapPos).toBeGreaterThan(headerLeftPos);
43
+ expect(legendPos).toBeGreaterThan(wrapPos);
44
+ });
45
+ it('uses type="search" for native clear button', () => {
46
+ const html = generateHTMLGraphViewer(nodes, relationships, 'TestProject');
47
+ expect(html).toContain('type="search"');
48
+ });
49
+ it('includes applySearch function and search state variables', () => {
50
+ const html = generateHTMLGraphViewer(nodes, relationships, 'TestProject');
51
+ expect(html).toContain('applySearch');
52
+ expect(html).toContain('searchMatches');
53
+ expect(html).toContain('searchIndex');
54
+ expect(html).toContain('searchActive');
55
+ });
56
+ it('wires input event listener to search-input', () => {
57
+ const html = generateHTMLGraphViewer(nodes, relationships, 'TestProject');
58
+ expect(html).toContain("getElementById('search-input')");
59
+ expect(html).toContain("addEventListener('input'");
60
+ });
61
+ it('clickNode handler clears search state', () => {
62
+ const html = generateHTMLGraphViewer(nodes, relationships, 'TestProject');
63
+ // The searchActive guard must appear in the clickNode context
64
+ expect(html).toContain('searchActive');
65
+ });
66
+ });
67
+ });
@@ -13,12 +13,12 @@ const buildEnrichmentPrompt = (members, heuristicLabel) => {
13
13
  const memberList = limitedMembers
14
14
  .map(m => `${m.name} (${m.type})`)
15
15
  .join(', ');
16
- return `Analyze this code cluster and provide a semantic name and short description.
17
-
18
- Heuristic: "${heuristicLabel}"
19
- Members: ${memberList}${members.length > 20 ? ` (+${members.length - 20} more)` : ''}
20
-
21
- Reply with JSON only:
16
+ return `Analyze this code cluster and provide a semantic name and short description.
17
+
18
+ Heuristic: "${heuristicLabel}"
19
+ Members: ${memberList}${members.length > 20 ? ` (+${members.length - 20} more)` : ''}
20
+
21
+ Reply with JSON only:
22
22
  {"name": "2-4 word semantic name", "description": "One sentence describing purpose"}`;
23
23
  };
24
24
  // ============================================================================
@@ -115,18 +115,18 @@ export const enrichClustersBatch = async (communities, memberMap, llmClient, bat
115
115
  const memberList = limitedMembers
116
116
  .map(m => `${m.name} (${m.type})`)
117
117
  .join(', ');
118
- return `Cluster ${idx + 1} (id: ${community.id}):
119
- Heuristic: "${community.heuristicLabel}"
118
+ return `Cluster ${idx + 1} (id: ${community.id}):
119
+ Heuristic: "${community.heuristicLabel}"
120
120
  Members: ${memberList}`;
121
121
  }).join('\n\n');
122
- const prompt = `Analyze these code clusters and generate semantic names, keywords, and descriptions.
123
-
124
- ${batchPrompt}
125
-
126
- Output JSON array:
127
- [
128
- {"id": "comm_X", "name": "...", "keywords": [...], "description": "..."},
129
- ...
122
+ const prompt = `Analyze these code clusters and generate semantic names, keywords, and descriptions.
123
+
124
+ ${batchPrompt}
125
+
126
+ Output JSON array:
127
+ [
128
+ {"id": "comm_X", "name": "...", "keywords": [...], "description": "..."},
129
+ ...
130
130
  ]`;
131
131
  try {
132
132
  const response = await llmClient.generate(prompt);