mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-26 17:21:43 +00:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3bc3feed54
commit
49252d3cb7
+9
-2
@@ -547,8 +547,15 @@ def build_from_json(extraction: dict, *, directed: bool = False, root: str | Pat
|
||||
".ts": "js", ".tsx": "js",
|
||||
".go": "go", ".rs": "rs",
|
||||
".java": "jvm", ".kt": "jvm", ".scala": "jvm", ".groovy": "jvm",
|
||||
".c": "c", ".h": "c", ".cc": "cpp", ".cpp": "cpp", ".hpp": "cpp",
|
||||
".cu": "cpp", ".cuh": "cpp", ".metal": "cpp",
|
||||
# C, C++, and ObjC interoperate within one compilation unit: a method
|
||||
# declared in a shared `.h` is defined/called from a `.c`/`.cpp`/`.m`
|
||||
# sibling, so a cross-file INFERRED call from impl to its header decl
|
||||
# is legitimate, not a phantom name-collision across languages. Treat
|
||||
# the whole C family as one so the receiver-typed C++/ObjC member-call
|
||||
# resolvers' header-targeting edges survive build (#1547/#1556).
|
||||
".c": "c", ".h": "c", ".cc": "c", ".cpp": "c", ".hpp": "c",
|
||||
".cxx": "c", ".hh": "c", ".hxx": "c",
|
||||
".cu": "c", ".cuh": "c", ".metal": "c", ".m": "c", ".mm": "c",
|
||||
".rb": "rb", ".php": "php", ".cs": "cs", ".swift": "swift", ".lua": "lua",
|
||||
}
|
||||
src_ext = Path(G.nodes[src].get("source_file") or "").suffix.lower()
|
||||
|
||||
+416
-3
@@ -1864,6 +1864,100 @@ def _get_cpp_func_name(node, source: bytes) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _cpp_declarator_name(node, source: bytes) -> str | None:
|
||||
"""Return the bare variable name from a C++ declaration declarator, unwrapping
|
||||
pointer/reference/init wrappers (``*f``, ``&r``, ``f = Foo()``). Returns None
|
||||
for anything that isn't a plain named local (arrays, function pointers,
|
||||
structured bindings) so the type table never records a guessed receiver."""
|
||||
t = node.type
|
||||
if t == "identifier":
|
||||
return _read_text(node, source)
|
||||
if t in ("pointer_declarator", "reference_declarator", "init_declarator"):
|
||||
inner = node.child_by_field_name("declarator")
|
||||
if inner is None:
|
||||
for c in node.children:
|
||||
if c.type in ("identifier", "pointer_declarator",
|
||||
"reference_declarator"):
|
||||
inner = c
|
||||
break
|
||||
if inner is not None:
|
||||
return _cpp_declarator_name(inner, source)
|
||||
return None
|
||||
|
||||
|
||||
def _cpp_local_var_types(body_node, source: bytes, table: dict[str, str]) -> None:
|
||||
"""Collect ``var -> ClassName`` from local variable declarations in a C++
|
||||
function body, for receiver-type inference in the cross-file member-call pass
|
||||
(#1547). Handles ``Foo f;``, ``Foo* f;``, ``Foo *f = ...;``, ``Foo f = Foo();``.
|
||||
|
||||
Only a class-like (``type_identifier``/``qualified_identifier``) type with a
|
||||
single named declarator is recorded — PRECISION over recall: a built-in type
|
||||
(``int x``), an ambiguous multi-declarator line, or an un-nameable declarator
|
||||
contributes nothing rather than a guess. A qualified type ``ns::Foo`` records
|
||||
its simple tail ``Foo`` so it keys to the type's definition node label.
|
||||
"""
|
||||
stack = [body_node]
|
||||
while stack:
|
||||
n = stack.pop()
|
||||
if n.type in ("function_definition", "lambda_expression"):
|
||||
# Don't descend into a nested function/lambda: its locals are scoped
|
||||
# away and would pollute this body's table.
|
||||
if n is not body_node:
|
||||
continue
|
||||
if n.type == "declaration":
|
||||
type_node = n.child_by_field_name("type")
|
||||
if type_node is not None and type_node.type in (
|
||||
"type_identifier", "qualified_identifier"
|
||||
):
|
||||
type_name = _read_text(type_node, source).split("::")[-1].strip()
|
||||
declarators = [
|
||||
c for c in n.children
|
||||
if c.type in ("identifier", "pointer_declarator",
|
||||
"reference_declarator", "init_declarator")
|
||||
]
|
||||
# A single declarator only: `Foo a, b;` is ambiguous to attribute
|
||||
# to one receiver name cleanly, so skip multi-declarator lines.
|
||||
if type_name and type_name[:1].isupper() and len(declarators) == 1:
|
||||
var = _cpp_declarator_name(declarators[0], source)
|
||||
if var and var not in table:
|
||||
table[var] = type_name
|
||||
for c in n.children:
|
||||
stack.append(c)
|
||||
|
||||
|
||||
def _objc_local_var_types(body_node, source: bytes, table: dict[str, str]) -> None:
|
||||
"""Collect ``var -> ClassName`` from ObjC local declarations (``Foo *f = ...;``)
|
||||
in a method body, for receiver typing in the cross-file message-send pass
|
||||
(#1556). Only a capitalized ``type_identifier`` with a single named declarator
|
||||
is recorded; a built-in/lower-cased type or an un-nameable declarator is skipped
|
||||
(precision over recall). Reuses the C++ declarator unwrapper (identical grammar).
|
||||
"""
|
||||
stack = [body_node]
|
||||
while stack:
|
||||
n = stack.pop()
|
||||
if n.type == "method_definition" and n is not body_node:
|
||||
continue
|
||||
if n.type == "declaration":
|
||||
type_node = n.child_by_field_name("type")
|
||||
if type_node is None:
|
||||
for c in n.children:
|
||||
if c.type == "type_identifier":
|
||||
type_node = c
|
||||
break
|
||||
if type_node is not None and type_node.type == "type_identifier":
|
||||
type_name = _read_text(type_node, source).strip()
|
||||
declarators = [
|
||||
c for c in n.children
|
||||
if c.type in ("identifier", "pointer_declarator", "init_declarator")
|
||||
]
|
||||
if type_name and type_name[:1].isupper() and len(declarators) == 1:
|
||||
var = _cpp_declarator_name(declarators[0], source)
|
||||
if var and var not in table:
|
||||
table[var] = type_name
|
||||
for c in n.children:
|
||||
stack.append(c)
|
||||
|
||||
|
||||
# ── JS/TS extra walk for arrow functions ──────────────────────────────────────
|
||||
|
||||
def _find_require_call(value_node):
|
||||
@@ -3918,11 +4012,31 @@ def _extract_generic(
|
||||
if func_node:
|
||||
if func_node.type == "identifier":
|
||||
callee_name = _read_text(func_node, source)
|
||||
elif func_node.type in ("field_expression", "qualified_identifier"):
|
||||
elif func_node.type == "field_expression":
|
||||
# `f.bar()` / `f->bar()` / `this->bar()`: receiver is the
|
||||
# `argument` (object) field, callee is the `field` (#1547).
|
||||
# Capture a simple-identifier (or `this`) receiver so the
|
||||
# cross-file pass can resolve it through the file's type
|
||||
# table; chained receivers (`a.b.method()`) are left to bail.
|
||||
is_member_call = True
|
||||
name = func_node.child_by_field_name("field") or func_node.child_by_field_name("name")
|
||||
name = func_node.child_by_field_name("field")
|
||||
if name:
|
||||
callee_name = _read_text(name, source)
|
||||
obj = func_node.child_by_field_name("argument")
|
||||
if obj is not None and obj.type == "identifier":
|
||||
member_receiver = _read_text(obj, source)
|
||||
elif obj is not None and obj.type == "this":
|
||||
member_receiver = "this"
|
||||
elif func_node.type == "qualified_identifier":
|
||||
# `Foo::bar()`: the scope (`Foo`) is the receiver type named
|
||||
# explicitly in source (EXTRACTED), the name is the callee.
|
||||
is_member_call = True
|
||||
name = func_node.child_by_field_name("name")
|
||||
if name:
|
||||
callee_name = _read_text(name, source)
|
||||
scope = func_node.child_by_field_name("scope")
|
||||
if scope is not None:
|
||||
member_receiver = _read_text(scope, source)
|
||||
elif config.ts_module == "tree_sitter_java" and node.type == "object_creation_expression":
|
||||
# `new Foo(...)` — the constructed type is in the `type` field, not
|
||||
# `name`, so the generic path misses it (#1373). Reduce a qualified
|
||||
@@ -4023,6 +4137,12 @@ def _extract_generic(
|
||||
rc_entry["receiver_type"] = ruby_var_types.get(
|
||||
caller_nid, {}
|
||||
).get(member_receiver)
|
||||
# Tag the C++ raw_call's language so the cross-file C++ resolver
|
||||
# claims it unambiguously: a `.h` file routes to extract_cpp or
|
||||
# extract_objc by content, and both resolvers see `.h` in their
|
||||
# suffix sets, so a source_file suffix alone can't separate them.
|
||||
if config.ts_module == "tree_sitter_cpp":
|
||||
rc_entry["lang"] = "cpp"
|
||||
raw_calls.append(rc_entry)
|
||||
|
||||
# Helper function calls: config('foo.bar') → uses_config edge to "foo"
|
||||
@@ -4156,6 +4276,14 @@ def _extract_generic(
|
||||
for caller_nid, body_node in function_bodies:
|
||||
ruby_var_types[caller_nid] = _ruby_local_class_bindings(body_node, source)
|
||||
|
||||
# C++: build the per-file `var -> ClassName` table from local declarations in
|
||||
# every function body so the cross-file member-call pass can type a receiver
|
||||
# (#1547). File-scoped (not per-body): a later body's `Foo f;` doesn't clobber
|
||||
# an earlier binding (`var not in table`), keeping resolution conservative.
|
||||
if config.ts_module == "tree_sitter_cpp":
|
||||
for _caller_nid, body_node in function_bodies:
|
||||
_cpp_local_var_types(body_node, source, type_table)
|
||||
|
||||
for caller_nid, body_node in function_bodies:
|
||||
walk_calls(body_node, caller_nid)
|
||||
|
||||
@@ -4203,6 +4331,8 @@ def _extract_generic(
|
||||
result["swift_type_table"] = {"path": str_path, "table": type_table}
|
||||
elif config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"):
|
||||
result["ts_type_table"] = {"path": str_path, "table": type_table}
|
||||
elif config.ts_module == "tree_sitter_cpp":
|
||||
result["cpp_type_table"] = {"path": str_path, "table": type_table}
|
||||
return result
|
||||
|
||||
|
||||
@@ -10055,6 +10185,244 @@ def _resolve_typescript_member_calls(
|
||||
})
|
||||
|
||||
|
||||
def _resolve_cpp_member_calls(
|
||||
per_file: list[dict],
|
||||
all_nodes: list[dict],
|
||||
all_edges: list[dict],
|
||||
) -> None:
|
||||
"""Resolve cross-file C++ member calls (``f.bar()``, ``f->bar()``,
|
||||
``Foo::bar()``, ``this->bar()``) to the real definition of the receiver's type
|
||||
(#1547).
|
||||
|
||||
The shared cross-file pass drops every ``is_member_call`` because a bare method
|
||||
name (``bar``) collides across the corpus and inflates god-nodes (#543/#1219).
|
||||
The C++ extractor records each member call's receiver and a per-file
|
||||
``var -> ClassName`` table (``cpp_type_table``) built from local declarations.
|
||||
This pass types the receiver, then emits an edge ONLY when that type resolves
|
||||
to exactly ONE definition (the god-node guard).
|
||||
|
||||
Receiver typing, by precision tier:
|
||||
* ``Foo::bar()`` — the scope ``Foo`` names the type explicitly -> EXTRACTED.
|
||||
* ``this->bar()`` — the receiver is the caller's own enclosing class -> EXTRACTED.
|
||||
* ``f.bar()`` / ``f->bar()`` — ``f`` typed via the file's local table -> INFERRED.
|
||||
A receiver whose type can't be inferred locally is SKIPPED (no guess): a false
|
||||
call edge is worse than a missing one. The ``_merge_decl_def_classes`` pass has
|
||||
already folded each header/impl class pair into one node, so a paired class is a
|
||||
single definition and clears the single-definition guard.
|
||||
|
||||
Must run after id-disambiguation so node ids and caller_nids are final.
|
||||
"""
|
||||
type_table_by_file: dict[str, dict[str, str]] = {}
|
||||
for result in per_file:
|
||||
tt = result.get("cpp_type_table")
|
||||
if tt and tt.get("path"):
|
||||
type_table_by_file[tt["path"]] = tt.get("table", {})
|
||||
|
||||
def _key(label: str) -> str:
|
||||
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
|
||||
|
||||
# A genuine C++ type is the target of a `contains` edge from its file node;
|
||||
# bare-reference shadow nodes (ensure_named_node stubs) are not contained, so
|
||||
# excluding non-contained nodes keeps them from making a real type ambiguous.
|
||||
contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"}
|
||||
|
||||
type_def_nids: dict[str, list[str]] = {}
|
||||
node_by_id: dict[str, dict] = {}
|
||||
for n in all_nodes:
|
||||
node_by_id[n.get("id")] = n
|
||||
if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n):
|
||||
type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"])
|
||||
|
||||
# (type_node_id, method_key) -> method_node_id, and caller -> enclosing type
|
||||
# (the owning class) for `this->` calls. A C++ class owns its members via
|
||||
# `method` edges (out-of-line definitions) AND `defines` edges (in-class
|
||||
# declarations, which the extractor models as fields); index both so a header-
|
||||
# declared `void bar();` resolves. `method` wins when a key has both.
|
||||
method_index: dict[tuple[str, str], str] = {}
|
||||
enclosing_type: dict[str, str] = {}
|
||||
for rel in ("defines", "method"):
|
||||
for e in all_edges:
|
||||
if e.get("relation") != rel:
|
||||
continue
|
||||
src, tgt = e.get("source"), e.get("target")
|
||||
tnode = node_by_id.get(tgt)
|
||||
if tnode is None:
|
||||
continue
|
||||
enclosing_type.setdefault(tgt, src)
|
||||
method_index[(src, _key(tnode.get("label", "")))] = tgt
|
||||
|
||||
all_raw_calls: list[dict] = []
|
||||
for result in per_file:
|
||||
all_raw_calls.extend(result.get("raw_calls", []))
|
||||
|
||||
existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
|
||||
for rc in all_raw_calls:
|
||||
if not rc.get("is_member_call"):
|
||||
continue
|
||||
receiver = rc.get("receiver")
|
||||
callee = rc.get("callee")
|
||||
caller = rc.get("caller_nid")
|
||||
if not receiver or not callee or not caller:
|
||||
continue
|
||||
src_file = rc.get("source_file", "")
|
||||
# Only resolve C++ raw_calls (other languages share the raw_calls list;
|
||||
# a `.h` may route to either extract_cpp or extract_objc by content, so the
|
||||
# extractor-stamped `lang` tag — not the suffix — is the unambiguous gate).
|
||||
if rc.get("lang") != "cpp":
|
||||
continue
|
||||
# Determine the receiver's type and the resulting confidence.
|
||||
if receiver == "this":
|
||||
# this->bar(): receiver is the caller's own enclosing class.
|
||||
type_nid = enclosing_type.get(caller)
|
||||
if not type_nid:
|
||||
continue
|
||||
type_qualified = True
|
||||
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)
|
||||
continue
|
||||
type_nid = type_defs[0]
|
||||
type_qualified = True
|
||||
else:
|
||||
# f.bar() / f->bar(): type the receiver via the file's local table.
|
||||
type_name = type_table_by_file.get(src_file, {}).get(receiver)
|
||||
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)
|
||||
continue
|
||||
type_nid = type_defs[0]
|
||||
type_qualified = False
|
||||
method_nid = method_index.get((type_nid, _key(callee)))
|
||||
target = method_nid or type_nid
|
||||
relation = "calls" if method_nid else "references"
|
||||
if target == caller or (caller, target) in existing_pairs:
|
||||
continue
|
||||
existing_pairs.add((caller, target))
|
||||
all_edges.append({
|
||||
"source": caller,
|
||||
"target": target,
|
||||
"relation": relation,
|
||||
"context": "call",
|
||||
"confidence": "EXTRACTED" if type_qualified else "INFERRED",
|
||||
"confidence_score": 1.0 if type_qualified else 0.8,
|
||||
"source_file": src_file,
|
||||
"source_location": rc.get("source_location"),
|
||||
"weight": 1.0,
|
||||
})
|
||||
|
||||
|
||||
def _resolve_objc_member_calls(
|
||||
per_file: list[dict],
|
||||
all_nodes: list[dict],
|
||||
all_edges: list[dict],
|
||||
) -> None:
|
||||
"""Resolve cross-file Objective-C message sends (``[recv sel]``) to the real
|
||||
definition of the receiver's type (#1556).
|
||||
|
||||
The ObjC extractor keeps its same-file selector matching (alloc/init refs,
|
||||
dot-syntax accesses, @selector) and additionally emits ``raw_calls`` for every
|
||||
message send, with the receiver and the reconstructed selector as the callee.
|
||||
This pass types the receiver and emits a cross-file ``calls`` edge ONLY when the
|
||||
type resolves to exactly ONE definition (the god-node guard).
|
||||
|
||||
Receiver typing:
|
||||
* ``self`` / ``super`` — the caller's own enclosing class -> EXTRACTED.
|
||||
* Capitalized receiver (``[Foo new]``) — the type named explicitly -> EXTRACTED.
|
||||
* ``[f doThing]`` — ``f`` typed via the file's ``Foo *f`` local table -> INFERRED.
|
||||
An uninferable receiver is SKIPPED (no guess), so an ambiguous selector across
|
||||
classes never fans out. ``_merge_decl_def_classes`` folds each @interface/@impl
|
||||
pair into one node, so a paired class clears the single-definition guard.
|
||||
|
||||
Must run after id-disambiguation so node ids and caller_nids are final.
|
||||
"""
|
||||
type_table_by_file: dict[str, dict[str, str]] = {}
|
||||
for result in per_file:
|
||||
tt = result.get("objc_type_table")
|
||||
if tt and tt.get("path"):
|
||||
type_table_by_file[tt["path"]] = tt.get("table", {})
|
||||
|
||||
def _key(label: str) -> str:
|
||||
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
|
||||
|
||||
contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"}
|
||||
|
||||
type_def_nids: dict[str, list[str]] = {}
|
||||
node_by_id: dict[str, dict] = {}
|
||||
for n in all_nodes:
|
||||
node_by_id[n.get("id")] = n
|
||||
if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n):
|
||||
type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"])
|
||||
|
||||
method_index: dict[tuple[str, str], str] = {}
|
||||
enclosing_type: dict[str, str] = {}
|
||||
for e in all_edges:
|
||||
if e.get("relation") != "method":
|
||||
continue
|
||||
src, tgt = e.get("source"), e.get("target")
|
||||
enclosing_type.setdefault(tgt, src)
|
||||
tnode = node_by_id.get(tgt)
|
||||
if tnode is not None:
|
||||
# ObjC method labels carry a +/- sigil (`-doThing`); strip it so the
|
||||
# selector `doThing` keys to the method.
|
||||
method_index[(src, _key(tnode.get("label", "")))] = tgt
|
||||
|
||||
all_raw_calls: list[dict] = []
|
||||
for result in per_file:
|
||||
all_raw_calls.extend(result.get("raw_calls", []))
|
||||
|
||||
existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
|
||||
for rc in all_raw_calls:
|
||||
if not rc.get("is_member_call"):
|
||||
continue
|
||||
receiver = rc.get("receiver")
|
||||
callee = rc.get("callee")
|
||||
caller = rc.get("caller_nid")
|
||||
if not receiver or not callee or not caller:
|
||||
continue
|
||||
src_file = rc.get("source_file", "")
|
||||
if rc.get("lang") != "objc":
|
||||
continue
|
||||
if receiver in ("self", "super"):
|
||||
type_nid = enclosing_type.get(caller)
|
||||
if not type_nid:
|
||||
continue
|
||||
type_qualified = True
|
||||
elif receiver[:1].isupper():
|
||||
type_defs = type_def_nids.get(_key(receiver), [])
|
||||
if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard)
|
||||
continue
|
||||
type_nid = type_defs[0]
|
||||
type_qualified = True
|
||||
else:
|
||||
type_name = type_table_by_file.get(src_file, {}).get(receiver)
|
||||
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)
|
||||
continue
|
||||
type_nid = type_defs[0]
|
||||
type_qualified = False
|
||||
method_nid = method_index.get((type_nid, _key(callee)))
|
||||
target = method_nid or type_nid
|
||||
relation = "calls" if method_nid else "references"
|
||||
if target == caller or (caller, target) in existing_pairs:
|
||||
continue
|
||||
existing_pairs.add((caller, target))
|
||||
all_edges.append({
|
||||
"source": caller,
|
||||
"target": target,
|
||||
"relation": relation,
|
||||
"context": "call",
|
||||
"confidence": "EXTRACTED" if type_qualified else "INFERRED",
|
||||
"confidence_score": 1.0 if type_qualified else 0.8,
|
||||
"source_file": src_file,
|
||||
"source_location": rc.get("source_location"),
|
||||
"weight": 1.0,
|
||||
})
|
||||
|
||||
|
||||
# Register the cross-file, language-specific member-call resolvers into the shared
|
||||
# registry (framework lives in graphify.resolver_registry). A new language plugs in
|
||||
# by adding one register() call below — no edits to extract()'s body. Order
|
||||
@@ -10073,6 +10441,23 @@ register_language_resolver(
|
||||
register_language_resolver(
|
||||
LanguageResolver("typescript_member_calls", frozenset({".ts", ".tsx", ".js", ".jsx"}), _resolve_typescript_member_calls)
|
||||
)
|
||||
# C++ (#1547) and ObjC (#1556) receiver-typed member-call resolution. `.h` is in
|
||||
# both suffix sets because it routes to extract_cpp or extract_objc by content; the
|
||||
# resolvers each claim only their own raw_calls via the extractor-stamped `lang`.
|
||||
register_language_resolver(
|
||||
LanguageResolver(
|
||||
"cpp_member_calls",
|
||||
frozenset({".cpp", ".cc", ".cxx", ".hpp", ".cu", ".cuh", ".metal", ".h"}),
|
||||
_resolve_cpp_member_calls,
|
||||
)
|
||||
)
|
||||
register_language_resolver(
|
||||
LanguageResolver(
|
||||
"objc_member_calls",
|
||||
frozenset({".m", ".mm", ".h"}),
|
||||
_resolve_objc_member_calls,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def extract_objc(path: Path) -> dict:
|
||||
@@ -10105,6 +10490,10 @@ def extract_objc(path: Path) -> dict:
|
||||
edges: list[dict] = []
|
||||
seen_ids: set[str] = set()
|
||||
method_bodies: list[tuple[str, Any, str]] = []
|
||||
# #1556: unresolved message sends saved for the cross-file ObjC resolver, plus a
|
||||
# per-file `var -> ClassName` table from `Foo *f = ...;` local declarations.
|
||||
raw_calls: list[dict] = []
|
||||
objc_type_table: dict[str, str] = {}
|
||||
|
||||
def add_node(nid: str, label: str, line: int) -> None:
|
||||
if nid not in seen_ids:
|
||||
@@ -10332,6 +10721,11 @@ def extract_objc(path: Path) -> dict:
|
||||
for m_nid, _, container_nid in method_bodies:
|
||||
class_method_nids.setdefault(container_nid, set()).add(m_nid)
|
||||
seen_calls: set[tuple[str, str]] = set()
|
||||
# #1556: per-file `var -> ClassName` table from local declarations in every
|
||||
# method body, so the cross-file resolver can type a `[f doThing]` receiver.
|
||||
for _m_nid, body_node, _container in method_bodies:
|
||||
_objc_local_var_types(body_node, source, objc_type_table)
|
||||
|
||||
for caller_nid, body_node, container_nid in method_bodies:
|
||||
sibling_nids = class_method_nids.get(container_nid, set())
|
||||
|
||||
@@ -10378,6 +10772,21 @@ def extract_objc(path: Path) -> dict:
|
||||
seen_calls.add(pair)
|
||||
add_edge(caller_nid, candidate, "calls", n.start_point[0] + 1,
|
||||
confidence="EXTRACTED", weight=1.0, context="call")
|
||||
# #1556: also emit a raw_call so the cross-file resolver can type
|
||||
# the receiver and link to a method in ANOTHER file. A bare
|
||||
# identifier receiver (`f`, `self`, `Foo`) is captured; a nested
|
||||
# message send (`[[Foo alloc] init]`) has no simple receiver name
|
||||
# to type, so it is left to the alloc/init `references` edge above.
|
||||
if recv is not None and recv.type == "identifier":
|
||||
raw_calls.append({
|
||||
"caller_nid": caller_nid,
|
||||
"callee": method_name,
|
||||
"is_member_call": True,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{n.start_point[0] + 1}",
|
||||
"receiver": _read(recv),
|
||||
"lang": "objc",
|
||||
})
|
||||
elif n.type == "field_expression":
|
||||
# self.name / self.product.name — dot-syntax sugar for [self name].
|
||||
# Resolve to a sibling method of the SAME class, matched by EXACT
|
||||
@@ -10422,7 +10831,11 @@ def extract_objc(path: Path) -> dict:
|
||||
walk_calls(child)
|
||||
walk_calls(body_node)
|
||||
|
||||
return {"nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0}
|
||||
result = {"nodes": nodes, "edges": edges, "raw_calls": raw_calls,
|
||||
"input_tokens": 0, "output_tokens": 0}
|
||||
if objc_type_table:
|
||||
result["objc_type_table"] = {"path": str_path, "table": objc_type_table}
|
||||
return result
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user