From 3e8ec339944e410ee756509b1b5fd38eecf834f8 Mon Sep 17 00:00:00 2001 From: brobl2008 <161651560+brobl2008@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:30:16 +0100 Subject: [PATCH] fix(csharp): walk primary-constructor parameters for references and calls (#2829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A C# 12 primary constructor puts its parameters on the class/record declaration itself, which the extractor never walked: class Holder(IDep dep) emitted no references edge Holder->IDep, and because dep was never registered in the class receiver table, a dep.Method() call inside the class was dropped too. Scan the declaration's parameter_list, register each param name->type, and emit the param type reference — mirroring the field/property handlers. Built-in and type-parameter types are not fabricated. Co-Authored-By: Claude Opus 4.8 (1M context) --- 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 8359d38c..8af0e416 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3500,6 +3500,59 @@ def _extract_generic( 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 175d03fb..263f2311 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"