From c097479b8552a01d37a4577bc9972ab4ccf9a142 Mon Sep 17 00:00:00 2001 From: Brian Robl <161651560+brobl2008@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:59:14 -0500 Subject: [PATCH] fix(csharp): walk primary-constructor parameters for references and calls A primary constructor (`class Foo(IBar bar)`, C# 12+) declares its dependencies on the type declaration itself rather than in a field or a property, so neither the field_declaration nor the property_declaration handler ever saw them. The parameter type got no references edge, and because the name was never registered in csharp_field_types, _csharp_method_receiver_types could not type the receiver, so calls made through it (`bar.Baz()`) lost their calls edge as well. Add a class_declaration / record_declaration / struct_declaration branch that walks the parameter_list, registers name -> type for the receiver tables, and emits the references edge - mirroring the Scala class_parameters branch directly above it; Kotlin's equivalent is #2063. Grammar note: the list is an unnamed child of the declaration, so child_by_field_name("parameters") returns None and the children must be scanned. Pascal-case-only and type-parameter guards match the existing field_declaration rule, so `T item` yields no phantom node. Fixes #2829 Co-Authored-By: Claude Opus 5 --- graphify/extractors/engine.py | 53 +++++++++++++++++++++++ tests/test_csharp_member_calls.py | 72 +++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 61b5995f28..a6b7783c67 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3441,6 +3441,59 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: add_edge(class_nid, target_nid, "references", cp_line, context=ctx) + # C#: a primary constructor (`class Foo(IBar bar)`, C# 12+) declares + # its dependencies on the type declaration itself rather than in a + # field or property, so neither the field_declaration nor the + # property_declaration handler ever sees them — the parameter type + # got no references edge, and because the name was never registered + # in csharp_field_types, _csharp_method_receiver_types could not type + # the receiver either, so calls through it (`bar.Baz()`) lost their + # calls edge as well. The Scala class_parameters branch directly + # above is the analogue; Kotlin's is #2063. Grammar note: the list is + # an UNNAMED child of the declaration, so child_by_field_name( + # "parameters") returns None and the children must be scanned. + if config.ts_module == "tree_sitter_c_sharp" and t in ( + "class_declaration", + "record_declaration", + "struct_declaration", + ): + csharp_type_params = _csharp_type_parameters_in_scope(node, source) + for c in node.children: + if c.type != "parameter_list": + continue + for param in c.children: + if param.type != "parameter": + continue + ptype = param.child_by_field_name("type") + if ptype is None: + continue + pname = param.child_by_field_name("name") + p_line = param.start_point[0] + 1 + # Receiver binding mirrors the field_declaration rule: + # Pascal-case only (a primitive owns no resolvable + # method) and never a bare type parameter (`T item`). + recv = _csharp_receiver_type_name(ptype, source) + if (pname is not None and recv and recv[:1].isupper() + and recv not in csharp_type_params): + csharp_field_types.setdefault(class_nid, {})[ + _read_text(pname, source) + ] = recv + refs = [] + _csharp_collect_type_refs( + ptype, source, False, refs, csharp_type_params + ) + for ref_name, role, qualified, qualifier in refs: + ctx = "generic_arg" if role == "generic_arg" else "field" + target_nid = ensure_named_node(ref_name, p_line) + if target_nid != class_nid: + metadata = {"ref_token": ref_name} + if qualified: + metadata["qualified"] = True + if qualifier: + metadata["ref_qualifier"] = qualifier + add_edge(class_nid, target_nid, "references", + p_line, context=ctx, metadata=metadata) + # C++-specific: inheritance via base_class_clause (class and struct). # tree-sitter-cpp shape: # class_specifier / struct_specifier diff --git a/tests/test_csharp_member_calls.py b/tests/test_csharp_member_calls.py index 175d03fbce..263f231166 100644 --- a/tests/test_csharp_member_calls.py +++ b/tests/test_csharp_member_calls.py @@ -668,3 +668,75 @@ 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" + + +def _refs(r): + return {(e["source"], e["target"]) for e in r["edges"] + if e["relation"] == "references"} + + +_PRIMARY_CTOR = { + "S.cs": ( + "public interface IDep { bool Plain(); }\n" + "public class Holder(IDep dep) {\n" + " public bool Run() { return dep.Plain(); }\n" + "}\n" + ) +} + + +def test_primary_constructor_parameter_emits_references_edge(tmp_path): + """`class Holder(IDep dep)` declares its dependency on the type declaration + rather than in a field or property, so neither the field_declaration nor the + property_declaration handler sees it — the type still needs a references edge.""" + _, r = _calls(tmp_path, _PRIMARY_CTOR) + holder = _find(r, "Holder", "holder") + idep = _find(r, "IDep", "idep") + assert (holder, idep) in _refs(r), \ + "a primary-constructor parameter type must produce a references edge" + + +def test_primary_constructor_parameter_resolves_member_calls(tmp_path): + """A call through a primary-constructor parameter must resolve to the + parameter's declared type, exactly as a field receiver does.""" + calls, r = _calls(tmp_path, _PRIMARY_CTOR) + run = _find(r, ".Run()", "holder") + plain = _find(r, ".Plain()", "idep") + assert (run, plain) in calls, \ + "dep.Plain() must resolve through the primary-constructor parameter" + + +def test_record_positional_parameter_emits_references_edge(tmp_path): + """Positional record parameters use the same parameter_list shape.""" + _, r = _calls(tmp_path, { + "S.cs": ( + "public interface IDep { bool Plain(); }\n" + "public record Holder(IDep Dep) {\n" + " public bool Run() { return Dep.Plain(); }\n" + "}\n" + ) + }) + holder = _find(r, "Holder", "holder") + idep = _find(r, "IDep", "idep") + assert (holder, idep) in _refs(r), \ + "a positional record parameter type must produce a references edge" + + +def test_primary_constructor_type_parameter_is_not_referenced(tmp_path): + """A bare type parameter (`T item`) names no real type — it must not be + emitted as a phantom referenced node.""" + _, r = _calls(tmp_path, { + "S.cs": ( + "public interface IDep { bool Plain(); }\n" + "public class Holder(IDep dep, T item) {\n" + " public bool Run() { return dep.Plain(); }\n" + "}\n" + ) + }) + holder = _find(r, "Holder", "holder") + idep = _find(r, "IDep", "idep") + refs = _refs(r) + assert (holder, idep) in refs, "the real dependency must still be referenced" + labels = {n["id"]: n["label"] for n in r["nodes"]} + assert not [t for s, t in refs if s == holder and labels.get(t) == "T"], \ + "a type parameter must not be emitted as a referenced type"