diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index ab6ed0c90..b8dc00c92 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -5085,6 +5085,60 @@ def walk_calls( else: callee_name = raw break + # C#: emit a `references[generic_arg]` edge for every type + # argument at the call site (`recv.Do()`, the + # `services.AddScoped()` DI shape, static + # `Foo()`). The property/return/parameter branches + # already walk their declared type for the same reason; the + # call-site branch didn't, so the type arguments never + # became nodes and dependency edges were silently erased + # (#2911). The C# class_declaration's field_declaration and + # property_declaration branches above are the direct + # analogue. The call-site function carries its type-arg list + # either as a `type_argument_list` child on a `generic_name` + # (static call) or as the same child on the + # `member_access_expression`'s `name` `generic_name` (member + # call); the fallback path uses raw text and never sees the + # structured type-arg list. The class declaration's + # field_declaration case is closed by the parallel fix in + # #2913; this branch covers what that PR deliberately left + # out. + if fn_node is not None: + call_tal = None + if fn_node.type == "member_access_expression": + ma_name = fn_node.child_by_field_name("name") + if ma_name is not None and ma_name.type == "generic_name": + for tal_child in ma_name.children: + if tal_child.type == "type_argument_list": + call_tal = tal_child + break + elif fn_node.type == "generic_name": + for tal_child in fn_node.children: + if tal_child.type == "type_argument_list": + call_tal = tal_child + break + if call_tal is not None: + call_type_params = _csharp_type_parameters_in_scope(node, source) + call_line = node.start_point[0] + 1 + for call_arg in call_tal.children: + if not call_arg.is_named: + continue + call_refs: list[tuple[str, str, bool, str]] = [] + _csharp_collect_type_refs( + call_arg, source, True, call_refs, call_type_params + ) + for call_ref_name, _call_role, call_qualified, call_qualifier in call_refs: + call_target = ensure_named_node(call_ref_name, call_line) + if call_target == caller_nid: + continue + call_meta = {"ref_token": call_ref_name} + if call_qualified: + call_meta["qualified"] = True + if call_qualifier: + call_meta["ref_qualifier"] = call_qualifier + add_edge(caller_nid, call_target, "references", + call_line, context="generic_arg", + metadata=call_meta) elif config.ts_module == "tree_sitter_php": # PHP: distinguish call expression subtypes if node.type == "function_call_expression": diff --git a/tests/test_csharp_call_site_generic_args.py b/tests/test_csharp_call_site_generic_args.py new file mode 100644 index 000000000..670f44711 --- /dev/null +++ b/tests/test_csharp_call_site_generic_args.py @@ -0,0 +1,219 @@ +"""C# generic type arguments at CALL SITES. + +Properties, returns, and parameters already walk the full type expression and +emit ``references[generic_arg]`` edges for every type argument. The C# +``invocation_expression`` handler did not -- the type-argument list on a call +site (``recv.Do()``, ``services.AddScoped()``, the +``Microsoft.Extensions.DependencyInjection`` shape) was dropped, so the +generic arguments never became nodes. This erased dependency edges silently +(``affected`` returns a smaller, confident answer rather than an error). + +PR #2913 closed the FIELD-position gap (the parallel ``field_declaration`` +handler); this test covers the call-site gap that PR #2913 deliberately left +for a follow-up. Each case asserts the edge exists (no count) -- absence is +the bug; counts are an implementation detail. +""" +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" + } + + +def _all_refs(tmp_path, files: dict[str, str]) -> list[tuple[str, str, str | None]]: + """Extract, returning [(source_label, target_label, context)] for every + `references` edge, preserving duplicates (so two IZeta references in the + same call site are visible). + """ + 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"], ""), e.get("context")) + for e in r["edges"] + if e["relation"] == "references" + ] + + +_TYPES = ( + "public interface IThing { }\n" + "public interface IService { }\n" + "public interface IImpl { }\n" + "public class Box { }\n" + "public static class StaticHolder\n" + "{\n" + " public static void Invoke() { }\n" + " public static void Register() { }\n" + "}\n" + "public class Registry { public void Do() { } }\n" +) + + +def test_member_call_with_one_type_argument(tmp_path): + refs = _refs(tmp_path, { + "T.cs": _TYPES, + "P.cs": "public class Probe { public void A(Registry r) => r.Do(); }\n", + }) + assert (".A()", "IThing") in refs, ( + "member call `recv.Do()` must emit a generic_arg reference to T" + ) + + +def test_member_call_with_multiple_type_arguments(tmp_path): + """The Microsoft.Extensions.DependencyInjection shape that the issue calls out.""" + refs = _refs(tmp_path, { + "T.cs": _TYPES, + "P.cs": ( + "public interface IServiceCollection { }\n" + "public static class Ext\n" + "{\n" + " public static void AddScoped(this IServiceCollection s) { }\n" + "}\n" + "public class Probe\n" + "{\n" + " public void A(IServiceCollection s) => s.AddScoped();\n" + "}\n" + ), + }) + assert (".A()", "IService") in refs, ( + "two-arg call must emit a generic_arg reference to the first type argument" + ) + assert (".A()", "IImpl") in refs, ( + "two-arg call must emit a generic_arg reference to the second type argument" + ) + + +def test_nested_type_argument_in_call_site(tmp_path): + refs = _refs(tmp_path, { + "T.cs": _TYPES, + "P.cs": "public class Probe { public void A(Registry r) => r.Do>(); }\n", + }) + assert (".A()", "IThing") in refs, ( + "innermost generic argument in a call site must produce a generic_arg reference" + ) + + +def test_call_without_type_argument_is_unchanged(tmp_path): + """A plain call site (no explicit type args) must not regress.""" + refs = _refs(tmp_path, { + "T.cs": _TYPES, + "P.cs": "public class Probe { public void A(Registry r) => r.Do(); }\n", + }) + # No IThing reference should appear from a type-arg-less call. + assert not any(src == ".A()" and tgt == "IThing" for src, tgt in refs), ( + "call without type arguments must not invent generic_arg references" + ) + + +def test_type_parameter_in_call_site_arg_is_not_fabricated(tmp_path): + refs = _refs(tmp_path, { + "T.cs": _TYPES, + "P.cs": ( + "public class Holder\n" + "{\n" + " public void Use(Registry r) => r.Do();\n" + "}\n" + ), + }) + refs_with_t_target = {(s, t) for s, t in refs if t == "T"} + assert not refs_with_t_target, ( + "a type parameter (T) used as a call-site type argument must not become a node" + ) + + +def test_call_site_generic_args_appear_in_issue_repro(tmp_path): + """End-to-end: the exact two-file repro from #2911 produces all six edges.""" + refs = _refs(tmp_path, { + "Types.cs": ( + "public interface IAlpha { }\n" + "public interface IBeta { }\n" + "public interface IGamma { }\n" + "public interface IDelta { }\n" + "public interface IEpsilon { }\n" + "public interface IZeta { }\n" + "public class Box { }\n" + "public class Registry { public void Do() { } }\n" + "public interface IServiceCollection { }\n" + "public static class Ext\n" + "{\n" + " public static void AddScoped(this IServiceCollection s) { }\n" + "}\n" + ), + "Probe.cs": ( + "public class Probe\n" + "{\n" + " private Box _field = null!;\n" + " public Box Prop { get; set; } = null!;\n" + " public Box Ret() => null!;\n" + " public void Param(Box p) { }\n" + " public void Call(Registry r) => r.Do();\n" + " public void Di(IServiceCollection s) => s.AddScoped>();\n" + "}\n" + ), + }) + # Call-site positions (5 and 6) -- the field position is covered by the + # parallel PR #2913, so we focus on what THIS fix is responsible for. + assert (".Call()", "IEpsilon") in refs, ( + "r.Do() must link IEpsilon from the Call method" + ) + # The DI registration has two type arguments: IZeta and Box. + # Both the outer IZeta and the inner IZeta (inside Box<...>) must link. + # Use _all_refs so duplicate (source, target) edges are visible -- + # deduplication is an implementation detail; the bug is a MISSING edge. + all_di_refs = _all_refs(tmp_path, { + "Types.cs": ( + "public interface IAlpha { }\n" + "public interface IBeta { }\n" + "public interface IGamma { }\n" + "public interface IDelta { }\n" + "public interface IEpsilon { }\n" + "public interface IZeta { }\n" + "public class Box { }\n" + "public class Registry { public void Do() { } }\n" + "public interface IServiceCollection { }\n" + "public static class Ext\n" + "{\n" + " public static void AddScoped(this IServiceCollection s) { }\n" + "}\n" + ), + "Di.cs": ( + "public class DiHost\n" + "{\n" + " public void Di(IServiceCollection s) => s.AddScoped>();\n" + "}\n" + ), + }) + izeta_refs = [tgt for src, tgt, _ctx in all_di_refs if src == ".Di()" and tgt == "IZeta"] + assert len(izeta_refs) >= 2, ( + "s.AddScoped>() must link BOTH the outer IZeta " + f"and the inner IZeta (inside the Box<...> argument); got {izeta_refs!r}" + )