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,342 @@
1
+ """Rule loading + evaluation against a ``GraphDiff``."""
2
+ from __future__ import annotations
3
+
4
+ import fnmatch
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+ import networkx as nx
11
+ import yaml
12
+
13
+ from codegraph.review.differ import GraphDiff, NodeChange
14
+ from codegraph.review.risk import (
15
+ _count_callers,
16
+ _find_node_id,
17
+ _has_callers_in_new,
18
+ _hotspot_files,
19
+ _introduces_cycle,
20
+ _is_public_api,
21
+ _param_count_changed,
22
+ score_change,
23
+ )
24
+
25
+
26
+ @dataclass
27
+ class RuleMatch:
28
+ qualname_prefix: str = ""
29
+ qualname_regex: str = ""
30
+ kind: str = ""
31
+ file_glob: str = ""
32
+
33
+
34
+ @dataclass
35
+ class Rule:
36
+ id: str
37
+ when: str # added_node|removed_node|modified_node|removed_referenced|
38
+ # introduces_cycle|high_fan_in|new_dead_code
39
+ severity: str # low | med | high | critical
40
+ message: str
41
+ match: RuleMatch = field(default_factory=RuleMatch)
42
+ threshold: int = 0
43
+
44
+
45
+ @dataclass
46
+ class Finding:
47
+ rule_id: str
48
+ severity: str
49
+ message: str
50
+ qualname: str
51
+ file: str
52
+ line: int
53
+ score: int
54
+ reasons: list[str] = field(default_factory=list)
55
+
56
+
57
+ DEFAULT_RULES: list[Rule] = [
58
+ Rule(
59
+ id="high-blast-radius",
60
+ when="high_fan_in",
61
+ severity="high",
62
+ message="Modifying a symbol with {fan_in} callers",
63
+ threshold=10,
64
+ ),
65
+ Rule(
66
+ id="removed-referenced",
67
+ when="removed_referenced",
68
+ severity="critical",
69
+ message="Removed symbol still referenced by callers",
70
+ ),
71
+ Rule(
72
+ id="new-dead-code",
73
+ when="new_dead_code",
74
+ severity="low",
75
+ message="Potentially unreachable new code",
76
+ ),
77
+ Rule(
78
+ id="no-cycles",
79
+ when="introduces_cycle",
80
+ severity="high",
81
+ message="PR introduces an import/call cycle",
82
+ ),
83
+ Rule(
84
+ id="modified-signature",
85
+ when="modified_node",
86
+ severity="med",
87
+ message="Modified node signature change",
88
+ ),
89
+ ]
90
+
91
+
92
+ _VALID_WHEN = {
93
+ "added_node",
94
+ "removed_node",
95
+ "modified_node",
96
+ "removed_referenced",
97
+ "introduces_cycle",
98
+ "high_fan_in",
99
+ "new_dead_code",
100
+ }
101
+
102
+
103
+ _SEVERITY_RANK = {"low": 0, "med": 1, "high": 2, "critical": 3}
104
+
105
+
106
+ def severity_at_least(value: str, threshold: str) -> bool:
107
+ return _SEVERITY_RANK.get(value, 0) >= _SEVERITY_RANK.get(threshold, 0)
108
+
109
+
110
+ def _rule_from_dict(data: dict[str, Any]) -> Rule:
111
+ match_data = cast(dict[str, Any], data.get("match") or {})
112
+ return Rule(
113
+ id=str(data.get("id") or ""),
114
+ when=str(data.get("when") or ""),
115
+ severity=str(data.get("severity") or "med"),
116
+ message=str(data.get("message") or ""),
117
+ threshold=int(data.get("threshold") or 0),
118
+ match=RuleMatch(
119
+ qualname_prefix=str(match_data.get("qualname_prefix") or ""),
120
+ qualname_regex=str(match_data.get("qualname_regex") or ""),
121
+ kind=str(match_data.get("kind") or ""),
122
+ file_glob=str(match_data.get("file_glob") or ""),
123
+ ),
124
+ )
125
+
126
+
127
+ def load_rules(rules_path: Path | None = None) -> list[Rule]:
128
+ """Load rules from a YAML file.
129
+
130
+ When ``rules_path`` is ``None``, search for ``.codegraph/rules.yml`` and
131
+ ``.codegraph.rules.yml`` in the current working directory. Falls back to
132
+ :data:`DEFAULT_RULES` when no file is found.
133
+ """
134
+ candidates: list[Path] = []
135
+ if rules_path is not None:
136
+ candidates.append(rules_path)
137
+ else:
138
+ cwd = Path.cwd()
139
+ candidates.extend(
140
+ [
141
+ cwd / ".codegraph" / "rules.yml",
142
+ cwd / ".codegraph.rules.yml",
143
+ ]
144
+ )
145
+ for path in candidates:
146
+ if not path.exists():
147
+ continue
148
+ text = path.read_text()
149
+ data = cast(dict[str, Any], yaml.safe_load(text) or {})
150
+ raw_rules = data.get("rules") or []
151
+ rules: list[Rule] = []
152
+ for entry in raw_rules:
153
+ if not isinstance(entry, dict):
154
+ continue
155
+ rule = _rule_from_dict(cast(dict[str, Any], entry))
156
+ if rule.id and rule.when in _VALID_WHEN:
157
+ rules.append(rule)
158
+ if rules:
159
+ return rules
160
+ return list(DEFAULT_RULES)
161
+
162
+
163
+ def _node_matches(rule: Rule, change: NodeChange) -> bool:
164
+ m = rule.match
165
+ if m.kind and m.kind != change.kind:
166
+ return False
167
+ if m.qualname_prefix and not change.qualname.startswith(m.qualname_prefix):
168
+ return False
169
+ if m.qualname_regex and not re.search(m.qualname_regex, change.qualname):
170
+ return False
171
+ return not (m.file_glob and not fnmatch.fnmatch(change.file, m.file_glob))
172
+
173
+
174
+ def _make_finding(
175
+ rule: Rule,
176
+ change: NodeChange,
177
+ *,
178
+ new_graph: nx.MultiDiGraph,
179
+ old_graph: nx.MultiDiGraph,
180
+ extra: dict[str, Any],
181
+ fmt_kwargs: dict[str, Any] | None = None,
182
+ ) -> Finding:
183
+ risk = score_change(change, new_graph=new_graph, old_graph=old_graph, extra=extra)
184
+ severity = rule.severity
185
+ if severity_at_least(risk.level, severity):
186
+ severity = risk.level
187
+ fmt = dict(fmt_kwargs or {})
188
+ fmt.setdefault("qualname", change.qualname)
189
+ try:
190
+ message = rule.message.format(**fmt)
191
+ except (KeyError, IndexError):
192
+ message = rule.message
193
+ return Finding(
194
+ rule_id=rule.id,
195
+ severity=severity,
196
+ message=message,
197
+ qualname=change.qualname,
198
+ file=change.file,
199
+ line=change.line_start,
200
+ score=risk.score,
201
+ reasons=list(risk.reasons),
202
+ )
203
+
204
+
205
+ def evaluate_rules(
206
+ diff: GraphDiff,
207
+ *,
208
+ new_graph: nx.MultiDiGraph,
209
+ old_graph: nx.MultiDiGraph,
210
+ rules: list[Rule] | None = None,
211
+ ) -> list[Finding]:
212
+ """Evaluate ``rules`` against ``diff`` and return findings."""
213
+ rules = rules if rules is not None else list(DEFAULT_RULES)
214
+
215
+ hotspot_cache: dict[str, frozenset[str]] = {
216
+ "files": _hotspot_files(new_graph)
217
+ }
218
+ cycle_cache: dict[str, int] = {}
219
+ extra: dict[str, Any] = {
220
+ "hotspot_cache": hotspot_cache,
221
+ "cycle_cache": cycle_cache,
222
+ }
223
+
224
+ findings: list[Finding] = []
225
+ cycle_introduced = _introduces_cycle(new_graph, old_graph, cycle_cache)
226
+
227
+ for rule in rules:
228
+ when = rule.when
229
+ if when == "added_node":
230
+ for change in diff.added_nodes:
231
+ if not _node_matches(rule, change):
232
+ continue
233
+ findings.append(
234
+ _make_finding(
235
+ rule, change,
236
+ new_graph=new_graph, old_graph=old_graph, extra=extra,
237
+ )
238
+ )
239
+ elif when == "removed_node":
240
+ for change in diff.removed_nodes:
241
+ if not _node_matches(rule, change):
242
+ continue
243
+ findings.append(
244
+ _make_finding(
245
+ rule, change,
246
+ new_graph=new_graph, old_graph=old_graph, extra=extra,
247
+ )
248
+ )
249
+ elif when == "modified_node":
250
+ for change in diff.modified_nodes:
251
+ if not _node_matches(rule, change):
252
+ continue
253
+ sig = change.details.get("signature") or {}
254
+ old_sig = str(sig.get("old") or "")
255
+ new_sig = str(sig.get("new") or "")
256
+ if old_sig and new_sig and not _param_count_changed(
257
+ old_sig, new_sig
258
+ ):
259
+ continue
260
+ findings.append(
261
+ _make_finding(
262
+ rule, change,
263
+ new_graph=new_graph, old_graph=old_graph, extra=extra,
264
+ )
265
+ )
266
+ elif when == "removed_referenced":
267
+ for change in diff.removed_nodes:
268
+ if not _node_matches(rule, change):
269
+ continue
270
+ old_id = _find_node_id(change.qualname, change.kind, old_graph)
271
+ if old_id is None:
272
+ continue
273
+ if not _has_callers_in_new(old_id, old_graph, new_graph):
274
+ continue
275
+ findings.append(
276
+ _make_finding(
277
+ rule, change,
278
+ new_graph=new_graph, old_graph=old_graph, extra=extra,
279
+ )
280
+ )
281
+ elif when == "high_fan_in":
282
+ threshold = rule.threshold or 10
283
+ for change in diff.modified_nodes:
284
+ if not _node_matches(rule, change):
285
+ continue
286
+ new_id = _find_node_id(change.qualname, change.kind, new_graph)
287
+ if new_id is None:
288
+ continue
289
+ fan_in = _count_callers(new_id, new_graph)
290
+ if fan_in < threshold:
291
+ continue
292
+ findings.append(
293
+ _make_finding(
294
+ rule, change,
295
+ new_graph=new_graph, old_graph=old_graph, extra=extra,
296
+ fmt_kwargs={"fan_in": fan_in},
297
+ )
298
+ )
299
+ elif when == "new_dead_code":
300
+ for change in diff.added_nodes:
301
+ if not _node_matches(rule, change):
302
+ continue
303
+ if change.kind not in ("FUNCTION", "METHOD"):
304
+ continue
305
+ new_id = _find_node_id(change.qualname, change.kind, new_graph)
306
+ if new_id is None:
307
+ continue
308
+ if _count_callers(new_id, new_graph) > 0:
309
+ continue
310
+ if _is_public_api(change.qualname):
311
+ continue
312
+ findings.append(
313
+ _make_finding(
314
+ rule, change,
315
+ new_graph=new_graph, old_graph=old_graph, extra=extra,
316
+ )
317
+ )
318
+ elif when == "introduces_cycle":
319
+ if not cycle_introduced:
320
+ continue
321
+ findings.append(
322
+ Finding(
323
+ rule_id=rule.id,
324
+ severity=rule.severity,
325
+ message=rule.message,
326
+ qualname="",
327
+ file="",
328
+ line=0,
329
+ score=30,
330
+ reasons=["introduces import/call cycle"],
331
+ )
332
+ )
333
+
334
+ findings.sort(
335
+ key=lambda f: (
336
+ -_SEVERITY_RANK.get(f.severity, 0),
337
+ -f.score,
338
+ f.qualname,
339
+ f.rule_id,
340
+ )
341
+ )
342
+ return findings
@@ -0,0 +1,17 @@
1
+ """Visualization renderers for codegraph."""
2
+ from codegraph.viz._style import KIND_CLASS, KIND_COLOR
3
+ from codegraph.viz.explore import ExploreResult, render_explore
4
+ from codegraph.viz.html import render_html
5
+ from codegraph.viz.mermaid import render_mermaid
6
+ from codegraph.viz.svg import GraphvizUnavailableError, render_svg
7
+
8
+ __all__ = [
9
+ "KIND_CLASS",
10
+ "KIND_COLOR",
11
+ "ExploreResult",
12
+ "GraphvizUnavailableError",
13
+ "render_explore",
14
+ "render_html",
15
+ "render_mermaid",
16
+ "render_svg",
17
+ ]
@@ -0,0 +1,45 @@
1
+ """Shared styling for codegraph visualizations."""
2
+ from __future__ import annotations
3
+
4
+ from codegraph.graph.schema import EdgeKind, NodeKind
5
+
6
+ KIND_COLOR: dict[str, str] = {
7
+ NodeKind.FILE.value: "#94a3b8", # slate
8
+ NodeKind.MODULE.value: "#6366f1", # indigo
9
+ NodeKind.CLASS.value: "#f59e0b", # amber
10
+ NodeKind.FUNCTION.value: "#10b981", # emerald
11
+ NodeKind.METHOD.value: "#22c55e", # green
12
+ NodeKind.VARIABLE.value: "#9ca3af", # gray
13
+ NodeKind.PARAMETER.value: "#9ca3af",
14
+ NodeKind.IMPORT.value: "#0ea5e9", # sky
15
+ NodeKind.TEST.value: "#ec4899", # pink
16
+ }
17
+
18
+ KIND_CLASS: dict[str, str] = {
19
+ NodeKind.FILE.value: "file",
20
+ NodeKind.MODULE.value: "module",
21
+ NodeKind.CLASS.value: "klass",
22
+ NodeKind.FUNCTION.value: "func",
23
+ NodeKind.METHOD.value: "method",
24
+ NodeKind.VARIABLE.value: "var",
25
+ NodeKind.PARAMETER.value: "param",
26
+ NodeKind.IMPORT.value: "imp",
27
+ NodeKind.TEST.value: "test",
28
+ }
29
+
30
+ EDGE_STYLE: dict[str, str] = {
31
+ EdgeKind.DEFINED_IN.value: "dashed",
32
+ EdgeKind.IMPORTS.value: "dotted",
33
+ EdgeKind.CALLS.value: "solid",
34
+ EdgeKind.INHERITS.value: "bold",
35
+ EdgeKind.IMPLEMENTS.value: "bold",
36
+ EdgeKind.READS.value: "dotted",
37
+ EdgeKind.WRITES.value: "dotted",
38
+ EdgeKind.RETURNS.value: "solid",
39
+ EdgeKind.PARAM_OF.value: "dashed",
40
+ EdgeKind.TESTED_BY.value: "dashed",
41
+ }
42
+
43
+
44
+ def kind_str(value: object) -> str:
45
+ return str(getattr(value, "value", value) or "")