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