Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<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":
Expand Down
219 changes: 219 additions & 0 deletions tests/test_csharp_call_site_generic_args.py
Original file line number Diff line number Diff line change
@@ -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}"
)