archgraph 0.1.0

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 (35) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +123 -0
  3. package/bin/bgx.js +2 -0
  4. package/docs/examples/executors.example.json +15 -0
  5. package/docs/examples/view-spec.yaml +6 -0
  6. package/docs/release.md +15 -0
  7. package/docs/view-spec.md +28 -0
  8. package/integrations/README.md +20 -0
  9. package/integrations/claude/.claude/skills/backend-graphing/SKILL.md +56 -0
  10. package/integrations/claude/.claude/skills/backend-graphing-describe/SKILL.md +50 -0
  11. package/integrations/claude/.claude-plugin/marketplace.json +18 -0
  12. package/integrations/claude/.claude-plugin/plugin.json +9 -0
  13. package/integrations/claude/skills/backend-graphing/SKILL.md +56 -0
  14. package/integrations/claude/skills/backend-graphing-describe/SKILL.md +50 -0
  15. package/integrations/codex/skills/backend-graphing/SKILL.md +56 -0
  16. package/integrations/codex/skills/backend-graphing-describe/SKILL.md +50 -0
  17. package/package.json +49 -0
  18. package/packages/cli/src/index.js +415 -0
  19. package/packages/core/src/analyze-project.js +1238 -0
  20. package/packages/core/src/export.js +77 -0
  21. package/packages/core/src/index.js +4 -0
  22. package/packages/core/src/types.js +37 -0
  23. package/packages/core/src/view.js +86 -0
  24. package/packages/viewer/public/app.js +226 -0
  25. package/packages/viewer/public/canvas.js +181 -0
  26. package/packages/viewer/public/comments.js +193 -0
  27. package/packages/viewer/public/index.html +95 -0
  28. package/packages/viewer/public/layout.js +72 -0
  29. package/packages/viewer/public/minimap.js +92 -0
  30. package/packages/viewer/public/render.js +366 -0
  31. package/packages/viewer/public/sidebar.js +107 -0
  32. package/packages/viewer/public/styles.css +728 -0
  33. package/packages/viewer/public/theme.js +19 -0
  34. package/packages/viewer/public/tooltip.js +44 -0
  35. package/packages/viewer/src/index.js +590 -0
@@ -0,0 +1,366 @@
1
+ // render.js — Graph rendering, comment pins, node repositioning
2
+
3
+ import { layoutDag } from './layout.js';
4
+ import { nodePositions, canvasTransform } from './canvas.js';
5
+ import { showTooltip, moveTooltip, hideTooltip } from './tooltip.js';
6
+ import { openCommentOverlay, getMostUrgentStatus } from './comments.js';
7
+
8
+ export const collapsedGroups = new Set(); // group IDs
9
+ export const groupRoots = new Map(); // groupId → rootNodeId
10
+
11
+ let canvasGroup = null;
12
+ let graphSvg = null;
13
+ let viewRef = null;
14
+ let currentTasks = [];
15
+ let onSelectNode = null; // callback(nodeId)
16
+ let selectedNodeId = null;
17
+
18
+ export function initRender(svgEl, groupEl, { onSelect }) {
19
+ graphSvg = svgEl;
20
+ canvasGroup = groupEl;
21
+ onSelectNode = onSelect;
22
+ }
23
+
24
+ export function setRenderView(view) {
25
+ viewRef = view;
26
+ computeGroupRoots(view);
27
+ }
28
+
29
+ export function setSelectedNode(nodeId) {
30
+ selectedNodeId = nodeId;
31
+ }
32
+
33
+ export function setCurrentTasks(tasks) {
34
+ currentTasks = tasks;
35
+ }
36
+
37
+ function computeGroupRoots(view) {
38
+ groupRoots.clear();
39
+ for (const group of view.groups ?? []) {
40
+ // Find the node within the group that has the most incoming edges from outside the group
41
+ // Simplest heuristic: the endpoint node (kind === 'endpoint' or 'ui_route')
42
+ const memberSet = new Set(group.memberNodeIds);
43
+ let root = null;
44
+ for (const nodeId of group.memberNodeIds) {
45
+ const node = view.nodes.find((n) => n.id === nodeId);
46
+ if (node && (node.kind === 'endpoint' || node.kind === 'ui_route')) {
47
+ root = nodeId;
48
+ break;
49
+ }
50
+ }
51
+ // Fallback: node with no incoming edges from within the group
52
+ if (!root) {
53
+ const internalEdges = new Set(
54
+ view.edges.filter((e) => memberSet.has(e.from) && memberSet.has(e.to)).map((e) => e.to),
55
+ );
56
+ for (const nodeId of group.memberNodeIds) {
57
+ if (!internalEdges.has(nodeId)) {
58
+ root = nodeId;
59
+ break;
60
+ }
61
+ }
62
+ }
63
+ if (!root && group.memberNodeIds.length > 0) {
64
+ root = group.memberNodeIds[0];
65
+ }
66
+ if (root) groupRoots.set(group.id, root);
67
+ }
68
+ }
69
+
70
+ // Returns { nodes, edges } with collapse logic applied
71
+ export function applyCollapse(nodes, edges) {
72
+ if (collapsedGroups.size === 0) return { nodes, edges };
73
+
74
+ const hiddenNodes = new Set();
75
+ for (const groupId of collapsedGroups) {
76
+ const group = viewRef?.groups?.find((g) => g.id === groupId);
77
+ if (!group) continue;
78
+ const root = groupRoots.get(groupId);
79
+ for (const memberId of group.memberNodeIds) {
80
+ if (memberId !== root) hiddenNodes.add(memberId);
81
+ }
82
+ }
83
+
84
+ const visibleNodes = nodes.filter((n) => !hiddenNodes.has(n.id));
85
+ const visibleIds = new Set(visibleNodes.map((n) => n.id));
86
+
87
+ // Remap edges: if an edge goes to a hidden node, redirect to the group root
88
+ const remappedEdges = [];
89
+ for (const edge of edges) {
90
+ const fromVisible = visibleIds.has(edge.from);
91
+ const toVisible = visibleIds.has(edge.to);
92
+ if (fromVisible && toVisible) {
93
+ remappedEdges.push(edge);
94
+ } else if (fromVisible && !toVisible) {
95
+ // Find which collapsed group contains edge.to
96
+ for (const groupId of collapsedGroups) {
97
+ const group = viewRef?.groups?.find((g) => g.id === groupId);
98
+ if (group?.memberNodeIds.includes(edge.to)) {
99
+ const root = groupRoots.get(groupId);
100
+ if (root && visibleIds.has(root) && edge.from !== root) {
101
+ remappedEdges.push({ ...edge, to: root });
102
+ }
103
+ break;
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ return { nodes: visibleNodes, edges: remappedEdges };
110
+ }
111
+
112
+ export function renderGraph(nodes, edges) {
113
+ // Clear canvas group content (but keep defs)
114
+ const existingDefs = canvasGroup.querySelector('defs');
115
+ canvasGroup.innerHTML = '';
116
+
117
+ // Add defs with arrowhead marker
118
+ const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
119
+ defs.innerHTML = `
120
+ <marker id="arrow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
121
+ <polygon points="0 0, 10 3.5, 0 7" fill="#51627f" />
122
+ </marker>
123
+ <marker id="arrow-cond" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
124
+ <polygon points="0 0, 10 3.5, 0 7" fill="#b45309" />
125
+ </marker>
126
+ `;
127
+ canvasGroup.appendChild(defs);
128
+
129
+ if (!nodes.length) return;
130
+
131
+ // Layout: only compute positions for nodes not already in nodePositions
132
+ const newNodes = nodes.filter((n) => !nodePositions.has(n.id));
133
+ if (newNodes.length > 0) {
134
+ const layoutPoints = layoutDag(nodes, edges);
135
+ for (const [id, pos] of layoutPoints) {
136
+ if (!nodePositions.has(id)) {
137
+ nodePositions.set(id, pos);
138
+ }
139
+ }
140
+ }
141
+
142
+ // Render edges
143
+ for (const edge of edges) {
144
+ const from = nodePositions.get(edge.from);
145
+ const to = nodePositions.get(edge.to);
146
+ if (!from || !to) continue;
147
+
148
+ const cls = edgeClass(edge.kind);
149
+ const isCondition = cls === 'edge-condition';
150
+
151
+ // Draw curved path instead of straight line for nicer look
152
+ const x1 = from.x + 90;
153
+ const y1 = from.y;
154
+ const x2 = to.x - 90;
155
+ const y2 = to.y;
156
+ const mx = (x1 + x2) / 2;
157
+
158
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
159
+ path.setAttribute('d', `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`);
160
+ path.setAttribute('class', `edge ${cls}`);
161
+ path.setAttribute('fill', 'none');
162
+ path.setAttribute('marker-end', isCondition ? 'url(#arrow-cond)' : 'url(#arrow)');
163
+ path.setAttribute('data-from', edge.from);
164
+ path.setAttribute('data-to', edge.to);
165
+ canvasGroup.appendChild(path);
166
+
167
+ if (edge.label) {
168
+ const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
169
+ label.setAttribute('x', String(mx));
170
+ label.setAttribute('y', String((y1 + y2) / 2 - 6));
171
+ label.setAttribute('class', 'edge-label');
172
+ label.setAttribute('text-anchor', 'middle');
173
+ label.textContent = edge.label;
174
+ canvasGroup.appendChild(label);
175
+ }
176
+ }
177
+
178
+ // Render nodes
179
+ for (const node of nodes) {
180
+ const pos = nodePositions.get(node.id);
181
+ if (!pos) continue;
182
+
183
+ const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
184
+ group.setAttribute('class', `node ${nodeClass(node.kind)} ${selectedNodeId === node.id ? 'selected' : ''}`);
185
+ group.setAttribute('transform', `translate(${pos.x},${pos.y})`);
186
+ group.dataset.nodeId = node.id;
187
+
188
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
189
+ rect.setAttribute('x', '-90');
190
+ rect.setAttribute('y', '-28');
191
+ rect.setAttribute('width', '180');
192
+ rect.setAttribute('height', '56');
193
+ rect.setAttribute('rx', '10');
194
+ group.appendChild(rect);
195
+
196
+ const kindLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
197
+ kindLabel.setAttribute('x', '-78');
198
+ kindLabel.setAttribute('y', '-8');
199
+ kindLabel.setAttribute('class', 'node-kind');
200
+ kindLabel.textContent = node.kind;
201
+ group.appendChild(kindLabel);
202
+
203
+ const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
204
+ label.setAttribute('x', '-78');
205
+ label.setAttribute('y', '14');
206
+ label.setAttribute('class', 'node-label');
207
+ label.textContent = node.label.length > 30 ? `${node.label.slice(0, 27)}…` : node.label;
208
+ group.appendChild(label);
209
+
210
+ // Collapse toggle for endpoint/agent groups
211
+ const ownsGroup = findNodeGroup(node.id);
212
+ if (ownsGroup && ownsGroup.memberNodeIds.length > 1) {
213
+ const toggle = document.createElementNS('http://www.w3.org/2000/svg', 'g');
214
+ toggle.setAttribute('class', 'collapse-toggle');
215
+ toggle.dataset.groupId = ownsGroup.id;
216
+
217
+ const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
218
+ circle.setAttribute('cx', '74');
219
+ circle.setAttribute('cy', '-20');
220
+ circle.setAttribute('r', '9');
221
+ toggle.appendChild(circle);
222
+
223
+ const isCollapsed = collapsedGroups.has(ownsGroup.id);
224
+ const sign = document.createElementNS('http://www.w3.org/2000/svg', 'text');
225
+ sign.setAttribute('x', '74');
226
+ sign.setAttribute('y', '-16');
227
+ sign.setAttribute('text-anchor', 'middle');
228
+ sign.setAttribute('class', 'collapse-sign');
229
+ sign.textContent = isCollapsed ? '+' : '−';
230
+ toggle.appendChild(sign);
231
+
232
+ toggle.addEventListener('click', (e) => {
233
+ e.stopPropagation();
234
+ const groupId = e.currentTarget.dataset.groupId;
235
+ if (collapsedGroups.has(groupId)) {
236
+ collapsedGroups.delete(groupId);
237
+ } else {
238
+ collapsedGroups.add(groupId);
239
+ }
240
+ if (window.__bgxRender) window.__bgxRender();
241
+ });
242
+
243
+ group.appendChild(toggle);
244
+ }
245
+
246
+ // Tooltip
247
+ group.addEventListener('pointerenter', (e) => {
248
+ showTooltip(e.clientX, e.clientY, node);
249
+ });
250
+ group.addEventListener('pointermove', (e) => {
251
+ moveTooltip(e.clientX, e.clientY);
252
+ });
253
+ group.addEventListener('pointerleave', () => {
254
+ hideTooltip();
255
+ });
256
+
257
+ canvasGroup.appendChild(group);
258
+ }
259
+
260
+ // Register repositionNodeAndEdges globally for canvas.js to call
261
+ window.__bgxRepositionNode = (nodeId) => repositionNodeAndEdges(nodeId);
262
+ }
263
+
264
+ export function renderCommentPins(tasks) {
265
+ // Remove old pins
266
+ for (const pin of canvasGroup.querySelectorAll('.comment-pin')) pin.remove();
267
+
268
+ // Group tasks by nodeId
269
+ const pinsByNode = new Map();
270
+ for (const task of tasks) {
271
+ const nid = task.target?.nodeId;
272
+ if (!nid) continue;
273
+ if (!pinsByNode.has(nid)) pinsByNode.set(nid, []);
274
+ pinsByNode.get(nid).push(task);
275
+ }
276
+
277
+ for (const [nodeId, nodeTasks] of pinsByNode) {
278
+ const pos = nodePositions.get(nodeId);
279
+ if (!pos) continue;
280
+
281
+ const pin = document.createElementNS('http://www.w3.org/2000/svg', 'g');
282
+ pin.setAttribute('class', 'comment-pin');
283
+ pin.dataset.nodeId = nodeId;
284
+ pin.setAttribute('transform', `translate(${pos.x + 90},${pos.y - 38})`);
285
+
286
+ const status = getMostUrgentStatus(nodeTasks);
287
+
288
+ const bubble = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
289
+ bubble.setAttribute('r', '12');
290
+ bubble.setAttribute('class', `comment-bubble status-${status}`);
291
+ pin.appendChild(bubble);
292
+
293
+ const countText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
294
+ countText.setAttribute('text-anchor', 'middle');
295
+ countText.setAttribute('dominant-baseline', 'central');
296
+ countText.setAttribute('class', 'comment-count');
297
+ countText.textContent = nodeTasks.length > 9 ? '9+' : String(nodeTasks.length);
298
+ pin.appendChild(countText);
299
+
300
+ pin.addEventListener('click', (e) => {
301
+ e.stopPropagation();
302
+ openCommentOverlay(nodeId, nodeTasks, pos, canvasTransform, graphSvg);
303
+ });
304
+
305
+ canvasGroup.appendChild(pin);
306
+ }
307
+ }
308
+
309
+ export function repositionNodeAndEdges(nodeId) {
310
+ const pos = nodePositions.get(nodeId);
311
+ if (!pos) return;
312
+
313
+ // Move node group
314
+ const nodeGroup = canvasGroup.querySelector(`g[data-node-id="${nodeId}"]`);
315
+ if (nodeGroup) {
316
+ nodeGroup.setAttribute('transform', `translate(${pos.x},${pos.y})`);
317
+ }
318
+
319
+ // Update connected edges (paths with data-from or data-to)
320
+ for (const pathEl of canvasGroup.querySelectorAll(`path[data-from="${nodeId}"], path[data-to="${nodeId}"]`)) {
321
+ const fromId = pathEl.getAttribute('data-from');
322
+ const toId = pathEl.getAttribute('data-to');
323
+ const from = nodePositions.get(fromId);
324
+ const to = nodePositions.get(toId);
325
+ if (!from || !to) continue;
326
+
327
+ const x1 = from.x + 90;
328
+ const y1 = from.y;
329
+ const x2 = to.x - 90;
330
+ const y2 = to.y;
331
+ const mx = (x1 + x2) / 2;
332
+ pathEl.setAttribute('d', `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`);
333
+ }
334
+
335
+ // Update comment pin position
336
+ const pin = canvasGroup.querySelector(`.comment-pin[data-node-id="${nodeId}"]`);
337
+ if (pin) {
338
+ pin.setAttribute('transform', `translate(${pos.x + 90},${pos.y - 38})`);
339
+ }
340
+ }
341
+
342
+ function findNodeGroup(nodeId) {
343
+ if (!viewRef) return null;
344
+ for (const group of viewRef.groups ?? []) {
345
+ const root = groupRoots.get(group.id);
346
+ if (root === nodeId) return group;
347
+ }
348
+ return null;
349
+ }
350
+
351
+ export function edgeClass(kind) {
352
+ if (kind.startsWith('condition_') || kind === 'evaluates_condition') return 'edge-condition';
353
+ if (kind === 'handles') return 'edge-handles';
354
+ if (kind === 'calls') return 'edge-calls';
355
+ if (kind === 'langgraph_edge') return 'edge-langgraph';
356
+ return 'edge-default';
357
+ }
358
+
359
+ export function nodeClass(kind) {
360
+ if (kind === 'endpoint' || kind === 'ui_route') return 'node-endpoint';
361
+ if (kind === 'condition') return 'node-condition';
362
+ if (kind === 'route_handler') return 'node-handler';
363
+ if (kind === 'agent' || kind === 'langgraph_node') return 'node-agent';
364
+ if (kind === 'mcp_tool') return 'node-mcp';
365
+ return 'node-default';
366
+ }
@@ -0,0 +1,107 @@
1
+ // sidebar.js — Code sidebar (slides in from right)
2
+
3
+ let sidebarEl = null;
4
+ let sidebarContent = null;
5
+ let sidebarNodeTitle = null;
6
+ let closeSidebarBtn = null;
7
+
8
+ export let codeSidebarOpen = false;
9
+
10
+ export function initSidebar() {
11
+ sidebarEl = document.getElementById('codeSidebar');
12
+ sidebarContent = document.getElementById('sidebarContent');
13
+ sidebarNodeTitle = document.getElementById('sidebarNodeTitle');
14
+ closeSidebarBtn = document.getElementById('closeSidebarBtn');
15
+
16
+ closeSidebarBtn.addEventListener('click', closeCodeSidebar);
17
+
18
+ document.addEventListener('keydown', (e) => {
19
+ if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
20
+ if (e.key === 'Escape' && codeSidebarOpen) {
21
+ closeCodeSidebar();
22
+ }
23
+ });
24
+ }
25
+
26
+ export function openCodeSidebar(payload, descriptions) {
27
+ renderCodePayload(payload, descriptions);
28
+ sidebarEl.classList.add('sidebar--open');
29
+ sidebarEl.setAttribute('aria-hidden', 'false');
30
+ codeSidebarOpen = true;
31
+ }
32
+
33
+ export function closeCodeSidebar() {
34
+ sidebarEl.classList.remove('sidebar--open');
35
+ sidebarEl.setAttribute('aria-hidden', 'true');
36
+ codeSidebarOpen = false;
37
+ }
38
+
39
+ function renderCodePayload(payload, descriptions) {
40
+ sidebarNodeTitle.textContent = payload.node.label;
41
+
42
+ const description = lookupDescription(payload.node.id, descriptions);
43
+ const relatedHtml = (payload.related ?? [])
44
+ .map(
45
+ (entry) => `
46
+ <details>
47
+ <summary>${escHtml(entry.edge.kind)} → ${escHtml(entry.node.label)}</summary>
48
+ <pre>${highlightCode(escHtml(entry.snippet || '(no local source)'))}</pre>
49
+ </details>
50
+ `,
51
+ )
52
+ .join('');
53
+
54
+ sidebarContent.innerHTML = `
55
+ <div class="sidebar-file">${escHtml(payload.node.file || '(external)')}</div>
56
+ ${
57
+ description
58
+ ? `<div class="desc-card">
59
+ <h4>Description</h4>
60
+ <p><strong>KO:</strong> ${escHtml(description.sections?.ko?.purpose || '-')}</p>
61
+ <p><strong>EN:</strong> ${escHtml(description.sections?.en?.purpose || '-')}</p>
62
+ </div>`
63
+ : ''
64
+ }
65
+ <pre class="code-block">${highlightCode(escHtml(payload.snippet || '(no local source)'))}</pre>
66
+ <h4 class="related-title">Related (1-hop)</h4>
67
+ <div class="related-list">${relatedHtml || '<small>No related nodes.</small>'}</div>
68
+ `;
69
+ }
70
+
71
+ function lookupDescription(nodeId, descriptions) {
72
+ const entries = descriptions?.descriptions?.entries;
73
+ if (!Array.isArray(entries)) return null;
74
+ return entries.find((entry) => entry.id === nodeId || entry.id === `group:${nodeId}`) ?? null;
75
+ }
76
+
77
+ // Minimal regex-based syntax highlighter
78
+ function highlightCode(escapedCode) {
79
+ // Input is already HTML-escaped. We apply span wrapping in order.
80
+ let code = escapedCode;
81
+
82
+ // Comments (must come first to prevent keyword matching inside them)
83
+ code = code.replace(/(\/\/[^\n]*)/g, '<span class="tok-comment">$1</span>');
84
+ code = code.replace(/(\/\*[\s\S]*?\*\/)/g, '<span class="tok-comment">$1</span>');
85
+
86
+ // Strings (simple heuristic — works for most cases)
87
+ code = code.replace(/(&#39;[^&#]*&#39;|&quot;[^&]*&quot;)/g, '<span class="tok-string">$1</span>');
88
+
89
+ // Keywords
90
+ const keywords =
91
+ /\b(const|let|var|function|return|if|else|for|while|async|await|class|export|import|from|of|in|new|this|null|undefined|true|false|try|catch|finally|throw|switch|case|break|default|typeof|instanceof|void|delete|static|extends|super)\b/g;
92
+ code = code.replace(keywords, '<span class="tok-keyword">$1</span>');
93
+
94
+ // Numbers
95
+ code = code.replace(/\b(\d+\.?\d*)\b/g, '<span class="tok-number">$1</span>');
96
+
97
+ return code;
98
+ }
99
+
100
+ function escHtml(value) {
101
+ return String(value)
102
+ .replaceAll('&', '&amp;')
103
+ .replaceAll('<', '&lt;')
104
+ .replaceAll('>', '&gt;')
105
+ .replaceAll('"', '&quot;')
106
+ .replaceAll("'", '&#39;');
107
+ }