spec-gen-cli 1.2.2 → 1.2.3

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 (121) hide show
  1. package/README.md +197 -11
  2. package/dist/api/generate.d.ts.map +1 -1
  3. package/dist/api/generate.js +5 -4
  4. package/dist/api/generate.js.map +1 -1
  5. package/dist/cli/commands/analyze.d.ts.map +1 -1
  6. package/dist/cli/commands/analyze.js +101 -41
  7. package/dist/cli/commands/analyze.js.map +1 -1
  8. package/dist/cli/commands/generate.d.ts.map +1 -1
  9. package/dist/cli/commands/generate.js +25 -21
  10. package/dist/cli/commands/generate.js.map +1 -1
  11. package/dist/cli/commands/mcp.d.ts +353 -10
  12. package/dist/cli/commands/mcp.d.ts.map +1 -1
  13. package/dist/cli/commands/mcp.js +236 -45
  14. package/dist/cli/commands/mcp.js.map +1 -1
  15. package/dist/cli/commands/view.d.ts.map +1 -1
  16. package/dist/cli/commands/view.js +33 -4
  17. package/dist/cli/commands/view.js.map +1 -1
  18. package/dist/constants.d.ts +10 -0
  19. package/dist/constants.d.ts.map +1 -1
  20. package/dist/constants.js +10 -0
  21. package/dist/constants.js.map +1 -1
  22. package/dist/core/analyzer/ast-chunker.d.ts +24 -0
  23. package/dist/core/analyzer/ast-chunker.d.ts.map +1 -0
  24. package/dist/core/analyzer/ast-chunker.js +198 -0
  25. package/dist/core/analyzer/ast-chunker.js.map +1 -0
  26. package/dist/core/analyzer/call-graph.d.ts +51 -4
  27. package/dist/core/analyzer/call-graph.d.ts.map +1 -1
  28. package/dist/core/analyzer/call-graph.js +634 -44
  29. package/dist/core/analyzer/call-graph.js.map +1 -1
  30. package/dist/core/analyzer/code-shaper.d.ts.map +1 -1
  31. package/dist/core/analyzer/code-shaper.js +5 -0
  32. package/dist/core/analyzer/code-shaper.js.map +1 -1
  33. package/dist/core/analyzer/codebase-digest.d.ts +40 -0
  34. package/dist/core/analyzer/codebase-digest.d.ts.map +1 -0
  35. package/dist/core/analyzer/codebase-digest.js +194 -0
  36. package/dist/core/analyzer/codebase-digest.js.map +1 -0
  37. package/dist/core/analyzer/cpp-header-resolver.d.ts +30 -0
  38. package/dist/core/analyzer/cpp-header-resolver.d.ts.map +1 -0
  39. package/dist/core/analyzer/cpp-header-resolver.js +71 -0
  40. package/dist/core/analyzer/cpp-header-resolver.js.map +1 -0
  41. package/dist/core/analyzer/function-registry-trie.d.ts +21 -0
  42. package/dist/core/analyzer/function-registry-trie.d.ts.map +1 -0
  43. package/dist/core/analyzer/function-registry-trie.js +39 -0
  44. package/dist/core/analyzer/function-registry-trie.js.map +1 -0
  45. package/dist/core/analyzer/import-resolver-bridge.d.ts +25 -0
  46. package/dist/core/analyzer/import-resolver-bridge.d.ts.map +1 -0
  47. package/dist/core/analyzer/import-resolver-bridge.js +99 -0
  48. package/dist/core/analyzer/import-resolver-bridge.js.map +1 -0
  49. package/dist/core/analyzer/signature-extractor.d.ts.map +1 -1
  50. package/dist/core/analyzer/signature-extractor.js +72 -3
  51. package/dist/core/analyzer/signature-extractor.js.map +1 -1
  52. package/dist/core/analyzer/subgraph-extractor.d.ts +10 -2
  53. package/dist/core/analyzer/subgraph-extractor.d.ts.map +1 -1
  54. package/dist/core/analyzer/subgraph-extractor.js +25 -7
  55. package/dist/core/analyzer/subgraph-extractor.js.map +1 -1
  56. package/dist/core/analyzer/type-inference-engine.d.ts +23 -0
  57. package/dist/core/analyzer/type-inference-engine.d.ts.map +1 -0
  58. package/dist/core/analyzer/type-inference-engine.js +130 -0
  59. package/dist/core/analyzer/type-inference-engine.js.map +1 -0
  60. package/dist/core/analyzer/vector-index.d.ts +35 -6
  61. package/dist/core/analyzer/vector-index.d.ts.map +1 -1
  62. package/dist/core/analyzer/vector-index.js +308 -54
  63. package/dist/core/analyzer/vector-index.js.map +1 -1
  64. package/dist/core/generator/spec-pipeline.d.ts +31 -11
  65. package/dist/core/generator/spec-pipeline.d.ts.map +1 -1
  66. package/dist/core/generator/spec-pipeline.js +170 -39
  67. package/dist/core/generator/spec-pipeline.js.map +1 -1
  68. package/dist/core/generator/stages/stage2-entities.d.ts.map +1 -1
  69. package/dist/core/generator/stages/stage2-entities.js +2 -1
  70. package/dist/core/generator/stages/stage2-entities.js.map +1 -1
  71. package/dist/core/generator/stages/stage3-services.d.ts.map +1 -1
  72. package/dist/core/generator/stages/stage3-services.js +2 -1
  73. package/dist/core/generator/stages/stage3-services.js.map +1 -1
  74. package/dist/core/generator/stages/stage4-api.d.ts.map +1 -1
  75. package/dist/core/generator/stages/stage4-api.js +2 -1
  76. package/dist/core/generator/stages/stage4-api.js.map +1 -1
  77. package/dist/core/generator/stages/stage5-architecture.d.ts +2 -1
  78. package/dist/core/generator/stages/stage5-architecture.d.ts.map +1 -1
  79. package/dist/core/generator/stages/stage5-architecture.js +15 -3
  80. package/dist/core/generator/stages/stage5-architecture.js.map +1 -1
  81. package/dist/core/services/chat-agent.d.ts +5 -0
  82. package/dist/core/services/chat-agent.d.ts.map +1 -1
  83. package/dist/core/services/chat-agent.js +14 -0
  84. package/dist/core/services/chat-agent.js.map +1 -1
  85. package/dist/core/services/chat-tools.d.ts.map +1 -1
  86. package/dist/core/services/chat-tools.js +172 -50
  87. package/dist/core/services/chat-tools.js.map +1 -1
  88. package/dist/core/services/mcp-handlers/analysis.d.ts +12 -0
  89. package/dist/core/services/mcp-handlers/analysis.d.ts.map +1 -1
  90. package/dist/core/services/mcp-handlers/analysis.js +138 -2
  91. package/dist/core/services/mcp-handlers/analysis.js.map +1 -1
  92. package/dist/core/services/mcp-handlers/graph.d.ts +21 -1
  93. package/dist/core/services/mcp-handlers/graph.d.ts.map +1 -1
  94. package/dist/core/services/mcp-handlers/graph.js +142 -2
  95. package/dist/core/services/mcp-handlers/graph.js.map +1 -1
  96. package/dist/core/services/mcp-handlers/orient.d.ts +17 -0
  97. package/dist/core/services/mcp-handlers/orient.d.ts.map +1 -0
  98. package/dist/core/services/mcp-handlers/orient.js +200 -0
  99. package/dist/core/services/mcp-handlers/orient.js.map +1 -0
  100. package/dist/core/services/mcp-handlers/semantic.d.ts +18 -4
  101. package/dist/core/services/mcp-handlers/semantic.d.ts.map +1 -1
  102. package/dist/core/services/mcp-handlers/semantic.js +161 -17
  103. package/dist/core/services/mcp-handlers/semantic.js.map +1 -1
  104. package/dist/core/services/mcp-handlers/utils.d.ts +43 -0
  105. package/dist/core/services/mcp-handlers/utils.d.ts.map +1 -1
  106. package/dist/core/services/mcp-handlers/utils.js +66 -1
  107. package/dist/core/services/mcp-handlers/utils.js.map +1 -1
  108. package/dist/core/services/mcp-watcher.d.ts +41 -0
  109. package/dist/core/services/mcp-watcher.d.ts.map +1 -0
  110. package/dist/core/services/mcp-watcher.js +177 -0
  111. package/dist/core/services/mcp-watcher.js.map +1 -0
  112. package/dist/types/index.d.ts +1 -1
  113. package/dist/types/index.d.ts.map +1 -1
  114. package/dist/types/pipeline.d.ts +7 -0
  115. package/dist/types/pipeline.d.ts.map +1 -1
  116. package/package.json +3 -2
  117. package/src/viewer/InteractiveGraphViewer.jsx +33 -8
  118. package/src/viewer/components/ChatPanel.jsx +8 -5
  119. package/src/viewer/components/ClassGraph.jsx +699 -0
  120. package/src/viewer/utils/graph-helpers.js +1 -1
  121. package/src/viewer/utils/themes.js +36 -0
@@ -0,0 +1,699 @@
1
+ /**
2
+ * ClassGraph — force-directed graph mixing real class nodes and file-level
3
+ * module nodes (free functions grouped by file).
4
+ *
5
+ * Node types:
6
+ * - Class node → circle, colored by language
7
+ * - Module node → rounded-rect (hexagonal feel), dimmer fill, name in [brackets]
8
+ *
9
+ * Edge types:
10
+ * - extends / embeds / implements → solid/dashed UML arrows
11
+ * - cross-class/module calls → curved bezier, weight = call count
12
+ *
13
+ * Hover shows a floating tooltip with full details.
14
+ * Click to expand and see individual method sub-nodes.
15
+ */
16
+
17
+ import { useState, useMemo, useCallback } from 'react';
18
+ import { usePanZoom } from '../hooks/usePanZoom.js';
19
+
20
+ // ============================================================================
21
+ // CONSTANTS
22
+ // ============================================================================
23
+
24
+ const W = 1400;
25
+ const H = 800;
26
+ const CLASS_R = 28; // base radius for class nodes
27
+ const MODULE_R = 24; // base radius for module nodes (smaller)
28
+ const ITERS = 240; // force-layout iterations
29
+
30
+ const LANG_CSS_VAR = {
31
+ TypeScript: 'var(--lc-cyan)',
32
+ JavaScript: 'var(--lc-yellow)',
33
+ Python: 'var(--lc-green)',
34
+ Java: 'var(--lc-orange)',
35
+ 'C++': 'var(--lc-purple)',
36
+ Go: 'var(--lc-cyan)',
37
+ Rust: 'var(--lc-red)',
38
+ Ruby: 'var(--lc-pink)',
39
+ };
40
+
41
+ function langColor(lang) {
42
+ return LANG_CSS_VAR[lang] ?? 'var(--ac-primary)';
43
+ }
44
+
45
+ // ============================================================================
46
+ // FORCE LAYOUT
47
+ // ============================================================================
48
+
49
+ function forceLayout(nodes, edges) {
50
+ if (!nodes.length) return {};
51
+ const pos = {};
52
+ const angle0 = -Math.PI / 2;
53
+
54
+ // Seed in a circle
55
+ nodes.forEach((n, i) => {
56
+ const a = angle0 + (i / nodes.length) * Math.PI * 2;
57
+ pos[n.id] = {
58
+ x: W / 2 + Math.cos(a) * W * 0.38,
59
+ y: H / 2 + Math.sin(a) * H * 0.33,
60
+ };
61
+ });
62
+
63
+ const k = Math.sqrt((W * H) / Math.max(nodes.length, 1)) * 0.75;
64
+
65
+ for (let iter = 0; iter < ITERS; iter++) {
66
+ const disp = {};
67
+ nodes.forEach((n) => { disp[n.id] = { x: 0, y: 0 }; });
68
+
69
+ // Repulsion
70
+ for (let i = 0; i < nodes.length; i++) {
71
+ for (let j = i + 1; j < nodes.length; j++) {
72
+ const a = nodes[i], b = nodes[j];
73
+ const dx = pos[a.id].x - pos[b.id].x;
74
+ const dy = pos[a.id].y - pos[b.id].y;
75
+ const d = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
76
+ const f = (k * k) / d;
77
+ disp[a.id].x += (dx / d) * f;
78
+ disp[a.id].y += (dy / d) * f;
79
+ disp[b.id].x -= (dx / d) * f;
80
+ disp[b.id].y -= (dy / d) * f;
81
+ }
82
+ }
83
+
84
+ // Attraction along edges
85
+ edges.forEach((e) => {
86
+ if (!pos[e.source] || !pos[e.target]) return;
87
+ const dx = pos[e.source].x - pos[e.target].x;
88
+ const dy = pos[e.source].y - pos[e.target].y;
89
+ const d = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
90
+ const f = (d * d) / (k * 2);
91
+ disp[e.source].x -= (dx / d) * f;
92
+ disp[e.source].y -= (dy / d) * f;
93
+ disp[e.target].x += (dx / d) * f;
94
+ disp[e.target].y += (dy / d) * f;
95
+ });
96
+
97
+ const temp = k * Math.max(0.01, 1 - iter / ITERS) * 0.7;
98
+ nodes.forEach((n) => {
99
+ const d = Math.sqrt(disp[n.id].x ** 2 + disp[n.id].y ** 2);
100
+ if (d > 0) {
101
+ pos[n.id].x += (disp[n.id].x / d) * Math.min(d, temp);
102
+ pos[n.id].y += (disp[n.id].y / d) * Math.min(d, temp);
103
+ }
104
+ pos[n.id].x = Math.max(70, Math.min(W - 70, pos[n.id].x));
105
+ pos[n.id].y = Math.max(70, Math.min(H - 70, pos[n.id].y));
106
+ });
107
+ }
108
+ return pos;
109
+ }
110
+
111
+ // ============================================================================
112
+ // CURVED PATH HELPER
113
+ // ============================================================================
114
+
115
+ function curvePath(x1, y1, x2, y2, bend = 0.18) {
116
+ const mx = (x1 + x2) / 2;
117
+ const my = (y1 + y2) / 2;
118
+ const dx = x2 - x1;
119
+ const dy = y2 - y1;
120
+ // Perpendicular offset for bezier control point
121
+ const cpx = mx - dy * bend;
122
+ const cpy = my + dx * bend;
123
+ return `M ${x1} ${y1} Q ${cpx} ${cpy} ${x2} ${y2}`;
124
+ }
125
+
126
+ // ============================================================================
127
+ // SVG DEFS
128
+ // ============================================================================
129
+
130
+ function Defs() {
131
+ return (
132
+ <defs>
133
+ <marker id="cg-inherit" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto">
134
+ <polygon points="0 0, 9 4, 0 8" fill="none" stroke="var(--lc-purple)" strokeWidth="1.3" />
135
+ </marker>
136
+ <marker id="cg-impl" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto">
137
+ <polygon points="0 0, 9 4, 0 8" fill="none" stroke="var(--lc-cyan)" strokeWidth="1.3" />
138
+ </marker>
139
+ <marker id="cg-embeds" markerWidth="10" markerHeight="8" refX="9" refY="4" orient="auto">
140
+ <polygon points="0 4, 5 0, 10 4, 5 8" fill="none" stroke="var(--lc-green)" strokeWidth="1.3" />
141
+ </marker>
142
+ <marker id="cg-call" markerWidth="7" markerHeight="6" refX="6" refY="3" orient="auto">
143
+ <polygon points="0 0, 6 3, 0 6" fill="var(--tx-secondary)" />
144
+ </marker>
145
+ </defs>
146
+ );
147
+ }
148
+
149
+ // ============================================================================
150
+ // EXPANDED CLASS — method grid inside a dashed rectangle
151
+ // ============================================================================
152
+
153
+ function ExpandedClass({ cls, cx, cy, methods, color, onMethodHover, onMethodLeave }) {
154
+ const ROW_H = 14; // px per function row
155
+ const PAD_X = 12;
156
+ const PAD_TOP = 22;
157
+ const PAD_BOT = 10;
158
+ const maxLabelLen = Math.max(...methods.map(m => m.name.length), cls.name.length, 8);
159
+ const rw = Math.max(maxLabelLen * 6.2 + PAD_X * 2, 110);
160
+ const rh = PAD_TOP + methods.length * ROW_H + PAD_BOT;
161
+
162
+ return (
163
+ <g>
164
+ {/* Card background */}
165
+ <rect
166
+ x={cx - rw / 2} y={cy - rh / 2}
167
+ width={rw} height={rh}
168
+ rx={6}
169
+ fill="var(--bg-raised)"
170
+ stroke={color}
171
+ strokeWidth={1.5}
172
+ />
173
+ {/* Header bar */}
174
+ <rect
175
+ x={cx - rw / 2} y={cy - rh / 2}
176
+ width={rw} height={18}
177
+ rx={6}
178
+ fill={color} fillOpacity={0.18}
179
+ stroke="none"
180
+ />
181
+ <rect
182
+ x={cx - rw / 2} y={cy - rh / 2 + 12}
183
+ width={rw} height={6}
184
+ fill={color} fillOpacity={0.18}
185
+ stroke="none"
186
+ />
187
+ {/* Class / module name */}
188
+ <text x={cx} y={cy - rh / 2 + 13}
189
+ textAnchor="middle" fontSize={9.5} fill={color} fontWeight="700"
190
+ style={{ fontFamily: 'monospace', pointerEvents: 'none' }}>
191
+ {cls.name}
192
+ </text>
193
+ {/* Divider */}
194
+ <line
195
+ x1={cx - rw / 2 + 6} y1={cy - rh / 2 + 19}
196
+ x2={cx + rw / 2 - 6} y2={cy - rh / 2 + 19}
197
+ stroke={color} strokeWidth={0.5} opacity={0.4}
198
+ />
199
+ {/* Function list */}
200
+ {methods.map((m, mi) => {
201
+ const fy = cy - rh / 2 + PAD_TOP + mi * ROW_H + ROW_H / 2;
202
+ const isAsync = m.isAsync;
203
+ return (
204
+ <g key={m.id}
205
+ onMouseEnter={(e) => onMethodHover(e, m)}
206
+ onMouseLeave={onMethodLeave}
207
+ style={{ cursor: 'default' }}>
208
+ {/* Hover band (invisible but interactive) */}
209
+ <rect x={cx - rw / 2 + 2} y={fy - ROW_H / 2 + 1}
210
+ width={rw - 4} height={ROW_H - 1}
211
+ fill="transparent" />
212
+ {/* Async indicator dot */}
213
+ {isAsync && (
214
+ <circle cx={cx - rw / 2 + PAD_X - 4} cy={fy} r={2}
215
+ fill={color} opacity={0.6} />
216
+ )}
217
+ {/* Function name */}
218
+ <text
219
+ x={cx - rw / 2 + PAD_X}
220
+ y={fy + 4}
221
+ fontSize={8.5}
222
+ fill="var(--tx-primary)"
223
+ style={{ fontFamily: 'monospace', pointerEvents: 'none' }}>
224
+ {m.name}
225
+ </text>
226
+ {/* fanIn badge on the right */}
227
+ {m.fanIn > 0 && (
228
+ <text
229
+ x={cx + rw / 2 - PAD_X}
230
+ y={fy + 4}
231
+ textAnchor="end"
232
+ fontSize={7}
233
+ fill="var(--tx-dim)"
234
+ style={{ pointerEvents: 'none' }}>
235
+ ↙{m.fanIn}
236
+ </text>
237
+ )}
238
+ </g>
239
+ );
240
+ })}
241
+ </g>
242
+ );
243
+ }
244
+
245
+ // ============================================================================
246
+ // CLASS NODE (collapsed)
247
+ // ============================================================================
248
+
249
+ function ClassNode({ cls, cx, cy, selected, onToggle, onHover, onLeave }) {
250
+ const color = langColor(cls.language);
251
+ const isModule = cls.isModule;
252
+ const r = (isModule ? MODULE_R : CLASS_R) + Math.min(cls.methodIds.length, 16) * 1.2;
253
+
254
+ return (
255
+ <g
256
+ onClick={(e) => { e.stopPropagation(); onToggle(cls.id); }}
257
+ onMouseEnter={(e) => onHover(e, cls)}
258
+ onMouseLeave={onLeave}
259
+ style={{ cursor: 'pointer' }}
260
+ >
261
+ {selected && (
262
+ <circle cx={cx} cy={cy} r={r + 6}
263
+ fill="none" stroke="var(--ac-primary)" strokeWidth={2} opacity={0.7} />
264
+ )}
265
+ {isModule
266
+ ? /* Rounded-rect for module nodes */
267
+ <rect
268
+ x={cx - r} y={cy - r * 0.65}
269
+ width={r * 2} height={r * 1.3}
270
+ rx={6}
271
+ fill={color}
272
+ fillOpacity={0.06}
273
+ stroke={color}
274
+ strokeWidth={1.1}
275
+ strokeDasharray="3 2"
276
+ strokeOpacity={0.45}
277
+ />
278
+ : /* Circle for class nodes */
279
+ <circle cx={cx} cy={cy} r={r}
280
+ fill={color}
281
+ fillOpacity={0.09}
282
+ stroke={color}
283
+ strokeWidth={1.5}
284
+ strokeOpacity={0.5}
285
+ />
286
+ }
287
+ {/* Name */}
288
+ <text x={cx} y={cy - (isModule ? 2 : 5)}
289
+ textAnchor="middle" fontSize={isModule ? 8.5 : 10}
290
+ fill="var(--tx-primary)" fontWeight="600"
291
+ style={{ fontFamily: 'monospace', pointerEvents: 'none' }}>
292
+ {cls.name.length > 16 ? cls.name.slice(0, 15) + '…' : cls.name}
293
+ </text>
294
+ {/* Stats */}
295
+ <text x={cx} y={cy + (isModule ? 9 : 8)}
296
+ textAnchor="middle" fontSize={7.5}
297
+ fill="var(--tx-muted)"
298
+ style={{ pointerEvents: 'none' }}>
299
+ {cls.methodIds.length} fn · ↙{cls.fanIn}
300
+ </text>
301
+ {/* Language */}
302
+ <text x={cx} y={cy + (isModule ? 20 : 21)}
303
+ textAnchor="middle" fontSize={6.5}
304
+ fill={color} opacity={0.85}
305
+ style={{ pointerEvents: 'none' }}>
306
+ {cls.language}
307
+ </text>
308
+ </g>
309
+ );
310
+ }
311
+
312
+ // ============================================================================
313
+ // TOOLTIP
314
+ // ============================================================================
315
+
316
+ function Tooltip({ tip }) {
317
+ if (!tip) return null;
318
+ const { x, y, lines } = tip;
319
+ return (
320
+ <div style={{
321
+ position: 'fixed',
322
+ left: x + 14,
323
+ top: y - 10,
324
+ background: 'var(--bg-raised)',
325
+ border: '1px solid var(--bd-muted)',
326
+ borderRadius: 6,
327
+ padding: '6px 10px',
328
+ fontSize: 11,
329
+ color: 'var(--tx-primary)',
330
+ pointerEvents: 'none',
331
+ zIndex: 999,
332
+ maxWidth: 320,
333
+ boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
334
+ lineHeight: 1.6,
335
+ fontFamily: 'monospace',
336
+ }}>
337
+ {lines.map((l, i) => (
338
+ <div key={i} style={{ color: l.dim ? 'var(--tx-muted)' : 'var(--tx-primary)', fontSize: l.small ? 10 : 11 }}>
339
+ {l.text}
340
+ </div>
341
+ ))}
342
+ </div>
343
+ );
344
+ }
345
+
346
+ // ============================================================================
347
+ // MAIN COMPONENT
348
+ // ============================================================================
349
+
350
+ export function ClassGraph({ classData, onSelectClass, selectedClassId, focusedPaths = [], onClear }) {
351
+ const { classes: serverClasses = [], inheritanceEdges = [], edges: callEdges = [], nodes: fnNodes = [] } = classData ?? {};
352
+
353
+ const [expanded, setExpanded] = useState(new Set());
354
+ const [tooltip, setTooltip] = useState(null);
355
+ const [hoveredEdge, setHoveredEdge] = useState(null);
356
+
357
+ const focusedPathSet = useMemo(() => new Set(focusedPaths), [focusedPaths]);
358
+
359
+ const { transform, onMouseDown, onMouseMove, onMouseUp, onWheel, onMouseLeave, onDblClick, reset } =
360
+ usePanZoom();
361
+
362
+ const fnMap = useMemo(() => {
363
+ const m = new Map();
364
+ for (const n of fnNodes) m.set(n.id, n);
365
+ return m;
366
+ }, [fnNodes]);
367
+
368
+ // IDs already covered by server-provided class nodes
369
+ const coveredIds = useMemo(() => {
370
+ const s = new Set();
371
+ for (const cls of serverClasses)
372
+ for (const mid of cls.methodIds) s.add(mid);
373
+ return s;
374
+ }, [serverClasses]);
375
+
376
+ // Synthetic module nodes — group uncovered fnNodes by filePath
377
+ const moduleNodes = useMemo(() => {
378
+ const groups = new Map(); // filePath → { methods: FunctionNode[] }
379
+ for (const fn of fnNodes) {
380
+ if (coveredIds.has(fn.id)) continue;
381
+ if (!groups.has(fn.filePath))
382
+ groups.set(fn.filePath, { methods: [] });
383
+ groups.get(fn.filePath).methods.push(fn);
384
+ }
385
+ return Array.from(groups.entries()).map(([fp, g]) => {
386
+ const base = fp.split('/').pop() ?? fp;
387
+ const name = '[' + base.replace(/\.[^.]+$/, '') + ']';
388
+ return {
389
+ id: fp,
390
+ name,
391
+ filePath: fp,
392
+ language: g.methods[0]?.language ?? 'TypeScript',
393
+ parentClasses: [],
394
+ interfaces: [],
395
+ methodIds: g.methods.map(m => m.id),
396
+ fanIn: g.methods.reduce((s, m) => s + (m.fanIn ?? 0), 0),
397
+ fanOut: g.methods.reduce((s, m) => s + (m.fanOut ?? 0), 0),
398
+ isModule: true,
399
+ };
400
+ });
401
+ }, [fnNodes, coveredIds]);
402
+
403
+ // All nodes used for layout + rendering
404
+ const allClasses = useMemo(() => [...serverClasses, ...moduleNodes], [serverClasses, moduleNodes]);
405
+
406
+ const classCallEdges = useMemo(() => {
407
+ // Full methodToClass: server classes + synthetic modules
408
+ const methodToClass = new Map();
409
+ for (const cls of allClasses) {
410
+ for (const mid of cls.methodIds) methodToClass.set(mid, cls.id);
411
+ }
412
+ const counts = new Map();
413
+ const samples = new Map();
414
+ for (const e of callEdges) {
415
+ const src = methodToClass.get(e.callerId);
416
+ const tgt = methodToClass.get(e.calleeId);
417
+ if (!src || !tgt || src === tgt) continue;
418
+ const key = `${src}|||${tgt}`;
419
+ counts.set(key, (counts.get(key) ?? 0) + 1);
420
+ if (!samples.has(key)) samples.set(key, []);
421
+ if (samples.get(key).length < 3) {
422
+ const callerFn = fnMap.get(e.callerId);
423
+ const calleeFn = fnMap.get(e.calleeId);
424
+ if (callerFn && calleeFn) samples.get(key).push(`${callerFn.name} → ${calleeFn.name}`);
425
+ }
426
+ }
427
+ return Array.from(counts.entries()).map(([key, count]) => {
428
+ const [source, target] = key.split('|||');
429
+ return { source, target, count, kind: 'call', samples: samples.get(key) ?? [] };
430
+ });
431
+ }, [allClasses, callEdges, fnMap]);
432
+
433
+ const layoutEdges = useMemo(() => [
434
+ ...inheritanceEdges.map(e => ({ source: e.childId, target: e.parentId, kind: e.kind })),
435
+ ...classCallEdges,
436
+ ], [inheritanceEdges, classCallEdges]);
437
+
438
+ const classKey = allClasses.map(c => c.id).join('|');
439
+ const edgeCount = layoutEdges.length;
440
+ const pos = useMemo(
441
+ () => forceLayout(allClasses, layoutEdges),
442
+ [classKey, edgeCount], // stable keys — intentionally omit allClasses/layoutEdges refs
443
+ );
444
+
445
+ const showTip = useCallback((e, lines) => {
446
+ setTooltip({ x: e.clientX, y: e.clientY, lines });
447
+ }, []);
448
+ const hideTip = useCallback(() => setTooltip(null), []);
449
+
450
+ const handleClassHover = useCallback((e, cls) => {
451
+ const lines = [
452
+ { text: cls.name },
453
+ { text: cls.filePath.split('/').slice(-2).join('/'), dim: true, small: true },
454
+ { text: `${cls.methodIds.length} functions · fanIn ${cls.fanIn} · fanOut ${cls.fanOut}`, dim: true, small: true },
455
+ ];
456
+ if (cls.parentClasses?.length) lines.push({ text: `extends: ${cls.parentClasses.join(', ')}`, dim: true, small: true });
457
+ if (cls.interfaces?.length) lines.push({ text: `implements: ${cls.interfaces.join(', ')}`, dim: true, small: true });
458
+ showTip(e, lines);
459
+ }, [showTip]);
460
+
461
+ const handleMethodHover = useCallback((e, fn) => {
462
+ showTip(e, [
463
+ { text: fn.name },
464
+ { text: fn.filePath.split('/').slice(-2).join('/'), dim: true, small: true },
465
+ { text: `fanIn ${fn.fanIn} · fanOut ${fn.fanOut}${fn.isAsync ? ' · async' : ''}`, dim: true, small: true },
466
+ ]);
467
+ }, [showTip]);
468
+
469
+ const handleEdgeHover = useCallback((e, edge) => {
470
+ const srcCls = allClasses.find(c => c.id === edge.source);
471
+ const tgtCls = allClasses.find(c => c.id === edge.target);
472
+ const lines = [
473
+ { text: `${srcCls?.name ?? '?'} → ${tgtCls?.name ?? '?'}` },
474
+ { text: `${edge.count} cross-boundary call${edge.count > 1 ? 's' : ''}`, dim: true, small: true },
475
+ ...edge.samples.map(s => ({ text: s, dim: true, small: true })),
476
+ ];
477
+ showTip(e, lines);
478
+ setHoveredEdge(`${edge.source}|||${edge.target}`);
479
+ }, [allClasses, showTip]);
480
+
481
+ function toggleExpand(id) {
482
+ setExpanded(prev => {
483
+ const next = new Set(prev);
484
+ if (next.has(id)) next.delete(id); else next.add(id);
485
+ return next;
486
+ });
487
+ }
488
+
489
+ if (!allClasses.length) {
490
+ return (
491
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center',
492
+ height: '100%', color: 'var(--tx-muted)', fontSize: 13 }}>
493
+ No class data — run <code style={{ margin: '0 6px' }}>spec-gen analyze</code> to generate the call graph.
494
+ </div>
495
+ );
496
+ }
497
+
498
+ const classCount = serverClasses.length;
499
+ const moduleCount = moduleNodes.length;
500
+
501
+ return (
502
+ <div style={{ position: 'relative', width: '100%', height: '100%' }}>
503
+ {/* Stats bar */}
504
+ <div style={{
505
+ position: 'absolute', top: 8, right: 12, zIndex: 2,
506
+ fontSize: 9, color: 'var(--tx-muted)',
507
+ display: 'flex', gap: 14, pointerEvents: 'none',
508
+ }}>
509
+ {classCount > 0 && <span>{classCount} classes</span>}
510
+ <span>{moduleCount} modules</span>
511
+ {inheritanceEdges.length > 0 && <span>{inheritanceEdges.length} inheritance</span>}
512
+ <span>{classCallEdges.length} cross-module calls</span>
513
+ <span style={{ color: 'var(--tx-dim)' }}>hover for details · click to expand</span>
514
+ </div>
515
+
516
+ <svg
517
+ viewBox={`0 0 ${W} ${H}`}
518
+ width="100%" height="100%"
519
+ style={{ background: 'var(--bg-base)', display: 'block' }}
520
+ onMouseDown={onMouseDown}
521
+ onMouseMove={onMouseMove}
522
+ onMouseUp={onMouseUp}
523
+ onWheel={onWheel}
524
+ onMouseLeave={(e) => { onMouseLeave(e); hideTip(); setHoveredEdge(null); }}
525
+ onDoubleClick={onDblClick}
526
+ onClick={() => onSelectClass?.(null)}
527
+ >
528
+ <Defs />
529
+
530
+ {/* ── View / Clear buttons ────────────────────────────────────────── */}
531
+ <foreignObject x="8" y="8" width="52" height="44" style={{ pointerEvents: 'all' }}>
532
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
533
+ <button
534
+ onClick={reset}
535
+ title="Reset pan/zoom (or double-click background)"
536
+ style={{
537
+ fontSize: 8, padding: '2px 6px',
538
+ background: 'var(--bg-input)',
539
+ border: `1px solid ${transform.x !== 0 || transform.y !== 0 || transform.k !== 1 ? 'var(--ac-primary)' : 'var(--bd-muted)'}`,
540
+ borderRadius: 4,
541
+ color: transform.x !== 0 || transform.y !== 0 || transform.k !== 1 ? 'var(--ac-primary)' : 'var(--tx-faint)',
542
+ cursor: 'pointer',
543
+ fontFamily: "'JetBrains Mono',monospace",
544
+ letterSpacing: '0.05em',
545
+ }}
546
+ >⌖ view</button>
547
+ <button
548
+ onClick={() => { setExpanded(new Set()); onSelectClass?.(null); onClear?.(); }}
549
+ title="Clear expanded nodes and focus (Escape)"
550
+ style={{
551
+ fontSize: 8, padding: '2px 6px',
552
+ background: 'var(--bg-input)',
553
+ border: `1px solid ${expanded.size > 0 || focusedPaths.length > 0 ? 'var(--ac-primary)' : 'var(--bd-muted)'}`,
554
+ borderRadius: 4,
555
+ color: expanded.size > 0 || focusedPaths.length > 0 ? 'var(--ac-primary)' : 'var(--tx-faint)',
556
+ cursor: expanded.size > 0 || focusedPaths.length > 0 ? 'pointer' : 'default',
557
+ fontFamily: "'JetBrains Mono',monospace",
558
+ letterSpacing: '0.05em',
559
+ }}
560
+ >x clear</button>
561
+ </div>
562
+ </foreignObject>
563
+
564
+ <g transform={`translate(${transform.x},${transform.y}) scale(${transform.k})`}>
565
+
566
+ {/* ── Call edges ─────────────────────────────────────────────────── */}
567
+ {classCallEdges.map(e => {
568
+ const sp = pos[e.source];
569
+ const tp = pos[e.target];
570
+ if (!sp || !tp) return null;
571
+ const key = `${e.source}|||${e.target}`;
572
+ const isHovered = hoveredEdge === key;
573
+ const w = Math.min(0.7 + e.count * 0.25, 4);
574
+ const d = curvePath(sp.x, sp.y, tp.x, tp.y, 0.15);
575
+ const hasFocus = focusedPathSet.size > 0;
576
+ const edgeFocused = hasFocus && (focusedPathSet.has(e.source) || focusedPathSet.has(e.target));
577
+ const edgeOpacity = isHovered ? 0.9 : hasFocus ? (edgeFocused ? 0.6 : 0.06) : 0.45;
578
+ return (
579
+ <path key={`call-${key}`}
580
+ d={d}
581
+ fill="none"
582
+ stroke="var(--tx-secondary)"
583
+ strokeWidth={isHovered ? w + 1.5 : w}
584
+ strokeDasharray="5 3"
585
+ markerEnd="url(#cg-call)"
586
+ opacity={edgeOpacity}
587
+ onMouseEnter={(ev) => handleEdgeHover(ev, e)}
588
+ onMouseLeave={() => { hideTip(); setHoveredEdge(null); }}
589
+ style={{ cursor: 'pointer', transition: 'opacity 0.2s' }}
590
+ />
591
+ );
592
+ })}
593
+
594
+ {/* ── Inheritance edges ──────────────────────────────────────────── */}
595
+ {inheritanceEdges.map(e => {
596
+ const sp = pos[e.childId];
597
+ const tp = pos[e.parentId];
598
+ if (!sp || !tp) return null;
599
+ const isImpl = e.kind === 'implements';
600
+ const isEmbed = e.kind === 'embeds';
601
+ const markerId = isEmbed ? 'cg-embeds' : isImpl ? 'cg-impl' : 'cg-inherit';
602
+ const stroke = isImpl ? 'var(--lc-cyan)' : isEmbed ? 'var(--lc-green)' : 'var(--lc-purple)';
603
+ const d = curvePath(sp.x, sp.y, tp.x, tp.y, 0.08);
604
+ return (
605
+ <path key={e.id}
606
+ d={d}
607
+ fill="none"
608
+ stroke={stroke}
609
+ strokeWidth={1.8}
610
+ strokeDasharray={isImpl ? '6 3' : 'none'}
611
+ markerEnd={`url(#${markerId})`}
612
+ opacity={0.85}
613
+ />
614
+ );
615
+ })}
616
+
617
+ {/* ── Nodes ─────────────────────────────────────────────────────── */}
618
+ {allClasses.map(cls => {
619
+ const p = pos[cls.id];
620
+ if (!p) return null;
621
+ const isExpanded = expanded.has(cls.id);
622
+ const methods = cls.methodIds.map(id => fnMap.get(id)).filter(Boolean);
623
+ const color = langColor(cls.language);
624
+ const isFocused = focusedPathSet.has(cls.filePath);
625
+ const hasFocus = focusedPathSet.size > 0;
626
+ const isDimmed = hasFocus && !isFocused;
627
+
628
+ return (
629
+ <g key={cls.id} style={{ opacity: isDimmed ? 0.15 : 1, transition: 'opacity 0.2s' }}>
630
+ {isExpanded
631
+ ? <>
632
+ <ExpandedClass
633
+ cls={cls} cx={p.x} cy={p.y}
634
+ methods={methods} color={color}
635
+ onMethodHover={handleMethodHover}
636
+ onMethodLeave={hideTip}
637
+ />
638
+ <g onClick={(ev) => { ev.stopPropagation(); toggleExpand(cls.id); }}
639
+ style={{ cursor: 'pointer' }}>
640
+ <circle cx={p.x} cy={p.y - 4} r={10}
641
+ fill="var(--bg-raised)" stroke={color} strokeWidth={1.2} />
642
+ <text x={p.x} y={p.y + 1} textAnchor="middle"
643
+ fontSize={10} fill={color} fontWeight="bold"
644
+ style={{ pointerEvents: 'none' }}>▲</text>
645
+ </g>
646
+ </>
647
+ : <>
648
+ {isFocused && (
649
+ <circle cx={p.x} cy={p.y}
650
+ r={(cls.isModule ? MODULE_R : CLASS_R) + Math.min(cls.methodIds.length, 16) * 1.2 + 10}
651
+ fill="none"
652
+ stroke="var(--ac-primary)"
653
+ strokeWidth={2.5}
654
+ opacity={0.8}
655
+ style={{ pointerEvents: 'none' }}
656
+ />
657
+ )}
658
+ <ClassNode
659
+ cls={cls} cx={p.x} cy={p.y}
660
+ selected={selectedClassId === cls.id}
661
+ onToggle={(id) => { toggleExpand(id); onSelectClass?.(cls); }}
662
+ onHover={handleClassHover}
663
+ onLeave={hideTip}
664
+ />
665
+ </>
666
+ }
667
+ </g>
668
+ );
669
+ })}
670
+ </g>
671
+
672
+ {/* ── Legend ─────────────────────────────────────────────────────── */}
673
+ <g transform="translate(14,60)" fontSize={8}>
674
+ <rect x={-2} y={-2} width={90} height={70} rx={4}
675
+ fill="var(--bg-raised)" opacity={0.7} />
676
+ <line x1={4} y1={8} x2={24} y2={8} stroke="var(--lc-purple)" strokeWidth={1.8}
677
+ markerEnd="url(#cg-inherit)" />
678
+ <text x={28} y={11} fill="var(--tx-secondary)">extends</text>
679
+
680
+ <line x1={4} y1={22} x2={24} y2={22} stroke="var(--lc-cyan)" strokeWidth={1.2}
681
+ strokeDasharray="5 3" markerEnd="url(#cg-impl)" />
682
+ <text x={28} y={25} fill="var(--tx-secondary)">implements</text>
683
+
684
+ <line x1={4} y1={36} x2={24} y2={36} stroke="var(--tx-secondary)" strokeWidth={1.2}
685
+ strokeDasharray="5 3" markerEnd="url(#cg-call)" opacity={0.7} />
686
+ <text x={28} y={39} fill="var(--tx-secondary)">calls</text>
687
+
688
+ <circle cx={8} cy={52} r={5} fill="none" stroke="var(--tx-secondary)" strokeWidth={1.5} />
689
+ <text x={16} y={55} fill="var(--tx-secondary)">class</text>
690
+ <rect x={42} y={48} width={10} height={7} rx={2} fill="none"
691
+ stroke="var(--tx-secondary)" strokeWidth={1} strokeDasharray="2 1" />
692
+ <text x={56} y={55} fill="var(--tx-secondary)">module</text>
693
+ </g>
694
+ </svg>
695
+
696
+ <Tooltip tip={tooltip} />
697
+ </div>
698
+ );
699
+ }