From 2352bf4da2f4731a5a257d4c01792d40d2263986 Mon Sep 17 00:00:00 2001 From: xiongjianxu Date: Mon, 31 Aug 2026 10:33:36 +0800 Subject: [PATCH 1/3] Finish member calls across a repo boundary on merge (#3152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single-repo build binds `obj.method()` only when the receiver's type is declared in the same build. When the type lives in another repository the Java resolver already holds the receiver type but drops the call, so nothing about it reaches graph.json — the only artifact `merge-graphs` and `global add` read — and no merge-time pass can recover what was never written down. Merging two repos produced 7 edges where the same code in one corpus produced 8: exactly the edges that make it a call graph. The resolver now parks such a call on the caller node as a `metadata.unresolved_calls` entry (callee, receiver type, language, line — names only, never node ids, which the id remaps and the repo prefixing rewrite), and a new pass finishes the edge after the graphs are composed. The pass keeps the single-definition guard the single-repo resolvers use, only crosses a repo boundary, and requires the answering declaration to be in the same language, so a name collision fabricates nothing. It adds edges only — no node merging or renaming — and clears its own previous output before recomputing, which is what makes an incremental `global add` land where a single `merge-graphs` of the same inputs does. Parking is only wired into the Java resolver here; the pass is language-keyed so other extractors can opt in the same way. --- graphify/cli.py | 8 + graphify/cross_repo_calls.py | 184 ++++++++++++++++++ graphify/extract.py | 57 ++++++ graphify/global_graph.py | 16 +- tests/test_cross_repo_member_calls.py | 264 ++++++++++++++++++++++++++ 5 files changed, 526 insertions(+), 3 deletions(-) create mode 100644 graphify/cross_repo_calls.py create mode 100644 tests/test_cross_repo_member_calls.py diff --git a/graphify/cli.py b/graphify/cli.py index 6642f57d52..ec93e9cbb8 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -2702,6 +2702,12 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": shared_links = _link_shared(merged) if shared_links: print(f" linked {shared_links} type declaration(s) shared across repos") + # A member call whose receiver type lives in another repo was dropped at + # extraction; the caller node carries it and this finishes the edge (#3152). + from graphify.cross_repo_calls import link_cross_repo_member_calls as _link_calls + call_links = _link_calls(merged) + if call_links: + print(f" resolved {call_links} member call(s) across repos") # Drop whatever compose left behind (the last input's list, possibly # with internal duplicates) so attach_hyperedges dedups the full # collection by id from a clean slate. @@ -3111,6 +3117,8 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": else: print(f"Added '{tag}' to global graph: +{result['nodes_added']} nodes, " f"-{result['nodes_removed']} pruned. Global: {_global_path()}") + if result.get("cross_repo_calls"): + print(f" resolved {result['cross_repo_calls']} member call(s) across repos") except Exception as exc: print(f"error: {exc}", file=sys.stderr); sys.exit(1) elif subcmd == "remove": diff --git a/graphify/cross_repo_calls.py b/graphify/cross_repo_calls.py new file mode 100644 index 0000000000..4879d7efe3 --- /dev/null +++ b/graphify/cross_repo_calls.py @@ -0,0 +1,184 @@ +"""Finish member calls that cross a repository boundary in a merged graph (#3152). + +A single-repo build can only bind ``obj.method()`` when the receiver's type is +declared in that same build. When the type lives in another repository the +resolver holds the receiver type and drops the call anyway, so ``graph.json`` — +the only artifact ``merge-graphs`` and ``global add`` consume — records nothing, +and no merge-time pass can recover what was never written down. A two-repo call +graph was therefore missing exactly the edges that make it a call graph: eight +edges when the code sits in one corpus, seven after merging the same code from +two repos. + +The resolvers now park those calls on the caller node as +``metadata.unresolved_calls`` entries (names only, no node ids — see +``_park_unresolved_member_call``). This pass reads them back after the graphs are +composed and emits the ``calls`` edge when the receiver's type resolves to +exactly one declaration in another repo, keeping the single-definition guard the +single-repo resolvers use: an ambiguous name still fabricates nothing. + +Edges only, no node merging or renaming, so the pass composes with the prefixing +and pruning already in place. Every edge it adds is tagged, and it clears its own +previous output before recomputing: ``global add`` composes one repo at a time +and revisits the same pairs on every add, and recomputing from the parked entries +keeps the result identical whether three repos arrived together or one at a time. +""" +from __future__ import annotations + +import os +from collections import defaultdict +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover - typing only + import networkx as nx + +CROSS_REPO_CALL_MARKER = "_cross_repo_call" +UNRESOLVED_CALLS_KEY = "unresolved_calls" + +# A parked entry names the language it was written in, and the declaration that +# answers it must be written in the same one: without this a Java `Greeter` binds +# just as happily to a Python class of the same name in another repo. Extend this +# map when another extractor starts parking calls. +_LANG_SUFFIXES: dict[str, frozenset[str]] = { + "java": frozenset({".java"}), +} + + +def _key(label: object) -> str: + """Normalize a node label or a parked name to its bare identifier. + + Type labels are plain (``Greeter``) while method labels carry the extractor's + decoration (``.greet()``). Case is preserved: every language that parks calls + here is case-sensitive, and folding case would let `greeter` answer for + `Greeter`. + """ + return str(label or "").strip().removeprefix(".").removesuffix("()") + + +def _suffix(source_file: object) -> str: + return os.path.splitext(str(source_file or ""))[1].lower() + + +def _parked_entries(data: dict) -> list[dict]: + metadata = data.get("metadata") + if not isinstance(metadata, dict): + return [] + parked = metadata.get(UNRESOLVED_CALLS_KEY) + if not isinstance(parked, list): + return [] + return [entry for entry in parked if isinstance(entry, dict)] + + +def _drop_previous_output(merged: "nx.Graph") -> None: + """Clear the edges this pass added on an earlier run. + + Recompute-from-scratch is what makes an incremental ``global add`` agree with + a single ``merge-graphs`` of the same inputs, and it is also how a repo whose + types moved stops answering for calls it no longer owns. + """ + stale = [(u, v) for u, v, data in merged.edges(data=True) + if data.get(CROSS_REPO_CALL_MARKER)] + merged.remove_edges_from(stale) + + +def _index_declarations(merged: "nx.Graph") -> tuple[dict[str, list[str]], set[str]]: + """Index sourced type declarations by bare name. Returns (index, id set).""" + by_name: dict[str, list[str]] = defaultdict(list) + type_ids: set[str] = set() + for node, data in merged.nodes(data=True): + if not data.get("_callable_class") or not data.get("source_file"): + continue + if not data.get("repo"): + continue + name = _key(data.get("label")) + if not name: + continue + by_name[name].append(node) + type_ids.add(node) + return by_name, type_ids + + +def _index_methods(merged: "nx.Graph", type_ids: set[str]) -> dict[tuple[str, str], list[str]]: + """Index each declaration's methods by name. + + The merged graph is undirected, and a ``method`` edge carries no reliable + direction once composed, so the owner is identified as the endpoint that is a + type declaration. A nested declaration puts a type on both ends; that pair is + skipped rather than guessed at. + """ + by_owner: dict[tuple[str, str], list[str]] = defaultdict(list) + for u, v, data in merged.edges(data=True): + if data.get("relation") != "method": + continue + if u in type_ids and v not in type_ids: + owner, member = u, v + elif v in type_ids and u not in type_ids: + owner, member = v, u + else: + continue + name = _key(merged.nodes[member].get("label")) + if name: + by_owner[(owner, name)].append(member) + return by_owner + + +def link_cross_repo_member_calls(merged: "nx.Graph") -> int: + """Emit `calls` edges for parked member calls another repo answers. + + Returns the number of edges added. Idempotent: the pass drops its own earlier + output first, so re-merging or adding a repo twice cannot duplicate an edge. + """ + _drop_previous_output(merged) + parked_nodes = [(node, data) for node, data in merged.nodes(data=True) + if _parked_entries(data)] + if not parked_nodes: + return 0 + + by_name, type_ids = _index_declarations(merged) + if not by_name: + return 0 + methods_by_owner = _index_methods(merged, type_ids) + + added = 0 + for caller, caller_data in parked_nodes: + caller_repo = caller_data.get("repo") + if not caller_repo: + # Without a repo tag "another repo" has no meaning, and this pass + # deliberately never re-decides a call inside one repo. + continue + for entry in _parked_entries(caller_data): + suffixes = _LANG_SUFFIXES.get(str(entry.get("lang") or "")) + receiver_type = _key(entry.get("receiver_type")) + callee = _key(entry.get("callee")) + if not suffixes or not receiver_type or not callee: + continue + candidates = [ + node for node in by_name.get(receiver_type, []) + if merged.nodes[node].get("repo") != caller_repo + and _suffix(merged.nodes[node].get("source_file")) in suffixes + ] + if len(candidates) != 1: + # The same guard the single-repo resolvers apply: two repos + # declaring the same name is an ambiguity, not a hit. + continue + targets = methods_by_owner.get((candidates[0], callee), []) + if len(targets) != 1: + continue + target = targets[0] + if target == caller or merged.has_edge(caller, target): + continue + merged.add_edge( + caller, + target, + relation="calls", + context="cross_repo", + confidence="INFERRED", + confidence_score=0.8, + source_file=str(caller_data.get("source_file") or ""), + source_location=entry.get("line"), + weight=1.0, + _src=caller, + _tgt=target, + **{CROSS_REPO_CALL_MARKER: True}, + ) + added += 1 + return added diff --git a/graphify/extract.py b/graphify/extract.py index e015c9d715..6fcfb45cdb 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3702,6 +3702,52 @@ def _bind_member_field_tables( return bound +UNRESOLVED_CALLS_KEY = "unresolved_calls" +_MAX_PARKED_CALLS_PER_NODE = 64 + + +def _park_unresolved_member_call( + caller_node: dict | None, + callee: str, + receiver_type: str, + lang: str, + raw_call: dict, +) -> None: + """Keep a member call whose receiver type is declared nowhere in this corpus. + + A single-repo build can only bind ``obj.method()`` when the receiver's type is + declared in the same build, so a call into another repository is dropped with + the receiver type already in hand and nothing about it reaches ``graph.json`` + — the one artifact ``merge-graphs`` and ``global add`` consume. Parking the + pair on the caller node lets a merged graph finish the edge (#3152). + + The payload carries names only, never node ids: ids are rewritten by the + remaps and again by the repo prefixing, and a stale id inside metadata would + fail silently (#3150 was that bug). Names survive every rewrite. + """ + if not caller_node or not callee or not receiver_type: + return + metadata = caller_node.setdefault("metadata", {}) + if not isinstance(metadata, dict): + return + parked = metadata.setdefault(UNRESOLVED_CALLS_KEY, []) + if not isinstance(parked, list) or len(parked) >= _MAX_PARKED_CALLS_PER_NODE: + return + callee, receiver_type = str(callee), str(receiver_type) + for previous in parked: + if ( + isinstance(previous, dict) + and previous.get("callee") == callee + and previous.get("receiver_type") == receiver_type + ): + return + entry = {"callee": callee, "receiver_type": receiver_type, "lang": lang} + location = raw_call.get("source_location") + if location: + entry["line"] = str(location) + parked.append(entry) + + def _resolve_java_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -3796,6 +3842,17 @@ def _inherited_field_type(class_nid, field: str): if not type_name: continue type_defs = type_def_nids.get(key(type_name), []) + if not type_defs: + # The type is declared nowhere in this corpus, which in a + # multi-repo setup usually means "in a repo this build does + # not contain" rather than "does not exist" — park it for the + # merge (#3152). An ambiguous name (>1 declaration) is a + # local ambiguity that merging only widens, so it stays + # dropped, exactly as the guard below already decided. + _park_unresolved_member_call( + node_by_id.get(caller), callee, type_name, "java", raw_call, + ) + continue if len(type_defs) != 1: continue type_nid = type_defs[0] diff --git a/graphify/global_graph.py b/graphify/global_graph.py index eddd0c92a4..cd1ea19244 100644 --- a/graphify/global_graph.py +++ b/graphify/global_graph.py @@ -79,7 +79,8 @@ def _file_hash(path: Path) -> str: def global_add(source_path: Path, repo_tag: str) -> dict: """Add or update a project graph in the global graph. - Returns a summary dict with keys: repo_tag, nodes_added, nodes_removed, skipped. + Returns a summary dict with keys: repo_tag, nodes_added, nodes_removed, skipped, + cross_repo_calls. Skipped=True means the source graph hasn't changed since last add. """ from graphify.build import prefix_graph_for_global, prune_repo_from_graph @@ -100,7 +101,8 @@ def global_add(source_path: Path, repo_tag: str) -> dict: file=sys.stderr, ) if existing.get("source_hash") == src_hash: - return {"repo_tag": repo_tag, "nodes_added": 0, "nodes_removed": 0, "skipped": True} + return {"repo_tag": repo_tag, "nodes_added": 0, "nodes_removed": 0, "skipped": True, + "cross_repo_calls": 0} # Load source graph from graphify.security import check_graph_file_size_cap @@ -144,6 +146,13 @@ def global_add(source_path: Path, repo_tag: str) -> dict: G.add_edge(u, v, **data) added = prefixed.number_of_nodes() - len(remap) + # A member call parked on a caller node (#3152) may be answered by a repo + # already in the global graph, or by this one for a repo added earlier. The + # pass recomputes its own output, so adding repos one at a time lands where a + # single merge-graphs of the same inputs would. + from graphify.cross_repo_calls import link_cross_repo_member_calls + + cross_repo_calls = link_cross_repo_member_calls(G) _save_global_graph(G) manifest["repos"][repo_tag] = { @@ -155,7 +164,8 @@ def global_add(source_path: Path, repo_tag: str) -> dict: } _save_manifest(manifest) - return {"repo_tag": repo_tag, "nodes_added": added, "nodes_removed": removed, "skipped": False} + return {"repo_tag": repo_tag, "nodes_added": added, "nodes_removed": removed, + "skipped": False, "cross_repo_calls": cross_repo_calls} def global_remove(repo_tag: str) -> int: diff --git a/tests/test_cross_repo_member_calls.py b/tests/test_cross_repo_member_calls.py new file mode 100644 index 0000000000..bfd58372f1 --- /dev/null +++ b/tests/test_cross_repo_member_calls.py @@ -0,0 +1,264 @@ +"""`merge-graphs` finishes a member call whose receiver type is in another repo (#3152). + +A single-repo build binds `obj.method()` only when the receiver's type is declared +in the same build, so a call into another repository was dropped with the receiver +type already in hand and nothing about it reached `graph.json` — the only artifact +`merge-graphs` and `global add` read. The two-repo graph was missing precisely the +edges that make it a call graph. + +The Java resolver now parks those calls on the caller node and this pass finishes +them after the merge. The cases below pin what it must NOT do as much as what it +must: the single-definition guard, the cross-repo-only scope, and the language +guard are what keep it from fabricating an edge from a name collision. +""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import networkx as nx +import pytest + +from graphify.cross_repo_calls import ( + CROSS_REPO_CALL_MARKER, + link_cross_repo_member_calls, +) + +PYTHON = sys.executable + +try: + import tree_sitter_java # noqa: F401 + HAVE_JAVA = True +except ImportError: + HAVE_JAVA = False + +needs_java = pytest.mark.skipif(not HAVE_JAVA, reason="tree-sitter-java not installed") + + +def _caller(repo: str, parked: list[dict], node_id: str = "app_run") -> tuple[str, dict]: + return f"{repo}::{node_id}", { + "label": ".run()", "source_file": "src/App.java", "repo": repo, + "metadata": {"unresolved_calls": parked}, + } + + +def _declaration(repo: str, label: str, source_file: str = "src/Greeter.java", + node_id: str = "greeter", sourced: bool = True) -> tuple[str, dict]: + data: dict = {"label": label, "repo": repo, "_callable_class": True, "_callable": True} + if sourced: + data["source_file"] = source_file + return f"{repo}::{node_id}", data + + +def _method(repo: str, label: str = ".greet()", node_id: str = "greeter_greet", + source_file: str = "src/Greeter.java") -> tuple[str, dict]: + return f"{repo}::{node_id}", {"label": label, "repo": repo, + "source_file": source_file, "_callable": True} + + +def _graph(*, caller, declarations) -> nx.Graph: + """Build a merged-graph shape: one caller plus (declaration, method) pairs.""" + G = nx.Graph() + G.add_node(caller[0], **caller[1]) + for decl, method in declarations: + G.add_node(decl[0], **decl[1]) + G.add_node(method[0], **method[1]) + G.add_edge(decl[0], method[0], relation="method") + return G + + +def _added_calls(G: nx.Graph) -> set[tuple[str, str]]: + return {(data.get("_src"), data.get("_tgt")) + for _, _, data in G.edges(data=True) if data.get(CROSS_REPO_CALL_MARKER)} + + +PARKED_GREET = [{"callee": "greet", "receiver_type": "Greeter", "lang": "java", "line": "L10"}] + + +def test_a_parked_call_binds_to_the_one_declaration_in_another_repo(): + G = _graph( + caller=_caller("a", PARKED_GREET), + declarations=[(_declaration("b", "Greeter"), _method("b"))], + ) + assert link_cross_repo_member_calls(G) == 1 + assert _added_calls(G) == {("a::app_run", "b::greeter_greet")} + data = G.edges["a::app_run", "b::greeter_greet"] + assert data["relation"] == "calls" + assert data["confidence"] == "INFERRED" + assert data["context"] == "cross_repo" + assert data["source_location"] == "L10" + + +def test_two_repos_declaring_the_same_name_bind_nothing(): + # The single-definition guard the single-repo resolvers apply. Two `Greeter` + # declarations mean the call is ambiguous, and guessing one is worse than + # leaving the edge out. + G = _graph( + caller=_caller("a", PARKED_GREET), + declarations=[ + (_declaration("b", "Greeter"), _method("b")), + (_declaration("c", "Greeter"), _method("c")), + ], + ) + assert link_cross_repo_member_calls(G) == 0 + assert _added_calls(G) == set() + + +def test_a_declaration_in_the_callers_own_repo_binds_nothing(): + # A call parked from repo `a` was parked because `a` has no such type. If one + # shows up under `a` anyway the single-repo resolver already had its chance + # and refused; this pass only ever crosses a repo boundary. + G = _graph( + caller=_caller("a", PARKED_GREET), + declarations=[(_declaration("a", "Greeter"), _method("a"))], + ) + assert link_cross_repo_member_calls(G) == 0 + + +def test_a_declaration_in_another_language_binds_nothing(): + # Without the language guard a Java `Greeter` binds just as happily to a + # Python class of the same name in another repo. + G = _graph( + caller=_caller("a", PARKED_GREET), + declarations=[(_declaration("b", "Greeter", "greeter.py"), + _method("b", source_file="greeter.py"))], + ) + assert link_cross_repo_member_calls(G) == 0 + + +def test_a_sourceless_stub_does_not_answer_a_parked_call(): + # A stub minted for a dangling reference has no declaration behind it, so it + # cannot own the method the call is looking for. + G = _graph( + caller=_caller("a", PARKED_GREET), + declarations=[(_declaration("b", "Greeter", sourced=False), _method("b"))], + ) + assert link_cross_repo_member_calls(G) == 0 + + +def test_a_declaration_without_that_method_binds_nothing(): + G = _graph( + caller=_caller("a", PARKED_GREET), + declarations=[(_declaration("b", "Greeter"), _method("b", ".farewell()"))], + ) + assert link_cross_repo_member_calls(G) == 0 + + +def test_an_unknown_language_binds_nothing(): + # Only languages listed in _LANG_SUFFIXES park calls; an entry from anywhere + # else cannot be language-checked, so it is not acted on. + parked = [{"callee": "greet", "receiver_type": "Greeter", "lang": "cobol"}] + G = _graph( + caller=_caller("a", parked), + declarations=[(_declaration("b", "Greeter"), _method("b"))], + ) + assert link_cross_repo_member_calls(G) == 0 + + +def test_running_twice_does_not_duplicate_the_edge(): + # `global add` composes one repo at a time and revisits the same pairs on + # every add, so the pass must be safe to re-run over its own output. + G = _graph( + caller=_caller("a", PARKED_GREET), + declarations=[(_declaration("b", "Greeter"), _method("b"))], + ) + assert link_cross_repo_member_calls(G) == 1 + edges_after_first = G.number_of_edges() + assert link_cross_repo_member_calls(G) == 1 + assert G.number_of_edges() == edges_after_first + + +def test_a_repo_that_stops_declaring_the_type_loses_the_edge(): + """Recompute-from-scratch is what keeps a stale answer from surviving.""" + G = _graph( + caller=_caller("a", PARKED_GREET), + declarations=[(_declaration("b", "Greeter"), _method("b"))], + ) + assert link_cross_repo_member_calls(G) == 1 + G.remove_node("b::greeter_greet") + assert link_cross_repo_member_calls(G) == 0 + assert _added_calls(G) == set() + + +def _write_graph(path: Path, nodes: list[dict], links: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"directed": False, "multigraph": False, "graph": {}, + "nodes": nodes, "links": links}), encoding="utf-8") + + +def _merge(tmp_path: Path, a: Path, b: Path) -> dict: + out = tmp_path / "merged.json" + result = subprocess.run([PYTHON, "-m", "graphify", "merge-graphs", str(a), str(b), + "--out", str(out)], + cwd=tmp_path, capture_output=True, text=True) + assert result.returncode == 0, f"merge failed: {result.stderr}" + return {"data": json.loads(out.read_text(encoding="utf-8")), "stdout": result.stdout} + + +def test_merge_graphs_cli_finishes_the_parked_call(tmp_path: Path): + # The pass has to be reached through the real command, not just called + # directly, and it has to survive the repo prefixing the merge applies. + a = tmp_path / "app" / "graphify-out" / "graph.json" + b = tmp_path / "lib" / "graphify-out" / "graph.json" + _write_graph(a, [{"id": "app_run", "label": ".run()", "source_file": "src/App.java", + "metadata": {"unresolved_calls": PARKED_GREET}}], []) + _write_graph( + b, + [{"id": "greeter", "label": "Greeter", "source_file": "src/Greeter.java", + "_callable_class": True, "_callable": True}, + {"id": "greeter_greet", "label": ".greet()", "source_file": "src/Greeter.java", + "_callable": True}], + [{"source": "greeter", "target": "greeter_greet", "relation": "method"}], + ) + + merged = _merge(tmp_path, a, b) + calls = [e for e in merged["data"]["links"] + if e.get("relation") == "calls" and e.get("context") == "cross_repo"] + assert len(calls) == 1, merged["stdout"] + assert {calls[0]["source"], calls[0]["target"]} == {"app::app_run", "lib::greeter_greet"} + assert calls[0]["confidence"] == "INFERRED" + + +@needs_java +def test_a_java_build_parks_the_call_and_the_merge_finishes_it(tmp_path: Path): + """The two halves together: a real Java extraction of each repo, then the + merge. `App` calls a method on a `Greeter` that only the other repo declares, + which is the case a single build resolves for one corpus and drops for two.""" + from graphify.extract import extract + + sources = { + "app": ("src/App.java", + "class App {\n" + " Greeter greeter;\n" + " void run() { this.greeter.greet(); }\n" + "}\n"), + "lib": ("src/Greeter.java", + "class Greeter { void greet() {} }\n"), + } + graphs = {} + for repo, (name, body) in sources.items(): + path = tmp_path / repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + result = extract([path], cache_root=tmp_path / repo / "graphify-out") + graphs[repo] = tmp_path / repo / "graphify-out" / "graph.json" + _write_graph(graphs[repo], result["nodes"], result["edges"]) + + parked = [n["metadata"]["unresolved_calls"] + for n in json.loads(graphs["app"].read_text(encoding="utf-8"))["nodes"] + if isinstance(n.get("metadata"), dict) and n["metadata"].get("unresolved_calls")] + assert len(parked) == 1 and len(parked[0]) == 1, parked + entry = parked[0][0] + assert (entry["callee"], entry["receiver_type"], entry["lang"]) == ("greet", "Greeter", "java") + assert entry["line"], "the call site travels with the entry so the merged edge can carry it" + + merged = _merge(tmp_path, graphs["app"], graphs["lib"]) + calls = [e for e in merged["data"]["links"] + if e.get("relation") == "calls" and e.get("context") == "cross_repo"] + assert len(calls) == 1, merged["stdout"] + endpoints = {calls[0]["source"], calls[0]["target"]} + assert any(e.startswith("app::") and "run" in e for e in endpoints), endpoints + assert any(e.startswith("lib::") and "greet" in e for e in endpoints), endpoints + From 92c680fe25dc9391225e6b3837484588d01ee51f Mon Sep 17 00:00:00 2001 From: xiongjianxu Date: Mon, 31 Aug 2026 10:49:19 +0800 Subject: [PATCH 2/3] Wrap the new global-add line to the project's 100-column limit pyproject sets ruff line-length = 100; the added print was 101. --- graphify/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/graphify/cli.py b/graphify/cli.py index ec93e9cbb8..7bec4ec800 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3118,7 +3118,8 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": print(f"Added '{tag}' to global graph: +{result['nodes_added']} nodes, " f"-{result['nodes_removed']} pruned. Global: {_global_path()}") if result.get("cross_repo_calls"): - print(f" resolved {result['cross_repo_calls']} member call(s) across repos") + print(f" resolved {result['cross_repo_calls']} " + f"member call(s) across repos") except Exception as exc: print(f"error: {exc}", file=sys.stderr); sys.exit(1) elif subcmd == "remove": From 09bec1d42906eea66133212278ff965ed0562b01 Mon Sep 17 00:00:00 2001 From: xuxiongjian Date: Mon, 31 Aug 2026 11:18:59 +0800 Subject: [PATCH 3/3] Park cross-repo member calls in C++, C# and Swift too (#3152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge-time pass only had Java residue to work with, so a C++, C# or Swift call whose receiver type lives in another repo was still dropped with the type already in hand. Each of those three resolvers has the same bail-out Java had — receiver typed, type declared nowhere in this corpus — so each now parks the call by name for the merge to finish. Two details the added languages force: * Swift shares `all_raw_calls` with every other language and its raw_calls carry no `lang` tag, so the parked entry's language comes from the declaring file's suffix rather than from a tag that isn't there. * C# collapses "absent", "ambiguous" and "scoping was decisive" into one `None` from `_resolve_type_name_nid`, and only the first is a cross-repo candidate, so the park re-checks the bare-name index instead of trusting the `None`. On the merge side, a C++ class that only declares `void bar();` in a header owns it through `defines`, not `method`, so the member index is kept per relation: a C++ entry may fall back to `defines` when no `method` of that name exists, and for every other language a `defines` target is a field and cannot answer a call. Tests cover a real two-repo build per language (both C++ receiver shapes), the `defines` owner and its language restriction, the definition winning over a same-named header declaration, and C++ not binding to a C# class of the same name. --- graphify/cross_repo_calls.py | 50 +++++- graphify/extract.py | 144 ++++++++++------ tests/test_cross_repo_member_calls.py | 227 +++++++++++++++++++++----- 3 files changed, 314 insertions(+), 107 deletions(-) diff --git a/graphify/cross_repo_calls.py b/graphify/cross_repo_calls.py index 4879d7efe3..63a319e471 100644 --- a/graphify/cross_repo_calls.py +++ b/graphify/cross_repo_calls.py @@ -39,9 +39,17 @@ # just as happily to a Python class of the same name in another repo. Extend this # map when another extractor starts parking calls. _LANG_SUFFIXES: dict[str, frozenset[str]] = { + "cpp": frozenset({".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".h", ".cu", ".cuh"}), + "csharp": frozenset({".cs"}), "java": frozenset({".java"}), + "swift": frozenset({".swift"}), } +# A declaration owns its members through a `method` edge, except in C++, where an +# in-class declaration (`void bar();` in a header) is modelled as a field and +# carries `defines` instead. `method` wins when both name the same member. +_MEMBER_RELATIONS = ("defines", "method") + def _key(label: object) -> str: """Normalize a node label or a parked name to its bare identifier. @@ -97,17 +105,26 @@ def _index_declarations(merged: "nx.Graph") -> tuple[dict[str, list[str]], set[s return by_name, type_ids -def _index_methods(merged: "nx.Graph", type_ids: set[str]) -> dict[tuple[str, str], list[str]]: - """Index each declaration's methods by name. +def _index_members( + merged: "nx.Graph", type_ids: set[str] +) -> dict[str, dict[tuple[str, str], list[str]]]: + """Index each declaration's members by relation, then by name. The merged graph is undirected, and a ``method`` edge carries no reliable direction once composed, so the owner is identified as the endpoint that is a type declaration. A nested declaration puts a type on both ends; that pair is skipped rather than guessed at. + + Kept per relation rather than pooled: ``defines`` covers fields as well as + C++'s in-class member declarations, so only a language that needs it may look + there, and only when no ``method`` of that name exists. """ - by_owner: dict[tuple[str, str], list[str]] = defaultdict(list) + by_relation: dict[str, dict[tuple[str, str], list[str]]] = { + relation: defaultdict(list) for relation in _MEMBER_RELATIONS + } for u, v, data in merged.edges(data=True): - if data.get("relation") != "method": + relation = data.get("relation") + if relation not in by_relation: continue if u in type_ids and v not in type_ids: owner, member = u, v @@ -117,8 +134,18 @@ def _index_methods(merged: "nx.Graph", type_ids: set[str]) -> dict[tuple[str, st continue name = _key(merged.nodes[member].get("label")) if name: - by_owner[(owner, name)].append(member) - return by_owner + by_relation[relation][(owner, name)].append(member) + return by_relation + + +def _member_relations(lang: str) -> tuple[str, ...]: + """Which owner→member relations may answer a call parked by ``lang``. + + Only C++ models an in-class declaration (``void bar();`` in a header) as a + field, so only a C++ entry may fall back to ``defines``; for every other + language a ``defines`` target is a field, and a field cannot answer a call. + """ + return ("method", "defines") if lang == "cpp" else ("method",) def link_cross_repo_member_calls(merged: "nx.Graph") -> int: @@ -136,7 +163,7 @@ def link_cross_repo_member_calls(merged: "nx.Graph") -> int: by_name, type_ids = _index_declarations(merged) if not by_name: return 0 - methods_by_owner = _index_methods(merged, type_ids) + members_by_relation = _index_members(merged, type_ids) added = 0 for caller, caller_data in parked_nodes: @@ -146,7 +173,8 @@ def link_cross_repo_member_calls(merged: "nx.Graph") -> int: # deliberately never re-decides a call inside one repo. continue for entry in _parked_entries(caller_data): - suffixes = _LANG_SUFFIXES.get(str(entry.get("lang") or "")) + lang = str(entry.get("lang") or "") + suffixes = _LANG_SUFFIXES.get(lang) receiver_type = _key(entry.get("receiver_type")) callee = _key(entry.get("callee")) if not suffixes or not receiver_type or not callee: @@ -160,7 +188,11 @@ def link_cross_repo_member_calls(merged: "nx.Graph") -> int: # The same guard the single-repo resolvers apply: two repos # declaring the same name is an ambiguity, not a hit. continue - targets = methods_by_owner.get((candidates[0], callee), []) + targets: list[str] = [] + for relation in _member_relations(lang): + targets = members_by_relation[relation].get((candidates[0], callee), []) + if targets: + break if len(targets) != 1: continue target = targets[0] diff --git a/graphify/extract.py b/graphify/extract.py index 6fcfb45cdb..a9cf0b7dd9 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -2861,6 +2861,52 @@ def _assembly_of_node(nid: str) -> str: rc["caller_nid"] = remap[cn] +UNRESOLVED_CALLS_KEY = "unresolved_calls" +_MAX_PARKED_CALLS_PER_NODE = 64 + + +def _park_unresolved_member_call( + caller_node: dict | None, + callee: str, + receiver_type: str, + lang: str, + raw_call: dict, +) -> None: + """Keep a member call whose receiver type is declared nowhere in this corpus. + + A single-repo build can only bind ``obj.method()`` when the receiver's type is + declared in the same build, so a call into another repository is dropped with + the receiver type already in hand and nothing about it reaches ``graph.json`` + — the one artifact ``merge-graphs`` and ``global add`` consume. Parking the + pair on the caller node lets a merged graph finish the edge (#3152). + + The payload carries names only, never node ids: ids are rewritten by the + remaps and again by the repo prefixing, and a stale id inside metadata would + fail silently (#3150 was that bug). Names survive every rewrite. + """ + if not caller_node or not callee or not receiver_type: + return + metadata = caller_node.setdefault("metadata", {}) + if not isinstance(metadata, dict): + return + parked = metadata.setdefault(UNRESOLVED_CALLS_KEY, []) + if not isinstance(parked, list) or len(parked) >= _MAX_PARKED_CALLS_PER_NODE: + return + callee, receiver_type = str(callee), str(receiver_type) + for previous in parked: + if ( + isinstance(previous, dict) + and previous.get("callee") == callee + and previous.get("receiver_type") == receiver_type + ): + return + entry = {"callee": callee, "receiver_type": receiver_type, "lang": lang} + location = raw_call.get("source_location") + if location: + entry["line"] = str(location) + parked.append(entry) + + def _resolve_swift_member_calls( per_file: list[dict], all_nodes: list[dict], @@ -2977,7 +3023,8 @@ def _key(label: str) -> str: continue receiver = rc.get("receiver") callee = rc.get("callee") - if not receiver or not callee: + caller = rc.get("caller_nid") + if not receiver or not callee or not caller: continue # Determine the receiver's type. An upper-cased receiver is itself a type # (Type.staticMethod(), Singleton.shared.x()); otherwise look it up in the @@ -2997,12 +3044,20 @@ def _key(label: str) -> str: if type_name in _LANGUAGE_BUILTIN_GLOBALS: continue type_defs = type_def_nids.get(_key(type_name), []) - if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard) + if not type_defs: + # Declared nowhere here — in a multi-repo setup that usually means "in + # a repo this build does not contain", so park it for the merge + # (#3152). This resolver shares `all_raw_calls` with every other + # language and Swift raw_calls carry no `lang` tag, so the parked + # entry's language comes from the declaring file's suffix. + if str(rc.get("source_file", "")).lower().endswith(".swift"): + _park_unresolved_member_call( + node_by_id.get(caller), callee, type_name, "swift", rc, + ) continue - type_nid = type_defs[0] - caller = rc.get("caller_nid") - if not caller: + if len(type_defs) != 1: # ambiguous -> bail (god-node guard) continue + type_nid = type_defs[0] method_nid = method_index.get((type_nid, _key(callee))) target = method_nid or type_nid relation = "calls" if method_nid else "references" @@ -3410,7 +3465,17 @@ def _key(label: str) -> str: elif receiver[:1].isupper(): # Foo::bar(): the type is named explicitly in source. type_defs = type_def_nids.get(_key(receiver), []) - if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard) + if not type_defs: + # Declared nowhere here, which in a multi-repo setup usually means + # "in a repo this build does not contain" (#3152). `Foo::bar()` is + # also the shape of a namespace-qualified free function, so the + # merge side's guards do the deciding: it acts only when exactly + # one other repo declares a `Foo` owning exactly one `bar`. + _park_unresolved_member_call( + node_by_id.get(caller), callee, receiver, "cpp", rc, + ) + continue + if len(type_defs) != 1: # ambiguous -> bail (god-node guard) continue type_nid = type_defs[0] type_qualified = True @@ -3420,7 +3485,12 @@ def _key(label: str) -> str: if not type_name: continue type_defs = type_def_nids.get(_key(type_name), []) - if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard) + if not type_defs: + _park_unresolved_member_call( + node_by_id.get(caller), callee, type_name, "cpp", rc, + ) + continue + if len(type_defs) != 1: # ambiguous -> bail (god-node guard) continue type_nid = type_defs[0] type_qualified = False @@ -3584,6 +3654,18 @@ def _resolve_type_name_nid(type_name: str | None, caller_node: dict | None, type_defs = type_def_nids.get(_key(type_name), []) return type_defs[0] if len(type_defs) == 1 else None + def _park_if_absent(type_name: str | None, caller_node: dict | None, rc: dict) -> None: + """Park a call whose receiver type is declared nowhere in this corpus (#3152). + + ``_resolve_type_name_nid`` collapses "absent", "ambiguous" and "scoping was + decisive" into one ``None``, and only the first is a cross-repo candidate, + so re-check the bare-name index instead of trusting the ``None``. + """ + if type_name and not type_def_nids.get(_key(type_name)): + _park_unresolved_member_call( + caller_node, rc.get("callee"), type_name, "csharp", rc, + ) + all_raw_calls: list[dict] = [] for result in per_file: all_raw_calls.extend(result.get("raw_calls", [])) @@ -3622,6 +3704,7 @@ def _resolve_type_name_nid(type_name: str | None, caller_node: dict | None, type_name = rc.get("receiver_type") type_nid = _resolve_type_name_nid(type_name, caller_node, src_file) if not type_nid: + _park_if_absent(type_name or receiver, caller_node, rc) continue type_qualified = True else: @@ -3630,6 +3713,7 @@ def _resolve_type_name_nid(type_name: str | None, caller_node: dict | None, continue type_nid = _resolve_type_name_nid(type_name, caller_node, src_file) if not type_nid: # ambiguous or absent -> bail (god-node guard) + _park_if_absent(type_name, caller_node, rc) continue type_qualified = False method_nid = _method_on_type_or_bases(type_nid, _key(callee)) @@ -3702,52 +3786,6 @@ def _bind_member_field_tables( return bound -UNRESOLVED_CALLS_KEY = "unresolved_calls" -_MAX_PARKED_CALLS_PER_NODE = 64 - - -def _park_unresolved_member_call( - caller_node: dict | None, - callee: str, - receiver_type: str, - lang: str, - raw_call: dict, -) -> None: - """Keep a member call whose receiver type is declared nowhere in this corpus. - - A single-repo build can only bind ``obj.method()`` when the receiver's type is - declared in the same build, so a call into another repository is dropped with - the receiver type already in hand and nothing about it reaches ``graph.json`` - — the one artifact ``merge-graphs`` and ``global add`` consume. Parking the - pair on the caller node lets a merged graph finish the edge (#3152). - - The payload carries names only, never node ids: ids are rewritten by the - remaps and again by the repo prefixing, and a stale id inside metadata would - fail silently (#3150 was that bug). Names survive every rewrite. - """ - if not caller_node or not callee or not receiver_type: - return - metadata = caller_node.setdefault("metadata", {}) - if not isinstance(metadata, dict): - return - parked = metadata.setdefault(UNRESOLVED_CALLS_KEY, []) - if not isinstance(parked, list) or len(parked) >= _MAX_PARKED_CALLS_PER_NODE: - return - callee, receiver_type = str(callee), str(receiver_type) - for previous in parked: - if ( - isinstance(previous, dict) - and previous.get("callee") == callee - and previous.get("receiver_type") == receiver_type - ): - return - entry = {"callee": callee, "receiver_type": receiver_type, "lang": lang} - location = raw_call.get("source_location") - if location: - entry["line"] = str(location) - parked.append(entry) - - def _resolve_java_member_calls( per_file: list[dict], all_nodes: list[dict], diff --git a/tests/test_cross_repo_member_calls.py b/tests/test_cross_repo_member_calls.py index bfd58372f1..3c16724646 100644 --- a/tests/test_cross_repo_member_calls.py +++ b/tests/test_cross_repo_member_calls.py @@ -6,13 +6,15 @@ `merge-graphs` and `global add` read. The two-repo graph was missing precisely the edges that make it a call graph. -The Java resolver now parks those calls on the caller node and this pass finishes -them after the merge. The cases below pin what it must NOT do as much as what it -must: the single-definition guard, the cross-repo-only scope, and the language -guard are what keep it from fabricating an edge from a name collision. +The Java, C++, C# and Swift resolvers now park those calls on the caller node and +this pass finishes them after the merge. The cases below pin what it must NOT do +as much as what it must: the single-definition guard, the cross-repo-only scope, +and the language guard are what keep it from fabricating an edge from a name +collision. """ from __future__ import annotations +import importlib import json import subprocess import sys @@ -28,18 +30,27 @@ PYTHON = sys.executable -try: - import tree_sitter_java # noqa: F401 - HAVE_JAVA = True -except ImportError: - HAVE_JAVA = False -needs_java = pytest.mark.skipif(not HAVE_JAVA, reason="tree-sitter-java not installed") +def _needs(module: str): + """Skip a real-extraction case when its tree-sitter grammar is absent.""" + try: + importlib.import_module(module) + missing = False + except ImportError: + missing = True + return pytest.mark.skipif(missing, reason=f"{module} not installed") -def _caller(repo: str, parked: list[dict], node_id: str = "app_run") -> tuple[str, dict]: +needs_java = _needs("tree_sitter_java") +needs_cpp = _needs("tree_sitter_cpp") +needs_csharp = _needs("tree_sitter_c_sharp") +needs_swift = _needs("tree_sitter_swift") + + +def _caller(repo: str, parked: list[dict], node_id: str = "app_run", + source_file: str = "src/App.java") -> tuple[str, dict]: return f"{repo}::{node_id}", { - "label": ".run()", "source_file": "src/App.java", "repo": repo, + "label": ".run()", "source_file": source_file, "repo": repo, "metadata": {"unresolved_calls": parked}, } @@ -58,14 +69,14 @@ def _method(repo: str, label: str = ".greet()", node_id: str = "greeter_greet", "source_file": source_file, "_callable": True} -def _graph(*, caller, declarations) -> nx.Graph: - """Build a merged-graph shape: one caller plus (declaration, method) pairs.""" +def _graph(*, caller, declarations, relation: str = "method") -> nx.Graph: + """Build a merged-graph shape: one caller plus (declaration, member) pairs.""" G = nx.Graph() G.add_node(caller[0], **caller[1]) for decl, method in declarations: G.add_node(decl[0], **decl[1]) G.add_node(method[0], **method[1]) - G.add_edge(decl[0], method[0], relation="method") + G.add_edge(decl[0], method[0], relation=relation) return G @@ -128,6 +139,62 @@ def test_a_declaration_in_another_language_binds_nothing(): assert link_cross_repo_member_calls(G) == 0 +PARKED_CPP = [{"callee": "greet", "receiver_type": "Greeter", "lang": "cpp", "line": "L1"}] + + +def test_a_cpp_call_does_not_bind_to_a_csharp_declaration(): + # Every parking language now has its own suffix set, so the guard has to keep + # them apart from each other and not just from the languages that never park. + G = _graph( + caller=_caller("a", PARKED_CPP, source_file="src/app.cpp"), + declarations=[(_declaration("b", "Greeter", "Greeter.cs"), + _method("b", ".greet()", source_file="Greeter.cs"))], + ) + assert link_cross_repo_member_calls(G) == 0 + + +def test_a_cpp_header_declaration_answers_through_defines(): + # A C++ class that only declares `void greet();` owns it through `defines`, + # the relation the extractor also uses for fields, so a header-only library + # would otherwise answer nothing. + G = _graph( + caller=_caller("a", PARKED_CPP, source_file="src/app.cpp"), + declarations=[(_declaration("b", "Greeter", "greeter.h"), + _method("b", "greet", source_file="greeter.h"))], + relation="defines", + ) + assert link_cross_repo_member_calls(G) == 1 + assert _added_calls(G) == {("a::app_run", "b::greeter_greet")} + + +def test_a_defines_member_does_not_answer_a_java_call(): + # Outside C++ a `defines` target is a field, and a field cannot be called. + G = _graph( + caller=_caller("a", PARKED_GREET), + declarations=[(_declaration("b", "Greeter"), _method("b"))], + relation="defines", + ) + assert link_cross_repo_member_calls(G) == 0 + + +def test_the_definition_answers_before_a_same_named_declaration(): + # A C++ class declares `void greet();` in its header (`defines`) and defines + # it out of line in the `.cpp` (`method`). Both hang off the one folded class + # node, and the definition is the better target. + decl = _declaration("b", "Greeter", "greeter.h") + G = _graph( + caller=_caller("a", PARKED_CPP, source_file="src/app.cpp"), + declarations=[(decl, _method("b", "greet", "greeter_decl", "greeter.h"))], + relation="defines", + ) + definition = _method("b", ".greet()", "greeter_def", "greeter.cpp") + G.add_node(definition[0], **definition[1]) + G.add_edge(decl[0], definition[0], relation="method") + + assert link_cross_repo_member_calls(G) == 1 + assert _added_calls(G) == {("a::app_run", "b::greeter_def")} + + def test_a_sourceless_stub_does_not_answer_a_parked_call(): # A stub minted for a dangling reference has no declaration behind it, so it # cannot own the method the call is looking for. @@ -197,6 +264,32 @@ def _merge(tmp_path: Path, a: Path, b: Path) -> dict: return {"data": json.loads(out.read_text(encoding="utf-8")), "stdout": result.stdout} +def _build(tmp_path: Path, repo: str, name: str, body: str) -> Path: + """Extract one repo the way a real build does and write its `graph.json`.""" + from graphify.extract import extract + + path = tmp_path / repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + result = extract([path], cache_root=tmp_path / repo / "graphify-out") + graph = tmp_path / repo / "graphify-out" / "graph.json" + _write_graph(graph, result["nodes"], result["edges"]) + return graph + + +def _parked(graph: Path) -> list[dict]: + """Every parked entry in a written `graph.json`, flattened across nodes.""" + return [entry + for node in json.loads(graph.read_text(encoding="utf-8"))["nodes"] + if isinstance(node.get("metadata"), dict) + for entry in node["metadata"].get("unresolved_calls", [])] + + +def _cross_repo_calls(merged: dict) -> list[dict]: + return [e for e in merged["data"]["links"] + if e.get("relation") == "calls" and e.get("context") == "cross_repo"] + + def test_merge_graphs_cli_finishes_the_parked_call(tmp_path: Path): # The pass has to be reached through the real command, not just called # directly, and it has to survive the repo prefixing the merge applies. @@ -214,8 +307,7 @@ def test_merge_graphs_cli_finishes_the_parked_call(tmp_path: Path): ) merged = _merge(tmp_path, a, b) - calls = [e for e in merged["data"]["links"] - if e.get("relation") == "calls" and e.get("context") == "cross_repo"] + calls = _cross_repo_calls(merged) assert len(calls) == 1, merged["stdout"] assert {calls[0]["source"], calls[0]["target"]} == {"app::app_run", "lib::greeter_greet"} assert calls[0]["confidence"] == "INFERRED" @@ -226,39 +318,84 @@ def test_a_java_build_parks_the_call_and_the_merge_finishes_it(tmp_path: Path): """The two halves together: a real Java extraction of each repo, then the merge. `App` calls a method on a `Greeter` that only the other repo declares, which is the case a single build resolves for one corpus and drops for two.""" - from graphify.extract import extract - - sources = { - "app": ("src/App.java", - "class App {\n" - " Greeter greeter;\n" - " void run() { this.greeter.greet(); }\n" - "}\n"), - "lib": ("src/Greeter.java", - "class Greeter { void greet() {} }\n"), - } - graphs = {} - for repo, (name, body) in sources.items(): - path = tmp_path / repo / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(body, encoding="utf-8") - result = extract([path], cache_root=tmp_path / repo / "graphify-out") - graphs[repo] = tmp_path / repo / "graphify-out" / "graph.json" - _write_graph(graphs[repo], result["nodes"], result["edges"]) - - parked = [n["metadata"]["unresolved_calls"] - for n in json.loads(graphs["app"].read_text(encoding="utf-8"))["nodes"] - if isinstance(n.get("metadata"), dict) and n["metadata"].get("unresolved_calls")] - assert len(parked) == 1 and len(parked[0]) == 1, parked - entry = parked[0][0] + app = _build(tmp_path, "app", "src/App.java", + "class App {\n" + " Greeter greeter;\n" + " void run() { this.greeter.greet(); }\n" + "}\n") + lib = _build(tmp_path, "lib", "src/Greeter.java", + "class Greeter { void greet() {} }\n") + + parked = _parked(app) + assert len(parked) == 1, parked + entry = parked[0] assert (entry["callee"], entry["receiver_type"], entry["lang"]) == ("greet", "Greeter", "java") assert entry["line"], "the call site travels with the entry so the merged edge can carry it" - merged = _merge(tmp_path, graphs["app"], graphs["lib"]) - calls = [e for e in merged["data"]["links"] - if e.get("relation") == "calls" and e.get("context") == "cross_repo"] + merged = _merge(tmp_path, app, lib) + calls = _cross_repo_calls(merged) assert len(calls) == 1, merged["stdout"] endpoints = {calls[0]["source"], calls[0]["target"]} assert any(e.startswith("app::") and "run" in e for e in endpoints), endpoints assert any(e.startswith("lib::") and "greet" in e for e in endpoints), endpoints + +@pytest.mark.parametrize("lang,callee,app_file,lib_file", [ + pytest.param( + "cpp", "greet", + ("src/app.cpp", "void run() { Greeter g; g.greet(); }\n"), + ("src/greeter.cpp", "class Greeter {\n public:\n void greet() {}\n};\n"), + marks=needs_cpp, id="cpp-typed-local", + ), + pytest.param( + # `Greeter::greet()` is also the shape of a namespace-qualified free + # function, so this is the arm that leans hardest on the merge's guards. + "cpp", "greet", + ("src/app.cpp", "void run() { Greeter::greet(); }\n"), + ("src/greeter.cpp", "class Greeter {\n public:\n static void greet() {}\n};\n"), + marks=needs_cpp, id="cpp-qualified-receiver", + ), + pytest.param( + "csharp", "Greet", + ("src/App.cs", "class App {\n" + " Greeter greeter;\n" + " void Run() { greeter.Greet(); }\n" + "}\n"), + ("src/Greeter.cs", "class Greeter { public void Greet() {} }\n"), + marks=needs_csharp, id="csharp-field-receiver", + ), + pytest.param( + "swift", "greet", + ("src/App.swift", "class App {\n" + " var greeter: Greeter\n" + " func run() { greeter.greet() }\n" + "}\n"), + ("src/Greeter.swift", "class Greeter { func greet() {} }\n"), + marks=needs_swift, id="swift-property-receiver", + ), +]) +def test_each_language_parks_the_call_and_the_merge_finishes_it( + tmp_path: Path, lang: str, callee: str, app_file: tuple[str, str], + lib_file: tuple[str, str], +): + """The Java case above, once per language that parks: the receiver's type is + declared only in the other repo, so the build parks the call by name and the + merge finishes it. The parked `lang` is asserted because it is what the merge + matches the declaring file's suffix against.""" + app = _build(tmp_path, "app", *app_file) + lib = _build(tmp_path, "lib", *lib_file) + + parked = _parked(app) + assert len(parked) == 1, parked + assert (parked[0]["callee"], parked[0]["receiver_type"], parked[0]["lang"]) == ( + callee, "Greeter", lang) + assert parked[0]["line"], "the call site travels with the entry" + + merged = _merge(tmp_path, app, lib) + calls = _cross_repo_calls(merged) + assert len(calls) == 1, merged["stdout"] + endpoints = {calls[0]["source"], calls[0]["target"]} + assert any(e.startswith("app::") and "run" in e.lower() for e in endpoints), endpoints + assert any(e.startswith("lib::") and callee.lower() in e.lower() + for e in endpoints), endpoints +