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
147 changes: 93 additions & 54 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,74 @@ def _csharp_collect_type_refs(
if c.is_named:
_csharp_collect_type_refs(c, source, generic, out, skip)


def _csharp_call_type_argument_list(fn_node):
"""The type-argument list of a C# call target, or None.

A static call carries it on a `generic_name`; a member call carries it on the
`member_access_expression`'s `name`. Deliberately not a subtree search: the receiver of
`outer<A>.Inner<B>()` is inside this node too, and its arguments belong to the receiver.
"""
if fn_node is None:
return None
if fn_node.type == "member_access_expression":
fn_node = fn_node.child_by_field_name("name")
if fn_node is not None and fn_node.type == "generic_name":
for child in fn_node.children:
if child.type == "type_argument_list":
return child
return None


def _csharp_constructed_type_argument_list(type_node):
"""The type-argument list of a `new T<...>()` constructed type, or None.

`new A.B.Wrapper<Widget>()` nests the `generic_name` inside a `qualified_name`, so this
descends rather than checking immediate children. Bounding the search to the `type` field
is what keeps constructor arguments out -- they are a sibling field, never inside the type.
"""
if type_node is None:
return None
if type_node.type == "type_argument_list":
return type_node
for child in type_node.children:
found = _csharp_constructed_type_argument_list(child)
if found is not None:
return found
return None


def _emit_csharp_generic_arg_edges(
call_node, tal, source: bytes, caller_nid: str, ensure_named_node, add_edge
) -> None:
"""Emit `references[generic_arg]` for each type argument of a call or a construction.

Shared by the invocation and object-creation branches so both positions of the same
language feature stay in step: `Do<T>()` and `new Wrapper<T>()` should not differ in
whether T becomes a dependency.
"""
if tal is None:
return
type_params = _csharp_type_parameters_in_scope(call_node, source)
line = call_node.start_point[0] + 1
for arg in tal.children:
if not arg.is_named:
continue
refs: list[tuple[str, str, bool, str]] = []
_csharp_collect_type_refs(arg, source, True, refs, type_params)
for ref_name, _role, qualified, qualifier in refs:
target = ensure_named_node(ref_name, line)
if target == caller_nid:
continue
meta = {"ref_token": ref_name}
if qualified:
meta["qualified"] = True
if qualifier:
meta["ref_qualifier"] = qualifier
add_edge(caller_nid, target, "references", line,
context="generic_arg", metadata=meta)


def _csharp_attribute_names(method_node, source: bytes) -> list[tuple[str, bool, str]]:
"""Collect attribute names from a C# method/declaration's attribute_list children."""
names: list[tuple[str, bool, str]] = []
Expand Down Expand Up @@ -5081,6 +5149,17 @@ def walk_calls(
callee_name = type_info[0]
if type_info[1] and type_info[2]:
csharp_qualified_prefix = type_info[2]
# `_read_csharp_type_name` names only the constructed type, so the arguments
# it drops need the same treatment the invocation branch gives a call's type
# arguments (#2911). Without this `new Wrapper<Widget>()` linked `Wrapper` and
# lost `Widget`, which for a type only ever constructed -- a wrapper around a
# collaborator, a typed collection built from a literal -- was the sole edge
# recording the dependency.
_emit_csharp_generic_arg_edges(
node,
_csharp_constructed_type_argument_list(node.child_by_field_name("type")),
source, caller_nid, ensure_named_node, add_edge,
)
elif config.ts_module == "tree_sitter_c_sharp" and node.type == "invocation_expression":
# C#: the invoked function is the `function` field. A member call
# `recv.Method(...)` is a member_access_expression (receiver in its
Expand Down Expand Up @@ -5139,60 +5218,20 @@ 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)
# 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 class declaration's
# field_declaration case is closed by the parallel fix in #2913; this
# branch covers what that PR deliberately left out. Where the type-arg
# list lives for each call shape is documented on the helper, which the
# object-creation branch shares.
_emit_csharp_generic_arg_edges(
node, _csharp_call_type_argument_list(fn_node),
source, caller_nid, ensure_named_node, add_edge,
)
elif config.ts_module == "tree_sitter_php":
# PHP: distinguish call expression subtypes
if node.type == "function_call_expression":
Expand Down
144 changes: 144 additions & 0 deletions tests/test_csharp_generic_args_in_object_creation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""C# type arguments on `new Foo<Bar>()` must produce `references[generic_arg]` edges.

The call-site fix in #2911 covered `Do<T>()` and `services.AddScoped<ISvc, Impl>()`, both of
which reach the type-argument list through an invocation's `function` field.
`object_creation_expression` keeps the constructed type in its `type` field instead, so that
branch never saw a type-argument list and every argument was dropped: `new Wrapper<Widget>()`
linked `Wrapper` and lost `Widget` entirely.

The shapes below are the ones that go missing in practice -- a generic wrapper built around a
collaborator (test doubles, lazy factories, typed caches) and a generic collection built from
a literal. In each case the wrapper is present in the source only as a construction, so the
dropped argument is the only edge that records the dependency at all.
"""
from __future__ import annotations

import textwrap
from pathlib import Path

from graphify.extract import extract


def _graph(tmp_path: Path, files: dict[str, str]) -> dict:
paths = []
for name, body in files.items():
path = tmp_path / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(textwrap.dedent(body).lstrip(), encoding="utf-8")
paths.append(path)
return extract(paths, cache_root=tmp_path)


def _generic_arg_targets(graph, *, from_label: str) -> set[str]:
"""Labels reached by `references[generic_arg]` edges out of `from_label`."""
nodes = {n["id"]: n for n in graph["nodes"]}
out = set()
for edge in graph["edges"]:
if edge.get("relation") != "references":
continue
if edge.get("context") != "generic_arg":
continue
source = nodes.get(edge.get("source"), {})
if source.get("label") != from_label:
continue
target = nodes.get(edge.get("target"), {})
if target.get("label"):
out.add(target["label"])
return out


CORPUS = {
"Widget.cs": """
namespace App.Data;
public class Widget { public int Id { get; set; } }
public interface IGadget { void Go(); }
public class Wrapper<T> { }
public class Bag<T> { }
public class Pair<TFirst, TSecond> { }
""",
"Consumer.cs": """
namespace App.Use;
using App.Data;

public class Consumer
{
public void WrapsAnInterface() { var w = new Wrapper<IGadget>(); }
public void WrapsAClass() { var w = new Wrapper<Widget>(); }
public void BuildsACollection() { var b = new Bag<Widget>(); }
public void TwoArguments() { var p = new Pair<Widget, IGadget>(); }
public void Nested() { var n = new Wrapper<Bag<Widget>>(); }
public void Qualified() { var q = new App.Data.Wrapper<Widget>(); }
public void WithInitializer() { var w = new Wrapper<Widget> { }; }
}
""",
}


def test_object_creation_links_a_generic_interface_argument(tmp_path):
graph = _graph(tmp_path, CORPUS)
assert "IGadget" in _generic_arg_targets(graph, from_label=".WrapsAnInterface()")


def test_object_creation_links_a_generic_class_argument(tmp_path):
graph = _graph(tmp_path, CORPUS)
assert "Widget" in _generic_arg_targets(graph, from_label=".WrapsAClass()")


def test_object_creation_links_a_collection_element_type(tmp_path):
graph = _graph(tmp_path, CORPUS)
assert "Widget" in _generic_arg_targets(graph, from_label=".BuildsACollection()")


def test_object_creation_links_every_argument_of_a_two_parameter_type(tmp_path):
graph = _graph(tmp_path, CORPUS)
targets = _generic_arg_targets(graph, from_label=".TwoArguments()")
assert {"Widget", "IGadget"} <= targets


def test_object_creation_links_a_nested_type_argument(tmp_path):
"""`new Wrapper<Bag<Widget>>()` should reach the innermost argument too."""
graph = _graph(tmp_path, CORPUS)
targets = _generic_arg_targets(graph, from_label=".Nested()")
assert "Widget" in targets


def test_object_creation_links_arguments_on_a_namespace_qualified_type(tmp_path):
graph = _graph(tmp_path, CORPUS)
assert "Widget" in _generic_arg_targets(graph, from_label=".Qualified()")


def test_object_creation_with_an_initializer_still_links_arguments(tmp_path):
"""The object-initializer form parses with an extra child; arguments still count."""
graph = _graph(tmp_path, CORPUS)
assert "Widget" in _generic_arg_targets(graph, from_label=".WithInitializer()")


def test_constructed_type_itself_is_still_linked(tmp_path):
"""The pre-existing `calls` edge to the constructed type must survive."""
graph = _graph(tmp_path, CORPUS)
nodes = {n["id"]: n for n in graph["nodes"]}
labels = set()
for edge in graph["edges"]:
source = nodes.get(edge.get("source"), {})
if source.get("label") == ".WrapsAClass()":
target = nodes.get(edge.get("target"), {})
if target.get("label"):
labels.add(target["label"])
assert "Wrapper" in labels


def test_a_type_parameter_is_not_linked_as_a_type(tmp_path):
"""`new Wrapper<T>()` inside a generic method names a parameter, not a real type."""
graph = _graph(tmp_path, {
"Widget.cs": CORPUS["Widget.cs"],
"Generic.cs": """
namespace App.Use;
using App.Data;

public class Factory
{
public Wrapper<T> Make<T>() { return new Wrapper<T>(); }
}
""",
})
assert "T" not in _generic_arg_targets(graph, from_label=".Make()")
Loading