Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Name>` 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).
Expand Down
23 changes: 23 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", []))
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
81 changes: 81 additions & 0 deletions tests/test_cpp_objc_cross_file_calls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading