diff --git a/graphify/extract.py b/graphify/extract.py index 5d9181fba..5a41fa89d 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -4679,13 +4679,14 @@ def _resolve_csharp_member_calls( type_nid = bases[0] type_qualified = True elif receiver[:1].isupper(): - # Type.M() — the type is named explicitly (also covers a Pascal-cased - # local whose name equals its type, resolved via the table below if the - # explicit-type lookup misses). - type_nid = _resolve_type_name_nid(receiver, caller_node, src_file) + # Type.M() — the type is named explicitly. A field/property/local of + # that name in scope shadows the type (`IStore Store => ...; + # Store.Save()`, #3797), so the receiver's declared type from the + # table wins when known; otherwise fall back to the type name. + type_name = rc.get("receiver_type") + type_nid = _resolve_type_name_nid(type_name, caller_node, src_file) if not type_nid: - type_name = rc.get("receiver_type") - type_nid = _resolve_type_name_nid(type_name, caller_node, src_file) + type_nid = _resolve_type_name_nid(receiver, caller_node, src_file) if not type_nid: _park_if_absent(type_name or receiver, caller_node, rc) continue diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 8fb433e06..2c48fc872 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -7120,6 +7120,19 @@ def _extract_generic( and fname.type == "identifier" ): member_receiver = _read_text(fname, source) + elif recv is not None and recv.type == "parenthesized_expression": + # ((IStore)x).M() / (x as IStore).M(): the cast names + # the receiver's type in source, so resolve it as + # IStore.M() (#3797). + inner = recv.named_children[0] if recv.named_children else None + cast_type = None + if inner is not None and inner.type == "cast_expression": + cast_type = inner.child_by_field_name("type") + elif inner is not None and inner.type == "as_expression": + cast_type = inner.child_by_field_name("right") + type_info = _read_csharp_type_name(cast_type, source) + if type_info and type_info[0]: + member_receiver = type_info[0] elif fn_node is not None and fn_node.type == "identifier": callee_name = _read_text(fn_node, source) elif fn_node is not None and fn_node.type == "generic_name": diff --git a/tests/test_csharp_member_calls.py b/tests/test_csharp_member_calls.py index ba0090d53..220daaa56 100644 --- a/tests/test_csharp_member_calls.py +++ b/tests/test_csharp_member_calls.py @@ -353,6 +353,53 @@ def test_null_conditional_cross_file_receiver_resolves(tmp_path): assert not any("commit" in s and "cache_save" in t for s, t in calls) +_STORE = ( + "public interface IStore { void Save(); }\n" + "public class Store : IStore { public void Save() { } }\n" +) + + +def _store_call(tmp_path, editor_body: str): + """Call `IStore.Save()` from another file; return (calls, caller, IStore.Save, Store.Save).""" + calls, r = _calls(tmp_path, { + "Store.cs": _STORE, + "Editor.cs": ( + "public class Editor {\n" + " object _obj;\n" + " IStore Store => (IStore)_obj;\n" + f" public void Run() {{ {editor_body} }}\n" + "}\n" + ), + }) + run = _find(r, ".Run()", "editor") + istore_save = _find(r, ".Save()", "istore") + store_save = next(n["id"] for n in r["nodes"] + if n["label"] == ".Save()" and n["id"] != istore_save) + return calls, run, istore_save, store_save + + +def test_cast_receiver_resolves_to_cast_type(tmp_path): + """`((IStore)_obj).Save()`: the cast names the receiver type (#3797).""" + calls, run, istore_save, store_save = _store_call(tmp_path, "((IStore)_obj).Save();") + assert (run, istore_save) in calls + assert (run, store_save) not in calls + + +def test_as_cast_receiver_resolves_to_cast_type(tmp_path): + """`(_obj as IStore).Save()` types the receiver the same way (#3797).""" + calls, run, istore_save, store_save = _store_call(tmp_path, "(_obj as IStore).Save();") + assert (run, istore_save) in calls + assert (run, store_save) not in calls + + +def test_property_receiver_shadows_same_named_type(tmp_path): + """`IStore Store => ...; Store.Save()`: inside Editor `Store` is the + property, so the call is IStore.Save, not the class Store's (#3797).""" + calls, run, istore_save, store_save = _store_call(tmp_path, "Store.Save();") + assert (run, istore_save) in calls + assert (run, store_save) not in calls + + def test_base_receiver_resolves_to_base_class_method(tmp_path): calls, r = _calls(tmp_path, { "Base.cs": "public class BaseSvc { public bool Ping() => true; }\n",