From 5bf273aa9a25139a076503c9cced6008711bc681 Mon Sep 17 00:00:00 2001 From: Paulo Pinto <18454956+papinto@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:04:10 -0700 Subject: [PATCH 1/4] fix(extract): extract C# delegate declarations as nodes The C# extractor recognized only class/interface/enum/struct/record as type definitions, so `delegate_declaration` was never emitted as a node -- a named delegate type like `delegate int Handler(...)` had no node at all. Any parameter, field, or return typed as that delegate referenced a non-existent node, so the edge dangled and was pruned: delegate types were invisible to the graph and to definition->usage tracing. Add `delegate_declaration` to the C# `class_types`. A delegate has a `name` field (so the node is labeled correctly) but no `declaration_list` body, so no members are walked -- just the type node and the file `contains` edge, and existing references to the delegate now resolve to it instead of dangling. Adds a regression test and a delegate to the C# fixture. Co-Authored-By: Claude Opus 4.8 --- graphify/extract.py | 3 +++ tests/fixtures/sample.cs | 2 ++ tests/test_languages.py | 7 +++++++ 3 files changed, 12 insertions(+) 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/tests/fixtures/sample.cs b/tests/fixtures/sample.cs index 729651c61..3b9987883 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 { } diff --git a/tests/test_languages.py b/tests/test_languages.py index f610fbc40..f3ca51048 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) From 44743db11f7b83a155357b7ee7d5714007349c3e Mon Sep 17 00:00:00 2001 From: Paulo Pinto <18454956+papinto@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:05:40 -0700 Subject: [PATCH 2/4] fix(extract): emit C# field generic type-argument references The C# `field_declaration` handler read a single outer type name via _read_csharp_type_name, so a field like `Dictionary` produced a `references` edge to the collection type but none to its type arguments. Generic type-argument couplings were therefore invisible in the graph, breaking definition->usage traceability. Walk the field type with _csharp_collect_type_refs -- the same helper the parameter/return-type paths and the C# property handler already use -- so each generic argument is emitted with generic_arg context while the outer type keeps field context. This mirrors the property handler directly below it; _read_csharp_type_name is still used by the base-type and local-declaration paths, so it is left in place. Adds a regression test (generic field -> generic_arg edge) and a generic field to the C# fixture; verified the test fails without the fix. Co-Authored-By: Claude Opus 4.8 --- graphify/extractors/engine.py | 34 +++++++++++++++++++--------------- tests/fixtures/sample.cs | 2 ++ tests/test_languages.py | 14 ++++++++++++++ 3 files changed, 35 insertions(+), 15 deletions(-) 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/tests/fixtures/sample.cs b/tests/fixtures/sample.cs index 3b9987883..453753b36 100644 --- a/tests/fixtures/sample.cs +++ b/tests/fixtures/sample.cs @@ -23,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_languages.py b/tests/test_languages.py index f3ca51048..604b916bf 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -392,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") From 3a41d1d4405107926b1526008168cd97d77564b9 Mon Sep 17 00:00:00 2001 From: Paulo Pinto <18454956+papinto@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:14:13 -0700 Subject: [PATCH 3/4] fix(extract): resolve Razor @model/@inherits/@inject type refs to real classes Razor type-reference directives recorded their target as a SOURCED node labeled with the full directive text, e.g. `@model Some.Deep.Namespace.MyViewModel` became a node labeled with that whole dotted string. That never matched the real class node (label `MyViewModel`, defined in another file): the corpus-level _rewire_unique_stub_nodes only collapses SOURCELESS stubs whose SIMPLE label matches exactly one real type definition, and _is_type_like_definition rejects any label containing a dot. So .cshtml/.razor views were left unlinked to the view models they bind. Record @model/@inherits/@inject like the C# extractor records cross-file type refs: the simple type name (namespace/generics/array stripped) on a sourceless stub, so the existing rewire collapses it onto the real class. @using (a namespace import, not a type) is unchanged. Adds a unit test (sourceless simple-name stub) and an integration test (stub rewired onto the real class); both verified to fail without the fix. Co-Authored-By: Claude Opus 4.8 --- graphify/extractors/razor.py | 33 ++++++++++++++++++++++-------- tests/test_dotnet.py | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/graphify/extractors/razor.py b/graphify/extractors/razor.py index 09dc54a6b..a8fbe9a83 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,15 +30,24 @@ 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}", @@ -44,17 +61,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/test_dotnet.py b/tests/test_dotnet.py index 37e0ba511..c01a0f2f0 100644 --- a/tests/test_dotnet.py +++ b/tests/test_dotnet.py @@ -490,6 +490,45 @@ 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_missing_file(): r = extract_razor(Path("/nonexistent/file.razor")) assert "error" in r From 098d84979e9ca8286cdfc1262eed498da796ac70 Mon Sep 17 00:00:00 2001 From: Paulo Pinto <18454956+papinto@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:16:49 -0700 Subject: [PATCH 4/4] fix(extract): resolve Razor @using type-aliases to the aliased type A using-alias directive `@using Alias = Qualified.Type` names a TYPE, not a namespace, but the `@using` handler matched it with the plain-namespace regex and recorded the ALIAS name (`@using Foo = Some.Namespace.Widget` captured `Foo`) as a SOURCED node per view. Those sourced, simple-name, type-like nodes are decoys: _rewire_unique_stub_nodes only collapses a stub onto a real definition when exactly ONE sourced def of that label exists, so N views aliasing the same type made the label ambiguous and the real consumer stub never collapsed onto the real class. The target node sat at degree 1, disconnected from its usages. Detect the alias form first and resolve it to the ALIASED (RHS) type as a sourceless simple-name stub (the same treatment as @model/@inherits/@inject), so the corpus rewire collapses it onto the real class and the decoys vanish. Plain namespace imports `@using Ns.Sub` are unchanged (still a sourced namespace node). Adds unit tests (sourceless aliased-type stub; RHS-not-alias naming; plain namespace unchanged) and an integration test reproducing the decoys-block-rewire regression; all verified to fail without the fix. Co-Authored-By: Claude Opus 4.8 --- graphify/extractors/razor.py | 11 +++++ tests/test_dotnet.py | 82 ++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/graphify/extractors/razor.py b/graphify/extractors/razor.py index a8fbe9a83..e4661df8d 100644 --- a/graphify/extractors/razor.py +++ b/graphify/extractors/razor.py @@ -54,6 +54,17 @@ def _add_ref(target_name: str, relation: str, line: int, *, type_ref: bool = Fal "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) diff --git a/tests/test_dotnet.py b/tests/test_dotnet.py index c01a0f2f0..6cc5808cd 100644 --- a/tests/test_dotnet.py +++ b/tests/test_dotnet.py @@ -529,6 +529,88 @@ class of the same simple name via _rewire_unique_stub_nodes (the corpus pass)."" 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