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.
Files changed (67) hide show
  1. codegraph/__init__.py +10 -0
  2. codegraph/analysis/__init__.py +30 -0
  3. codegraph/analysis/_common.py +125 -0
  4. codegraph/analysis/blast_radius.py +63 -0
  5. codegraph/analysis/cycles.py +79 -0
  6. codegraph/analysis/dataflow.py +861 -0
  7. codegraph/analysis/dead_code.py +165 -0
  8. codegraph/analysis/hotspots.py +68 -0
  9. codegraph/analysis/infrastructure.py +439 -0
  10. codegraph/analysis/metrics.py +52 -0
  11. codegraph/analysis/report.py +222 -0
  12. codegraph/analysis/roles.py +323 -0
  13. codegraph/analysis/untested.py +79 -0
  14. codegraph/cli.py +1506 -0
  15. codegraph/config.py +64 -0
  16. codegraph/embed/__init__.py +35 -0
  17. codegraph/embed/chunker.py +120 -0
  18. codegraph/embed/embedder.py +113 -0
  19. codegraph/embed/query.py +181 -0
  20. codegraph/embed/store.py +360 -0
  21. codegraph/graph/__init__.py +0 -0
  22. codegraph/graph/builder.py +212 -0
  23. codegraph/graph/schema.py +69 -0
  24. codegraph/graph/store_networkx.py +55 -0
  25. codegraph/graph/store_sqlite.py +249 -0
  26. codegraph/mcp_server/__init__.py +6 -0
  27. codegraph/mcp_server/server.py +933 -0
  28. codegraph/parsers/__init__.py +0 -0
  29. codegraph/parsers/base.py +70 -0
  30. codegraph/parsers/go.py +570 -0
  31. codegraph/parsers/python.py +1707 -0
  32. codegraph/parsers/typescript.py +1397 -0
  33. codegraph/py.typed +0 -0
  34. codegraph/resolve/__init__.py +4 -0
  35. codegraph/resolve/calls.py +480 -0
  36. codegraph/review/__init__.py +31 -0
  37. codegraph/review/baseline.py +32 -0
  38. codegraph/review/differ.py +211 -0
  39. codegraph/review/hook.py +70 -0
  40. codegraph/review/risk.py +219 -0
  41. codegraph/review/rules.py +342 -0
  42. codegraph/viz/__init__.py +17 -0
  43. codegraph/viz/_style.py +45 -0
  44. codegraph/viz/dashboard.py +740 -0
  45. codegraph/viz/diagrams.py +370 -0
  46. codegraph/viz/explore.py +453 -0
  47. codegraph/viz/hld.py +683 -0
  48. codegraph/viz/html.py +115 -0
  49. codegraph/viz/mermaid.py +111 -0
  50. codegraph/viz/svg.py +77 -0
  51. codegraph/web/__init__.py +4 -0
  52. codegraph/web/server.py +165 -0
  53. codegraph/web/static/app.css +664 -0
  54. codegraph/web/static/app.js +919 -0
  55. codegraph/web/static/index.html +112 -0
  56. codegraph/web/static/views/architecture.js +1671 -0
  57. codegraph/web/static/views/graph3d.css +564 -0
  58. codegraph/web/static/views/graph3d.js +999 -0
  59. codegraph/web/static/views/graph3d_transform.js +984 -0
  60. codegraph/workspace/__init__.py +34 -0
  61. codegraph/workspace/config.py +110 -0
  62. codegraph/workspace/operations.py +294 -0
  63. polycodegraph-0.1.0.dist-info/METADATA +687 -0
  64. polycodegraph-0.1.0.dist-info/RECORD +67 -0
  65. polycodegraph-0.1.0.dist-info/WHEEL +4 -0
  66. polycodegraph-0.1.0.dist-info/entry_points.txt +2 -0
  67. polycodegraph-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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"]