diff --git a/graphify/cross_repo_calls.py b/graphify/cross_repo_calls.py index 32117e182..f1b993806 100644 --- a/graphify/cross_repo_calls.py +++ b/graphify/cross_repo_calls.py @@ -42,6 +42,7 @@ _LANG_SUFFIXES: dict[str, frozenset[str]] = { "cpp": frozenset({".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".h", ".cu", ".cuh"}), "csharp": frozenset({".cs"}), "java": frozenset({".java"}), + "objc": frozenset({".h", ".m", ".mm"}), "swift": frozenset({".swift"}), } @@ -57,7 +58,8 @@ def _key(label: object) -> str: Type labels are plain (``Greeter``) while method labels carry the extractor's decoration (``.greet()``). Case is preserved: every language that parks calls here is case-sensitive, and folding case would let `greeter` answer for - `Greeter`. + `Greeter`. Objective-C's ``+``/``-`` sigil is deliberately kept, so an ObjC + ``-greet`` and a C++ ``.greet()`` stay distinct members of a shared header. """ return str(label or "").strip().removeprefix(".").removesuffix("()") @@ -148,6 +150,20 @@ def _member_relations(lang: str) -> tuple[str, ...]: return ("method", "defines") if lang == "cpp" else ("method",) +def _member_keys(lang: str, callee: str) -> tuple[str, ...]: + """Which member-index keys a parked callee may answer to. + + An ObjC selector carries no class/instance distinction, so both sigils are + tried; a type declaring `+greet` and `-greet` both is an ambiguity, not a hit. + A selector cannot itself begin with a sigil, so stripping one first keeps an + entry that already carries it from asking for `--greet`. + """ + if lang == "objc": + selector = callee.lstrip("+-") + return (f"-{selector}", f"+{selector}") + return (callee,) + + def link_cross_repo_member_calls(merged: "nx.Graph") -> int: """Emit `calls` edges for parked member calls another repo answers. @@ -221,7 +237,9 @@ def link_cross_repo_member_calls(merged: "nx.Graph") -> int: continue targets: list[str] = [] for relation in _member_relations(lang): - targets = members_by_relation[relation].get((candidates[0], callee), []) + members = members_by_relation[relation] + for member_key in _member_keys(lang, callee): + targets.extend(members.get((candidates[0], member_key), [])) if targets: break if len(targets) != 1: diff --git a/graphify/extract.py b/graphify/extract.py index bd8d541c5..e31c3997e 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -6079,7 +6079,9 @@ def _resolve_objc_member_calls( captured; a dotted receiver like ``Foo.shared`` is never passed through, because ``_key`` would strip the dot and collide with a real ``FooShared``. An uninferable receiver is SKIPPED (no guess), so an ambiguous selector across - classes never fans out. ``_merge_decl_def_classes`` folds each @interface/@impl + classes never fans out. A receiver typed to a class this corpus declares nowhere + is parked on the caller for a merged graph to finish (#3152). + ``_merge_decl_def_classes`` folds each @interface/@impl pair into one node, so a paired class clears the single-definition guard. ``@protocol`` declarations are excluded from the receiver-type index: a protocol is a contract, not a message receiver, and ObjC keeps protocol and class names in @@ -6178,6 +6180,16 @@ def _resolve_objc_member_calls( queue.extend(_objc_bases.get(c, [])) return None + def _park_absent(caller: str, callee: str, type_name: str, rc: dict) -> None: + """Park a typed receiver whose class is declared nowhere here (#3152). + + A builtin type (NSString, DispatchQueue, ...) is not what another repo + declares, so parking one would only let a same-named user class answer it. + """ + if type_name in _LANGUAGE_BUILTIN_GLOBALS: + return + _park_unresolved_member_call(node_by_id.get(caller), callee, type_name, "objc", rc) + for rc in all_raw_calls: if not rc.get("is_member_call"): continue @@ -6198,7 +6210,10 @@ def _resolve_objc_member_calls( 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) + if not type_defs: + _park_absent(caller, callee, type_name, rc) + continue + if len(type_defs) != 1: # ambiguous -> bail (god-node guard) continue type_nid = type_defs[0] type_qualified = False @@ -6209,7 +6224,10 @@ def _resolve_objc_member_calls( 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) + if not type_defs: + _park_absent(caller, callee, receiver, rc) + continue + if len(type_defs) != 1: # ambiguous -> bail (god-node guard) continue type_nid = type_defs[0] type_qualified = True @@ -6223,7 +6241,10 @@ def _resolve_objc_member_calls( 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) + if not type_defs: + _park_absent(caller, callee, type_name, rc) + continue + if len(type_defs) != 1: # ambiguous -> bail (god-node guard) continue type_nid = type_defs[0] type_qualified = False diff --git a/tests/test_cross_repo_member_calls.py b/tests/test_cross_repo_member_calls.py index fc6bc030e..8db097c49 100644 --- a/tests/test_cross_repo_member_calls.py +++ b/tests/test_cross_repo_member_calls.py @@ -6,11 +6,11 @@ type already in hand and nothing about it reached `graph.json` — the only arti `merge-graphs` and `global add` read. The two-repo graph was missing precisely the edges that make it a call graph. -The Java, C++, C# and Swift resolvers now park those calls on the caller node and -this pass finishes them after the merge. The cases below pin what it must NOT do -as much as what it must: the single-definition guard, the cross-repo-only scope, -and the language guard are what keep it from fabricating an edge from a name -collision. +The Java, C++, C#, Objective-C and Swift resolvers now park those calls on the +caller node and this pass finishes them after the merge. The cases below pin what +it must NOT do as much as what it must: the single-definition guard, the +cross-repo-only scope, and the language guard are what keep it from fabricating an +edge from a name collision. """ from __future__ import annotations @@ -44,6 +44,7 @@ def _needs(module: str): needs_java = _needs("tree_sitter_java") needs_cpp = _needs("tree_sitter_cpp") needs_csharp = _needs("tree_sitter_c_sharp") +needs_objc = _needs("tree_sitter_objc") needs_swift = _needs("tree_sitter_swift") @@ -178,6 +179,79 @@ def test_a_defines_member_does_not_answer_a_java_call(): assert link_cross_repo_member_calls(G) == 0 +PARKED_OBJC = [{"callee": "greet", "receiver_type": "Greeter", "lang": "objc", "line": "L5"}] + + +def test_an_objc_selector_answers_through_either_sigil(): + # An ObjC method label keeps its +/- sigil while a parked selector carries no + # class/instance distinction, so both spellings have to be tried. + for label in ("-greet", "+greet"): + G = _graph( + caller=_caller("a", PARKED_OBJC, source_file="src/App.m"), + declarations=[(_declaration("b", "Greeter", "Greeter.m"), + _method("b", label, source_file="Greeter.m"))], + ) + assert link_cross_repo_member_calls(G) == 1, label + assert _added_calls(G) == {("a::app_run", "b::greeter_greet")} + + +def test_an_objc_callee_that_already_carries_a_sigil_still_binds(): + # A parked selector is written bare, but the payload comes from `graph.json` + # and a sigiled one must not ask the index for `--greet`. + G = _graph( + caller=_caller("a", [dict(PARKED_OBJC[0], callee="-greet")], + source_file="src/App.m"), + declarations=[(_declaration("b", "Greeter", "Greeter.m"), + _method("b", "-greet", source_file="Greeter.m"))], + ) + assert link_cross_repo_member_calls(G) == 1 + assert _added_calls(G) == {("a::app_run", "b::greeter_greet")} + + +def test_a_type_declaring_both_objc_sigils_binds_nothing(): + # `+greet` and `-greet` on one class are two different methods and the parked + # selector cannot say which was meant. + decl = _declaration("b", "Greeter", "Greeter.m") + G = _graph( + caller=_caller("a", PARKED_OBJC, source_file="src/App.m"), + declarations=[(decl, _method("b", "-greet", "greeter_inst", "Greeter.m"))], + ) + class_method = _method("b", "+greet", "greeter_cls", "Greeter.m") + G.add_node(class_method[0], **class_method[1]) + G.add_edge(decl[0], class_method[0], relation="method") + + assert link_cross_repo_member_calls(G) == 0 + + +def test_an_objc_call_does_not_bind_to_a_cpp_member_of_a_shared_header(): + # `.h` belongs to both languages, so the sigil is what keeps an ObjC `-greet` + # and a C++ `.greet()` from answering for each other inside one header. + G = _graph( + caller=_caller("a", PARKED_OBJC, source_file="src/App.m"), + declarations=[(_declaration("b", "Greeter", "greeter.h"), + _method("b", ".greet()", source_file="greeter.h"))], + ) + assert link_cross_repo_member_calls(G) == 0 + + G = _graph( + caller=_caller("a", PARKED_CPP, source_file="src/app.cpp"), + declarations=[(_declaration("b", "Greeter", "greeter.h"), + _method("b", "-greet", source_file="greeter.h"))], + ) + assert link_cross_repo_member_calls(G) == 0 + + +def test_an_objc_protocol_does_not_answer_a_parked_call(): + # A protocol carries the same markers as a class but is labelled ``, + # which no parked receiver type ever spells. + G = _graph( + caller=_caller("a", PARKED_OBJC, source_file="src/App.m"), + declarations=[(_declaration("b", "", "Greeter.h"), + _method("b", "-greet", source_file="Greeter.h"))], + ) + assert link_cross_repo_member_calls(G) == 0 + + def test_the_definition_answers_before_a_same_named_declaration(): # A C++ class declares `void greet();` in its header (`defines`) and defines # it out of line in the `.cpp` (`method`). Both hang off the one folded class @@ -365,6 +439,22 @@ def test_a_java_build_parks_the_call_and_the_merge_finishes_it(tmp_path: Path): ("src/Greeter.cs", "class Greeter { public void Greet() {} }\n"), marks=needs_csharp, id="csharp-field-receiver", ), + pytest.param( + "objc", "greet", + ("src/App.m", "@interface App : NSObject\n" + "@property (nonatomic, strong) Greeter *greeter;\n" + "@end\n" + "@implementation App\n" + "- (void)run { [self.greeter greet]; }\n" + "@end\n"), + ("src/Greeter.m", "@interface Greeter : NSObject\n" + "- (void)greet;\n" + "@end\n" + "@implementation Greeter\n" + "- (void)greet {}\n" + "@end\n"), + marks=needs_objc, id="objc-property-receiver", + ), pytest.param( "swift", "greet", ("src/App.swift", "class App {\n"