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
30 changes: 23 additions & 7 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<Widget>` 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<SomeContext>` lost SomeContext,
# and `Mock<IThing>` 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"
Expand Down
121 changes: 121 additions & 0 deletions tests/test_csharp_field_generic_args.py
Original file line number Diff line number Diff line change
@@ -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<Widget>` 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<SomeContext>` lost SomeContext, and
`Mock<IThing>` 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<T> { }\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<IThing> _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<IThing> _f = null!; }\n"
"public class WithProp { public Box<IThing> 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<Box<IThing>> _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<IThing> _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<T> { 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<T> { private Box<T> _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<int> _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
Loading