mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-25 00:03:32 +00:00
feat(merge): finish member calls across a repo boundary (#3152)
When a member call's receiver type resolves to zero in-corpus declarations, park
{callee, receiver_type, lang, line} (names only, #3150-safe) on the caller; a merge-time pass
emits one calls/cross_repo/INFERRED edge only when the receiver type resolves to exactly one
declaration in another repo owning exactly one matching member (language-suffix matched,
non-sourceless). Strong-signal, single-definition guarded, edges-only — composes with the
#3007 cross-repo type link and the #3068 community offset. Wired for Java/C++/C#/Swift.
This commit is contained in:
1 parent
6f9b79ae87
commit
c604f489cd
5 files changed
+741
-10
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user