From 8750f61e75b5ca573184b94fa4643fd2b338f367 Mon Sep 17 00:00:00 2001 From: rohit-jsfreaky Date: Wed, 12 Aug 2026 19:58:37 +0530 Subject: [PATCH] fix(csharp): strip call-site type arguments from generic calls (#2624) --- graphify/extractors/engine.py | 50 ++++++++++++++-- tests/test_csharp_member_calls.py | 98 +++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 5 deletions(-) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 2b3433cfc..92e184aea 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -2371,6 +2371,37 @@ def _has_multiline_error(root) -> bool: return False +def _csharp_name_without_type_args(node, source: bytes) -> str | None: + """The bare name of a C# call-site name node, with any type-argument list off. + + A call site may spell explicit type arguments -- ``Registry.Fetch(...)``, + ``Bag.Make(...)``, ``Local(...)`` -- and tree-sitter wraps those + in a ``generic_name``. Declarations store the bare name (``.Fetch()``, ``Bag``), + so reading the node verbatim yields ``Fetch``, which never matches its + declaration and silently drops the ``calls`` edge (#2624). Type inference at the + call site was unaffected, which is why only the explicit form broke. + + The grammar exposes no ``name`` field on ``generic_name``, so fall back to the + first ``identifier`` child -- the same order ``_csharp_collect_type_refs`` uses. + """ + if node is None: + return None + if node.type != "generic_name": + return _read_text(node, source) + name_child = node.child_by_field_name("name") + if name_child is None: + for sub in node.children: + if sub.type == "identifier": + name_child = sub + break + if name_child is not None: + return _read_text(name_child, source) + # A qualified generic (`Demo.Bag`) has no bare `identifier` child. Keep + # the text and drop only the type-argument list, so such a call is still recorded + # under the name it had before rather than disappearing. + return _read_text(node, source).split("<", 1)[0] or None + + def _read_csharp_type_name(node, source: bytes) -> tuple[str, bool, str] | None: """Resolve a C# type name, whether it was qualified, and its qualifier prefix.""" if node is None: @@ -4647,10 +4678,16 @@ def walk_calls( mname = fn_node.child_by_field_name("name") recv = fn_node.child_by_field_name("expression") if mname is not None: - callee_name = _read_text(mname, source) + # `name` is a generic_name when the call site spells explicit + # type arguments (`recv.Read()`); the declaration is + # stored bare, so strip the type-argument list (#2624). + callee_name = _csharp_name_without_type_args(mname, source) is_member_call = True - if recv is not None and recv.type == "identifier": - member_receiver = _read_text(recv, source) + if recv is not None and recv.type in ("identifier", "generic_name"): + # generic_name: a constructed generic type as the receiver + # (`Bag.Make()`). Type nodes are stored under the + # bare name, so this binds like any `Type.M()` (#2624). + member_receiver = _csharp_name_without_type_args(recv, source) elif recv is not None and recv.type in ("this", "this_expression"): member_receiver = "this" elif recv is not None and recv.type in ("base", "base_expression"): @@ -4671,8 +4708,11 @@ def walk_calls( and fname.type == "identifier" ): member_receiver = _read_text(fname, source) - 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 in ("identifier", "generic_name"): + # generic_name: an unqualified call carrying explicit type + # arguments (`Local()`), which otherwise fell through to + # the raw-text fallback below and kept the `<...>` (#2624). + callee_name = _csharp_name_without_type_args(fn_node, source) else: # Fallback: original name-field / first-named-child scan. name_node = node.child_by_field_name("name") diff --git a/tests/test_csharp_member_calls.py b/tests/test_csharp_member_calls.py index 175d03fbc..ac644651e 100644 --- a/tests/test_csharp_member_calls.py +++ b/tests/test_csharp_member_calls.py @@ -668,3 +668,101 @@ def test_sibling_pattern_rebind_conflict_poisons(tmp_path): twig_go = _find(r, ".Go()", "twig") assert (r_a, sect_go) not in calls, "conflicting pattern bindings must poison the name" assert (r_a, twig_go) not in calls, "conflicting pattern bindings must poison the name" + + +# --- #2624: explicit type arguments at the call site ------------------------- +# `X.M(...)` spells a type-argument list, which tree-sitter wraps in a +# `generic_name`. Declarations are stored bare (`.Fetch()`, `Bag`), so reading the +# node verbatim gave `Fetch` and the lookup never matched — the edge +# silently vanished. Type inference at the call site (`Registry.Pick(item)`) was +# unaffected, which is why only the explicit form broke. + +_GENERIC = ( + "public class Payload { public int Size; }\n" + "public static class Registry {\n" + " public static bool Fetch(string key) { return true; }\n" + " public static bool Has(string key) { return true; }\n" + "}\n" + "public class Box { public bool Read(string key) { return true; } }\n" + "public static class Bag { public static bool Make(string key) { return true; } }\n" +) + + +def test_explicit_type_argument_on_type_receiver_call(tmp_path): + """`Type.M()` binds like `Type.M()` — type arguments are not part of the + method name, and the non-generic control must keep working (#2624).""" + calls, r = _calls(tmp_path, {"S.cs": _GENERIC + ( + "public class Consumer {\n" + " public bool A() { return Registry.Fetch(\"k\"); }\n" + " public bool C() { return Registry.Has(\"k\"); }\n" + "}\n" + )}) + a, c = _find(r, ".A()", "consumer"), _find(r, ".C()", "consumer") + assert (a, _find(r, ".Fetch()", "registry")) in calls, \ + "explicit type arguments must not drop the calls edge" + assert (c, _find(r, ".Has()", "registry")) in calls, "control: non-generic call" + + +def test_explicit_type_argument_on_typed_local_receiver(tmp_path): + """A receiver typed through the method-scoped table still resolves when the + call carries explicit type arguments (#2624).""" + calls, r = _calls(tmp_path, {"S.cs": _GENERIC + ( + "public class Consumer {\n" + " public bool B() { Box box = new Box(); return box.Read(\"k\"); }\n" + "}\n" + )}) + b = _find(r, ".B()", "consumer") + assert (b, _find(r, ".Read()", "box")) in calls + + +def test_constructed_generic_type_as_receiver(tmp_path): + """`Bag.Make()` — the RECEIVER carries the type arguments. Type nodes + are stored under the bare name, so it must bind like any `Type.M()` (#2624). + + Split across two files on purpose: in a single file the callee resolves by a + plain in-file label match and never reaches receiver typing, so a same-file + version of this test passes even with the receiver dropped. + """ + calls, r = _calls(tmp_path, { + "Lib.cs": _GENERIC, + "Consumer.cs": ( + "public class Consumer {\n" + " public bool E() { return Bag.Make(\"k\"); }\n" + "}\n" + ), + }) + e = _find(r, ".E()", "consumer") + assert (e, _find(r, ".Make()", "bag")) in calls + + +def test_unqualified_generic_call_and_this_receiver(tmp_path): + """`Local()` and `this.Local()` — the same defect reached calls with no + receiver at all, which fell through to the raw-text scan and kept the `<...>`.""" + calls, r = _calls(tmp_path, {"S.cs": _GENERIC + ( + "public class Consumer {\n" + " public bool Local(string k) { return true; }\n" + " public bool F() { return Local(\"k\"); }\n" + " public bool H() { return this.Local(\"k\"); }\n" + "}\n" + )}) + local = _find(r, ".Local()", "consumer") + assert (_find(r, ".F()", "consumer"), local) in calls + assert (_find(r, ".H()", "consumer"), local) in calls + + +def test_type_arguments_do_not_bypass_receiver_typing(tmp_path): + """Stripping the type-argument list must not weaken resolution: a generic call + on a typed field still binds to that field's type, never a same-named method on + an unrelated class (the #1609 guard, now exercised through a generic call).""" + calls, r = _calls(tmp_path, {"S.cs": ( + "public class Server { public bool Save() => true; }\n" + "public class Cache { public bool Save() => false; }\n" + "public class Repo {\n" + " private Server _server = new Server();\n" + " public bool Commit() { return _server.Save(); }\n" + "}\n" + )}) + commit = _find(r, ".Commit()", "commit") + assert (commit, _find(r, ".Save()", "server")) in calls + assert (commit, _find(r, ".Save()", "cache")) not in calls, \ + "a generic call must not mis-bind to an unrelated same-named method"