diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0daf515f..e8d3155cbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.63 (2026-09-16) +- Fix: a C++ scope-qualified static call (`Class::Method()`) now resolves even when the method exists only as a qualified-labeled definition with no `defines`/`method` edge to its class — the systematic case for Unreal Engine `UCLASS()`/`GENERATED_BODY()` classes, whose macro-laden header declaration the bundled grammar's error recovery can fail to parse as a member at all, leaving the out-of-line `.cpp` definition attributed to its file instead of its class. A second-chance lookup by the exact qualified label recovers the edge, still refusing to guess when the label is shared by two unrelated classes (#2348, thanks @PedroMourao). - Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use ` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10). - Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10). - Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov). diff --git a/graphify/extract.py b/graphify/extract.py index d54b841d95..b23d1b65d3 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3793,6 +3793,23 @@ def _key(label: str) -> str: enclosing_type.setdefault(tgt, src) method_index[(src, _key(tnode.get("label", "")))] = tgt + # Qualified label ("Class::method()") -> node id(s), for a Foo::bar() call + # whose callee exists ONLY as this fallback shape with no `defines`/`method` + # edge to its class at all (#2348). A macro-heavy class body (Unreal's + # UCLASS()/GENERATED_BODY()) can defeat the bundled grammar's error recovery + # badly enough that the in-class declaration is never parsed as a member; + # the out-of-line .cpp definition then has nothing to attach to, so the + # extractor falls back to a qualified-labeled node contained by its FILE + # instead of a bare-labeled one contained by its class. method_index can + # never find that node (it only indexes defines/method targets), so this is + # a second-chance lookup by the exact qualified label, still guarded by + # exactly-one-candidate. + qualified_method_nids: dict[str, list[str]] = {} + for n in all_nodes: + label = str(n.get("label", "")) + if n.get("source_file") and label.endswith("()") and "::" in label: + qualified_method_nids.setdefault(label, []).append(n["id"]) + all_raw_calls: list[dict] = [] for result in per_file: all_raw_calls.extend(result.get("raw_calls", [])) @@ -3813,6 +3830,7 @@ def _key(label: str) -> str: if rc.get("lang") != "cpp": continue # Determine the receiver's type and the resulting confidence. + qualified_fallback_label: str | None = None if receiver == "this": # this->bar(): receiver is the caller's own enclosing class. type_nid = enclosing_type.get(caller) @@ -3821,6 +3839,7 @@ def _key(label: str) -> str: type_qualified = True elif receiver[:1].isupper(): # Foo::bar(): the type is named explicitly in source. + qualified_fallback_label = f"{receiver}::{callee}()" type_defs = type_def_nids.get(_key(receiver), []) if not type_defs: # Declared nowhere here, which in a multi-repo setup usually means @@ -3852,6 +3871,10 @@ def _key(label: str) -> str: type_nid = type_defs[0] type_qualified = False method_nid = method_index.get((type_nid, _key(callee))) + if method_nid is None and qualified_fallback_label is not None: + candidates = qualified_method_nids.get(qualified_fallback_label, []) + if len(candidates) == 1: + method_nid = candidates[0] target = method_nid or type_nid relation = "calls" if method_nid else "references" if target == caller or (caller, target) in existing_pairs: diff --git a/tests/test_cpp_objc_cross_file_calls.py b/tests/test_cpp_objc_cross_file_calls.py index 051a243735..fbcd192c50 100644 --- a/tests/test_cpp_objc_cross_file_calls.py +++ b/tests/test_cpp_objc_cross_file_calls.py @@ -128,6 +128,87 @@ def test_cpp_qualified_member_call_is_extracted(tmp_path: Path): assert ("main()", "calls", "bar", "EXTRACTED") in calls +def test_cpp_qualified_call_resolves_to_a_qualified_only_definition(tmp_path: Path): + """#2348: a macro heavy class body (Unreal's UCLASS()/GENERATED_BODY()) can + defeat the bundled grammar's error recovery badly enough that the in-class + method declaration is never parsed as a member at all -- the out-of-line + .cpp definition then has nothing to attach to and the extractor falls back + to a node labeled with the qualified name ("AHelper::FindNearest()") + contained by its FILE rather than a bare-labeled one contained by its + class. A Foo::bar() call site must still resolve to that qualified only + node instead of silently dropping the edge.""" + base = tmp_path / "src" + _write(base / "helper.h", ( + "#pragma once\n" + '#include "CoreMinimal.h"\n' + '#include "helper.generated.h"\n' + "UCLASS()\n" + "class AHelper : public AActor {\n" + " GENERATED_BODY()\n" + "public:\n" + " UFUNCTION()\n" + " static AHelper* FindNearest(UWorld* W);\n" + "};\n" + )) + _write(base / "helper.cpp", ( + '#include "helper.h"\n' + "AHelper* AHelper::FindNearest(UWorld* W) { return nullptr; }\n" + )) + _write(base / "caller.cpp", ( + '#include "helper.h"\n' + "void useIt(UWorld* W) {\n" + " AHelper* h = AHelper::FindNearest(W);\n" + " (void)h;\n" + "}\n" + )) + result = extract(sorted(base.glob("*")), cache_root=tmp_path / "cache") + + calls = _call_edges(result) + assert ("useIt()", "calls", "AHelper::FindNearest()", "EXTRACTED") in calls + + +def test_cpp_qualified_only_call_stays_ambiguous_across_two_classes(tmp_path: Path): + """The exactly-one-candidate guard still applies to the qualified-only + fallback: two DIFFERENT classes that both fail header parsing and both + happen to share a class name and a method name must not let a call + resolve to either one.""" + base = tmp_path / "src" + header = ( + "#pragma once\n" + '#include "a.generated.h"\n' + "UCLASS()\n" + "class AFoo : public AActor {\n" + " GENERATED_BODY()\n" + "public:\n" + " UFUNCTION()\n" + " static AFoo* FindNearest(UWorld* W);\n" + "};\n" + ) + body = ( + '#include "a.h"\n' + "AFoo* AFoo::FindNearest(UWorld* W) { return nullptr; }\n" + ) + _write(base / "a.h", header) + _write(base / "a.cpp", body) + _write(base / "sub" / "a.h", header) + _write(base / "sub" / "a.cpp", body) + _write(base / "caller.cpp", ( + '#include "a.h"\n' + "void useIt(UWorld* W) {\n" + " AFoo* h = AFoo::FindNearest(W);\n" + " (void)h;\n" + "}\n" + )) + paths = sorted(base.glob("*")) + sorted((base / "sub").glob("*")) + result = extract(paths, cache_root=tmp_path / "cache") + + calls = _call_edges(result) + assert not any( + rel == "calls" and src == "useIt()" + for src, rel, _, _ 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.