polycodegraph 0.1.0__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.
- codegraph/__init__.py +10 -0
- codegraph/analysis/__init__.py +30 -0
- codegraph/analysis/_common.py +125 -0
- codegraph/analysis/blast_radius.py +63 -0
- codegraph/analysis/cycles.py +79 -0
- codegraph/analysis/dataflow.py +861 -0
- codegraph/analysis/dead_code.py +165 -0
- codegraph/analysis/hotspots.py +68 -0
- codegraph/analysis/infrastructure.py +439 -0
- codegraph/analysis/metrics.py +52 -0
- codegraph/analysis/report.py +222 -0
- codegraph/analysis/roles.py +323 -0
- codegraph/analysis/untested.py +79 -0
- codegraph/cli.py +1506 -0
- codegraph/config.py +64 -0
- codegraph/embed/__init__.py +35 -0
- codegraph/embed/chunker.py +120 -0
- codegraph/embed/embedder.py +113 -0
- codegraph/embed/query.py +181 -0
- codegraph/embed/store.py +360 -0
- codegraph/graph/__init__.py +0 -0
- codegraph/graph/builder.py +212 -0
- codegraph/graph/schema.py +69 -0
- codegraph/graph/store_networkx.py +55 -0
- codegraph/graph/store_sqlite.py +249 -0
- codegraph/mcp_server/__init__.py +6 -0
- codegraph/mcp_server/server.py +933 -0
- codegraph/parsers/__init__.py +0 -0
- codegraph/parsers/base.py +70 -0
- codegraph/parsers/go.py +570 -0
- codegraph/parsers/python.py +1707 -0
- codegraph/parsers/typescript.py +1397 -0
- codegraph/py.typed +0 -0
- codegraph/resolve/__init__.py +4 -0
- codegraph/resolve/calls.py +480 -0
- codegraph/review/__init__.py +31 -0
- codegraph/review/baseline.py +32 -0
- codegraph/review/differ.py +211 -0
- codegraph/review/hook.py +70 -0
- codegraph/review/risk.py +219 -0
- codegraph/review/rules.py +342 -0
- codegraph/viz/__init__.py +17 -0
- codegraph/viz/_style.py +45 -0
- codegraph/viz/dashboard.py +740 -0
- codegraph/viz/diagrams.py +370 -0
- codegraph/viz/explore.py +453 -0
- codegraph/viz/hld.py +683 -0
- codegraph/viz/html.py +115 -0
- codegraph/viz/mermaid.py +111 -0
- codegraph/viz/svg.py +77 -0
- codegraph/web/__init__.py +4 -0
- codegraph/web/server.py +165 -0
- codegraph/web/static/app.css +664 -0
- codegraph/web/static/app.js +919 -0
- codegraph/web/static/index.html +112 -0
- codegraph/web/static/views/architecture.js +1671 -0
- codegraph/web/static/views/graph3d.css +564 -0
- codegraph/web/static/views/graph3d.js +999 -0
- codegraph/web/static/views/graph3d_transform.js +984 -0
- codegraph/workspace/__init__.py +34 -0
- codegraph/workspace/config.py +110 -0
- codegraph/workspace/operations.py +294 -0
- polycodegraph-0.1.0.dist-info/METADATA +687 -0
- polycodegraph-0.1.0.dist-info/RECORD +67 -0
- polycodegraph-0.1.0.dist-info/WHEEL +4 -0
- polycodegraph-0.1.0.dist-info/entry_points.txt +2 -0
- polycodegraph-0.1.0.dist-info/licenses/LICENSE +21 -0
codegraph/viz/explore.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Multi-view interactive dashboard ("explore" mode).
|
|
2
|
+
|
|
3
|
+
Generates a folder of linked HTML pages so users can navigate a real-world
|
|
4
|
+
graph at multiple zoom levels:
|
|
5
|
+
|
|
6
|
+
* ``index.html`` — landing page with key metrics + links
|
|
7
|
+
* ``architecture.html`` — module-level diagram (one node per module, edges
|
|
8
|
+
aggregated by kind with weight = count)
|
|
9
|
+
* ``callgraph.html`` — only functions + methods, with pyvis filter UI
|
|
10
|
+
* ``inheritance.html`` — only classes, INHERITS / IMPLEMENTS edges
|
|
11
|
+
* ``files/<slug>.html`` — per-file detail (module + its symbols + 1-hop
|
|
12
|
+
neighbours so cross-file calls are visible in context)
|
|
13
|
+
|
|
14
|
+
Each page is self-contained (pyvis ``cdn_resources="in_line"``) so the
|
|
15
|
+
folder can be opened over file:// without a server.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import html
|
|
20
|
+
import re
|
|
21
|
+
from collections import Counter, defaultdict
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, cast
|
|
25
|
+
|
|
26
|
+
import networkx as nx
|
|
27
|
+
|
|
28
|
+
from codegraph.viz._style import EDGE_STYLE, KIND_COLOR, kind_str
|
|
29
|
+
|
|
30
|
+
_DEFINITION_KINDS: frozenset[str] = frozenset(
|
|
31
|
+
{"MODULE", "CLASS", "FUNCTION", "METHOD", "TEST"}
|
|
32
|
+
)
|
|
33
|
+
_CALLABLE_KINDS: frozenset[str] = frozenset({"FUNCTION", "METHOD"})
|
|
34
|
+
_NOISE_KINDS: frozenset[str] = frozenset({"FILE"})
|
|
35
|
+
|
|
36
|
+
_SLUG_RE = re.compile(r"[^a-zA-Z0-9_-]+")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ExploreResult:
|
|
41
|
+
out_dir: Path
|
|
42
|
+
pages: list[Path]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _slug(name: str) -> str:
|
|
46
|
+
return _SLUG_RE.sub("_", name).strip("_") or "page"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _shape_for_kind(kind: str) -> str:
|
|
50
|
+
if kind in ("FILE", "MODULE"):
|
|
51
|
+
return "box"
|
|
52
|
+
if kind == "CLASS":
|
|
53
|
+
return "ellipse"
|
|
54
|
+
if kind == "TEST":
|
|
55
|
+
return "diamond"
|
|
56
|
+
return "dot"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _node_title(attrs: dict[str, Any]) -> str:
|
|
60
|
+
parts = [
|
|
61
|
+
f"<b>{html.escape(str(attrs.get('name') or attrs.get('qualname') or ''))}</b>",
|
|
62
|
+
f"kind: {kind_str(attrs.get('kind'))}",
|
|
63
|
+
f"qualname: {html.escape(str(attrs.get('qualname') or '-'))}",
|
|
64
|
+
f"file: {html.escape(str(attrs.get('file') or '-'))}:"
|
|
65
|
+
f"{attrs.get('line_start') or '?'}",
|
|
66
|
+
]
|
|
67
|
+
sig = attrs.get("signature")
|
|
68
|
+
if sig:
|
|
69
|
+
parts.append(f"sig: {html.escape(str(sig))}")
|
|
70
|
+
return "<br>".join(parts)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _strip_noise(graph: nx.MultiDiGraph) -> nx.MultiDiGraph:
|
|
74
|
+
"""Drop unresolved::* phantom nodes and FILE nodes."""
|
|
75
|
+
drop: list[str] = [
|
|
76
|
+
nid for nid, attrs in graph.nodes(data=True)
|
|
77
|
+
if (isinstance(nid, str) and nid.startswith("unresolved::"))
|
|
78
|
+
or kind_str(attrs.get("kind")) in _NOISE_KINDS
|
|
79
|
+
]
|
|
80
|
+
if not drop:
|
|
81
|
+
return graph
|
|
82
|
+
out = cast(nx.MultiDiGraph, graph.copy())
|
|
83
|
+
out.remove_nodes_from(drop)
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _kind_subgraph(
|
|
88
|
+
graph: nx.MultiDiGraph, kinds: frozenset[str]
|
|
89
|
+
) -> nx.MultiDiGraph:
|
|
90
|
+
keep = {
|
|
91
|
+
nid for nid, attrs in graph.nodes(data=True)
|
|
92
|
+
if kind_str(attrs.get("kind")) in kinds
|
|
93
|
+
}
|
|
94
|
+
return cast(nx.MultiDiGraph, graph.subgraph(keep).copy())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _aggregate_to_modules(graph: nx.MultiDiGraph) -> nx.DiGraph:
|
|
98
|
+
"""Collapse every CLASS/FUNCTION/METHOD into its parent MODULE.
|
|
99
|
+
|
|
100
|
+
The resulting DiGraph has one node per MODULE plus aggregated edges
|
|
101
|
+
keyed by (kind) with ``weight`` = count of original edges.
|
|
102
|
+
"""
|
|
103
|
+
# Map any node id -> module id (its file's module node).
|
|
104
|
+
module_by_file: dict[str, str] = {}
|
|
105
|
+
for nid, attrs in graph.nodes(data=True):
|
|
106
|
+
if kind_str(attrs.get("kind")) == "MODULE":
|
|
107
|
+
file_path = attrs.get("file")
|
|
108
|
+
if isinstance(file_path, str):
|
|
109
|
+
module_by_file[file_path] = nid
|
|
110
|
+
node_to_module: dict[str, str] = {}
|
|
111
|
+
for nid, attrs in graph.nodes(data=True):
|
|
112
|
+
kind = kind_str(attrs.get("kind"))
|
|
113
|
+
if kind == "MODULE":
|
|
114
|
+
node_to_module[nid] = nid
|
|
115
|
+
continue
|
|
116
|
+
file_path = attrs.get("file")
|
|
117
|
+
if isinstance(file_path, str) and file_path in module_by_file:
|
|
118
|
+
node_to_module[nid] = module_by_file[file_path]
|
|
119
|
+
|
|
120
|
+
out: nx.DiGraph = nx.DiGraph()
|
|
121
|
+
for mid, attrs in graph.nodes(data=True):
|
|
122
|
+
if kind_str(attrs.get("kind")) != "MODULE":
|
|
123
|
+
continue
|
|
124
|
+
package = ""
|
|
125
|
+
qn = str(attrs.get("qualname") or "")
|
|
126
|
+
if "." in qn:
|
|
127
|
+
package = qn.rsplit(".", 1)[0]
|
|
128
|
+
out.add_node(
|
|
129
|
+
mid,
|
|
130
|
+
label=str(attrs.get("name") or qn or mid[:8]),
|
|
131
|
+
qualname=qn,
|
|
132
|
+
file=str(attrs.get("file") or ""),
|
|
133
|
+
language=str(attrs.get("language") or ""),
|
|
134
|
+
kind="MODULE",
|
|
135
|
+
package=package,
|
|
136
|
+
is_test=bool((attrs.get("metadata") or {}).get("is_test")),
|
|
137
|
+
symbols=0,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# Count symbols per module.
|
|
141
|
+
sym_counter: Counter[str] = Counter()
|
|
142
|
+
for nid, attrs in graph.nodes(data=True):
|
|
143
|
+
kind = kind_str(attrs.get("kind"))
|
|
144
|
+
if kind in ("FUNCTION", "METHOD", "CLASS"):
|
|
145
|
+
mid = node_to_module.get(nid)
|
|
146
|
+
if mid is not None:
|
|
147
|
+
sym_counter[mid] += 1
|
|
148
|
+
for mid, count in sym_counter.items():
|
|
149
|
+
if mid in out:
|
|
150
|
+
out.nodes[mid]["symbols"] = count
|
|
151
|
+
|
|
152
|
+
# Aggregate edges.
|
|
153
|
+
edge_w: dict[tuple[str, str, str], int] = defaultdict(int)
|
|
154
|
+
for src, dst, data in graph.edges(data=True):
|
|
155
|
+
ek = kind_str(data.get("kind"))
|
|
156
|
+
if ek in ("DEFINED_IN", "PARAM_OF"):
|
|
157
|
+
continue
|
|
158
|
+
src_m = node_to_module.get(src)
|
|
159
|
+
dst_m = node_to_module.get(dst)
|
|
160
|
+
if not src_m or not dst_m or src_m == dst_m:
|
|
161
|
+
continue
|
|
162
|
+
edge_w[(src_m, dst_m, ek)] += 1
|
|
163
|
+
for (s, d, k), w in edge_w.items():
|
|
164
|
+
out.add_edge(s, d, kind=k, weight=w)
|
|
165
|
+
return out
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _render_pyvis(
|
|
169
|
+
graph: nx.Graph,
|
|
170
|
+
output: Path,
|
|
171
|
+
*,
|
|
172
|
+
title: str,
|
|
173
|
+
select_menu: bool = False,
|
|
174
|
+
filter_menu: bool = False,
|
|
175
|
+
node_size_attr: str | None = None,
|
|
176
|
+
) -> Path:
|
|
177
|
+
"""Lower-level pyvis renderer used by every dashboard page."""
|
|
178
|
+
from pyvis.network import Network
|
|
179
|
+
|
|
180
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
net = Network(
|
|
182
|
+
height="780px",
|
|
183
|
+
width="100%",
|
|
184
|
+
directed=True,
|
|
185
|
+
cdn_resources="in_line",
|
|
186
|
+
bgcolor="#0f172a",
|
|
187
|
+
font_color="#f1f5f9",
|
|
188
|
+
select_menu=select_menu,
|
|
189
|
+
filter_menu=filter_menu,
|
|
190
|
+
heading=title,
|
|
191
|
+
)
|
|
192
|
+
net.barnes_hut(
|
|
193
|
+
gravity=-12000,
|
|
194
|
+
central_gravity=0.25,
|
|
195
|
+
spring_length=140,
|
|
196
|
+
spring_strength=0.04,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
for nid, attrs in graph.nodes(data=True):
|
|
200
|
+
kind = kind_str(attrs.get("kind"))
|
|
201
|
+
color = KIND_COLOR.get(kind, "#94a3b8")
|
|
202
|
+
label = str(
|
|
203
|
+
attrs.get("label") or attrs.get("name") or attrs.get("qualname") or nid[:8]
|
|
204
|
+
)
|
|
205
|
+
title_html = (
|
|
206
|
+
attrs.get("title")
|
|
207
|
+
if "title" in attrs
|
|
208
|
+
else _node_title(cast(dict[str, Any], attrs))
|
|
209
|
+
)
|
|
210
|
+
size: float = 14.0
|
|
211
|
+
if node_size_attr is not None:
|
|
212
|
+
raw = attrs.get(node_size_attr) or 0
|
|
213
|
+
try:
|
|
214
|
+
size = 12.0 + float(raw) * 2.0
|
|
215
|
+
except (TypeError, ValueError):
|
|
216
|
+
size = 14.0
|
|
217
|
+
kwargs: dict[str, Any] = {
|
|
218
|
+
"label": label,
|
|
219
|
+
"color": color,
|
|
220
|
+
"shape": _shape_for_kind(kind),
|
|
221
|
+
"title": title_html,
|
|
222
|
+
"group": kind or "OTHER",
|
|
223
|
+
"size": size,
|
|
224
|
+
}
|
|
225
|
+
# Surface arbitrary string attributes so filter_menu can use them.
|
|
226
|
+
for key in ("file", "language", "package", "qualname"):
|
|
227
|
+
val = attrs.get(key)
|
|
228
|
+
if isinstance(val, str) and val:
|
|
229
|
+
kwargs[key] = val
|
|
230
|
+
net.add_node(nid, **kwargs)
|
|
231
|
+
|
|
232
|
+
seen: set[tuple[str, str, str]] = set()
|
|
233
|
+
if isinstance(graph, nx.MultiDiGraph):
|
|
234
|
+
edge_iter = (
|
|
235
|
+
(s, d, data) for s, d, _key, data in graph.edges(keys=True, data=True)
|
|
236
|
+
)
|
|
237
|
+
else:
|
|
238
|
+
edge_iter = ((s, d, data) for s, d, data in graph.edges(data=True))
|
|
239
|
+
for src, dst, data in edge_iter:
|
|
240
|
+
ek = kind_str(data.get("kind"))
|
|
241
|
+
edge_key: tuple[str, str, str] = (src, dst, ek)
|
|
242
|
+
if edge_key in seen:
|
|
243
|
+
continue
|
|
244
|
+
seen.add(edge_key)
|
|
245
|
+
weight = int(data.get("weight") or 1)
|
|
246
|
+
style = EDGE_STYLE.get(ek, "solid")
|
|
247
|
+
dashes = style in ("dashed", "dotted")
|
|
248
|
+
width_n = 1 + min(6, weight - 1) if weight > 1 else (3 if style == "bold" else 1)
|
|
249
|
+
net.add_edge(
|
|
250
|
+
src,
|
|
251
|
+
dst,
|
|
252
|
+
label=ek if weight == 1 else f"{ek} x{weight}",
|
|
253
|
+
arrows="to",
|
|
254
|
+
dashes=dashes,
|
|
255
|
+
width=width_n,
|
|
256
|
+
title=f"{ek} (weight={weight})",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
html_text = cast(str, net.generate_html(notebook=False))
|
|
260
|
+
html_text = _inject_pyvis_theme_switch(html_text)
|
|
261
|
+
output.write_text(html_text, encoding="utf-8")
|
|
262
|
+
return output
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
_PYVIS_THEME_INJECT = """
|
|
266
|
+
<style id="cg-pyvis-theme">
|
|
267
|
+
html.cg-light, html.cg-light body { background: #f5f7fb !important; color: #0f172a !important; }
|
|
268
|
+
html.cg-light .card, html.cg-light #mynetwork { background: #ffffff !important; }
|
|
269
|
+
html.cg-light h1, html.cg-light h2, html.cg-light h3, html.cg-light p { color: #0f172a !important; }
|
|
270
|
+
html.cg-light #mynetwork { border: 1px solid #e2e8f0 !important; border-radius: 12px; }
|
|
271
|
+
body { transition: background 200ms ease, color 200ms ease; }
|
|
272
|
+
#cg-theme-toggle {
|
|
273
|
+
position: fixed; top: 14px; right: 14px; z-index: 9999;
|
|
274
|
+
background: rgba(15,23,42,.65); color: #f1f5f9; border: 1px solid #334155;
|
|
275
|
+
border-radius: 8px; padding: 6px 12px; cursor: pointer; font: 500 12px/1 system-ui;
|
|
276
|
+
}
|
|
277
|
+
html.cg-light #cg-theme-toggle { background: #ffffff; color: #0f172a; border-color: #cbd5e1; }
|
|
278
|
+
</style>
|
|
279
|
+
<script>
|
|
280
|
+
(function(){
|
|
281
|
+
function applyTheme(t){
|
|
282
|
+
var root = document.documentElement;
|
|
283
|
+
if (t === 'light') root.classList.add('cg-light');
|
|
284
|
+
else root.classList.remove('cg-light');
|
|
285
|
+
if (window.network && window.network.setOptions) {
|
|
286
|
+
window.network.setOptions({
|
|
287
|
+
nodes: { font: { color: t === 'light' ? '#0f172a' : '#f1f5f9' } },
|
|
288
|
+
edges: { font: { color: t === 'light' ? '#475569' : '#cbd5e1' } },
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
var mn = document.getElementById('mynetwork');
|
|
292
|
+
if (mn) mn.style.background = t === 'light' ? '#ffffff' : '#0f172a';
|
|
293
|
+
try { localStorage.setItem('cg-pyvis-theme', t); } catch(e){}
|
|
294
|
+
}
|
|
295
|
+
var p = new URLSearchParams(location.search);
|
|
296
|
+
var initial = p.get('theme');
|
|
297
|
+
if (!initial) {
|
|
298
|
+
try { initial = localStorage.getItem('cg-pyvis-theme'); } catch(e){}
|
|
299
|
+
}
|
|
300
|
+
if (!initial) initial = 'dark';
|
|
301
|
+
function ready(){
|
|
302
|
+
applyTheme(initial);
|
|
303
|
+
var btn = document.createElement('button');
|
|
304
|
+
btn.id = 'cg-theme-toggle';
|
|
305
|
+
btn.textContent = initial === 'light' ? '☾ dark' : '☀ light';
|
|
306
|
+
btn.onclick = function(){
|
|
307
|
+
var cur = document.documentElement.classList.contains('cg-light') ? 'light' : 'dark';
|
|
308
|
+
var nxt = cur === 'light' ? 'dark' : 'light';
|
|
309
|
+
applyTheme(nxt);
|
|
310
|
+
btn.textContent = nxt === 'light' ? '☾ dark' : '☀ light';
|
|
311
|
+
};
|
|
312
|
+
document.body.appendChild(btn);
|
|
313
|
+
}
|
|
314
|
+
if (document.readyState === 'loading') {
|
|
315
|
+
document.addEventListener('DOMContentLoaded', ready);
|
|
316
|
+
} else { ready(); }
|
|
317
|
+
})();
|
|
318
|
+
</script>
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _inject_pyvis_theme_switch(html_text: str) -> str:
|
|
323
|
+
"""Inject a light/dark toggle into pyvis-generated HTML."""
|
|
324
|
+
if "cg-pyvis-theme" in html_text:
|
|
325
|
+
return html_text
|
|
326
|
+
needle = "</body>"
|
|
327
|
+
if needle in html_text:
|
|
328
|
+
return html_text.replace(needle, _PYVIS_THEME_INJECT + needle, 1)
|
|
329
|
+
return html_text + _PYVIS_THEME_INJECT
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def render_explore(
|
|
333
|
+
graph: nx.MultiDiGraph,
|
|
334
|
+
out_dir: Path,
|
|
335
|
+
*,
|
|
336
|
+
top_files: int = 25,
|
|
337
|
+
callgraph_limit: int = 400,
|
|
338
|
+
) -> ExploreResult:
|
|
339
|
+
"""Build the multi-page explorer dashboard at ``out_dir``."""
|
|
340
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
341
|
+
files_dir = out_dir / "files"
|
|
342
|
+
files_dir.mkdir(exist_ok=True)
|
|
343
|
+
|
|
344
|
+
cleaned = _strip_noise(graph)
|
|
345
|
+
pages: list[Path] = []
|
|
346
|
+
nav: list[tuple[str, Path, str]] = []
|
|
347
|
+
|
|
348
|
+
# 1. Architecture (module-level).
|
|
349
|
+
arch = _aggregate_to_modules(cleaned)
|
|
350
|
+
arch_path = out_dir / "architecture.html"
|
|
351
|
+
_render_pyvis(
|
|
352
|
+
arch,
|
|
353
|
+
arch_path,
|
|
354
|
+
title="Architecture — modules and aggregated dependencies",
|
|
355
|
+
select_menu=True,
|
|
356
|
+
filter_menu=True,
|
|
357
|
+
node_size_attr="symbols",
|
|
358
|
+
)
|
|
359
|
+
pages.append(arch_path)
|
|
360
|
+
nav.append((
|
|
361
|
+
"Architecture",
|
|
362
|
+
arch_path,
|
|
363
|
+
"module-level — one node per file, edges aggregated by kind with thickness = count",
|
|
364
|
+
))
|
|
365
|
+
|
|
366
|
+
# 2. Call graph (functions + methods only, with hotspot sizing).
|
|
367
|
+
callgraph = _kind_subgraph(cleaned, _CALLABLE_KINDS)
|
|
368
|
+
# Tag with fan-in for sizing.
|
|
369
|
+
for nid in callgraph.nodes():
|
|
370
|
+
callgraph.nodes[nid]["fan_in"] = sum(
|
|
371
|
+
1 for _s, _d, k in callgraph.in_edges(nid, keys=True)
|
|
372
|
+
if k == "CALLS"
|
|
373
|
+
)
|
|
374
|
+
if callgraph.number_of_nodes() > callgraph_limit:
|
|
375
|
+
degree_sorted = sorted(callgraph.degree(), key=lambda x: x[1], reverse=True)
|
|
376
|
+
top_ids = {n for n, _ in degree_sorted[:callgraph_limit]}
|
|
377
|
+
callgraph = cast(nx.MultiDiGraph, callgraph.subgraph(top_ids).copy())
|
|
378
|
+
callgraph_path = out_dir / "callgraph.html"
|
|
379
|
+
_render_pyvis(
|
|
380
|
+
callgraph,
|
|
381
|
+
callgraph_path,
|
|
382
|
+
title="Call graph — functions and methods (size = fan-in)",
|
|
383
|
+
select_menu=True,
|
|
384
|
+
filter_menu=True,
|
|
385
|
+
node_size_attr="fan_in",
|
|
386
|
+
)
|
|
387
|
+
pages.append(callgraph_path)
|
|
388
|
+
nav.append((
|
|
389
|
+
"Call graph",
|
|
390
|
+
callgraph_path,
|
|
391
|
+
"every CALLS edge between functions/methods, node size = number of callers",
|
|
392
|
+
))
|
|
393
|
+
|
|
394
|
+
# 3. Inheritance.
|
|
395
|
+
classes = _kind_subgraph(cleaned, frozenset({"CLASS"}))
|
|
396
|
+
inh_path = out_dir / "inheritance.html"
|
|
397
|
+
_render_pyvis(
|
|
398
|
+
classes,
|
|
399
|
+
inh_path,
|
|
400
|
+
title="Inheritance — classes, INHERITS / IMPLEMENTS edges",
|
|
401
|
+
select_menu=True,
|
|
402
|
+
filter_menu=True,
|
|
403
|
+
)
|
|
404
|
+
pages.append(inh_path)
|
|
405
|
+
nav.append((
|
|
406
|
+
"Inheritance",
|
|
407
|
+
inh_path,
|
|
408
|
+
"every CLASS in the repo, only INHERITS / IMPLEMENTS edges drawn",
|
|
409
|
+
))
|
|
410
|
+
|
|
411
|
+
# 4. Per-file detail pages — top files by node count.
|
|
412
|
+
file_node_counts: Counter[str] = Counter()
|
|
413
|
+
for _nid, attrs in cleaned.nodes(data=True):
|
|
414
|
+
fp = attrs.get("file")
|
|
415
|
+
if isinstance(fp, str) and fp:
|
|
416
|
+
file_node_counts[fp] += 1
|
|
417
|
+
file_pages: list[tuple[str, Path, int]] = []
|
|
418
|
+
for file_path, n_nodes in file_node_counts.most_common(top_files):
|
|
419
|
+
keep: set[str] = set()
|
|
420
|
+
for nid, attrs in cleaned.nodes(data=True):
|
|
421
|
+
if attrs.get("file") == file_path:
|
|
422
|
+
keep.add(nid)
|
|
423
|
+
# Add 1-hop neighbours so cross-file calls are visible in context.
|
|
424
|
+
neighbour_set: set[str] = set()
|
|
425
|
+
for nid in keep:
|
|
426
|
+
for src, _dst, _key in cleaned.in_edges(nid, keys=True):
|
|
427
|
+
neighbour_set.add(src)
|
|
428
|
+
for _src, dst, _key in cleaned.out_edges(nid, keys=True):
|
|
429
|
+
neighbour_set.add(dst)
|
|
430
|
+
sub = cast(
|
|
431
|
+
nx.MultiDiGraph, cleaned.subgraph(keep | neighbour_set).copy()
|
|
432
|
+
)
|
|
433
|
+
slug = _slug(file_path)
|
|
434
|
+
page_path = files_dir / f"{slug}.html"
|
|
435
|
+
_render_pyvis(
|
|
436
|
+
sub,
|
|
437
|
+
page_path,
|
|
438
|
+
title=f"File detail: {file_path}",
|
|
439
|
+
select_menu=True,
|
|
440
|
+
filter_menu=True,
|
|
441
|
+
)
|
|
442
|
+
file_pages.append((file_path, page_path, n_nodes))
|
|
443
|
+
pages.append(page_path)
|
|
444
|
+
|
|
445
|
+
# 5. Index (built last so it can reference everything).
|
|
446
|
+
from codegraph.viz.dashboard import render_dashboard
|
|
447
|
+
index_path = render_dashboard(cleaned, out_dir / "index.html")
|
|
448
|
+
pages.insert(0, index_path)
|
|
449
|
+
|
|
450
|
+
return ExploreResult(out_dir=out_dir, pages=pages)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
__all__ = ["ExploreResult", "render_explore"]
|