mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-26 16:26:42 +00:00
fix(csharp): walk generic type arguments at call sites (#2911)
A type used as a generic argument at a call site (repo.Get<User>(), services.AddSingleton<IFoo, Foo>()) did not emit a references edge, so DI registrations and generic calls dropped their type dependencies. Walk the invocation's type-argument list (member and static forms, nested args included); builtins and in-scope type parameters are not fabricated.
This commit is contained in:
committed by
safishamsi
parent
09c985c553
commit
a4cb89083c
@@ -5101,6 +5101,60 @@ def _extract_generic(
|
||||
else:
|
||||
callee_name = raw
|
||||
break
|
||||
# C#: emit a `references[generic_arg]` edge for every type
|
||||
# argument at the call site (`recv.Do<T>()`, the
|
||||
# `services.AddScoped<ISvc, Impl>()` DI shape, static
|
||||
# `Foo<IBar>()`). 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":
|
||||
|
||||
@@ -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<T>()``, ``services.AddScoped<ISvc, Impl>()``, 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<T> { }\n"
|
||||
"public static class StaticHolder\n"
|
||||
"{\n"
|
||||
" public static void Invoke<T>() { }\n"
|
||||
" public static void Register<T, U>() { }\n"
|
||||
"}\n"
|
||||
"public class Registry { public void Do<T>() { } }\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<IThing>(); }\n",
|
||||
})
|
||||
assert (".A()", "IThing") in refs, (
|
||||
"member call `recv.Do<T>()` 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<TService, TImpl>(this IServiceCollection s) { }\n"
|
||||
"}\n"
|
||||
"public class Probe\n"
|
||||
"{\n"
|
||||
" public void A(IServiceCollection s) => s.AddScoped<IService, IImpl>();\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<Box<IThing>>(); }\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<T>\n"
|
||||
"{\n"
|
||||
" public void Use(Registry r) => r.Do<T>();\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<T> { }\n"
|
||||
"public class Registry { public void Do<T>() { } }\n"
|
||||
"public interface IServiceCollection { }\n"
|
||||
"public static class Ext\n"
|
||||
"{\n"
|
||||
" public static void AddScoped<TService, TImpl>(this IServiceCollection s) { }\n"
|
||||
"}\n"
|
||||
),
|
||||
"Probe.cs": (
|
||||
"public class Probe\n"
|
||||
"{\n"
|
||||
" private Box<IAlpha> _field = null!;\n"
|
||||
" public Box<IBeta> Prop { get; set; } = null!;\n"
|
||||
" public Box<IGamma> Ret() => null!;\n"
|
||||
" public void Param(Box<IDelta> p) { }\n"
|
||||
" public void Call(Registry r) => r.Do<IEpsilon>();\n"
|
||||
" public void Di(IServiceCollection s) => s.AddScoped<IZeta, Box<IZeta>>();\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<IEpsilon>() must link IEpsilon from the Call method"
|
||||
)
|
||||
# The DI registration has two type arguments: IZeta and Box<IZeta>.
|
||||
# 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<T> { }\n"
|
||||
"public class Registry { public void Do<T>() { } }\n"
|
||||
"public interface IServiceCollection { }\n"
|
||||
"public static class Ext\n"
|
||||
"{\n"
|
||||
" public static void AddScoped<TService, TImpl>(this IServiceCollection s) { }\n"
|
||||
"}\n"
|
||||
),
|
||||
"Di.cs": (
|
||||
"public class DiHost\n"
|
||||
"{\n"
|
||||
" public void Di(IServiceCollection s) => s.AddScoped<IZeta, Box<IZeta>>();\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<IZeta, Box<IZeta>>() must link BOTH the outer IZeta "
|
||||
f"and the inner IZeta (inside the Box<...> argument); got {izeta_refs!r}"
|
||||
)
|
||||
Reference in New Issue
Block a user