diff --git a/graphify/extract.py b/graphify/extract.py index a4ac6cbbd..f7902575d 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -747,6 +747,9 @@ def _get_c_func_name(node, source: bytes) -> str | None: "enum_declaration", "struct_declaration", "record_declaration", + "delegate_declaration", # named delegate types (e.g. `delegate int Handler(...)`) — + # referenceable type definitions, like a class/interface. + # Has no declaration_list body, so no members are walked. }), function_types=frozenset({"method_declaration"}), import_types=frozenset({"using_directive"}), diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 90c95cc49..ec08abf69 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -2872,22 +2872,26 @@ def _emit_java_parent_type(type_node, rel: str, at_line: int) -> None: type_node = child.child_by_field_name("type") if type_node is not None: break - type_info = _read_csharp_type_name(type_node, source) - if type_info: - type_name, qualified, qualifier = type_info - csharp_type_params = _csharp_type_parameters_in_scope( - type_node if type_node is not None else node, source - ) - if not type_name or type_name in csharp_type_params: - return + # Walk the full field type with _csharp_collect_type_refs (like the + # property handler below and the Java/PHP/Kotlin siblings) instead of + # reading only the outer type name, so a generic field such as + # `Dictionary` yields both the Dictionary field ref and + # the Foo generic_arg ref rather than dropping the type arguments. + if type_node is not None: 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) + refs: list[tuple[str, str, bool, str]] = [] + _csharp_collect_type_refs(type_node, source, False, refs) + for ref_name, role, qualified, 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 qualified: + metadata["qualified"] = True + if qualifier: + metadata["ref_qualifier"] = qualifier + add_edge(parent_class_nid, target_nid, "references", + line, context=ctx, metadata=metadata) return if (config.ts_module == "tree_sitter_c_sharp" diff --git a/graphify/extractors/razor.py b/graphify/extractors/razor.py index 09dc54a6b..e4661df8d 100644 --- a/graphify/extractors/razor.py +++ b/graphify/extractors/razor.py @@ -7,6 +7,14 @@ from graphify.extractors.base import _file_stem, _make_id +def _simple_type_name(name: str) -> str: + """Reduce a (possibly namespaced/generic/array) type ref to its simple class + name: `Some.Deep.Namespace.MyViewModel` -> `MyViewModel`, + `List` -> `List`, `Foo[]` -> `Foo`.""" + core = name.split("<", 1)[0].split("[", 1)[0].strip() + return core.rsplit(".", 1)[-1] + + def extract_razor(path: Path) -> dict: """Extract directives, component refs, and @code methods from .razor/.cshtml.""" try: @@ -22,21 +30,41 @@ def extract_razor(path: Path) -> dict: seen_ids: set[str] = set() seen_ids.add(file_nid) - def _add_ref(target_name: str, relation: str, line: int) -> None: - tgt_nid = _make_id(target_name) + def _add_ref(target_name: str, relation: str, line: int, *, type_ref: bool = False) -> None: + # Type-reference directives (@model/@inherits/@inject) name a class defined + # in another file. Record it like the C# extractor's cross-file type refs: + # the simple type name (namespace/generics/array stripped) on a SOURCELESS + # stub, so the corpus-level _rewire_unique_stub_nodes collapses it onto the + # real class node. A sourced, fully-qualified node (e.g. + # `Some.Deep.Namespace.MyViewModel`) never matches the real class + # (label `MyViewModel`) and the edge dangles. + label = _simple_type_name(target_name) if type_ref else target_name + tgt_nid = _make_id(label) if not tgt_nid: return if tgt_nid not in seen_ids: seen_ids.add(tgt_nid) - nodes.append({"id": tgt_nid, "label": target_name, - "file_type": "code", "source_file": str_path, - "source_location": f"L{line}"}) + nodes.append({"id": tgt_nid, "label": label, + "file_type": "code", + "source_file": "" if type_ref else str_path, + "source_location": "" if type_ref else f"L{line}"}) edges.append({"source": file_nid, "target": tgt_nid, "relation": relation, "confidence": "EXTRACTED", "source_file": str_path, "source_location": f"L{line}", "weight": 1.0}) for i, line in enumerate(src.splitlines(), 1): + # A using-alias `@using Alias = Qualified.Type` names a TYPE, not a + # namespace. Resolve it to the ALIASED type as a sourceless simple-name + # stub (like @model/@inherits) so the corpus rewire collapses it onto + # the real class. Recording the alias itself as a sourced node creates a + # type-like decoy per view, which blocks _rewire_unique_stub_nodes from + # collapsing the real stub (it only merges when exactly one def exists). + m = re.match(r'@using\s+\w+\s*=\s*([\w.]+)', line) + if m: + _add_ref(m.group(1), "imports", i, type_ref=True) + continue + m = re.match(r'@using\s+([\w.]+)', line) if m: _add_ref(m.group(1), "imports", i) @@ -44,17 +72,17 @@ def _add_ref(target_name: str, relation: str, line: int) -> None: m = re.match(r'@inject\s+([\w.<>\[\]]+)\s+(\w+)', line) if m: - _add_ref(m.group(1), "imports", i) + _add_ref(m.group(1), "imports", i, type_ref=True) continue m = re.match(r'@inherits\s+([\w.<>\[\]]+)', line) if m: - _add_ref(m.group(1), "inherits", i) + _add_ref(m.group(1), "inherits", i, type_ref=True) continue m = re.match(r'@model\s+([\w.<>\[\]]+)', line) if m: - _add_ref(m.group(1), "references", i) + _add_ref(m.group(1), "references", i, type_ref=True) continue m = re.match(r'@page\s+"([^"]+)"', line) diff --git a/tests/fixtures/sample.cs b/tests/fixtures/sample.cs index 729651c61..453753b36 100644 --- a/tests/fixtures/sample.cs +++ b/tests/fixtures/sample.cs @@ -9,6 +9,8 @@ public interface IProcessor List Process(List items); } + public delegate string Transformer(string input); + public class Processor { } @@ -21,6 +23,8 @@ public class DataProcessor : Processor, IProcessor { private readonly HttpClient _client; + private readonly Dictionary _registry; + public Processor Owner { get; set; } public List Workers { get; set; } diff --git a/tests/test_dotnet.py b/tests/test_dotnet.py index 37e0ba511..6cc5808cd 100644 --- a/tests/test_dotnet.py +++ b/tests/test_dotnet.py @@ -490,6 +490,127 @@ def test_razor_code_methods(): assert "LoadData" in labels +def test_razor_model_directive_emits_sourceless_simple_type_stub(): + """A fully-qualified `@model` must reference the SIMPLE class name on a + sourceless stub so the corpus rewire can collapse it onto the real class. + Previously it created a sourced, fully-qualified orphan node that never + matched the real class (label `MyViewModel`) and the edge dangled. + """ + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "View.cshtml" + p.write_text("@model Some.Deep.Namespace.MyViewModel\n
@Model.Name
\n", + encoding="utf-8") + r = extract_razor(p) + stub = next(n for n in r["nodes"] if n["label"] == "MyViewModel") + model_targets = [e["target"] for e in r["edges"] if e["relation"] == "references"] + assert stub["id"] in model_targets, "@model edge should target the simple-name node" + assert stub["source_file"] == "", "type-ref stub must be sourceless for the rewire" + # No fully-qualified orphan node should remain. + assert not any(n["label"] == "Some.Deep.Namespace.MyViewModel" for n in r["nodes"]) + + +def test_razor_model_resolves_to_real_class_via_stub_rewire(): + """End-to-end: the sourceless `@model` stub collapses onto the unique real + class of the same simple name via _rewire_unique_stub_nodes (the corpus pass).""" + from graphify.extract import _rewire_unique_stub_nodes + from graphify.extractors.base import _make_id + with tempfile.TemporaryDirectory() as d: + view = Path(d) / "View.cshtml" + view.write_text("@model Some.Deep.Namespace.MyViewModel\n", encoding="utf-8") + r = extract_razor(view) + real = {"id": _make_id("models", "MyViewModel"), "label": "MyViewModel", + "file_type": "code", "source_file": "/app/Models/MyViewModel.cs", + "source_location": "L3"} + nodes = r["nodes"] + [real] + edges = list(r["edges"]) + _rewire_unique_stub_nodes(nodes, edges) + assert any(e["relation"] == "references" and e["target"] == real["id"] for e in edges), \ + "@model edge should be rewired onto the real class node" + assert real["id"] in {n["id"] for n in nodes} + + +def test_razor_using_alias_emits_sourceless_aliased_type_stub(): + """`@using Alias = Qualified.Type` names a TYPE: it must reference the + ALIASED type's simple name on a SOURCELESS stub (so the corpus rewire can + collapse it onto the real class), NOT record the alias as a sourced node. + A sourced alias node is a type-like decoy that blocks the rewire.""" + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "View.cshtml" + p.write_text( + "@using Alias = Some.Deep.Namespace.MyEnum\n" + "
@Alias.SomeValue
\n", + encoding="utf-8") + r = extract_razor(p) + stub = next(n for n in r["nodes"] if n["label"] == "MyEnum") + assert stub["source_file"] == "", "aliased-type stub must be sourceless for the rewire" + import_targets = [e["target"] for e in r["edges"] if e["relation"] == "imports"] + assert stub["id"] in import_targets, "@using-alias edge should target the aliased-type stub" + # No fully-qualified orphan and no sourced alias decoy should remain. + assert not any(n["label"] == "Some.Deep.Namespace.MyEnum" for n in r["nodes"]) + assert not any(n["label"] == "MyEnum" and n["source_file"] for n in r["nodes"]) + + +def test_razor_using_alias_resolves_to_rhs_type_not_alias_name(): + """When the alias name differs from the aliased type, the stub is named for + the RHS type (the real class), not the left-hand alias.""" + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "View.cshtml" + p.write_text("@using FF = Some.Deep.Namespace.MyEnum\n", encoding="utf-8") + r = extract_razor(p) + labels = {n["label"] for n in r["nodes"]} + assert "MyEnum" in labels, "stub should be named for the aliased (RHS) type" + assert "FF" not in labels, "the alias name must not become a node" + + +def test_razor_plain_using_namespace_still_sourced(): + """A plain namespace import `@using Ns.Sub` is NOT a type alias and keeps its + prior behavior: a sourced node labeled with the namespace.""" + with tempfile.TemporaryDirectory() as d: + p = Path(d) / "View.cshtml" + p.write_text("@using Some.Other.Namespace\n", encoding="utf-8") + r = extract_razor(p) + node = next(n for n in r["nodes"] if n["label"] == "Some.Other.Namespace") + assert node["source_file"] != "", "plain @using namespace import stays sourced" + + +def test_razor_using_alias_decoys_no_longer_block_stub_rewire(): + """End-to-end regression: several views aliasing the same enum used to emit + one SOURCED decoy each, so the enum label was ambiguous (>1 'real def') and + _rewire_unique_stub_nodes refused to collapse a consumer's stub onto the real + enum. With the alias emitted sourceless, the real enum is the unique def and + the consumer stub collapses onto it.""" + from graphify.extract import _rewire_unique_stub_nodes + from graphify.extractors.base import _make_id + nodes: list[dict] = [] + edges: list[dict] = [] + with tempfile.TemporaryDirectory() as d: + for name in ("ViewOne.cshtml", "ViewTwo.cshtml", "ViewThree.cshtml"): + v = Path(d) / name + v.write_text( + "@using Alias = Some.Deep.Namespace.MyEnum\n", + encoding="utf-8") + r = extract_razor(v) + nodes += r["nodes"] + edges += list(r["edges"]) + # The one real enum definition (sourced, simple-name label). + real = {"id": _make_id("enums", "MyEnum"), "label": "MyEnum", + "file_type": "code", "source_file": "/app/Enums/MyEnum.cs", + "source_location": "L5"} + # A consumer (e.g. a helper class) referencing the enum via a sourceless stub. + consumer_stub = {"id": _make_id("stub", "myenum"), "label": "MyEnum", + "file_type": "code", "source_file": "", "source_location": ""} + helper = _make_id("helpers", "WidgetHelper") + nodes += [real, consumer_stub] + edges.append({"source": helper, "target": consumer_stub["id"], + "relation": "references", "confidence": "EXTRACTED", "weight": 1.0}) + _rewire_unique_stub_nodes(nodes, edges) + assert any(e["source"] == helper and e["target"] == real["id"] for e in edges), \ + "consumer stub should collapse onto the unique real enum once alias decoys are sourceless" + assert real["id"] in {n["id"] for n in nodes} + # No sourced MyEnum node other than the real enum survives. + assert [n for n in nodes if n["label"] == "MyEnum" and n["source_file"]] == [real] + + def test_razor_missing_file(): r = extract_razor(Path("/nonexistent/file.razor")) assert "error" in r diff --git a/tests/test_languages.py b/tests/test_languages.py index f610fbc40..604b916bf 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -347,6 +347,13 @@ def test_csharp_finds_interface(): r = extract_csharp(FIXTURES / "sample.cs") assert any("IProcessor" in l for l in _labels(r)) +def test_csharp_finds_delegate(): + """A C# delegate declaration is a named, referenceable type and must be a node, + like class/interface. Without this, a parameter/field typed as the delegate + references a non-existent node and the edge dangles (pruned).""" + r = extract_csharp(FIXTURES / "sample.cs") + assert "Transformer" in _labels(r), "delegate declaration should be a node" + def test_csharp_finds_methods(): r = extract_csharp(FIXTURES / "sample.cs") labels = _labels(r) @@ -385,6 +392,20 @@ def test_csharp_parameter_return_and_generic_contexts(): assert ("Build", "DataProcessor") in _edge_labels(result, "references", "generic_arg") +def test_csharp_field_generic_type_arguments_have_generic_arg_context(): + """A field like `Dictionary _registry` must reference both + the outer collection (field context) and its generic type arguments + (generic_arg context). Previously the field handler read only the outer type + name, so a generic field produced an edge to the collection but none to its + type arguments, breaking definition->usage traceability. Mirrors the behavior + already applied to the property handler.""" + result = extract_csharp(FIXTURES / "sample.cs") + # Outer collection type still referenced with field context. + assert ("DataProcessor", "Dictionary") in _edge_labels(result, "references", "field") + # Generic type argument is now referenced (string is a predefined type, skipped). + assert ("DataProcessor", "IProcessor") in _edge_labels(result, "references", "generic_arg") + + def test_java_normalizes_inherits_and_implements(): result = extract_java(FIXTURES / "sample.java") assert ("DataProcessor", "BaseProcessor") in _edge_labels(result, "inherits")