diff --git a/graphify/cli.py b/graphify/cli.py index 6642f57d5..7bec4ec80 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,9 @@ 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']} " + f"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 000000000..63a319e47 --- /dev/null +++ b/graphify/cross_repo_calls.py @@ -0,0 +1,216 @@ +"""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]] = { + "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. + + 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_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_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): + 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 + 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_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: + """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 + members_by_relation = _index_members(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): + 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: + 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: 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] + 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 e015c9d71..a9cf0b7dd 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)) @@ -3796,6 +3880,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 eddd0c92a..cd1ea1924 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 000000000..3c1672464 --- /dev/null +++ b/tests/test_cross_repo_member_calls.py @@ -0,0 +1,401 @@ +"""`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, 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 +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 + + +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") + + +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": source_file, "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, 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=relation) + 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 + + +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. + 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 _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. + 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 = _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" + + +@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.""" + 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, 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 +