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
3 changes: 3 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}),
Expand Down
34 changes: 19 additions & 15 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<Foo, string>` 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"
Expand Down
44 changes: 36 additions & 8 deletions graphify/extractors/razor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<Foo>` -> `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:
Expand All @@ -22,39 +30,59 @@ 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)
continue

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)
Expand Down
4 changes: 4 additions & 0 deletions tests/fixtures/sample.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ public interface IProcessor
List<string> Process(List<string> items);
}

public delegate string Transformer(string input);

public class Processor
{
}
Expand All @@ -21,6 +23,8 @@ public class DataProcessor : Processor, IProcessor
{
private readonly HttpClient _client;

private readonly Dictionary<string, IProcessor> _registry;

public Processor Owner { get; set; }

public List<Processor> Workers { get; set; }
Expand Down
121 changes: 121 additions & 0 deletions tests/test_dotnet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<div>@Model.Name</div>\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"
"<div>@Alias.SomeValue</div>\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
Expand Down
21 changes: 21 additions & 0 deletions tests/test_languages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<string, IProcessor> _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")
Expand Down
Loading