diff --git a/graphify/build.py b/graphify/build.py index 951ea12e..eb7de419 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -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() diff --git a/graphify/extract.py b/graphify/extract.py index cb2fcc8e..e0e97ef7 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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 diff --git a/tests/test_cpp_objc_cross_file_calls.py b/tests/test_cpp_objc_cross_file_calls.py new file mode 100644 index 00000000..051a2437 --- /dev/null +++ b/tests/test_cpp_objc_cross_file_calls.py @@ -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