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.
- codebase_digest/__init__.py +8 -0
- codebase_digest/analyzer/__init__.py +7 -0
- codebase_digest/analyzer/codebase_analyzer.py +183 -0
- codebase_digest/analyzer/flow_analyzer.py +164 -0
- codebase_digest/analyzer/metrics_analyzer.py +130 -0
- codebase_digest/cli/__init__.py +1 -0
- codebase_digest/cli/main.py +284 -0
- codebase_digest/exporters/__init__.py +9 -0
- codebase_digest/exporters/graph_exporter.py +1038 -0
- codebase_digest/exporters/html_exporter.py +1052 -0
- codebase_digest/exporters/json_exporter.py +105 -0
- codebase_digest/exporters/markdown_exporter.py +273 -0
- codebase_digest/exporters/readme_exporter.py +306 -0
- codebase_digest/models.py +81 -0
- codebase_digest/parser/__init__.py +7 -0
- codebase_digest/parser/base.py +41 -0
- codebase_digest/parser/javascript_parser.py +36 -0
- codebase_digest/parser/python_parser.py +270 -0
- codebase_digest_ai-0.1.1.dist-info/METADATA +233 -0
- codebase_digest_ai-0.1.1.dist-info/RECORD +24 -0
- codebase_digest_ai-0.1.1.dist-info/WHEEL +5 -0
- codebase_digest_ai-0.1.1.dist-info/entry_points.txt +2 -0
- codebase_digest_ai-0.1.1.dist-info/licenses/LICENSE +21 -0
- codebase_digest_ai-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -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')
|