diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index d7d991a86..a14cf8f9f 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -8309,6 +8309,15 @@ def _extract_generic( ) if isinstance(elem_type, str): csharp_receiver_chain = [elem_type] + elif recv is not None and recv.type == "object_creation_expression": + # new X(...).M(): the receiver is the constructed X + # (#1770), typed here now that a nameless receiver no + # longer falls back to the file's bare-name map. + type_info = _read_csharp_type_name( + recv.child_by_field_name("type"), source + ) + if type_info and type_info[0]: + csharp_receiver_chain = [type_info[0]] 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 @@ -8670,9 +8679,13 @@ def _extract_generic( # receiver's declared type and mis-binds to an unrelated same-named # method (#1609). The receiver may be lowercase (`_server.Save()`), # so this is broader than the capitalized/this-field Python rule. + # A receiver with no capturable name (`run.Sequence.Apply()`, + # `Make().Apply()`, `s.Items.Count()`) defers too: the bare-name + # map would bind it to the caller's own same-named method. A typed + # chain still resolves through `receiver_chain`; anything else + # gets no edge. _csharp_defer = ( - config.ts_module == "tree_sitter_c_sharp" - and is_member_call and member_receiver + config.ts_module == "tree_sitter_c_sharp" and is_member_call ) _python_defer = ( config.ts_module == "tree_sitter_python" @@ -8691,13 +8704,12 @@ def _extract_generic( and is_member_call and not lua_self_qualified ) - if _python_defer or _java_defer or (_builtin_member_call and not php_builtin_self_call) or _lua_member_defer or is_scoped_call or ( + if _python_defer or _java_defer or _csharp_defer or (_builtin_member_call and not php_builtin_self_call) or _lua_member_defer or is_scoped_call or ( is_member_call and member_receiver and ( member_receiver[:1].isupper() or is_this_field_call - or _csharp_defer ) ): tgt_nid = None diff --git a/tests/test_csharp_member_chain_calls.py b/tests/test_csharp_member_chain_calls.py index e1c8c1e87..cde048e14 100644 --- a/tests/test_csharp_member_chain_calls.py +++ b/tests/test_csharp_member_chain_calls.py @@ -238,3 +238,45 @@ def test_element_of_nested_list_stays_unresolved(tmp_path): ), }) assert not [c for c in calls if "grid" in c[0]] + + +# A member call whose receiver is not a simple identifier, `this` or `base` +# (`run.Sequence.Apply()`, `Make().Apply()`, `s.Items.Count()`) captured no +# receiver name. With no receiver to defer on, the call fell back to the +# file's bare-name map and bound to the caller's own same-named method — a +# false self-loop or a wrong sibling edge, written as EXTRACTED. + + +def test_member_chain_never_binds_to_the_callers_same_named_method(tmp_path): + calls, r = _calls(tmp_path, { + "Model.cs": _MODEL, + "Controller.cs": _controller("run.Sequence.Apply();", "public void Apply() { }"), + }) + tick = _find(r, ".Tick()", "controller") + assert (tick, _find(r, ".Apply()", "controller")) not in calls + assert (tick, _find(r, ".Apply()", "sequence")) in calls + + +def test_chained_receiver_in_one_file_gives_no_self_loop(tmp_path): + calls, r = _calls(tmp_path, {"Crowd.cs": ( + "namespace Demo\n{\n" + " public class Crowd\n {\n" + " public void LateUpdate() { Pick().look.LateUpdate(); }\n" + " object Pick() { return null; }\n" + " }\n}\n" + )}) + late = _find(r, ".LateUpdate()", "crowd") + assert (late, late) not in calls + + +def test_untyped_chain_never_binds_to_a_same_file_method(tmp_path): + calls, r = _calls(tmp_path, {"Inventory.cs": ( + "using System.Collections.Generic;\nnamespace Demo\n{\n" + " public class State { public List Items; }\n" + " public class Inventory\n {\n" + " public int Count() { return 0; }\n" + " public int Owned(State s) { return s.Items.Count(); }\n" + " }\n}\n" + )}) + owned = _find(r, ".Owned()", "inventory") + assert (owned, _find(r, ".Count()", "inventory")) not in calls