codebase-digest-ai 0.1.1__py3-none-any.whl

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.
@@ -0,0 +1,1038 @@
1
+ """Interactive call graph exporter for developer inspection."""
2
+
3
+ from pathlib import Path
4
+ from typing import Dict, Any, List
5
+ import networkx as nx
6
+ from pyvis.network import Network
7
+
8
+ from ..models import CodebaseAnalysis, Symbol
9
+
10
+
11
+ class GraphExporter:
12
+ """Exports call graph as interactive HTML visualization."""
13
+
14
+ def __init__(self, analysis: CodebaseAnalysis, max_depth: int = None):
15
+ self.analysis = analysis
16
+ self.max_depth = max_depth
17
+ self.graph = self._build_networkx_graph()
18
+
19
+ def _build_networkx_graph(self) -> nx.DiGraph:
20
+ """Build NetworkX graph from analysis data."""
21
+ G = nx.DiGraph()
22
+
23
+ print(f"Building graph with {len(self.analysis.symbols)} symbols and {len(self.analysis.call_relations)} call relations")
24
+
25
+ # Build symbol index for fast lookup
26
+ symbol_index = {}
27
+ for symbol in self.analysis.symbols:
28
+ # Index by base name for resolution
29
+ base_name = symbol.name.split('.')[-1] # Handle Class.method -> method
30
+ symbol_index.setdefault(base_name, []).append(symbol)
31
+ # Also index by full name
32
+ symbol_index.setdefault(symbol.name, []).append(symbol)
33
+
34
+ # Add nodes for symbols
35
+ for symbol in self.analysis.symbols:
36
+ node_id = self._node_id(symbol)
37
+ rel_path = symbol.file_path.relative_to(self.analysis.root_path)
38
+
39
+ # Determine node color and size based on type
40
+ color = self._get_node_color(symbol.type)
41
+ size = self._get_node_size(symbol.type)
42
+
43
+ G.add_node(
44
+ node_id,
45
+ label=symbol.name,
46
+ title=f"{symbol.type}: {symbol.name}\nFile: {rel_path}\nLine: {symbol.line_number}",
47
+ color=color,
48
+ size=size,
49
+ symbol_type=symbol.type,
50
+ file_path=str(rel_path),
51
+ line_number=symbol.line_number,
52
+ docstring=symbol.docstring or "",
53
+ group=str(symbol.file_path.name) # Group by file for clustering
54
+ )
55
+
56
+ # Debug: Print some call relations
57
+ print("Sample call relations:")
58
+ for i, call in enumerate(self.analysis.call_relations[:5]):
59
+ print(f" {call.caller_symbol.name} -> {call.callee_name} (in {call.caller_symbol.file_path.name})")
60
+
61
+ # Add edges for call relationships - now trivial with symbol-aware relations
62
+ edges_added = 0
63
+ unresolved_calls = []
64
+
65
+ for call in self.analysis.call_relations:
66
+ # Caller is now directly available as a symbol
67
+ caller_id = self._node_id(call.caller_symbol)
68
+ callee_id = None
69
+
70
+ # Normalize callee name - strip object prefixes
71
+ callee_base = call.callee_name.split(".")[-1] # app.run -> run, self.validate -> validate
72
+
73
+ # Strategy 1: Direct match with normalized name
74
+ if callee_base in symbol_index:
75
+ # Find best match (prefer same file, then any file)
76
+ candidates = symbol_index[callee_base]
77
+
78
+ # Prefer same file as caller
79
+ same_file_candidates = [s for s in candidates if s.file_path == call.caller_symbol.file_path]
80
+ if same_file_candidates:
81
+ symbol = same_file_candidates[0]
82
+ callee_id = self._node_id(symbol)
83
+ else:
84
+ # Use first available candidate
85
+ symbol = candidates[0]
86
+ callee_id = self._node_id(symbol)
87
+
88
+ # Strategy 2: Constructor calls (Class() -> Class.__init__ or just Class)
89
+ elif call.callee_name in symbol_index:
90
+ candidates = symbol_index[call.callee_name]
91
+ # Look for class first
92
+ class_candidates = [s for s in candidates if s.type == 'class']
93
+ if class_candidates:
94
+ symbol = class_candidates[0]
95
+ callee_id = self._node_id(symbol)
96
+ else:
97
+ symbol = candidates[0]
98
+ callee_id = self._node_id(symbol)
99
+
100
+ # Strategy 3: Method calls (handle Class.method patterns)
101
+ elif "." in call.callee_name:
102
+ parts = call.callee_name.split(".")
103
+ if len(parts) == 2:
104
+ class_name, method_name = parts
105
+ # Look for the method in the class
106
+ method_key = f"{class_name}.{method_name}"
107
+ if method_key in symbol_index:
108
+ symbol = symbol_index[method_key][0]
109
+ callee_id = self._node_id(symbol)
110
+
111
+ # Add edge if callee found
112
+ if callee_id and G.has_node(caller_id) and G.has_node(callee_id):
113
+ G.add_edge(
114
+ caller_id,
115
+ callee_id,
116
+ title=f"{call.caller_symbol.name} → {call.callee_name}",
117
+ color="#666666",
118
+ width=2
119
+ )
120
+ edges_added += 1
121
+ else:
122
+ unresolved_calls.append(call.callee_name)
123
+
124
+ print(f"Added {edges_added} edges to graph")
125
+
126
+ # Debug: Show unresolved calls
127
+ if unresolved_calls:
128
+ print("Unresolved calls (first 10):")
129
+ for callee in list(set(unresolved_calls))[:10]:
130
+ print(f" - {callee}")
131
+
132
+ # Apply graph enhancements
133
+ # Remove builtin noise and isolated nodes FIRST
134
+ G = self._remove_builtin_noise(G)
135
+ isolated = list(nx.isolates(G))
136
+ G.remove_nodes_from(isolated)
137
+ print(f"Removed {len(isolated)} isolated nodes")
138
+
139
+ # Keep only largest connected component
140
+ components = list(nx.weakly_connected_components(G))
141
+ if components:
142
+ largest = max(components, key=len)
143
+ G = G.subgraph(largest).copy()
144
+ print(f"Kept largest component with {len(largest)} nodes")
145
+
146
+ # THEN enhance visualization (entrypoint marking happens after noise removal)
147
+ self._enhance_graph_visualization(G)
148
+
149
+ # Apply depth filtering if specified
150
+ if self.max_depth is not None:
151
+ G = self._apply_depth_filter(G, self.max_depth)
152
+
153
+ return G
154
+
155
+ def _apply_depth_filter(self, G: nx.DiGraph, max_depth: int) -> nx.DiGraph:
156
+ """Filter graph to show only nodes within max_depth from detected entrypoints."""
157
+ # Find detected entrypoints (nodes marked as entrypoints)
158
+ entrypoints = [node for node in G.nodes() if G.nodes[node].get("entrypoint", False)]
159
+
160
+ if not entrypoints:
161
+ # Fallback: use probabilistic detection if no entrypoints marked yet
162
+ entrypoints = self._detect_entrypoints(G)
163
+
164
+ if not entrypoints:
165
+ # Final fallback: return full graph if no entrypoints found
166
+ print("No entrypoints detected for depth filtering - returning full graph")
167
+ return G
168
+
169
+ # Collect all nodes within max_depth from any entrypoint using BFS
170
+ nodes_to_keep = set()
171
+ for entry in entrypoints:
172
+ if not G.has_node(entry):
173
+ continue
174
+
175
+ # BFS to find nodes within max_depth
176
+ visited = {entry}
177
+ queue = [(entry, 0)]
178
+
179
+ while queue:
180
+ node, depth = queue.pop(0)
181
+ nodes_to_keep.add(node)
182
+
183
+ if depth < max_depth:
184
+ for successor in G.successors(node):
185
+ if successor not in visited:
186
+ visited.add(successor)
187
+ queue.append((successor, depth + 1))
188
+
189
+ # Create subgraph with only the nodes to keep
190
+ filtered_graph = G.subgraph(nodes_to_keep).copy()
191
+ print(f"Depth filter: reduced from {len(G.nodes())} to {len(filtered_graph.nodes())} nodes (depth={max_depth})")
192
+
193
+ return filtered_graph
194
+
195
+ def _detect_entrypoints(self, G: nx.DiGraph) -> List[str]:
196
+ """Detect real execution entrypoints using weighted heuristic scoring."""
197
+ entrypoint_candidates = []
198
+
199
+ for node in G.nodes():
200
+ # Parse node identifier
201
+ parts = node.split("::")
202
+ if len(parts) != 2:
203
+ continue
204
+
205
+ file_name = parts[0].lower()
206
+ symbol_name = parts[1].lower()
207
+
208
+ # Calculate weighted score
209
+ score = 0
210
+
211
+ # File-based signals
212
+ if file_name in ("main.py", "app.py", "__main__.py"):
213
+ score += 3
214
+ elif file_name in ("cli.py", "server.py", "run.py"):
215
+ score += 2
216
+
217
+ # Symbol-based signals
218
+ if symbol_name in ("main", "run", "start"):
219
+ score += 2
220
+ elif symbol_name in ("app", "cli", "server"):
221
+ score += 1
222
+
223
+ # CLI bias: prefer "build" if present
224
+ if symbol_name == "build":
225
+ score += 3
226
+
227
+ # Topology signals
228
+ if G.in_degree(node) == 0:
229
+ score += 1
230
+
231
+ # Only consider nodes with meaningful score
232
+ if score >= 3:
233
+ entrypoint_candidates.append((node, score))
234
+
235
+ # Sort by score descending
236
+ entrypoint_candidates.sort(key=lambda x: x[1], reverse=True)
237
+
238
+ # Keep only strongest entrypoint unless multiple are truly equal
239
+ if entrypoint_candidates:
240
+ top_score = entrypoint_candidates[0][1]
241
+ dominant = [n for n, s in entrypoint_candidates if s == top_score]
242
+
243
+ # Limit to max 2 for safety
244
+ return dominant[:2]
245
+
246
+ return []
247
+
248
+ def _remove_builtin_noise(self, G: nx.DiGraph) -> nx.DiGraph:
249
+ """Remove nodes that do not correspond to project symbols."""
250
+
251
+ valid_nodes = set(self._node_id(s) for s in self.analysis.symbols)
252
+
253
+ nodes_to_remove = [n for n in G.nodes() if n not in valid_nodes]
254
+
255
+ G.remove_nodes_from(nodes_to_remove)
256
+ print(f"Removed {len(nodes_to_remove)} non-project nodes")
257
+
258
+ return G
259
+ def _enhance_graph_visualization(self, G: nx.DiGraph) -> None:
260
+ """Apply visual enhancements to make the graph more informative."""
261
+
262
+ # 1. PROBABILISTIC ENTRYPOINT DETECTION
263
+ entrypoints = self._detect_entrypoints(G)
264
+
265
+ for node in entrypoints:
266
+ if G.has_node(node): # Ensure node still exists after noise removal
267
+ G.nodes[node]["color"] = "#f59e0b" # Orange for entrypoints
268
+ G.nodes[node]["size"] += 12
269
+ G.nodes[node]["entrypoint"] = True
270
+
271
+ print(f"Detected {len(entrypoints)} probabilistic entrypoints: {[node.split('::')[-1] for node in entrypoints[:5]]}")
272
+
273
+ # 2. CENTRALITY WEIGHTING - Size nodes by structural importance
274
+ if len(G.nodes()) > 0:
275
+ centrality = nx.degree_centrality(G)
276
+ for node in G.nodes():
277
+ importance_boost = int(centrality[node] * 20)
278
+ G.nodes[node]["size"] += importance_boost
279
+
280
+ # 3. EXECUTION PATH EMPHASIS - Mark immediate successors of entrypoints
281
+ for entry in entrypoints:
282
+ if G.has_node(entry):
283
+ for successor in G.successors(entry):
284
+ if G.nodes[successor].get("color") != "#f59e0b": # Don't override entrypoints
285
+ G.nodes[successor]["borderWidth"] = 3
286
+ G.nodes[successor]["borderColor"] = "#f59e0b"
287
+
288
+ def _node_id(self, symbol: Symbol) -> str:
289
+ """Generate consistent node ID for a symbol."""
290
+ return f"{symbol.file_path.name}::{symbol.name}"
291
+
292
+ def _get_node_color(self, symbol_type: str) -> str:
293
+ """Get node color based on symbol type."""
294
+ colors = {
295
+ 'function': '#3b82f6', # blue
296
+ 'method': '#3b82f6', # blue
297
+ 'class': '#10b981', # green
298
+ 'file': '#6b7280' # gray
299
+ }
300
+ return colors.get(symbol_type, '#6b7280')
301
+
302
+ def _get_node_size(self, symbol_type: str) -> int:
303
+ """Get node size based on symbol type."""
304
+ sizes = {
305
+ 'class': 25,
306
+ 'function': 20,
307
+ 'method': 15,
308
+ 'file': 30
309
+ }
310
+ return sizes.get(symbol_type, 20)
311
+
312
+ def export(self, output_path: Path) -> None:
313
+ """Export graph as interactive HTML file."""
314
+ # Add nodes and edges from NetworkX graph
315
+ node_count = 0
316
+ edge_count = 0
317
+
318
+ # Collect nodes and edges data
319
+ nodes_data = []
320
+ edges_data = []
321
+
322
+ for node_id, data in self.graph.nodes(data=True):
323
+ nodes_data.append({
324
+ 'id': node_id,
325
+ 'label': data['label'],
326
+ 'title': data['title'],
327
+ 'color': data['color'],
328
+ 'size': data['size'],
329
+ 'group': data.get('group', 'default'),
330
+ 'font': {'color': '#1f2937'},
331
+ 'shape': 'dot'
332
+ })
333
+ node_count += 1
334
+
335
+ for source, target, data in self.graph.edges(data=True):
336
+ if self.graph.has_node(source) and self.graph.has_node(target):
337
+ edges_data.append({
338
+ 'from': source,
339
+ 'to': target,
340
+ 'title': data['title'],
341
+ 'color': data['color'],
342
+ 'width': data['width'],
343
+ 'arrows': 'to'
344
+ })
345
+ edge_count += 1
346
+
347
+ # Calculate real component count
348
+ components = nx.number_weakly_connected_components(self.graph)
349
+
350
+ # If no edges, create a simple layout with isolated nodes
351
+ if edge_count == 0:
352
+ print(f"Warning: No edges found in graph. Showing {node_count} isolated nodes.")
353
+
354
+ # Generate custom HTML with proper developer tool styling
355
+ html_content = self._generate_developer_html(nodes_data, edges_data, node_count, edge_count, components)
356
+
357
+ try:
358
+ output_path.write_text(html_content, encoding='utf-8')
359
+ print(f"Graph saved with {node_count} nodes and {edge_count} edges")
360
+ except Exception as e:
361
+ print(f"Error saving graph: {e}")
362
+ # Fallback: create a simple HTML file
363
+ self._create_fallback_html(output_path, node_count, edge_count)
364
+
365
+ def _generate_developer_html(self, nodes_data: list, edges_data: list, node_count: int, edge_count: int, components: int) -> str:
366
+ """Generate professional developer tool HTML with split layout."""
367
+ import json
368
+
369
+ nodes_json = json.dumps(nodes_data, indent=2)
370
+ edges_json = json.dumps(edges_data, indent=2)
371
+
372
+ warning_html = '''
373
+ <div class="warning-banner">
374
+ <div class="warning-icon">⚠️</div>
375
+ <div>
376
+ <strong>No connections found</strong>
377
+ <p>Showing isolated nodes. This may indicate parsing issues or a codebase with minimal cross-references.</p>
378
+ </div>
379
+ </div>''' if edge_count == 0 else ''
380
+
381
+ return f'''<!DOCTYPE html>
382
+ <html lang="en">
383
+ <head>
384
+ <meta charset="UTF-8">
385
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
386
+ <title>Call Graph - {self.analysis.root_path.name}</title>
387
+ <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
388
+ <style>
389
+ :root {{
390
+ --accent: #3b82f6;
391
+ --bg: #f8fafc;
392
+ --surface: #ffffff;
393
+ --soft: #f1f5f9;
394
+ --text: #0f172a;
395
+ --muted: #64748b;
396
+ --border: #e2e8f0;
397
+ --danger: #ef4444;
398
+ --warning: #f59e0b;
399
+ --success: #10b981;
400
+ }}
401
+
402
+ * {{
403
+ margin: 0;
404
+ padding: 0;
405
+ box-sizing: border-box;
406
+ }}
407
+
408
+ body {{
409
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
410
+ background: var(--bg);
411
+ color: var(--text);
412
+ height: 100vh;
413
+ overflow: hidden;
414
+ }}
415
+
416
+ .app-shell {{
417
+ display: flex;
418
+ flex-direction: column;
419
+ height: 100vh;
420
+ }}
421
+
422
+ .header {{
423
+ background: var(--surface);
424
+ border-bottom: 1px solid var(--border);
425
+ padding: 16px 24px;
426
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
427
+ position: sticky;
428
+ top: 0;
429
+ z-index: 100;
430
+ }}
431
+
432
+ .header h1 {{
433
+ font-size: 18px;
434
+ font-weight: 600;
435
+ color: var(--text);
436
+ margin-bottom: 4px;
437
+ }}
438
+
439
+ .header-meta {{
440
+ font-size: 13px;
441
+ color: var(--muted);
442
+ display: flex;
443
+ gap: 16px;
444
+ align-items: center;
445
+ }}
446
+
447
+ .header-meta .accent {{
448
+ color: var(--accent);
449
+ font-weight: 500;
450
+ }}
451
+
452
+ .main-content {{
453
+ flex: 1;
454
+ display: flex;
455
+ overflow: hidden;
456
+ }}
457
+
458
+ .graph-panel {{
459
+ flex: 1;
460
+ background: var(--surface);
461
+ position: relative;
462
+ border-right: 1px solid var(--border);
463
+ }}
464
+
465
+ .inspector-panel {{
466
+ width: 320px;
467
+ background: var(--surface);
468
+ border-left: 1px solid var(--border);
469
+ display: flex;
470
+ flex-direction: column;
471
+ overflow: hidden;
472
+ }}
473
+
474
+ .inspector-header {{
475
+ padding: 16px 20px;
476
+ border-bottom: 1px solid var(--border);
477
+ background: var(--soft);
478
+ }}
479
+
480
+ .inspector-header h2 {{
481
+ font-size: 14px;
482
+ font-weight: 600;
483
+ color: var(--text);
484
+ margin-bottom: 4px;
485
+ }}
486
+
487
+ .inspector-header p {{
488
+ font-size: 12px;
489
+ color: var(--muted);
490
+ }}
491
+
492
+ .inspector-content {{
493
+ flex: 1;
494
+ overflow-y: auto;
495
+ padding: 20px;
496
+ }}
497
+
498
+ .legend {{
499
+ margin-bottom: 24px;
500
+ }}
501
+
502
+ .legend h3 {{
503
+ font-size: 13px;
504
+ font-weight: 600;
505
+ color: var(--text);
506
+ margin-bottom: 12px;
507
+ }}
508
+
509
+ .legend-items {{
510
+ display: flex;
511
+ flex-direction: column;
512
+ gap: 8px;
513
+ }}
514
+
515
+ .legend-item {{
516
+ display: flex;
517
+ align-items: center;
518
+ gap: 8px;
519
+ font-size: 12px;
520
+ color: var(--muted);
521
+ }}
522
+
523
+ .legend-dot {{
524
+ width: 12px;
525
+ height: 12px;
526
+ border-radius: 50%;
527
+ border: 2px solid var(--border);
528
+ flex-shrink: 0;
529
+ }}
530
+
531
+ .legend-functions {{ background: var(--accent); }}
532
+ .legend-classes {{ background: var(--success); }}
533
+ .legend-files {{ background: var(--muted); }}
534
+
535
+ .stats-section {{
536
+ margin-bottom: 24px;
537
+ }}
538
+
539
+ .stats-section h3 {{
540
+ font-size: 13px;
541
+ font-weight: 600;
542
+ color: var(--text);
543
+ margin-bottom: 12px;
544
+ }}
545
+
546
+ .stat-item {{
547
+ display: flex;
548
+ justify-content: space-between;
549
+ align-items: center;
550
+ padding: 8px 0;
551
+ font-size: 12px;
552
+ border-bottom: 1px solid var(--border);
553
+ }}
554
+
555
+ .stat-item:last-child {{
556
+ border-bottom: none;
557
+ }}
558
+
559
+ .stat-label {{
560
+ color: var(--muted);
561
+ }}
562
+
563
+ .stat-value {{
564
+ color: var(--text);
565
+ font-weight: 500;
566
+ }}
567
+
568
+ .node-info {{
569
+ background: var(--soft);
570
+ border: 1px solid var(--border);
571
+ border-radius: 6px;
572
+ padding: 16px;
573
+ margin-bottom: 16px;
574
+ display: none;
575
+ }}
576
+
577
+ .node-info.active {{
578
+ display: block;
579
+ }}
580
+
581
+ .node-info h4 {{
582
+ font-size: 14px;
583
+ font-weight: 600;
584
+ color: var(--text);
585
+ margin-bottom: 8px;
586
+ }}
587
+
588
+ .node-info p {{
589
+ font-size: 12px;
590
+ color: var(--muted);
591
+ margin-bottom: 4px;
592
+ }}
593
+
594
+ .node-info .node-type {{
595
+ display: inline-block;
596
+ background: var(--accent);
597
+ color: white;
598
+ padding: 2px 6px;
599
+ border-radius: 3px;
600
+ font-size: 10px;
601
+ font-weight: 500;
602
+ text-transform: uppercase;
603
+ margin-bottom: 8px;
604
+ }}
605
+
606
+ .warning-banner {{
607
+ background: #fef3c7;
608
+ border: 1px solid #f59e0b;
609
+ border-radius: 6px;
610
+ padding: 12px;
611
+ margin: 16px 20px;
612
+ display: flex;
613
+ gap: 12px;
614
+ align-items: flex-start;
615
+ }}
616
+
617
+ .warning-icon {{
618
+ font-size: 16px;
619
+ flex-shrink: 0;
620
+ }}
621
+
622
+ .warning-banner strong {{
623
+ color: #92400e;
624
+ font-size: 13px;
625
+ display: block;
626
+ margin-bottom: 4px;
627
+ }}
628
+
629
+ .warning-banner p {{
630
+ color: #92400e;
631
+ font-size: 12px;
632
+ line-height: 1.4;
633
+ }}
634
+
635
+ #network {{
636
+ width: 100%;
637
+ height: 100%;
638
+ background: var(--surface);
639
+ }}
640
+
641
+ .controls {{
642
+ position: absolute;
643
+ top: 16px;
644
+ left: 16px;
645
+ background: var(--surface);
646
+ border: 1px solid var(--border);
647
+ border-radius: 6px;
648
+ padding: 8px 12px;
649
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
650
+ z-index: 10;
651
+ }}
652
+
653
+ .controls button {{
654
+ background: none;
655
+ border: none;
656
+ color: var(--muted);
657
+ font-size: 12px;
658
+ cursor: pointer;
659
+ padding: 4px 8px;
660
+ border-radius: 4px;
661
+ transition: all 0.15s ease;
662
+ }}
663
+
664
+ .controls button:hover {{
665
+ background: var(--soft);
666
+ color: var(--text);
667
+ }}
668
+
669
+ .empty-state {{
670
+ display: flex;
671
+ flex-direction: column;
672
+ align-items: center;
673
+ justify-content: center;
674
+ height: 200px;
675
+ color: var(--muted);
676
+ font-size: 13px;
677
+ text-align: center;
678
+ }}
679
+
680
+ .empty-state-icon {{
681
+ font-size: 24px;
682
+ margin-bottom: 8px;
683
+ opacity: 0.5;
684
+ }}
685
+
686
+ .insights-section {{
687
+ margin-bottom: 24px;
688
+ }}
689
+
690
+ .insights-section h3 {{
691
+ font-size: 13px;
692
+ font-weight: 600;
693
+ color: var(--text);
694
+ margin-bottom: 12px;
695
+ }}
696
+
697
+ .insight-item {{
698
+ display: flex;
699
+ align-items: flex-start;
700
+ gap: 10px;
701
+ margin-bottom: 12px;
702
+ padding: 8px;
703
+ background: var(--soft);
704
+ border-radius: 4px;
705
+ }}
706
+
707
+ .insight-icon {{
708
+ width: 24px;
709
+ height: 24px;
710
+ border-radius: 50%;
711
+ display: flex;
712
+ align-items: center;
713
+ justify-content: center;
714
+ font-size: 12px;
715
+ flex-shrink: 0;
716
+ }}
717
+
718
+ .insight-item strong {{
719
+ font-size: 12px;
720
+ color: var(--text);
721
+ display: block;
722
+ margin-bottom: 2px;
723
+ }}
724
+
725
+ .insight-item p {{
726
+ font-size: 11px;
727
+ color: var(--muted);
728
+ line-height: 1.3;
729
+ }}
730
+ </style>
731
+ </head>
732
+ <body>
733
+ <div class="app-shell">
734
+ <div class="header">
735
+ <h1>Call Graph: {self.analysis.root_path.name}</h1>
736
+ <div class="header-meta">
737
+ <span>Interactive visualization of function and method call relationships</span>
738
+ <span class="accent">{node_count} nodes</span>
739
+ <span class="accent">{edge_count} edges</span>
740
+ </div>
741
+ </div>
742
+
743
+ {warning_html}
744
+
745
+ <div class="main-content">
746
+ <div class="graph-panel">
747
+ <div class="controls">
748
+ <button onclick="network.fit()">Fit View</button>
749
+ <button onclick="togglePhysics()">Toggle Physics</button>
750
+ </div>
751
+ <div id="network"></div>
752
+ </div>
753
+
754
+ <div class="inspector-panel">
755
+ <div class="inspector-header">
756
+ <h2>Graph Inspector</h2>
757
+ <p>Click nodes to inspect details</p>
758
+ </div>
759
+
760
+ <div class="inspector-content">
761
+ <div class="legend">
762
+ <h3>Legend</h3>
763
+ <div class="legend-items">
764
+ <div class="legend-item">
765
+ <div class="legend-dot legend-functions"></div>
766
+ <span>Functions & Methods</span>
767
+ </div>
768
+ <div class="legend-item">
769
+ <div class="legend-dot legend-classes"></div>
770
+ <span>Classes</span>
771
+ </div>
772
+ <div class="legend-item">
773
+ <div class="legend-dot legend-files"></div>
774
+ <span>Files</span>
775
+ </div>
776
+ </div>
777
+ </div>
778
+
779
+ <div class="stats-section">
780
+ <h3>Statistics</h3>
781
+ <div class="stat-item">
782
+ <span class="stat-label">Total Nodes</span>
783
+ <span class="stat-value">{node_count}</span>
784
+ </div>
785
+ <div class="stat-item">
786
+ <span class="stat-label">Total Edges</span>
787
+ <span class="stat-value">{edge_count}</span>
788
+ </div>
789
+ <div class="stat-item">
790
+ <span class="stat-label">Files Analyzed</span>
791
+ <span class="stat-value">{len(set(node['group'] for node in nodes_data))}</span>
792
+ </div>
793
+ <div class="stat-item">
794
+ <span class="stat-label">Entrypoints</span>
795
+ <span class="stat-value" id="entrypoint-count">-</span>
796
+ </div>
797
+ <div class="stat-item">
798
+ <span class="stat-label">Components</span>
799
+ <span class="stat-value">{components}</span>
800
+ </div>
801
+ </div>
802
+
803
+ <div class="insights-section">
804
+ <h3>Graph Insights</h3>
805
+ <div class="insight-item">
806
+ <div class="insight-icon" style="background: #f59e0b;">🚀</div>
807
+ <div>
808
+ <strong>Entrypoints</strong>
809
+ <p>Orange nodes show execution starting points</p>
810
+ </div>
811
+ </div>
812
+ <div class="insight-item">
813
+ <div class="insight-icon" style="background: #3b82f6;">🔗</div>
814
+ <div>
815
+ <strong>Call Chains</strong>
816
+ <p>Follow arrows to trace execution flow</p>
817
+ </div>
818
+ </div>
819
+ <div class="insight-item">
820
+ <div class="insight-icon" style="background: #10b981;">📦</div>
821
+ <div>
822
+ <strong>Clusters</strong>
823
+ <p>Related functions group naturally</p>
824
+ </div>
825
+ </div>
826
+ </div>
827
+
828
+ <div class="node-info" id="nodeInfo">
829
+ <div class="empty-state">
830
+ <div class="empty-state-icon">👆</div>
831
+ <p>Click a node to view details</p>
832
+ </div>
833
+ </div>
834
+ </div>
835
+ </div>
836
+ </div>
837
+ </div>
838
+
839
+ <script type="text/javascript">
840
+ // Initialize data
841
+ const nodes = new vis.DataSet({nodes_json});
842
+ const edges = new vis.DataSet({edges_json});
843
+
844
+ // Network options with improved physics
845
+ const options = {{
846
+ physics: {{
847
+ enabled: true,
848
+ stabilization: {{ iterations: 300 }},
849
+ barnesHut: {{
850
+ gravitationalConstant: -6000,
851
+ centralGravity: 0.3,
852
+ springLength: 120,
853
+ springConstant: 0.08,
854
+ damping: 0.1,
855
+ avoidOverlap: 0.2
856
+ }}
857
+ }},
858
+ nodes: {{
859
+ font: {{
860
+ size: 14,
861
+ face: "system-ui, -apple-system, sans-serif"
862
+ }},
863
+ borderWidth: 2,
864
+ shadow: {{
865
+ enabled: true,
866
+ color: "rgba(0,0,0,0.2)",
867
+ size: 5,
868
+ x: 2,
869
+ y: 2
870
+ }},
871
+ chosen: true
872
+ }},
873
+ edges: {{
874
+ arrows: {{
875
+ to: {{ enabled: true, scaleFactor: 0.8 }}
876
+ }},
877
+ smooth: {{ type: "continuous" }},
878
+ color: {{ color: "#666666", highlight: "#3b82f6" }},
879
+ width: 2
880
+ }},
881
+ interaction: {{
882
+ hover: true,
883
+ tooltipDelay: 200,
884
+ hideEdgesOnDrag: false,
885
+ hideNodesOnDrag: false
886
+ }},
887
+ layout: {{
888
+ improvedLayout: true,
889
+ randomSeed: 42
890
+ }},
891
+ groups: {{
892
+ useDefaultGroups: true
893
+ }}
894
+ }};
895
+
896
+ // Initialize network
897
+ const container = document.getElementById('network');
898
+ const data = {{ nodes: nodes, edges: edges }};
899
+ const network = new vis.Network(container, data, options);
900
+
901
+ // Node click handler
902
+ network.on("click", function(params) {{
903
+ const nodeInfo = document.getElementById('nodeInfo');
904
+
905
+ if (params.nodes.length > 0) {{
906
+ const nodeId = params.nodes[0];
907
+ const node = nodes.get(nodeId);
908
+
909
+ // Extract info from title
910
+ const titleParts = node.title.split('\\n');
911
+ const type = titleParts[0].split(': ')[0];
912
+ const name = titleParts[0].split(': ')[1];
913
+ const file = titleParts[1] ? titleParts[1].split(': ')[1] : 'Unknown';
914
+ const line = titleParts[2] ? titleParts[2].split(': ')[1] : 'Unknown';
915
+
916
+ nodeInfo.innerHTML = `
917
+ <div class="node-type">${{type}}</div>
918
+ <h4>${{name}}</h4>
919
+ <p><strong>File:</strong> ${{file}}</p>
920
+ <p><strong>Line:</strong> ${{line}}</p>
921
+ <p><strong>Group:</strong> ${{node.group}}</p>
922
+ `;
923
+ nodeInfo.classList.add('active');
924
+ }} else {{
925
+ nodeInfo.innerHTML = `
926
+ <div class="empty-state">
927
+ <div class="empty-state-icon">👆</div>
928
+ <p>Click a node to view details</p>
929
+ </div>
930
+ `;
931
+ nodeInfo.classList.remove('active');
932
+ }}
933
+ }});
934
+
935
+ // Physics toggle
936
+ let physicsEnabled = true;
937
+ function togglePhysics() {{
938
+ physicsEnabled = !physicsEnabled;
939
+ network.setOptions({{ physics: {{ enabled: physicsEnabled }} }});
940
+ }}
941
+
942
+ // Cluster by file groups if we have many nodes
943
+ if (nodes.length > 20) {{
944
+
945
+ }}
946
+
947
+ // Auto-fit on load
948
+ network.once("stabilizationIterationsDone", function() {{
949
+ network.fit();
950
+
951
+ // Update dynamic statistics
952
+ updateGraphStats();
953
+ }});
954
+
955
+ function updateGraphStats() {{
956
+ // Count entrypoints (orange nodes)
957
+ const entrypoints = nodes.get({{
958
+ filter: function(node) {{
959
+ return node.color === '#f59e0b';
960
+ }}
961
+ }});
962
+ document.getElementById('entrypoint-count').textContent = entrypoints.length;
963
+ }}
964
+ </script>
965
+ </body>
966
+ </html>'''
967
+
968
+ def get_graph_stats(self) -> Dict[str, Any]:
969
+ """Get statistics about the call graph."""
970
+ return {
971
+ 'nodes': len(self.graph.nodes),
972
+ 'edges': len(self.graph.edges),
973
+ 'density': nx.density(self.graph),
974
+ 'is_connected': nx.is_weakly_connected(self.graph),
975
+ 'components': nx.number_weakly_connected_components(self.graph)
976
+ }
977
+
978
+ def _create_fallback_html(self, output_path: Path, node_count: int, edge_count: int) -> None:
979
+ """Create a simple fallback HTML when pyvis fails."""
980
+ html_content = f"""<!DOCTYPE html>
981
+ <html lang="en">
982
+ <head>
983
+ <meta charset="UTF-8">
984
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
985
+ <title>Call Graph - {self.analysis.root_path.name}</title>
986
+ <style>
987
+ body {{
988
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
989
+ background: #f8fafc;
990
+ color: #1f2937;
991
+ padding: 24px;
992
+ }}
993
+ .header {{
994
+ background: white;
995
+ padding: 24px;
996
+ border-radius: 8px;
997
+ border: 1px solid #e5e7eb;
998
+ margin-bottom: 24px;
999
+ }}
1000
+ .stats {{
1001
+ background: #f3f4f6;
1002
+ padding: 16px;
1003
+ border-radius: 6px;
1004
+ margin-bottom: 24px;
1005
+ }}
1006
+ .node-list {{
1007
+ background: white;
1008
+ padding: 24px;
1009
+ border-radius: 8px;
1010
+ border: 1px solid #e5e7eb;
1011
+ }}
1012
+ .node-item {{
1013
+ padding: 8px 0;
1014
+ border-bottom: 1px solid #e5e7eb;
1015
+ }}
1016
+ .node-item:last-child {{
1017
+ border-bottom: none;
1018
+ }}
1019
+ </style>
1020
+ </head>
1021
+ <body>
1022
+ <div class="header">
1023
+ <h1>Call Graph: {self.analysis.root_path.name}</h1>
1024
+ <p>Graph visualization failed. Showing node list instead.</p>
1025
+ </div>
1026
+
1027
+ <div class="stats">
1028
+ <p>Nodes: {node_count} | Edges: {edge_count}</p>
1029
+ </div>
1030
+
1031
+ <div class="node-list">
1032
+ <h2>Detected Symbols</h2>
1033
+ {''.join(f'<div class="node-item">{symbol.name} ({symbol.type}) - {symbol.file_path.name}:{symbol.line_number}</div>' for symbol in self.analysis.symbols[:20])}
1034
+ {f'<div class="node-item">... and {len(self.analysis.symbols) - 20} more</div>' if len(self.analysis.symbols) > 20 else ''}
1035
+ </div>
1036
+ </body>
1037
+ </html>"""
1038
+ output_path.write_text(html_content, encoding='utf-8')