diff --git a/codeanalyzer/semantic_analysis/pycg/pycg_analysis.py b/codeanalyzer/semantic_analysis/pycg/pycg_analysis.py index 3a19915..d953cad 100644 --- a/codeanalyzer/semantic_analysis/pycg/pycg_analysis.py +++ b/codeanalyzer/semantic_analysis/pycg/pycg_analysis.py @@ -88,6 +88,44 @@ def _handler(signum: int, frame: object) -> None: from codeanalyzer.utils import ProgressBar, logger +# PyCG spells the builtins module ````; 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 = {"": "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).""" @@ -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 diff --git a/test/test_pycg_builtin_canonicalization.py b/test/test_pycg_builtin_canonicalization.py new file mode 100644 index 0000000..8c10e5c --- /dev/null +++ b/test/test_pycg_builtin_canonicalization.py @@ -0,0 +1,56 @@ +"""PyCG builtin module spelling is canonicalized before edges leave the backend (#132). + +PyCG spells the builtins module ````; 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(".isinstance") == "builtins.isinstance" + assert _canonical_endpoint(".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 + "", # 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=".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=".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=".len", weight=1, prov=["pycg"]) + _canonicalize_edges([original]) + assert original.dst == ".len"