mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-12 02:17:04 +00:00
49252d3cb7
Connects paired classes across files: Main.cpp's `Foo f; f.bar()` now resolves to Foo::bar, and ObjC `Foo *f = [[Foo alloc] init]; [f doThing]` to Foo's doThing — the "connect with other classes" goal of #1547/#1556. Design grounded in prior-art research (ctags qualified-name matching, Doxygen's name-keyed false-edge failure modes, PAIGE's receiver-type approach, Clang USR): resolve by RECEIVER TYPE, never bare name, and skip when the type can't be inferred rather than guess (a false call edge / god-node is worse than a missing one). Mirrors the existing Swift/Python/Ruby/TS member-call resolvers. - C++ extractor now captures the member-call receiver (field_expression / qualified_identifier / pointer access) and builds a per-file type table from local declarations (`Foo f;`, `Foo* f;`, `Foo *f = ...;`); emits raw_calls. - ObjC extractor emits raw_calls for message sends with the receiver + selector and a type table from `Foo *f = ...;` locals (existing in-file selector / alloc-init / dot-syntax / @selector matching preserved). - New _resolve_cpp_member_calls / _resolve_objc_member_calls, registered for their suffixes. Receiver tiers: `Foo::bar()` / capitalized ObjC receiver and this/self/super (enclosing class) -> EXTRACTED; local-var-typed -> INFERRED. Single-definition god-node guard (skip unless exactly one type def matches); the just-shipped decl/def class merge makes a paired class one def so the guard resolves it. Verified: a.run() -> A::run only (not a same-named B::run); an uninferable receiver with run() in two classes emits zero edges (no fan-out); ObjC [f doThing] -> Foo only. - build.py: the cross-language INFERRED-call prune treated .h/.cpp/.m as different families and dropped header/impl interop calls; unified the C family (.c .h .cc .cpp .hpp .cxx .hh .hxx .cu .cuh .metal .m .mm) so a .cpp/.m call to a .h-declared method survives. Still open (tracked on #1547/#1556): the file-level `#include` edge can stay uncanonicalized when the project root isn't symlink-resolved (the extract() id-remap `continue`s on a /var-vs-/private/var mismatch) — the class connection above is robust to it; include-reachability candidate narrowing and ObjC dynamic-dispatch/id-typed receivers also deferred (expected low ObjC recall, per the research). Reported by @c0dezer019 (#1547) and @JabberYQ (#1556). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
264 lines
12 KiB
Python
264 lines
12 KiB
Python
"""Cross-file member-call and include resolution for C++ (#1547) and ObjC (#1556).
|
|
|
|
Mirrors tests/test_swift_cross_file_calls.py. The principle under test is PRECISION
|
|
over recall: resolution is by RECEIVER TYPE (never a bare method name), guarded by a
|
|
single-definition god-node check — an ambiguous or uninferable receiver yields ZERO
|
|
edges rather than a fan-out.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from graphify.build import build_from_json
|
|
from graphify.extract import extract
|
|
|
|
|
|
def _write(path: Path, text: str) -> Path:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(text, encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def _label(result: dict, nid: str) -> str:
|
|
for n in result["nodes"]:
|
|
if n["id"] == nid:
|
|
return n.get("label", "")
|
|
return f"<{nid}>"
|
|
|
|
|
|
def _call_edges(result: dict, relations=("calls",)):
|
|
"""{(source_label, relation, target_label, confidence)} for the given relations."""
|
|
out = set()
|
|
for e in result["edges"]:
|
|
if e.get("relation") in relations:
|
|
out.add((
|
|
_label(result, e["source"]),
|
|
e["relation"],
|
|
_label(result, e["target"]),
|
|
e.get("confidence"),
|
|
))
|
|
return out
|
|
|
|
|
|
# ── C++ #include survival (#1547) ─────────────────────────────────────────────
|
|
|
|
def test_cpp_cross_file_member_call_connects_with_relative_paths(tmp_path):
|
|
"""The headline #1547 fix: a paired class no longer islands — Main.cpp's use of
|
|
Foo connects to Foo's method across files. Use RELATIVE input paths (the real
|
|
`graphify extract .` usage), which is what exposes resolution gaps; an earlier
|
|
absolute-path-only test masked them.
|
|
|
|
NOTE: the file-level `#include` edge (Main.cpp file -> Foo.h file) is NOT asserted
|
|
here. It relies on the extract() file-node id-remap, which `continue`s when the
|
|
project `root` isn't symlink-resolved (e.g. macOS /var vs /private/var, worktrees),
|
|
leaving the absolute-derived include target uncanonicalized. That's a known
|
|
remaining gap tracked on #1547/#1556. The class connection below — the actual
|
|
"connect with other classes" goal — resolves via the type-def index + the merged
|
|
class and is robust to that gap.
|
|
"""
|
|
import os
|
|
base = tmp_path / "src"
|
|
_write(base / "Foo.h", "class Foo {\npublic:\n void bar();\n};\n")
|
|
_write(base / "Foo.cpp", '#include "Foo.h"\nvoid Foo::bar() {}\n')
|
|
_write(base / "Main.cpp", '#include "Foo.h"\nint main() { Foo f; f.bar(); return 0; }\n')
|
|
old = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = extract(
|
|
[Path("src/Foo.h"), Path("src/Foo.cpp"), Path("src/Main.cpp")],
|
|
cache_root=Path(".cache"), parallel=False,
|
|
)
|
|
finally:
|
|
os.chdir(old)
|
|
# Foo is one merged class (decl in .h + def in .cpp), not two fragments.
|
|
foo_classes = [n for n in result["nodes"] if n.get("label") == "Foo"]
|
|
assert len(foo_classes) == 1, f"Foo should be one node, got {[n['id'] for n in foo_classes]}"
|
|
# main() connects to Foo::bar across files (resolved by inferred receiver type `Foo f`).
|
|
labels = {n["id"]: n.get("label", "") for n in result["nodes"]}
|
|
main_bar = [
|
|
e for e in result["edges"]
|
|
if e.get("relation") == "calls"
|
|
and "main" in labels.get(e["source"], "")
|
|
and e["target"].endswith("_bar")
|
|
]
|
|
assert main_bar, "Main.cpp's f.bar() should resolve to Foo::bar across files"
|
|
# The resolved target is Foo's bar (id under the Foo class), not some other class.
|
|
assert all("foo" in e["target"] for e in main_bar), main_bar
|
|
|
|
|
|
# ── C++ member calls (#1547) ──────────────────────────────────────────────────
|
|
|
|
def test_cpp_instance_member_call_resolves(tmp_path: Path):
|
|
# `Foo f; f.bar();` in Main.cpp resolves to Foo::bar — INFERRED (receiver typed
|
|
# from the local declaration), exactly one calls edge.
|
|
base = tmp_path / "src"
|
|
_write(base / "Foo.h", "class Foo {\npublic:\n void bar();\n};\n")
|
|
_write(base / "Foo.cpp", '#include "Foo.h"\nvoid Foo::bar() {}\n')
|
|
_write(base / "Main.cpp", '#include "Foo.h"\nint main() { Foo f; f.bar(); }\n')
|
|
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
|
|
|
calls = _call_edges(result)
|
|
assert ("main()", "calls", "bar", "INFERRED") in calls
|
|
# Exactly one bar call edge from main (no fan-out, no duplicate).
|
|
bar_calls = [c for c in calls if c[0] == "main()" and c[2] == "bar"]
|
|
assert len(bar_calls) == 1
|
|
|
|
|
|
def test_cpp_pointer_member_call_resolves(tmp_path: Path):
|
|
# `Foo* f; f->bar();` resolves the same way via pointer-arrow access.
|
|
base = tmp_path / "src"
|
|
_write(base / "Foo.h", "class Foo {\npublic:\n void bar();\n};\n")
|
|
_write(base / "Foo.cpp", '#include "Foo.h"\nvoid Foo::bar() {}\n')
|
|
_write(base / "Main.cpp", '#include "Foo.h"\nint main() { Foo* f = new Foo(); f->bar(); }\n')
|
|
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
|
|
|
calls = _call_edges(result)
|
|
assert ("main()", "calls", "bar", "INFERRED") in calls
|
|
|
|
|
|
def test_cpp_qualified_member_call_is_extracted(tmp_path: Path):
|
|
# `Foo::bar()` names the type explicitly in source -> EXTRACTED.
|
|
base = tmp_path / "src"
|
|
_write(base / "Foo.h", "class Foo {\npublic:\n static void bar();\n};\n")
|
|
_write(base / "Foo.cpp", '#include "Foo.h"\nvoid Foo::bar() {}\n')
|
|
_write(base / "Main.cpp", '#include "Foo.h"\nint main() { Foo::bar(); }\n')
|
|
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
|
|
|
calls = _call_edges(result)
|
|
assert ("main()", "calls", "bar", "EXTRACTED") in calls
|
|
|
|
|
|
def test_cpp_this_member_call_resolves_to_enclosing_class(tmp_path: Path):
|
|
# `this->bar()` inside Foo::baz resolves to Foo::bar (the caller's own class) ->
|
|
# EXTRACTED. Cross-file: the body lives in Foo.cpp, the decl in Foo.h.
|
|
base = tmp_path / "src"
|
|
_write(base / "Foo.h", "class Foo {\npublic:\n void bar();\n void baz();\n};\n")
|
|
_write(base / "Foo.cpp", '#include "Foo.h"\nvoid Foo::bar() {}\nvoid Foo::baz() { this->bar(); }\n')
|
|
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
|
|
|
calls = _call_edges(result)
|
|
assert ("baz", "calls", "bar", "EXTRACTED") in calls
|
|
|
|
|
|
def test_cpp_godnode_guard_ambiguous_and_unknown_receiver(tmp_path: Path):
|
|
# Two classes A and B BOTH define run(). An uninferable receiver `x.run()`
|
|
# emits ZERO edges (no fan-out). `A a; a.run()` resolves to A::run ONLY.
|
|
base = tmp_path / "src"
|
|
_write(base / "A.h", "class A {\npublic:\n void run();\n};\n")
|
|
_write(base / "A.cpp", '#include "A.h"\nvoid A::run() {}\n')
|
|
_write(base / "B.h", "class B {\npublic:\n void run();\n};\n")
|
|
_write(base / "B.cpp", '#include "B.h"\nvoid B::run() {}\n')
|
|
_write(base / "Main.cpp",
|
|
'#include "A.h"\n#include "B.h"\nint main() { x.run(); A a; a.run(); }\n')
|
|
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
|
|
|
src_by_id = {n["id"]: n.get("source_file") for n in result["nodes"]}
|
|
run_calls = [
|
|
e for e in result["edges"]
|
|
if e.get("relation") == "calls"
|
|
and _label(result, e["source"]) == "main()"
|
|
and _label(result, e["target"]) == "run"
|
|
]
|
|
# Exactly one resolved run() call, and it targets A's run (not B's, not both).
|
|
assert len(run_calls) == 1
|
|
assert Path(src_by_id[run_calls[0]["target"]]).name == "A.h"
|
|
|
|
|
|
def test_cpp_resolved_call_survives_build(tmp_path: Path):
|
|
# The receiver-typed call targets the header-declared method node; build_from_json
|
|
# must keep it. The cross-language INFERRED-call guard treats C/C++ as one family,
|
|
# so a `.cpp` -> `.h`-declared-method edge is not pruned (#1547).
|
|
base = tmp_path / "src"
|
|
_write(base / "Foo.h", "class Foo {\npublic:\n void bar();\n};\n")
|
|
_write(base / "Foo.cpp", '#include "Foo.h"\nvoid Foo::bar() {}\n')
|
|
_write(base / "Main.cpp", '#include "Foo.h"\nint main() { Foo f; f.bar(); }\n')
|
|
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
|
|
|
g = build_from_json(result)
|
|
cross = [
|
|
d for _, _, d in g.edges(data=True)
|
|
if d.get("relation") == "calls" and d.get("confidence") == "INFERRED"
|
|
]
|
|
assert len(cross) >= 1
|
|
|
|
|
|
def test_cpp_unknown_receiver_emits_no_edge(tmp_path: Path):
|
|
# A lowercase receiver absent from the file's local type table is never guessed.
|
|
base = tmp_path / "src"
|
|
_write(base / "Helper.h", "class Helper {\npublic:\n void help();\n};\n")
|
|
_write(base / "Helper.cpp", '#include "Helper.h"\nvoid Helper::help() {}\n')
|
|
_write(base / "Main.cpp", '#include "Helper.h"\nint main() { mystery.help(); }\n')
|
|
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
|
|
|
calls = _call_edges(result)
|
|
assert not any(c[0] == "main()" and c[2] == "help" for c in calls)
|
|
|
|
|
|
# ── ObjC member calls (#1556) ─────────────────────────────────────────────────
|
|
|
|
def test_objc_instance_message_send_resolves(tmp_path: Path):
|
|
# `Foo *f = [[Foo alloc] init]; [f doThing];` in Bar.m -> cross-file calls edge
|
|
# to Foo's -doThing (INFERRED, receiver typed from the `Foo *f` local).
|
|
base = tmp_path / "src"
|
|
_write(base / "Foo.h", "@interface Foo : NSObject\n- (void)doThing;\n@end\n")
|
|
_write(base / "Foo.m", '#import "Foo.h"\n@implementation Foo\n- (void)doThing {}\n@end\n')
|
|
_write(base / "Bar.m",
|
|
'#import "Foo.h"\n@implementation Bar\n'
|
|
'- (void)go {\n Foo *f = [[Foo alloc] init];\n [f doThing];\n}\n@end\n')
|
|
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
|
|
|
calls = _call_edges(result)
|
|
assert ("-go", "calls", "-doThing", "INFERRED") in calls
|
|
|
|
|
|
def test_objc_self_message_send_resolves_to_enclosing_class(tmp_path: Path):
|
|
# `[self render]` inside Foo resolves to Foo's -render -> EXTRACTED.
|
|
base = tmp_path / "src"
|
|
_write(base / "Foo.h", "@interface Foo : NSObject\n- (void)render;\n- (void)setup;\n@end\n")
|
|
_write(base / "Foo.m",
|
|
'#import "Foo.h"\n@implementation Foo\n'
|
|
'- (void)setup { [self render]; }\n- (void)render {}\n@end\n')
|
|
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
|
|
|
calls = _call_edges(result)
|
|
assert ("-setup", "calls", "-render", "EXTRACTED") in calls
|
|
|
|
|
|
def test_objc_godnode_guard_ambiguous_selector(tmp_path: Path):
|
|
# Two classes A and B BOTH define -doStuff. An uninferable receiver `[thing
|
|
# doStuff]` emits ZERO edges across the corpus (no ambiguous fan-out).
|
|
base = tmp_path / "src"
|
|
_write(base / "A.h", "@interface A : NSObject\n- (void)doStuff;\n@end\n")
|
|
_write(base / "A.m", '#import "A.h"\n@implementation A\n- (void)doStuff {}\n@end\n')
|
|
_write(base / "B.h", "@interface B : NSObject\n- (void)doStuff;\n@end\n")
|
|
_write(base / "B.m", '#import "B.h"\n@implementation B\n- (void)doStuff {}\n@end\n')
|
|
_write(base / "C.m",
|
|
'#import "A.h"\n#import "B.h"\n@implementation C\n'
|
|
'- (void)go { [thing doStuff]; }\n@end\n')
|
|
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
|
|
|
go_calls = [
|
|
e for e in result["edges"]
|
|
if e.get("relation") == "calls" and _label(result, e["source"]) == "-go"
|
|
]
|
|
assert go_calls == []
|
|
|
|
|
|
def test_objc_resolved_calls_survive_build(tmp_path: Path):
|
|
# The cross-file ObjC call must land on a real definition node so
|
|
# build_from_json keeps it (no dangling target pruned).
|
|
base = tmp_path / "src"
|
|
_write(base / "Foo.h", "@interface Foo : NSObject\n- (void)doThing;\n@end\n")
|
|
_write(base / "Foo.m", '#import "Foo.h"\n@implementation Foo\n- (void)doThing {}\n@end\n')
|
|
_write(base / "Bar.m",
|
|
'#import "Foo.h"\n@implementation Bar\n'
|
|
'- (void)go {\n Foo *f = [[Foo alloc] init];\n [f doThing];\n}\n@end\n')
|
|
result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache")
|
|
|
|
g = build_from_json(result)
|
|
cross = [
|
|
d for _, _, d in g.edges(data=True)
|
|
if d.get("relation") == "calls" and d.get("confidence") == "INFERRED"
|
|
]
|
|
assert len(cross) >= 1
|