Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions codeanalyzer/semantic_analysis/pycg/pycg_analysis.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,44 @@ def _handler(signum: int, frame: object) -> None:
from codeanalyzer.utils import ProgressBar, logger


# PyCG spells the builtins module ``<builtin>``; Jedi spells it ``builtins``. Left
# unnormalized, one builtin gets two ``@external`` ``can://`` homes and the two
# backends' edges can never coalesce, so a call both resolvers agree on can never
# reach ``prov: ["jedi", "pycg"]`` (#132). PyCG only ever emits the bare module, so
# an exact-match alias is enough -- the dotted forms (``builtins.str`` etc.) are
# Jedi's and are already canonical.
_PYCG_MODULE_ALIASES = {"<builtin>": "builtins"}


def _canonical_endpoint(sig: str) -> str:
"""Rewrite a PyCG endpoint's module segment to the canonical spelling."""
module, dot, name = sig.rpartition(".")
if dot and module in _PYCG_MODULE_ALIASES:
return f"{_PYCG_MODULE_ALIASES[module]}.{name}"
return sig


def _canonicalize_edges(edges: List[PyCallEdge]) -> List[PyCallEdge]:
"""Canonicalize endpoint spellings, coalescing pairs that collide as a result.

Two spellings of one target are one edge: weights sum and provenance unions,
matching ``call_graph.merge_edges``. Deliberately does not route through
``_coalesce_edges``, which raises on its duplicate branch (#133).
"""
merged: Dict[Tuple[str, str], PyCallEdge] = {}
for edge in edges:
src = _canonical_endpoint(edge.src)
dst = _canonical_endpoint(edge.dst)
key = (src, dst)
current = merged.get(key)
if current is None:
merged[key] = edge.model_copy(update={"src": src, "dst": dst})
else:
current.weight += edge.weight
current.prov = sorted(set(current.prov) | set(edge.prov))
return list(merged.values())


def _shard_root_path(files: List[str], project_dir: Path) -> Path:
"""Content-derived mini-project root for a shard: same project + same file
set → same path on every run (determinism, issue #99)."""
Expand DownExpand Up@@ -1110,6 +1148,7 @@ def build_call_graph_edges(
with _shard_symlink_root(entry_points, self.project_dir) as (root, eps):
edges = self._run_pycg_batch(eps, root, resolver, prefix="")

edges = _canonicalize_edges(edges)
elapsed = time.perf_counter() - t0
logger.info("✅ PyCG: %d edges in %.1fs", len(edges), elapsed)
return edges
56 changes: 56 additions & 0 deletions test/test_pycg_builtin_canonicalization.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
"""PyCG builtin module spelling is canonicalized before edges leave the backend (#132).

PyCG spells the builtins module ``<builtin>``; Jedi spells it ``builtins``. Left
unnormalized, one builtin gets two ``@external`` ``can://`` homes and the two
backends' edges can never coalesce into ``prov: ["jedi", "pycg"]``.
"""
from codeanalyzer.schema.py_schema import PyCallEdge
from codeanalyzer.semantic_analysis.call_graph import merge_edges
from codeanalyzer.semantic_analysis.pycg.pycg_analysis import (
_canonical_endpoint,
_canonicalize_edges,
)


def test_builtin_module_is_rewritten():
assert _canonical_endpoint("<builtin>.isinstance") == "builtins.isinstance"
assert _canonical_endpoint("<builtin>.len") == "builtins.len"


def test_already_canonical_and_unrelated_names_are_untouched():
for sig in (
"builtins.isinstance", # Jedi's spelling, already canonical
"builtins.str.format", # dotted builtin type -- module is `builtins.str`
"requests.api.get", # ordinary first-party signature
"isinstance", # no module segment at all
"<builtin>", # bare, no dot -> not an endpoint we rewrite
):
assert _canonical_endpoint(sig) == sig


def test_colliding_spellings_coalesce_with_summed_weight():
edges = [
PyCallEdge(src="a.f", dst="<builtin>.len", weight=3, prov=["pycg"]),
PyCallEdge(src="a.f", dst="builtins.len", weight=2, prov=["pycg"]),
]
out = _canonicalize_edges(edges)
assert len(out) == 1
assert (out[0].src, out[0].dst) == ("a.f", "builtins.len")
assert out[0].weight == 5


def test_canonicalization_lets_provenance_merge_across_backends():
"""The point of #132: without this, a builtin can never reach prov=[jedi,pycg]."""
pycg = _canonicalize_edges(
[PyCallEdge(src="a.f", dst="<builtin>.len", weight=1, prov=["pycg"])]
)
jedi = [PyCallEdge(src="a.f", dst="builtins.len", weight=1, prov=["jedi"])]
merged = merge_edges(jedi, pycg)
assert len(merged) == 1
assert merged[0].prov == ["jedi", "pycg"]


def test_source_edges_are_not_mutated():
original = PyCallEdge(src="a.f", dst="<builtin>.len", weight=1, prov=["pycg"])
_canonicalize_edges([original])
assert original.dst == "<builtin>.len"