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
|
@@ -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
|
+
]
|
codegraph/viz/_style.py
ADDED
|
@@ -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 "")
|