Skip to content

Commit a0b94ff

Browse files
committed
fix(pycg): canonicalize the builtins module spelling on PyCG edges (#132)
PyCG spells the builtins module `<builtin>`; Jedi spells it `builtins`. Nothing normalized the two, so `_home_external_symbols` minted a separate `can://.../@external/<module>/<name>` home per spelling and one builtin ended up with two identities. Measured on the `requests` fixture at -a 2: 29 ids under `<builtin>/` against 14 under `builtins/`, with 12 names present under both (len, isinstance, getattr, sorted, ...). Two things follow from that. A consumer asking "who calls len" gets two disjoint answers, neither complete. And provenance can never merge for a builtin: `merge_edges` coalesces on (src, dst), so differing dst ids keep the two backends' edges apart -- in the same run 198 non-builtin edges do carry `prov: ["jedi", "pycg"]`, while builtins are structurally excluded from it. Canonicalization happens at `build_call_graph_edges`' single exit, so every shard strategy is covered, and before `merge_edges` runs in core.py -- doing it at id-minting time would leave two already-merged edges with identical endpoints and split provenance, moving the symptom rather than removing it. Endpoints that collide once rewritten are coalesced with summed weight and unioned provenance, matching merge_edges' semantics. That coalescing is done locally rather than through `_coalesce_edges`, which raises on its duplicate branch (#133). PyCG only ever emits the bare `<builtin>` module, so an exact-match alias suffices; the dotted forms (`builtins.str`, `builtins.dict`) are Jedi's and are already canonical.
1 parent 6f02581 commit a0b94ff

2 files changed

Lines changed: 95 additions & 0 deletions

File tree

codeanalyzer/semantic_analysis/pycg/pycg_analysis.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,44 @@ def _handler(signum: int, frame: object) -> None:
8888
from codeanalyzer.utils import ProgressBar, logger
8989

9090

91+
# PyCG spells the builtins module ``<builtin>``; Jedi spells it ``builtins``. Left
92+
# unnormalized, one builtin gets two ``@external`` ``can://`` homes and the two
93+
# backends' edges can never coalesce, so a call both resolvers agree on can never
94+
# reach ``prov: ["jedi", "pycg"]`` (#132). PyCG only ever emits the bare module, so
95+
# an exact-match alias is enough -- the dotted forms (``builtins.str`` etc.) are
96+
# Jedi's and are already canonical.
97+
_PYCG_MODULE_ALIASES = {"<builtin>": "builtins"}
98+
99+
100+
def _canonical_endpoint(sig: str) -> str:
101+
"""Rewrite a PyCG endpoint's module segment to the canonical spelling."""
102+
module, dot, name = sig.rpartition(".")
103+
if dot and module in _PYCG_MODULE_ALIASES:
104+
return f"{_PYCG_MODULE_ALIASES[module]}.{name}"
105+
return sig
106+
107+
108+
def _canonicalize_edges(edges: List[PyCallEdge]) -> List[PyCallEdge]:
109+
"""Canonicalize endpoint spellings, coalescing pairs that collide as a result.
110+
111+
Two spellings of one target are one edge: weights sum and provenance unions,
112+
matching ``call_graph.merge_edges``. Deliberately does not route through
113+
``_coalesce_edges``, which raises on its duplicate branch (#133).
114+
"""
115+
merged: Dict[Tuple[str, str], PyCallEdge] = {}
116+
for edge in edges:
117+
src = _canonical_endpoint(edge.src)
118+
dst = _canonical_endpoint(edge.dst)
119+
key = (src, dst)
120+
current = merged.get(key)
121+
if current is None:
122+
merged[key] = edge.model_copy(update={"src": src, "dst": dst})
123+
else:
124+
current.weight += edge.weight
125+
current.prov = sorted(set(current.prov) | set(edge.prov))
126+
return list(merged.values())
127+
128+
91129
def _shard_root_path(files: List[str], project_dir: Path) -> Path:
92130
"""Content-derived mini-project root for a shard: same project + same file
93131
set → same path on every run (determinism, issue #99)."""
@@ -1110,6 +1148,7 @@ def build_call_graph_edges(
11101148
with _shard_symlink_root(entry_points, self.project_dir) as (root, eps):
11111149
edges = self._run_pycg_batch(eps, root, resolver, prefix="")
11121150

1151+
edges = _canonicalize_edges(edges)
11131152
elapsed = time.perf_counter() - t0
11141153
logger.info("✅ PyCG: %d edges in %.1fs", len(edges), elapsed)
11151154
return edges
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""PyCG builtin module spelling is canonicalized before edges leave the backend (#132).
2+
3+
PyCG spells the builtins module ``<builtin>``; Jedi spells it ``builtins``. Left
4+
unnormalized, one builtin gets two ``@external`` ``can://`` homes and the two
5+
backends' edges can never coalesce into ``prov: ["jedi", "pycg"]``.
6+
"""
7+
from codeanalyzer.schema.py_schema import PyCallEdge
8+
from codeanalyzer.semantic_analysis.call_graph import merge_edges
9+
from codeanalyzer.semantic_analysis.pycg.pycg_analysis import (
10+
_canonical_endpoint,
11+
_canonicalize_edges,
12+
)
13+
14+
15+
def test_builtin_module_is_rewritten():
16+
assert _canonical_endpoint("<builtin>.isinstance") == "builtins.isinstance"
17+
assert _canonical_endpoint("<builtin>.len") == "builtins.len"
18+
19+
20+
def test_already_canonical_and_unrelated_names_are_untouched():
21+
for sig in (
22+
"builtins.isinstance", # Jedi's spelling, already canonical
23+
"builtins.str.format", # dotted builtin type -- module is `builtins.str`
24+
"requests.api.get", # ordinary first-party signature
25+
"isinstance", # no module segment at all
26+
"<builtin>", # bare, no dot -> not an endpoint we rewrite
27+
):
28+
assert _canonical_endpoint(sig) == sig
29+
30+
31+
def test_colliding_spellings_coalesce_with_summed_weight():
32+
edges = [
33+
PyCallEdge(src="a.f", dst="<builtin>.len", weight=3, prov=["pycg"]),
34+
PyCallEdge(src="a.f", dst="builtins.len", weight=2, prov=["pycg"]),
35+
]
36+
out = _canonicalize_edges(edges)
37+
assert len(out) == 1
38+
assert (out[0].src, out[0].dst) == ("a.f", "builtins.len")
39+
assert out[0].weight == 5
40+
41+
42+
def test_canonicalization_lets_provenance_merge_across_backends():
43+
"""The point of #132: without this, a builtin can never reach prov=[jedi,pycg]."""
44+
pycg = _canonicalize_edges(
45+
[PyCallEdge(src="a.f", dst="<builtin>.len", weight=1, prov=["pycg"])]
46+
)
47+
jedi = [PyCallEdge(src="a.f", dst="builtins.len", weight=1, prov=["jedi"])]
48+
merged = merge_edges(jedi, pycg)
49+
assert len(merged) == 1
50+
assert merged[0].prov == ["jedi", "pycg"]
51+
52+
53+
def test_source_edges_are_not_mutated():
54+
original = PyCallEdge(src="a.f", dst="<builtin>.len", weight=1, prov=["pycg"])
55+
_canonicalize_edges([original])
56+
assert original.dst == "<builtin>.len"

0 commit comments

Comments
 (0)