From b6b5ccf5dce82db79d243ed65230e0a083e3e7cc Mon Sep 17 00:00:00 2001 From: Brian Robl <161651560+brobl2008@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:41:34 -0500 Subject: [PATCH] fix(csharp): walk generic type arguments in field position The field_declaration handler read only the outer type name via _read_csharp_type_name and emitted a single references edge, so the inner argument of `Box` was never linked. Properties, return types and parameters already walk the whole type expression through _csharp_collect_type_refs, which left fields the odd one out -- the property handler's own comment even points at the Java/PHP/Kotlin siblings it was mirroring, and the tree_sitter_java field_declaration handler directly below does the same thing correctly. Two very common shapes lose their edge as a result: a stored dependency such as `IDbContextFactory` loses SomeContext, and `Mock` loses IThing across an entire test suite. Classic constructor injection stores its dependency in a field, so this is the same blind spot #2829 removed from primary constructors, just moved to where the dependency is kept. The loss is silent: `affected` returns a smaller, confident answer rather than an error. The fix routes the field handler through _csharp_collect_type_refs with the in-scope type parameters as the skip set, mirroring the property handler exactly. The outer type still emits context="field"; arguments emit context="generic_arg". This also stops fields fabricating nodes for predefined types -- `private string _s` previously created a `string` node, because _read_csharp_type_name returns builtins while _csharp_collect_type_refs returns early on predefined_type. That matches the builtin-not-fabricated behaviour added for primary constructors in 1eb356cd. Eight regression tests cover both directions: the generic argument is linked, a field and a property of the same type now agree, nested arguments resolve, and type parameters and builtins are still never fabricated. Five of the eight fail on unpatched HEAD. Full suite: 30 failures before, 25 after, and a set-difference of the failing test ids shows nothing newly broken -- the delta is exactly the five new tests. test_labeling's batching test is flaky independently of this change (it fails intermittently on unpatched HEAD too). Scoped deliberately to field position. The call-site half of #2911 (`services.AddScoped()`) lives in the call-resolution path and overlaps #2676, so it is better as its own change. Refs #2911. --- graphify/extractors/engine.py | 30 ++++-- tests/test_csharp_field_generic_args.py | 121 ++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 7 deletions(-) create mode 100644 tests/test_csharp_field_generic_args.py diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index ab6ed0c90..257d05972 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3714,13 +3714,29 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: if name_node is not None: fields[_read_text(name_node, source)] = type_name line = node.start_point[0] + 1 - metadata = {"ref_token": type_name} - if qualified: - metadata["qualified"] = True - if qualifier: - metadata["ref_qualifier"] = qualifier - add_edge(parent_class_nid, ensure_named_node(type_name, line), - "references", line, context="field", metadata=metadata) + # Walk the whole type expression rather than only its outer name, so + # `Box` yields the Box field ref AND the Widget generic_arg ref. + # Reading just the outer name left every generic argument in field + # position unlinked -- `IDbContextFactory` lost SomeContext, + # and `Mock` lost IThing across entire test suites. The C# + # property_declaration handler below and the tree_sitter_java + # field_declaration handler beside it already do exactly this; C# fields + # were the odd one out. + refs: list[tuple[str, str, bool, str]] = [] + _csharp_collect_type_refs( + type_node, source, False, refs, csharp_type_params + ) + for ref_name, role, ref_qualified, ref_qualifier in refs: + ctx = "generic_arg" if role == "generic_arg" else "field" + target_nid = ensure_named_node(ref_name, line) + if target_nid != parent_class_nid: + metadata = {"ref_token": ref_name} + if ref_qualified: + metadata["qualified"] = True + if ref_qualifier: + metadata["ref_qualifier"] = ref_qualifier + add_edge(parent_class_nid, target_nid, "references", + line, context=ctx, metadata=metadata) return if (config.ts_module == "tree_sitter_c_sharp" diff --git a/tests/test_csharp_field_generic_args.py b/tests/test_csharp_field_generic_args.py new file mode 100644 index 000000000..6cac3bba5 --- /dev/null +++ b/tests/test_csharp_field_generic_args.py @@ -0,0 +1,121 @@ +"""C# generic type arguments in FIELD position. + +The field_declaration handler read only the outer type name, so the inner argument of +`Box` produced no edge. Properties, return types and parameters already walked +the full type expression via _csharp_collect_type_refs, which made fields the odd one +out for two very common shapes: `IDbContextFactory` lost SomeContext, and +`Mock` lost IThing across whole test suites. + +Missing edges here are silent -- `affected` returns a smaller, confident answer rather +than an error -- so each case asserts the edge exists rather than asserting a count. +""" +from __future__ import annotations + +import os +from pathlib import Path + +from graphify.extract import extract + + +def _refs(tmp_path, files: dict[str, str]) -> set[tuple[str, str]]: + """Extract, returning {(source_label, target_label)} for `references` edges.""" + for name, body in files.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body) + old = os.getcwd() + try: + os.chdir(tmp_path) + r = extract([Path(n) for n in files], cache_root=tmp_path / ".cache") + finally: + os.chdir(old) + labels = {n["id"]: n.get("label", "") for n in r["nodes"]} + return { + (labels.get(e["source"], ""), labels.get(e["target"], "")) + for e in r["edges"] + if e["relation"] == "references" + } + + +_TYPES = ( + "public interface IThing { }\n" + "public class Box { }\n" + "public class Outer { public class Inner { } }\n" +) + + +def test_field_generic_argument_produces_edge(tmp_path): + refs = _refs(tmp_path, { + "T.cs": _TYPES, + "P.cs": "public class Probe { private Box _f = null!; }\n", + }) + assert ("Probe", "Box") in refs, "outer field type must still be linked" + assert ("Probe", "IThing") in refs, "generic argument in field position must be linked" + + +def test_field_matches_property_behaviour(tmp_path): + """A field and a property of the same type must produce the same references.""" + refs = _refs(tmp_path, { + "T.cs": _TYPES, + "P.cs": ( + "public class WithField { private Box _f = null!; }\n" + "public class WithProp { public Box P { get; set; } = null!; }\n" + ), + }) + field_targets = {t for s, t in refs if s == "WithField"} + prop_targets = {t for s, t in refs if s == "WithProp"} + assert field_targets == prop_targets, ( + f"field and property disagree: field={field_targets} property={prop_targets}" + ) + + +def test_nested_generic_arguments(tmp_path): + refs = _refs(tmp_path, { + "T.cs": _TYPES, + "P.cs": "public class Probe { private Box> _f = null!; }\n", + }) + assert ("Probe", "IThing") in refs, "innermost generic argument must be linked" + + +def test_multiple_declarators_share_the_type(tmp_path): + refs = _refs(tmp_path, { + "T.cs": _TYPES, + "P.cs": "public class Probe { private Box _a = null!, _b = null!; }\n", + }) + assert ("Probe", "IThing") in refs + + +def test_bare_type_parameter_is_not_fabricated(tmp_path): + """`T item` must not create a node for the type parameter itself.""" + refs = _refs(tmp_path, { + "P.cs": "public class Probe { private T _item = default!; }\n", + }) + assert not any(t == "T" for _, t in refs), "type parameter must not become a node" + + +def test_type_parameter_as_generic_argument_is_not_fabricated(tmp_path): + refs = _refs(tmp_path, { + "T.cs": _TYPES, + "P.cs": "public class Probe { private Box _f = null!; }\n", + }) + assert ("Probe", "Box") in refs, "outer type is still a real reference" + assert not any(t == "T" for _, t in refs), "type parameter must not become a node" + + +def test_predefined_types_are_not_fabricated(tmp_path): + refs = _refs(tmp_path, { + "T.cs": _TYPES, + "P.cs": "public class Probe { private Box _f = null!; private string _s = \"\"; }\n", + }) + assert not any(t in {"int", "string"} for _, t in refs), ( + "builtin types must not become nodes" + ) + + +def test_plain_field_still_links(tmp_path): + """The non-generic path must be unchanged.""" + refs = _refs(tmp_path, { + "T.cs": _TYPES, + "P.cs": "public class Probe { private IThing _f = null!; }\n", + }) + assert ("Probe", "IThing") in refs