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/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""Post-build cross-file resolution.
|
|
2
|
+
|
|
3
|
+
Extractors emit edges with ``dst="unresolved::<target_name>"`` whenever the
|
|
4
|
+
extractor cannot determine the call/import target without seeing the full
|
|
5
|
+
module graph. This pass walks every unresolved edge and tries to rewrite it
|
|
6
|
+
into a real node id using a few cheap heuristics:
|
|
7
|
+
|
|
8
|
+
* ``self.foo`` inside a method -> sibling method on the enclosing class
|
|
9
|
+
* bare ``foo`` inside a module/function -> function/class in the same module
|
|
10
|
+
* ``mod.foo`` -> resolved through the module's IMPORTS edges
|
|
11
|
+
* fully-qualified name -> exact qualname match
|
|
12
|
+
* otherwise: unique tail/qualname match across the whole graph
|
|
13
|
+
|
|
14
|
+
Anything that cannot be resolved unambiguously is left as ``unresolved::*`` so
|
|
15
|
+
later analyses can still ignore it without crashing.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from codegraph.graph.schema import Edge, EdgeKind, Node, NodeKind
|
|
23
|
+
from codegraph.graph.store_sqlite import SQLiteGraphStore
|
|
24
|
+
|
|
25
|
+
_REFERENCE_KINDS: frozenset[EdgeKind] = frozenset(
|
|
26
|
+
{
|
|
27
|
+
EdgeKind.CALLS, EdgeKind.IMPORTS, EdgeKind.INHERITS,
|
|
28
|
+
EdgeKind.IMPLEMENTS,
|
|
29
|
+
# DF1 — SQLAlchemy data-access edges. Emitted unresolved by the
|
|
30
|
+
# Python parser (model name only) and rewritten here when a
|
|
31
|
+
# matching CLASS exists in-repo.
|
|
32
|
+
EdgeKind.READS_FROM, EdgeKind.WRITES_TO,
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
# Edge kinds that the DF1 spec requires to be DROPPED when they cannot
|
|
36
|
+
# be resolved (rather than left as ``unresolved::*`` for downstream tools).
|
|
37
|
+
_DROP_IF_UNRESOLVED: frozenset[EdgeKind] = frozenset(
|
|
38
|
+
{EdgeKind.READS_FROM, EdgeKind.WRITES_TO}
|
|
39
|
+
)
|
|
40
|
+
_DEFINITION_KINDS: frozenset[NodeKind] = frozenset(
|
|
41
|
+
{NodeKind.FUNCTION, NodeKind.METHOD, NodeKind.CLASS, NodeKind.MODULE}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ResolveStats:
|
|
47
|
+
inspected: int = 0
|
|
48
|
+
resolved: int = 0
|
|
49
|
+
unresolved: int = 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _strip_unresolved(dst: str) -> str:
|
|
53
|
+
prefix = "unresolved::"
|
|
54
|
+
return dst[len(prefix):] if dst.startswith(prefix) else dst
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _normalize_target(name: str) -> str:
|
|
58
|
+
"""Strip call-syntax noise so "foo.bar()" / "foo()" become "foo.bar".
|
|
59
|
+
|
|
60
|
+
Also collapses fresh-instance chains like ``Builder().make`` /
|
|
61
|
+
``Builder(...).run.bar`` into ``Builder.make`` / ``Builder.run.bar``,
|
|
62
|
+
so the resolver can find the method on the constructed class instead
|
|
63
|
+
of the class itself.
|
|
64
|
+
"""
|
|
65
|
+
cleaned = name.strip()
|
|
66
|
+
if cleaned.startswith("await "):
|
|
67
|
+
cleaned = cleaned[len("await "):]
|
|
68
|
+
if cleaned.startswith("new "):
|
|
69
|
+
cleaned = cleaned[len("new "):]
|
|
70
|
+
# Collapse balanced ``(...)`` segments. We track depth so nested
|
|
71
|
+
# parens (``Builder(Inner()).make``) collapse correctly to
|
|
72
|
+
# ``Builder.make``.
|
|
73
|
+
out: list[str] = []
|
|
74
|
+
depth = 0
|
|
75
|
+
for ch in cleaned:
|
|
76
|
+
if ch == "(":
|
|
77
|
+
depth += 1
|
|
78
|
+
continue
|
|
79
|
+
if ch == ")":
|
|
80
|
+
if depth > 0:
|
|
81
|
+
depth -= 1
|
|
82
|
+
continue
|
|
83
|
+
if depth == 0:
|
|
84
|
+
out.append(ch)
|
|
85
|
+
cleaned = "".join(out).strip()
|
|
86
|
+
# Drop any trailing dots left over from ``Foo().``.
|
|
87
|
+
while cleaned.endswith("."):
|
|
88
|
+
cleaned = cleaned[:-1]
|
|
89
|
+
return cleaned
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _Index:
|
|
93
|
+
def __init__(self, nodes: list[Node]) -> None:
|
|
94
|
+
self.by_id: dict[str, Node] = {n.id: n for n in nodes}
|
|
95
|
+
self.by_qualname: dict[str, list[Node]] = defaultdict(list)
|
|
96
|
+
self.by_name: dict[str, list[Node]] = defaultdict(list)
|
|
97
|
+
self.module_by_qualname: dict[str, Node] = {}
|
|
98
|
+
self.module_by_file: dict[str, Node] = {}
|
|
99
|
+
for node in nodes:
|
|
100
|
+
if node.kind in _DEFINITION_KINDS:
|
|
101
|
+
self.by_qualname[node.qualname].append(node)
|
|
102
|
+
self.by_name[node.name].append(node)
|
|
103
|
+
if node.kind == NodeKind.MODULE:
|
|
104
|
+
self.module_by_qualname[node.qualname] = node
|
|
105
|
+
self.module_by_file[node.file] = node
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _attr_type_names(
|
|
109
|
+
class_node: Node, attr_head: str
|
|
110
|
+
) -> list[str]:
|
|
111
|
+
"""Return the list of declared type names for ``self.<attr_head>``.
|
|
112
|
+
|
|
113
|
+
Tolerates the legacy schema where ``attr_types[name]`` was a single
|
|
114
|
+
string rather than a list (R2). Empty list if the attribute is not
|
|
115
|
+
annotated.
|
|
116
|
+
"""
|
|
117
|
+
attr_types = class_node.metadata.get("attr_types")
|
|
118
|
+
if not isinstance(attr_types, dict):
|
|
119
|
+
return []
|
|
120
|
+
raw = attr_types.get(attr_head)
|
|
121
|
+
if isinstance(raw, str):
|
|
122
|
+
return [raw] if raw else []
|
|
123
|
+
if isinstance(raw, list):
|
|
124
|
+
return [t for t in raw if isinstance(t, str) and t]
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _resolve_self_attr_targets(
|
|
129
|
+
class_qual: str,
|
|
130
|
+
head: str,
|
|
131
|
+
tail: str,
|
|
132
|
+
index: _Index,
|
|
133
|
+
imports_for_module: dict[str, dict[str, str]],
|
|
134
|
+
src_module: Node | None,
|
|
135
|
+
) -> list[Node]:
|
|
136
|
+
"""Resolve ``self.<head>.<tail>`` to one or more candidate nodes.
|
|
137
|
+
|
|
138
|
+
Honors a list of candidate type names declared on the enclosing class
|
|
139
|
+
(R3 union annotation or if/else branch assignment in ``__init__``)
|
|
140
|
+
and returns one resolved node per type that owns ``tail``.
|
|
141
|
+
"""
|
|
142
|
+
class_nodes = index.by_qualname.get(class_qual, [])
|
|
143
|
+
if not class_nodes:
|
|
144
|
+
return []
|
|
145
|
+
type_names = _attr_type_names(class_nodes[0], head)
|
|
146
|
+
if not type_names:
|
|
147
|
+
return []
|
|
148
|
+
out: list[Node] = []
|
|
149
|
+
seen: set[str] = set()
|
|
150
|
+
for type_name in type_names:
|
|
151
|
+
node = _resolve_typed_attr_tail(
|
|
152
|
+
type_name, tail, index, imports_for_module, src_module,
|
|
153
|
+
)
|
|
154
|
+
if node is not None and node.id not in seen:
|
|
155
|
+
seen.add(node.id)
|
|
156
|
+
out.append(node)
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _resolve_typed_attr_tail(
|
|
161
|
+
type_name: str,
|
|
162
|
+
tail: str,
|
|
163
|
+
index: _Index,
|
|
164
|
+
imports_for_module: dict[str, dict[str, str]],
|
|
165
|
+
src_module: Node | None,
|
|
166
|
+
) -> Node | None:
|
|
167
|
+
"""Resolve ``<type_name>.<tail>`` via fully-qualified, import, or tail."""
|
|
168
|
+
# 1) Fully-qualified.
|
|
169
|
+
full = f"{type_name}.{tail}"
|
|
170
|
+
hit = index.by_qualname.get(full, [])
|
|
171
|
+
if hit:
|
|
172
|
+
return hit[0]
|
|
173
|
+
# 2) Import binding from the same module.
|
|
174
|
+
if src_module is not None:
|
|
175
|
+
bind = imports_for_module.get(src_module.id, {})
|
|
176
|
+
bound = bind.get(type_name)
|
|
177
|
+
if bound:
|
|
178
|
+
hit = index.by_qualname.get(f"{bound}.{tail}", [])
|
|
179
|
+
if hit:
|
|
180
|
+
return hit[0]
|
|
181
|
+
# 3) Tail-match: any class whose qualname ends with ``type_name``.
|
|
182
|
+
for qn in index.by_qualname:
|
|
183
|
+
if qn == type_name or qn.endswith("." + type_name):
|
|
184
|
+
hit = index.by_qualname.get(f"{qn}.{tail}", [])
|
|
185
|
+
if hit:
|
|
186
|
+
return hit[0]
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _try_multi_self_attr(
|
|
191
|
+
target: str,
|
|
192
|
+
src_node: Node | None,
|
|
193
|
+
edge_kind: EdgeKind,
|
|
194
|
+
index: _Index,
|
|
195
|
+
imports_for_module: dict[str, dict[str, str]],
|
|
196
|
+
) -> list[Node]:
|
|
197
|
+
"""Return >=2 candidate nodes when ``self.X.tail`` has a union type.
|
|
198
|
+
|
|
199
|
+
Returns an empty list when the target is not a self-attribute chain,
|
|
200
|
+
when the enclosing class doesn't declare a union for ``X``, or when
|
|
201
|
+
only zero/one type resolves. The single-candidate path is left to
|
|
202
|
+
the regular ``_resolve_target`` logic so we don't double-resolve.
|
|
203
|
+
"""
|
|
204
|
+
if edge_kind != EdgeKind.CALLS:
|
|
205
|
+
return []
|
|
206
|
+
if src_node is None or src_node.kind != NodeKind.METHOD:
|
|
207
|
+
return []
|
|
208
|
+
cleaned = _normalize_target(target)
|
|
209
|
+
if not cleaned.startswith("self."):
|
|
210
|
+
return []
|
|
211
|
+
rest = cleaned[len("self."):]
|
|
212
|
+
head, _, tail = rest.partition(".")
|
|
213
|
+
if not tail:
|
|
214
|
+
return []
|
|
215
|
+
parts = src_node.qualname.split(".")
|
|
216
|
+
if len(parts) < 2:
|
|
217
|
+
return []
|
|
218
|
+
class_qual = ".".join(parts[:-1])
|
|
219
|
+
src_module = index.module_by_file.get(src_node.file)
|
|
220
|
+
multi = _resolve_self_attr_targets(
|
|
221
|
+
class_qual, head, tail, index, imports_for_module, src_module,
|
|
222
|
+
)
|
|
223
|
+
if len(multi) < 2:
|
|
224
|
+
return []
|
|
225
|
+
return multi
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _resolve_target(
|
|
229
|
+
target: str,
|
|
230
|
+
src_node: Node | None,
|
|
231
|
+
index: _Index,
|
|
232
|
+
imports_for_module: dict[str, dict[str, str]],
|
|
233
|
+
) -> Node | None:
|
|
234
|
+
"""Return the best-matching node for ``target``, or None."""
|
|
235
|
+
if not target:
|
|
236
|
+
return None
|
|
237
|
+
target = _normalize_target(target)
|
|
238
|
+
if not target:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
src_module: Node | None = None
|
|
242
|
+
if src_node is not None:
|
|
243
|
+
src_module = index.module_by_file.get(src_node.file)
|
|
244
|
+
|
|
245
|
+
# 1. self.X -> derive enclosing class qualname from src qualname.
|
|
246
|
+
# For `self.foo.bar` style chains, only the first segment after `self.`
|
|
247
|
+
# is meaningfully resolvable against the enclosing class; the deeper
|
|
248
|
+
# tail (`.bar`) requires variable-type inference (R3). So we look up
|
|
249
|
+
# `class_qual.first_segment` and fall through to the remaining
|
|
250
|
+
# heuristics with the first segment as the new target rather than
|
|
251
|
+
# constructing a phantom dotted qualname.
|
|
252
|
+
if target.startswith("self."):
|
|
253
|
+
rest = target[len("self."):]
|
|
254
|
+
head = rest.split(".", 1)[0]
|
|
255
|
+
tail = rest[len(head) + 1:] if len(rest) > len(head) else ""
|
|
256
|
+
if src_node is not None and src_node.kind == NodeKind.METHOD:
|
|
257
|
+
parts = src_node.qualname.split(".")
|
|
258
|
+
if len(parts) >= 2:
|
|
259
|
+
class_qual = ".".join(parts[:-1])
|
|
260
|
+
# Direct match (no dotted tail).
|
|
261
|
+
cands = index.by_qualname.get(f"{class_qual}.{rest}", [])
|
|
262
|
+
if cands:
|
|
263
|
+
return cands[0]
|
|
264
|
+
# Dotted tail: try resolving via class-level type annotation
|
|
265
|
+
# (\`name: TypeName\` in the class body). If the enclosing
|
|
266
|
+
# class declares ``head: TypeName``, look up
|
|
267
|
+
# ``TypeName.<tail>`` against in-repo types. Multi-type
|
|
268
|
+
# candidates (R3 union / if-else) are returned via
|
|
269
|
+
# ``_resolve_self_attr_targets`` instead.
|
|
270
|
+
if tail:
|
|
271
|
+
multi = _resolve_self_attr_targets(
|
|
272
|
+
class_qual, head, tail, index,
|
|
273
|
+
imports_for_module, src_module,
|
|
274
|
+
)
|
|
275
|
+
if multi:
|
|
276
|
+
return multi[0]
|
|
277
|
+
# Dotted tail: try resolving just the first segment as a
|
|
278
|
+
# method/attribute on the enclosing class.
|
|
279
|
+
if head != rest:
|
|
280
|
+
cands = index.by_qualname.get(
|
|
281
|
+
f"{class_qual}.{head}", []
|
|
282
|
+
)
|
|
283
|
+
if cands:
|
|
284
|
+
return cands[0]
|
|
285
|
+
# Fall through with just the head; never let "foo.bar" leak as a
|
|
286
|
+
# phantom qualname into later heuristics.
|
|
287
|
+
target = head
|
|
288
|
+
|
|
289
|
+
# 1b. Scope-relative: when the caller is itself a function/method and
|
|
290
|
+
# the target names a function defined directly inside the caller (a
|
|
291
|
+
# nested helper), prefer that closure-local definition over any
|
|
292
|
+
# module-level same-name function.
|
|
293
|
+
if (
|
|
294
|
+
src_node is not None
|
|
295
|
+
and src_node.kind in (NodeKind.FUNCTION, NodeKind.METHOD)
|
|
296
|
+
):
|
|
297
|
+
nested_q = f"{src_node.qualname}.{target}"
|
|
298
|
+
cands = index.by_qualname.get(nested_q, [])
|
|
299
|
+
if cands:
|
|
300
|
+
return cands[0]
|
|
301
|
+
|
|
302
|
+
# 2. Exact qualname.
|
|
303
|
+
if target in index.by_qualname:
|
|
304
|
+
cands = index.by_qualname[target]
|
|
305
|
+
if len(cands) == 1:
|
|
306
|
+
return cands[0]
|
|
307
|
+
|
|
308
|
+
# 3. Same-module: <src_module>.<target>.
|
|
309
|
+
if src_module is not None:
|
|
310
|
+
candidate_q = f"{src_module.qualname}.{target}"
|
|
311
|
+
cands = index.by_qualname.get(candidate_q, [])
|
|
312
|
+
if cands:
|
|
313
|
+
return cands[0]
|
|
314
|
+
|
|
315
|
+
# 4. Through imports of the source module.
|
|
316
|
+
if src_module is not None:
|
|
317
|
+
bindings = imports_for_module.get(src_module.id, {})
|
|
318
|
+
head, _, tail = target.partition(".")
|
|
319
|
+
bound = bindings.get(head)
|
|
320
|
+
if bound is not None:
|
|
321
|
+
full = bound if not tail else f"{bound}.{tail}"
|
|
322
|
+
cands = index.by_qualname.get(full, [])
|
|
323
|
+
if cands:
|
|
324
|
+
return cands[0]
|
|
325
|
+
mod = index.module_by_qualname.get(bound)
|
|
326
|
+
if mod is not None and tail:
|
|
327
|
+
cands = index.by_qualname.get(f"{mod.qualname}.{tail}", [])
|
|
328
|
+
if cands:
|
|
329
|
+
return cands[0]
|
|
330
|
+
|
|
331
|
+
# 5. Module by qualname (for IMPORTS).
|
|
332
|
+
mod = index.module_by_qualname.get(target)
|
|
333
|
+
if mod is not None:
|
|
334
|
+
return mod
|
|
335
|
+
|
|
336
|
+
# 6. Tail match: any qualname ending with .target -- accept only if unique.
|
|
337
|
+
suffix_matches: list[Node] = []
|
|
338
|
+
for qn, nodes in index.by_qualname.items():
|
|
339
|
+
if qn == target or qn.endswith("." + target):
|
|
340
|
+
suffix_matches.extend(nodes)
|
|
341
|
+
if len(suffix_matches) == 1:
|
|
342
|
+
return suffix_matches[0]
|
|
343
|
+
|
|
344
|
+
# 7. Bare-name match across the whole graph (only if globally unique).
|
|
345
|
+
base = target.rsplit(".", 1)[-1]
|
|
346
|
+
by_name = index.by_name.get(base, [])
|
|
347
|
+
if len(by_name) == 1:
|
|
348
|
+
return by_name[0]
|
|
349
|
+
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _build_import_bindings(
|
|
354
|
+
edges: list[Edge], index: _Index
|
|
355
|
+
) -> dict[str, dict[str, str]]:
|
|
356
|
+
"""For each module node id, map import alias -> imported module qualname.
|
|
357
|
+
|
|
358
|
+
Currently we only know the textual target_name (e.g. "models" or
|
|
359
|
+
"./utils"), so the alias is the leaf segment.
|
|
360
|
+
"""
|
|
361
|
+
bindings: dict[str, dict[str, str]] = defaultdict(dict)
|
|
362
|
+
for edge in edges:
|
|
363
|
+
if edge.kind != EdgeKind.IMPORTS:
|
|
364
|
+
continue
|
|
365
|
+
src_node = index.by_id.get(edge.src)
|
|
366
|
+
if src_node is None or src_node.kind != NodeKind.MODULE:
|
|
367
|
+
continue
|
|
368
|
+
target = edge.metadata.get("target_name")
|
|
369
|
+
if not isinstance(target, str) or not target:
|
|
370
|
+
continue
|
|
371
|
+
# Python parser may already produce absolute dotted qualnames for
|
|
372
|
+
# relative imports (e.g. "pkg.models.Foo"). Only strip leading "./"
|
|
373
|
+
# and "../" path noise, not bare leading dots that may be part of
|
|
374
|
+
# a dotted qualname.
|
|
375
|
+
normalized = target.replace("\\", "/")
|
|
376
|
+
while normalized.startswith("./") or normalized.startswith("../"):
|
|
377
|
+
normalized = normalized[2:] if normalized.startswith("./") \
|
|
378
|
+
else normalized[3:]
|
|
379
|
+
normalized = normalized.replace("/", ".")
|
|
380
|
+
if not normalized:
|
|
381
|
+
continue
|
|
382
|
+
imported_name = edge.metadata.get("imported_name")
|
|
383
|
+
if isinstance(imported_name, str) and imported_name:
|
|
384
|
+
# Bind the alias used in the source file -> full qualname.
|
|
385
|
+
bindings[src_node.id][imported_name] = normalized
|
|
386
|
+
bindings[src_node.id][normalized] = normalized
|
|
387
|
+
else:
|
|
388
|
+
leaf = normalized.rsplit(".", 1)[-1]
|
|
389
|
+
bindings[src_node.id][leaf] = normalized
|
|
390
|
+
bindings[src_node.id][normalized] = normalized
|
|
391
|
+
return bindings
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def resolve_unresolved_edges(store: SQLiteGraphStore) -> ResolveStats:
|
|
395
|
+
"""Rewrite ``unresolved::*`` edges in-place, returning summary stats."""
|
|
396
|
+
nodes = list(store.iter_nodes())
|
|
397
|
+
edges = list(store.iter_edges())
|
|
398
|
+
index = _Index(nodes)
|
|
399
|
+
bindings = _build_import_bindings(edges, index)
|
|
400
|
+
|
|
401
|
+
stats = ResolveStats()
|
|
402
|
+
new_edges: list[Edge] = []
|
|
403
|
+
deletions: list[tuple[str, str, EdgeKind]] = []
|
|
404
|
+
|
|
405
|
+
for edge in edges:
|
|
406
|
+
if not edge.dst.startswith("unresolved::"):
|
|
407
|
+
continue
|
|
408
|
+
if edge.kind not in _REFERENCE_KINDS:
|
|
409
|
+
continue
|
|
410
|
+
stats.inspected += 1
|
|
411
|
+
meta_target = edge.metadata.get("target_name")
|
|
412
|
+
target = (
|
|
413
|
+
meta_target if isinstance(meta_target, str)
|
|
414
|
+
else _strip_unresolved(edge.dst)
|
|
415
|
+
)
|
|
416
|
+
src_node = index.by_id.get(edge.src)
|
|
417
|
+
|
|
418
|
+
# R3: ``self.X.tail`` may bind to multiple class types when the
|
|
419
|
+
# enclosing class declares a union annotation or assigns the
|
|
420
|
+
# attribute in branching ``__init__`` paths. We emit one edge per
|
|
421
|
+
# candidate so the dead-code analyzer can see all reachable
|
|
422
|
+
# implementations.
|
|
423
|
+
multi = _try_multi_self_attr(
|
|
424
|
+
target, src_node, edge.kind, index, bindings,
|
|
425
|
+
)
|
|
426
|
+
if multi:
|
|
427
|
+
deletions.append((edge.src, edge.dst, edge.kind))
|
|
428
|
+
for hit in multi:
|
|
429
|
+
if hit.id == edge.src:
|
|
430
|
+
continue
|
|
431
|
+
new_edges.append(
|
|
432
|
+
Edge(
|
|
433
|
+
src=edge.src,
|
|
434
|
+
dst=hit.id,
|
|
435
|
+
kind=edge.kind,
|
|
436
|
+
file=edge.file,
|
|
437
|
+
line=edge.line,
|
|
438
|
+
metadata={
|
|
439
|
+
**edge.metadata, "resolved_from": edge.dst,
|
|
440
|
+
},
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
stats.resolved += 1
|
|
444
|
+
continue
|
|
445
|
+
|
|
446
|
+
resolved = _resolve_target(target, src_node, index, bindings)
|
|
447
|
+
if resolved is None or resolved.id == edge.src:
|
|
448
|
+
stats.unresolved += 1
|
|
449
|
+
# DF1: drop unresolved data-access edges so the graph never
|
|
450
|
+
# carries ``unresolved::Model`` placeholders for SQL I/O.
|
|
451
|
+
if edge.kind in _DROP_IF_UNRESOLVED:
|
|
452
|
+
deletions.append((edge.src, edge.dst, edge.kind))
|
|
453
|
+
continue
|
|
454
|
+
# DF1 sanity: READS_FROM/WRITES_TO must resolve to a CLASS.
|
|
455
|
+
if (
|
|
456
|
+
edge.kind in _DROP_IF_UNRESOLVED
|
|
457
|
+
and resolved.kind != NodeKind.CLASS
|
|
458
|
+
):
|
|
459
|
+
stats.unresolved += 1
|
|
460
|
+
deletions.append((edge.src, edge.dst, edge.kind))
|
|
461
|
+
continue
|
|
462
|
+
deletions.append((edge.src, edge.dst, edge.kind))
|
|
463
|
+
new_edges.append(
|
|
464
|
+
Edge(
|
|
465
|
+
src=edge.src,
|
|
466
|
+
dst=resolved.id,
|
|
467
|
+
kind=edge.kind,
|
|
468
|
+
file=edge.file,
|
|
469
|
+
line=edge.line,
|
|
470
|
+
metadata={**edge.metadata, "resolved_from": edge.dst},
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
stats.resolved += 1
|
|
474
|
+
|
|
475
|
+
for src, dst, kind in deletions:
|
|
476
|
+
store.delete_edge(src, dst, kind)
|
|
477
|
+
if new_edges:
|
|
478
|
+
store.upsert_edges(new_edges)
|
|
479
|
+
|
|
480
|
+
return stats
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""PR review: graph diffs, risk scoring, and rule evaluation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from codegraph.review.baseline import load_baseline, save_baseline
|
|
5
|
+
from codegraph.review.differ import EdgeChange, GraphDiff, NodeChange, diff_graphs
|
|
6
|
+
from codegraph.review.risk import Risk, score_change
|
|
7
|
+
from codegraph.review.rules import (
|
|
8
|
+
DEFAULT_RULES,
|
|
9
|
+
Finding,
|
|
10
|
+
Rule,
|
|
11
|
+
RuleMatch,
|
|
12
|
+
evaluate_rules,
|
|
13
|
+
load_rules,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"DEFAULT_RULES",
|
|
18
|
+
"EdgeChange",
|
|
19
|
+
"Finding",
|
|
20
|
+
"GraphDiff",
|
|
21
|
+
"NodeChange",
|
|
22
|
+
"Risk",
|
|
23
|
+
"Rule",
|
|
24
|
+
"RuleMatch",
|
|
25
|
+
"diff_graphs",
|
|
26
|
+
"evaluate_rules",
|
|
27
|
+
"load_baseline",
|
|
28
|
+
"load_rules",
|
|
29
|
+
"save_baseline",
|
|
30
|
+
"score_change",
|
|
31
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Baseline graph snapshot management."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import networkx as nx
|
|
8
|
+
|
|
9
|
+
from codegraph.graph.store_networkx import to_digraph
|
|
10
|
+
from codegraph.graph.store_sqlite import SQLiteGraphStore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def save_baseline(db_path: Path, baseline_path: Path) -> None:
|
|
14
|
+
"""Copy ``db_path`` to ``baseline_path`` (creating parents as needed)."""
|
|
15
|
+
if not db_path.exists():
|
|
16
|
+
raise FileNotFoundError(f"graph database not found: {db_path}")
|
|
17
|
+
baseline_path.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
shutil.copy2(str(db_path), str(baseline_path))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_baseline(baseline_path: Path) -> nx.MultiDiGraph | None:
|
|
22
|
+
"""Load the baseline graph from ``baseline_path``.
|
|
23
|
+
|
|
24
|
+
Returns ``None`` when the baseline file is missing.
|
|
25
|
+
"""
|
|
26
|
+
if not baseline_path.exists():
|
|
27
|
+
return None
|
|
28
|
+
store = SQLiteGraphStore(baseline_path)
|
|
29
|
+
try:
|
|
30
|
+
return to_digraph(store)
|
|
31
|
+
finally:
|
|
32
|
+
store.close()
|