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
codegraph/viz/html.py ADDED
@@ -0,0 +1,115 @@
1
+ """Interactive pyvis HTML renderer."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any, cast
6
+
7
+ import networkx as nx
8
+
9
+ from codegraph.viz._style import EDGE_STYLE, KIND_COLOR, kind_str
10
+
11
+ _DEFAULT_HEIGHT = "780px"
12
+ _DEFAULT_WIDTH = "100%"
13
+
14
+
15
+ def _shape_for_kind(kind: str) -> str:
16
+ if kind in ("FILE", "MODULE"):
17
+ return "box"
18
+ if kind == "CLASS":
19
+ return "ellipse"
20
+ if kind == "TEST":
21
+ return "diamond"
22
+ return "dot"
23
+
24
+
25
+ def _node_title(attrs: dict[str, Any]) -> str:
26
+ parts = [
27
+ f"<b>{attrs.get('name') or attrs.get('qualname') or ''}</b>",
28
+ f"kind: {kind_str(attrs.get('kind'))}",
29
+ f"qualname: {attrs.get('qualname') or '-'}",
30
+ f"file: {attrs.get('file') or '-'}:"
31
+ f"{attrs.get('line_start') or '?'}",
32
+ f"language: {attrs.get('language') or '-'}",
33
+ ]
34
+ sig = attrs.get("signature")
35
+ if sig:
36
+ parts.append(f"signature: {sig}")
37
+ return "<br>".join(str(p) for p in parts)
38
+
39
+
40
+ def render_html(
41
+ graph: nx.MultiDiGraph,
42
+ output: Path,
43
+ *,
44
+ height: str = _DEFAULT_HEIGHT,
45
+ width: str = _DEFAULT_WIDTH,
46
+ notebook: bool = False,
47
+ ) -> Path:
48
+ """Render an interactive HTML visualization with pyvis.
49
+
50
+ Returns the path that was written. Pyvis is a required dependency, so
51
+ this function will only fail if the user has uninstalled it manually.
52
+ """
53
+ try:
54
+ from pyvis.network import Network
55
+ except ImportError as exc: # pragma: no cover - pyvis is a hard dep
56
+ raise RuntimeError(
57
+ "pyvis is required for HTML output: pip install pyvis"
58
+ ) from exc
59
+
60
+ output.parent.mkdir(parents=True, exist_ok=True)
61
+
62
+ net = Network(
63
+ height=height,
64
+ width=width,
65
+ directed=True,
66
+ notebook=notebook,
67
+ cdn_resources="in_line",
68
+ bgcolor="#0f172a",
69
+ font_color="#f1f5f9",
70
+ )
71
+ net.barnes_hut(
72
+ gravity=-8000,
73
+ central_gravity=0.3,
74
+ spring_length=120,
75
+ spring_strength=0.04,
76
+ )
77
+
78
+ for nid, attrs in graph.nodes(data=True):
79
+ kind = kind_str(attrs.get("kind"))
80
+ color = KIND_COLOR.get(kind, "#94a3b8")
81
+ label = str(attrs.get("name") or attrs.get("qualname") or nid[:8])
82
+ net.add_node(
83
+ nid,
84
+ label=label,
85
+ color=color,
86
+ shape=_shape_for_kind(kind),
87
+ title=_node_title(cast(dict[str, Any], attrs)),
88
+ group=kind or "OTHER",
89
+ )
90
+
91
+ seen: set[tuple[str, str, str]] = set()
92
+ for src, dst, data in graph.edges(data=True):
93
+ if src not in graph.nodes or dst not in graph.nodes:
94
+ continue
95
+ ek = kind_str(data.get("kind"))
96
+ key = (src, dst, ek)
97
+ if key in seen:
98
+ continue
99
+ seen.add(key)
100
+ style = EDGE_STYLE.get(ek, "solid")
101
+ dashes = style in ("dashed", "dotted")
102
+ width_n = 3 if style == "bold" else 1
103
+ net.add_edge(
104
+ src,
105
+ dst,
106
+ label=ek,
107
+ arrows="to",
108
+ dashes=dashes,
109
+ width=width_n,
110
+ title=ek,
111
+ )
112
+
113
+ html = cast(str, net.generate_html(notebook=notebook))
114
+ output.write_text(html, encoding="utf-8")
115
+ return output
@@ -0,0 +1,111 @@
1
+ """Mermaid renderer with file-clustering and per-kind coloring."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from collections import defaultdict
6
+
7
+ import networkx as nx
8
+
9
+ from codegraph.viz._style import EDGE_STYLE, KIND_CLASS, kind_str
10
+
11
+ _SAFE_RE = re.compile(r"[^a-zA-Z0-9_]+")
12
+
13
+
14
+ def _safe_id(node_id: str, prefix: str = "n_") -> str:
15
+ return prefix + _SAFE_RE.sub("_", node_id)[:32]
16
+
17
+
18
+ def _safe_subgraph_id(name: str) -> str:
19
+ return "g_" + _SAFE_RE.sub("_", name)[:48]
20
+
21
+
22
+ def _label(attrs: dict[str, object]) -> str:
23
+ raw = str(attrs.get("name") or "")
24
+ if not raw:
25
+ raw = str(attrs.get("qualname") or "?")
26
+ return raw.replace('"', "'").replace("[", "(").replace("]", ")")
27
+
28
+
29
+ def render_mermaid(
30
+ graph: nx.MultiDiGraph,
31
+ *,
32
+ cluster_by_file: bool = True,
33
+ show_legend: bool = True,
34
+ ) -> str:
35
+ """Return a Mermaid ``flowchart LR`` diagram of ``graph``.
36
+
37
+ Nodes are colored by NodeKind; edges by EdgeKind. When
38
+ ``cluster_by_file`` is True, nodes that share a file path are grouped
39
+ into a Mermaid subgraph.
40
+ """
41
+ lines: list[str] = ["flowchart LR"]
42
+
43
+ classes = {
44
+ "file": "stroke:#475569,fill:#e2e8f0,color:#1e293b",
45
+ "module": "stroke:#3730a3,fill:#e0e7ff,color:#1e1b4b",
46
+ "klass": "stroke:#b45309,fill:#fef3c7,color:#451a03",
47
+ "func": "stroke:#047857,fill:#d1fae5,color:#022c22",
48
+ "method": "stroke:#15803d,fill:#dcfce7,color:#052e16",
49
+ "var": "stroke:#4b5563,fill:#f3f4f6,color:#111827",
50
+ "param": "stroke:#6b7280,fill:#f9fafb,color:#111827",
51
+ "imp": "stroke:#0369a1,fill:#e0f2fe,color:#082f49",
52
+ "test": "stroke:#be185d,fill:#fce7f3,color:#500724",
53
+ }
54
+ for cls, style in classes.items():
55
+ lines.append(f" classDef {cls} {style};")
56
+
57
+ by_file: dict[str, list[tuple[str, dict[str, object]]]] = defaultdict(list)
58
+ free: list[tuple[str, dict[str, object]]] = []
59
+ for nid, attrs in graph.nodes(data=True):
60
+ file_path = attrs.get("file")
61
+ if cluster_by_file and isinstance(file_path, str) and file_path:
62
+ by_file[file_path].append((nid, attrs))
63
+ else:
64
+ free.append((nid, attrs))
65
+
66
+ safe_map: dict[str, str] = {}
67
+
68
+ def _emit_node(nid: str, attrs: dict[str, object], indent: str) -> None:
69
+ sid = _safe_id(nid)
70
+ safe_map[nid] = sid
71
+ kind = kind_str(attrs.get("kind"))
72
+ cls = KIND_CLASS.get(kind, "var")
73
+ label = f"{kind}: {_label(attrs)}"
74
+ lines.append(f'{indent}{sid}["{label}"]:::{cls}')
75
+
76
+ for file_path, members in sorted(by_file.items()):
77
+ sg_id = _safe_subgraph_id(file_path)
78
+ lines.append(f' subgraph {sg_id}["{file_path}"]')
79
+ for nid, attrs in members:
80
+ _emit_node(nid, attrs, " ")
81
+ lines.append(" end")
82
+ for nid, attrs in free:
83
+ _emit_node(nid, attrs, " ")
84
+
85
+ seen: set[tuple[str, str, str]] = set()
86
+ for src, dst, data in graph.edges(data=True):
87
+ if src not in safe_map or dst not in safe_map:
88
+ continue
89
+ ek = kind_str(data.get("kind"))
90
+ key = (src, dst, ek)
91
+ if key in seen:
92
+ continue
93
+ seen.add(key)
94
+ style = EDGE_STYLE.get(ek, "solid")
95
+ arrow = "-->" if style != "dotted" else "-.->"
96
+ if style == "bold":
97
+ arrow = "==>"
98
+ lines.append(
99
+ f" {safe_map[src]} {arrow}|{ek}| {safe_map[dst]}"
100
+ )
101
+
102
+ if show_legend:
103
+ lines.append(" %% legend")
104
+ lines.append(' legend_file["FILE"]:::file')
105
+ lines.append(' legend_module["MODULE"]:::module')
106
+ lines.append(' legend_class["CLASS"]:::klass')
107
+ lines.append(' legend_func["FUNCTION"]:::func')
108
+ lines.append(' legend_method["METHOD"]:::method')
109
+ lines.append(' legend_test["TEST"]:::test')
110
+
111
+ return "\n".join(lines) + "\n"
codegraph/viz/svg.py ADDED
@@ -0,0 +1,77 @@
1
+ """Optional Graphviz SVG renderer (no-op if `dot` binary is missing)."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import networkx as nx
9
+
10
+ from codegraph.viz._style import EDGE_STYLE, KIND_COLOR, kind_str
11
+
12
+
13
+ class GraphvizUnavailableError(RuntimeError):
14
+ """Raised when the graphviz Python package or `dot` binary is missing."""
15
+
16
+
17
+ def _ensure_graphviz() -> Any:
18
+ try:
19
+ import graphviz as _graphviz
20
+ except ImportError as exc:
21
+ raise GraphvizUnavailableError(
22
+ "graphviz Python package not installed. "
23
+ "Install with: pip install polycodegraph[viz]"
24
+ ) from exc
25
+ if shutil.which("dot") is None:
26
+ raise GraphvizUnavailableError(
27
+ "Graphviz `dot` binary not found in PATH. "
28
+ "Install Graphviz from https://graphviz.org/download/"
29
+ )
30
+ return _graphviz
31
+
32
+
33
+ def render_svg(graph: nx.MultiDiGraph, output: Path) -> Path:
34
+ """Render an SVG of ``graph`` to ``output``.
35
+
36
+ Raises ``GraphvizUnavailableError`` if the toolchain is missing so the
37
+ CLI can degrade gracefully.
38
+ """
39
+ gv = _ensure_graphviz()
40
+ output.parent.mkdir(parents=True, exist_ok=True)
41
+
42
+ dot = gv.Digraph(format="svg")
43
+ dot.attr(rankdir="LR", bgcolor="white", fontname="Helvetica")
44
+ dot.attr("node", style="filled", fontname="Helvetica", fontsize="11")
45
+ dot.attr("edge", fontname="Helvetica", fontsize="9", color="#475569")
46
+
47
+ for nid, attrs in graph.nodes(data=True):
48
+ kind = kind_str(attrs.get("kind"))
49
+ color = KIND_COLOR.get(kind, "#94a3b8")
50
+ label = str(attrs.get("name") or attrs.get("qualname") or nid[:8])
51
+ dot.node(
52
+ nid,
53
+ label=f"{kind}\\n{label}",
54
+ fillcolor=color,
55
+ color="#1e293b",
56
+ fontcolor="#0f172a",
57
+ )
58
+
59
+ seen: set[tuple[str, str, str]] = set()
60
+ for src, dst, data in graph.edges(data=True):
61
+ ek = kind_str(data.get("kind"))
62
+ key = (src, dst, ek)
63
+ if key in seen:
64
+ continue
65
+ seen.add(key)
66
+ style = EDGE_STYLE.get(ek, "solid")
67
+ dot.edge(src, dst, label=ek, style=style)
68
+
69
+ rendered_path = dot.render(
70
+ filename=output.with_suffix("").name,
71
+ directory=str(output.parent),
72
+ cleanup=True,
73
+ )
74
+ rendered = Path(rendered_path)
75
+ if rendered != output:
76
+ rendered.replace(output)
77
+ return output
@@ -0,0 +1,4 @@
1
+ """HTTP server + static assets for the codegraph dashboard."""
2
+ from codegraph.web.server import DashboardState, serve
3
+
4
+ __all__ = ["DashboardState", "serve"]
@@ -0,0 +1,165 @@
1
+ """Lightweight stdlib HTTP server for the codegraph dashboard."""
2
+ from __future__ import annotations
3
+
4
+ import datetime as dt
5
+ import importlib.resources as resources
6
+ import json
7
+ import logging
8
+ import mimetypes
9
+ import threading
10
+ import webbrowser
11
+ from collections.abc import Callable
12
+ from http import HTTPStatus
13
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14
+ from pathlib import Path
15
+ from typing import Any
16
+ from urllib.parse import urlparse
17
+
18
+ import networkx as nx
19
+
20
+ from codegraph.viz.dashboard import build_dashboard_payload
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _STATIC_PKG = "codegraph.web.static"
25
+
26
+
27
+ def _read_static(name: str) -> bytes:
28
+ return (resources.files(_STATIC_PKG) / name).read_bytes()
29
+
30
+
31
+ class DashboardState:
32
+ """Holds the rebuildable graph + cached payload."""
33
+
34
+ def __init__(
35
+ self,
36
+ repo_root: Path,
37
+ explore_dir: Path,
38
+ graph_loader: Callable[[], nx.MultiDiGraph],
39
+ rebuild: Callable[[], nx.MultiDiGraph] | None = None,
40
+ ) -> None:
41
+ self.repo_root = repo_root
42
+ self.explore_dir = explore_dir
43
+ self._graph_loader = graph_loader
44
+ self._rebuild = rebuild
45
+ self._lock = threading.Lock()
46
+ self._payload: dict[str, Any] | None = None
47
+
48
+ def payload(self) -> dict[str, Any]:
49
+ with self._lock:
50
+ if self._payload is None:
51
+ self._payload = self._build_payload(self._graph_loader())
52
+ return self._payload
53
+
54
+ def rebuild(self) -> dict[str, Any]:
55
+ with self._lock:
56
+ graph = (
57
+ self._graph_loader() if self._rebuild is None else self._rebuild()
58
+ )
59
+ self._payload = self._build_payload(graph)
60
+ return self._payload
61
+
62
+ def _build_payload(self, graph: nx.MultiDiGraph) -> dict[str, Any]:
63
+ payload = build_dashboard_payload(graph)
64
+ payload["repo"] = self.repo_root.name
65
+ payload["built_at"] = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
66
+ return payload
67
+
68
+
69
+ class _Handler(BaseHTTPRequestHandler):
70
+ state: DashboardState # set per-instance via factory
71
+
72
+ def log_message(self, format: str, *args: Any) -> None:
73
+ logger.debug("%s - %s", self.address_string(), format % args)
74
+
75
+ # ---- helpers ----
76
+ def _send_bytes(
77
+ self, body: bytes, content_type: str, status: int = 200
78
+ ) -> None:
79
+ self.send_response(status)
80
+ self.send_header("Content-Type", content_type)
81
+ self.send_header("Content-Length", str(len(body)))
82
+ self.send_header("Cache-Control", "no-store")
83
+ self.end_headers()
84
+ self.wfile.write(body)
85
+
86
+ def _send_json(self, data: dict[str, Any], status: int = 200) -> None:
87
+ body = json.dumps(data).encode("utf-8")
88
+ self._send_bytes(body, "application/json; charset=utf-8", status)
89
+
90
+ def _not_found(self) -> None:
91
+ self._send_bytes(b"not found", "text/plain", HTTPStatus.NOT_FOUND)
92
+
93
+ # ---- routes ----
94
+ def do_GET(self) -> None:
95
+ path = urlparse(self.path).path
96
+ if path == "/" or path == "/index.html":
97
+ self._send_bytes(_read_static("index.html"),
98
+ "text/html; charset=utf-8")
99
+ return
100
+ if path == "/api/data.json":
101
+ self._send_json(self.state.payload())
102
+ return
103
+ if path.startswith("/static/"):
104
+ name = path[len("/static/"):]
105
+ try:
106
+ data = _read_static(name)
107
+ except (FileNotFoundError, ModuleNotFoundError):
108
+ self._not_found()
109
+ return
110
+ ctype, _ = mimetypes.guess_type(name)
111
+ self._send_bytes(data, ctype or "application/octet-stream")
112
+ return
113
+ # Anything else: try the explore dir for pyvis pages.
114
+ candidate = self.state.explore_dir / path.lstrip("/")
115
+ try:
116
+ candidate = candidate.resolve()
117
+ candidate.relative_to(self.state.explore_dir.resolve())
118
+ except (ValueError, OSError):
119
+ self._not_found()
120
+ return
121
+ if candidate.is_file():
122
+ ctype, _ = mimetypes.guess_type(str(candidate))
123
+ self._send_bytes(candidate.read_bytes(),
124
+ ctype or "application/octet-stream")
125
+ return
126
+ self._not_found()
127
+
128
+ def do_POST(self) -> None:
129
+ path = urlparse(self.path).path
130
+ if path == "/api/rebuild":
131
+ try:
132
+ self.state.rebuild()
133
+ except Exception as exc:
134
+ logger.exception("rebuild failed")
135
+ self._send_json({"error": str(exc)}, status=500)
136
+ return
137
+ self._send_json({"ok": True})
138
+ return
139
+ self._not_found()
140
+
141
+
142
+ def serve(
143
+ state: DashboardState,
144
+ *,
145
+ host: str = "127.0.0.1",
146
+ port: int = 8765,
147
+ open_browser: bool = True,
148
+ ) -> None:
149
+ """Block on the dashboard HTTP server."""
150
+ handler_cls = type("_BoundHandler", (_Handler,), {"state": state})
151
+ server = ThreadingHTTPServer((host, port), handler_cls)
152
+ url = f"http://{host}:{port}/"
153
+ print(f"\n codegraph dashboard ready at \033[36m{url}\033[0m")
154
+ print(" press Ctrl+C to stop\n")
155
+ if open_browser:
156
+ threading.Timer(0.5, lambda: webbrowser.open(url)).start()
157
+ try:
158
+ server.serve_forever()
159
+ except KeyboardInterrupt:
160
+ print("\n shutting down...")
161
+ finally:
162
+ server.shutdown()
163
+
164
+
165
+ __all__ = ["DashboardState", "serve"]