]*?\bname\s*=\s*["']([^"']+)["']"""
+ r"""|\b(?:Partial|PartialAsync|RenderPartial|RenderPartialAsync)\s*\(\s*["']([^"']+)["']"""
+ r"""|\bLayout\s*=\s*["']([^"']+)["'])""",
+ re.IGNORECASE,
+)
+
+# View components resolve by convention: InvokeAsync("Basket") -> BasketViewComponent.
+_VIEW_COMPONENT_RE = re.compile(
+ r"""\bComponent\.InvokeAsync\s*(?:<\s*(\w+)\s*>\s*)?\(\s*(?:["']([^"']+)["'])?"""
+)
+
+# Tag helpers resolve by convention: -> PriceTagTagHelper.
+_CUSTOM_ELEMENT_RE = re.compile(r"?([a-z][a-z0-9]*(?:-[a-z0-9]+)+)\b")
+
+
+def find_razor_files(path: Path | str, exclusions: tuple[str, ...] = ()) -> list[str]:
+ """Find Razor view files below ``path``."""
+ return find_source_files(
+ path,
+ list(RAZOR_EXTENSIONS),
+ SourceDiscoveryOptions(exclusions=exclusions),
+ )
+
+
+def _read(filepath: str) -> str | None:
+ """Read view text, returning None on decode/IO errors."""
+ try:
+ return Path(resolve_path(filepath)).read_text()
+ except (OSError, UnicodeDecodeError):
+ return None
+
+
+class RazorView:
+ """Parsed facts about one Razor view."""
+
+ __slots__ = (
+ "namespace",
+ "usings",
+ "is_routable",
+ "component_refs",
+ "identifiers",
+ "invoked_members",
+ "view_refs",
+ "convention_types",
+ )
+
+ def __init__(
+ self,
+ namespace: str | None,
+ usings: set[str],
+ is_routable: bool,
+ component_refs: set[str],
+ identifiers: set[str],
+ invoked_members: set[str],
+ view_refs: set[str],
+ convention_types: set[str],
+ ) -> None:
+ self.namespace = namespace
+ self.usings = usings
+ self.is_routable = is_routable
+ self.component_refs = component_refs
+ self.identifiers = identifiers
+ self.invoked_members = invoked_members
+ self.view_refs = view_refs
+ self.convention_types = convention_types
+
+
+def parse_razor_metadata(filepath: str) -> RazorView:
+ """Parse the directives and symbol references of one Razor view."""
+ content = _read(filepath)
+ if content is None:
+ return RazorView(None, set(), False, set(), set(), set(), set(), set())
+
+ body = _RAZOR_COMMENT_RE.sub("", content)
+
+ namespace = None
+ ns_match = _RAZOR_NAMESPACE_RE.search(body)
+ if ns_match:
+ namespace = ns_match.group(1)
+
+ usings: set[str] = set()
+ usings.update(_RAZOR_USING_RE.findall(body))
+ usings.update(_RAZOR_USING_ALIAS_RE.findall(body))
+ # A qualified type in @inherits/@model implies a dependency on its namespace.
+ for qualified in _RAZOR_TYPE_DIRECTIVE_RE.findall(body):
+ namespace_part = qualified.rsplit(".", 1)[0]
+ if namespace_part:
+ usings.add(namespace_part)
+
+ is_routable = bool(_RAZOR_PAGE_RE.search(body))
+ component_refs = set(_COMPONENT_TAG_RE.findall(body))
+ identifiers = set(_IDENTIFIER_RE.findall(body)) | component_refs
+ invoked_members = set(_INVOKED_MEMBER_RE.findall(body))
+
+ view_refs = {
+ name
+ for groups in _VIEW_BY_NAME_RE.findall(body)
+ for name in groups
+ if name
+ }
+
+ # Types reachable only through a naming convention, never named literally.
+ convention_types: set[str] = set()
+ for generic_arg, quoted_name in _VIEW_COMPONENT_RE.findall(body):
+ if generic_arg:
+ convention_types.add(generic_arg)
+ if quoted_name:
+ convention_types.add(f"{quoted_name}ViewComponent")
+ for element in _CUSTOM_ELEMENT_RE.findall(body):
+ pascal = "".join(part.capitalize() for part in element.split("-"))
+ convention_types.add(f"{pascal}TagHelper")
+
+ return RazorView(
+ namespace,
+ usings,
+ is_routable,
+ component_refs,
+ identifiers,
+ invoked_members,
+ view_refs,
+ convention_types,
+ )
+
+
+def build_type_index(cs_files: list[str]) -> dict[str, set[str]]:
+ """Map declared type name to the C# files declaring it.
+
+ Views resolve types by name, so a name index is what the view edges need.
+ Partial classes legitimately map one name to several files.
+ """
+ index: dict[str, set[str]] = {}
+ for filepath in cs_files:
+ content = _read(filepath)
+ if content is None:
+ continue
+ for type_name in _TYPE_DECL_RE.findall(content):
+ index.setdefault(type_name, set()).add(resolve_path(filepath))
+ return index
+
+
+def build_extension_method_index(cs_files: list[str]) -> dict[str, set[str]]:
+ """Map extension method name to the C# files declaring it.
+
+ A view calls these as ``value.Method()``, so the declaring class name never
+ appears in the markup and the type index alone cannot reach the file.
+ """
+ index: dict[str, set[str]] = {}
+ for filepath in cs_files:
+ content = _read(filepath)
+ if content is None:
+ continue
+ for method_name in _EXTENSION_METHOD_RE.findall(content):
+ index.setdefault(method_name, set()).add(resolve_path(filepath))
+ return index
+
+
+def collect_ambient_usings(razor_files: list[str]) -> dict[str, set[str]]:
+ """Map each directory containing an ambient import file to its usings.
+
+ Razor applies ``_Imports.razor``/``_ViewImports.cshtml`` to every view in the
+ directory subtree below it, so these usings are inherited rather than local.
+ """
+ ambient: dict[str, set[str]] = {}
+ for filepath in razor_files:
+ resolved = Path(resolve_path(filepath))
+ if resolved.name not in _AMBIENT_IMPORT_NAMES:
+ continue
+ usings = parse_razor_metadata(filepath).usings
+ if not usings:
+ continue
+ directory = str(resolved.parent)
+ ambient.setdefault(directory, set()).update(usings)
+ return ambient
+
+
+def inherited_usings(filepath: str, ambient: dict[str, set[str]]) -> set[str]:
+ """Resolve the ambient usings that apply to one view, nearest-first upward."""
+ if not ambient:
+ return set()
+ out: set[str] = set()
+ current = Path(resolve_path(filepath)).parent
+ while True:
+ found = ambient.get(str(current))
+ if found:
+ out.update(found)
+ parent = current.parent
+ if parent == current:
+ break
+ current = parent
+ return out
+
+
+def code_behind_for(filepath: str) -> str | None:
+ """Return the code-behind path for a view, if one exists on disk.
+
+ ``Widget.razor`` is completed by the partial class in ``Widget.razor.cs``.
+ The markup is the only consumer of that file, so without this edge the
+ code-behind looks like an orphan.
+ """
+ resolved = Path(resolve_path(filepath))
+ candidate = resolved.with_name(resolved.name + ".cs")
+ if candidate.is_file():
+ return str(candidate)
+ return None
+
+
+def build_component_index(razor_files: list[str]) -> dict[str, str]:
+ """Map component name to defining view path.
+
+ A Blazor component's name is its filename stem, so ```` resolves
+ to ``Widget.razor``. Components declared in plain C# are left unresolved
+ rather than guessed at.
+ """
+ index: dict[str, str] = {}
+ for filepath in razor_files:
+ resolved = Path(resolve_path(filepath))
+ if resolved.suffix != ".razor":
+ continue
+ if resolved.name in _AMBIENT_IMPORT_NAMES:
+ continue
+ index.setdefault(resolved.stem, str(resolved))
+ return index
+
+
+def build_view_index(razor_files: list[str]) -> dict[str, str]:
+ """Map view name to view path, for views referenced by string name.
+
+ MVC and Razor Pages name partials and layouts as strings, sometimes with a
+ path or an extension, so both the stem and the bare filename are indexed.
+ """
+ index: dict[str, str] = {}
+ for filepath in razor_files:
+ resolved = Path(resolve_path(filepath))
+ index.setdefault(resolved.stem, str(resolved))
+ index.setdefault(resolved.name, str(resolved))
+ return index
+
+
+def normalize_view_ref(name: str) -> str:
+ """Reduce a view reference such as `~/Pages/Shared/_Card.cshtml` to its stem."""
+ trimmed = name.strip().replace("\\", "/").rstrip("/")
+ if not trimmed:
+ return ""
+ return Path(trimmed).stem
+
+
+__all__ = [
+ "RAZOR_EXTENSIONS",
+ "RazorView",
+ "build_component_index",
+ "build_extension_method_index",
+ "build_type_index",
+ "build_view_index",
+ "code_behind_for",
+ "collect_ambient_usings",
+ "find_razor_files",
+ "inherited_usings",
+ "normalize_view_ref",
+ "parse_razor_metadata",
+]
diff --git a/desloppify/languages/csharp/tests/test_csharp_deps_razor.py b/desloppify/languages/csharp/tests/test_csharp_deps_razor.py
new file mode 100644
index 000000000..33c79358a
--- /dev/null
+++ b/desloppify/languages/csharp/tests/test_csharp_deps_razor.py
@@ -0,0 +1,356 @@
+"""Tests for Razor/Blazor view support in the C# dependency graph.
+
+Views are not scored as C# source, but they are the only place a lot of C# is
+referenced from. These tests pin both halves of that: code reachable from a
+view is linked, and code reachable from nowhere stays orphaned.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+import desloppify.languages.csharp.detectors.deps as deps_detector_mod
+from desloppify.engine.detectors import orphaned as orphaned_detector_mod
+
+
+@pytest.fixture(autouse=True)
+def _root(set_project_root):
+ """Point PROJECT_ROOT at the tmp directory via RuntimeContext."""
+ return set_project_root
+
+
+def _write(tmp_path: Path, name: str, content: str) -> Path:
+ p = tmp_path / name
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(content)
+ return p
+
+
+def _key(tmp_path: Path, name: str) -> str:
+ return str((tmp_path / name).resolve())
+
+
+def _csproj(tmp_path: Path, name: str = "App.csproj", root_namespace: str = "App"):
+ return _write(
+ tmp_path,
+ name,
+ "\n"
+ f" {root_namespace}\n"
+ "\n",
+ )
+
+
+# ── Blazor components ───────────────────────────────────────────
+
+
+class TestBlazorViews:
+ def test_code_behind_is_linked_from_its_view(self, tmp_path):
+ """A .razor.cs partial is consumed by its own view, not by nothing."""
+
+ _csproj(tmp_path)
+ _write(tmp_path, "Components/Widget.razor", "@Describe()
\n")
+ _write(
+ tmp_path,
+ "Components/Widget.razor.cs",
+ "namespace App.Components;\n\n"
+ "public partial class Widget\n{\n"
+ " private string Describe() => \"widget\";\n}\n",
+ )
+
+ graph = deps_detector_mod.build_dep_graph(tmp_path)
+ view = _key(tmp_path, "Components/Widget.razor")
+ code_behind = _key(tmp_path, "Components/Widget.razor.cs")
+ assert view in graph[code_behind]["importers"]
+ assert code_behind in graph[view]["imports"]
+
+ def test_type_used_only_in_markup_is_linked(self, tmp_path):
+ """A helper named only from markup is reachable."""
+
+ _csproj(tmp_path)
+ _write(
+ tmp_path,
+ "Services/PriceFormatter.cs",
+ "namespace App.Services;\n\n"
+ "public static class PriceFormatter\n{\n"
+ " public static string ToDisplay(decimal a) => $\"{a}\";\n}\n",
+ )
+ _write(
+ tmp_path,
+ "Pages/Home.razor",
+ "@page \"/\"\n@using App.Services\n@PriceFormatter.ToDisplay(1.0m)
\n",
+ )
+
+ graph = deps_detector_mod.build_dep_graph(tmp_path)
+ formatter = _key(tmp_path, "Services/PriceFormatter.cs")
+ view = _key(tmp_path, "Pages/Home.razor")
+ assert view in graph[formatter]["importers"]
+
+ def test_unreferenced_type_stays_orphaned(self, tmp_path):
+ """A `@using` puts a namespace in scope; it does not make it all live."""
+
+ _csproj(tmp_path)
+ _write(
+ tmp_path,
+ "Services/Used.cs",
+ "namespace App.Services;\n\npublic static class Used\n{\n"
+ " public static string Go() => \"x\";\n}\n",
+ )
+ _write(
+ tmp_path,
+ "Services/NeverReferenced.cs",
+ "namespace App.Services;\n\npublic static class NeverReferenced\n{\n"
+ " public static string Go() => \"x\";\n}\n",
+ )
+ _write(
+ tmp_path,
+ "Pages/Home.razor",
+ "@page \"/\"\n@using App.Services\n@Used.Go()
\n",
+ )
+
+ graph = deps_detector_mod.build_dep_graph(tmp_path)
+ used = _key(tmp_path, "Services/Used.cs")
+ dead = _key(tmp_path, "Services/NeverReferenced.cs")
+ view = _key(tmp_path, "Pages/Home.razor")
+ assert view in graph[used]["importers"]
+ assert view not in graph[dead]["importers"]
+
+ def test_component_tag_creates_edge(self, tmp_path):
+ """Rendering links the view that declares it."""
+
+ _csproj(tmp_path)
+ _write(tmp_path, "Components/Widget.razor", "widget
\n")
+ _write(tmp_path, "Pages/Home.razor", "@page \"/\"\n\n")
+
+ graph = deps_detector_mod.build_dep_graph(tmp_path)
+ widget = _key(tmp_path, "Components/Widget.razor")
+ home = _key(tmp_path, "Pages/Home.razor")
+ assert home in graph[widget]["importers"]
+
+ def test_component_declared_in_csharp_resolves_from_tag(self, tmp_path):
+ """A component with no .razor file still resolves by type name."""
+
+ _csproj(tmp_path)
+ _write(
+ tmp_path,
+ "Components/Badge.cs",
+ "namespace App.Components;\n\npublic class Badge : ComponentBase { }\n",
+ )
+ _write(
+ tmp_path,
+ "Pages/Home.razor",
+ "@page \"/\"\n@using App.Components\n\n",
+ )
+
+ graph = deps_detector_mod.build_dep_graph(tmp_path)
+ badge = _key(tmp_path, "Components/Badge.cs")
+ home = _key(tmp_path, "Pages/Home.razor")
+ assert home in graph[badge]["importers"]
+
+ def test_extension_method_called_from_markup_is_linked(self, tmp_path):
+ """Extension methods name no type at the call site, so index by method."""
+
+ _csproj(tmp_path)
+ _write(
+ tmp_path,
+ "Services/DisplayExtensions.cs",
+ "namespace App.Services;\n\npublic static class DisplayExtensions\n{\n"
+ " public static string ToBadge(this decimal a) => $\"[{a}]\";\n}\n",
+ )
+ _write(
+ tmp_path,
+ "Pages/Home.razor",
+ "@page \"/\"\n@using App.Services\n@(1.0m).ToBadge()
\n",
+ )
+
+ graph = deps_detector_mod.build_dep_graph(tmp_path)
+ extensions = _key(tmp_path, "Services/DisplayExtensions.cs")
+ home = _key(tmp_path, "Pages/Home.razor")
+ assert home in graph[extensions]["importers"]
+
+ def test_ambient_imports_apply_to_subtree(self, tmp_path):
+ """_Imports.razor usings are inherited by views below it."""
+
+ _csproj(tmp_path)
+ _write(
+ tmp_path,
+ "Services/Helper.cs",
+ "namespace App.Services;\n\npublic static class Helper\n{\n"
+ " public static string Go() => \"x\";\n}\n",
+ )
+ _write(tmp_path, "Components/_Imports.razor", "@using App.Services\n")
+ # No local @using: the edge can only come from the ambient import file.
+ _write(tmp_path, "Components/Deep/Card.razor", "@Helper.Go()
\n")
+
+ graph = deps_detector_mod.build_dep_graph(tmp_path)
+ helper = _key(tmp_path, "Services/Helper.cs")
+ card = _key(tmp_path, "Components/Deep/Card.razor")
+ assert card in graph[helper]["importers"]
+
+ def test_page_directive_marks_entrypoint(self, tmp_path):
+ """A routable view is reachable by URL, so it is a root."""
+
+ _csproj(tmp_path)
+ _write(tmp_path, "Pages/Home.razor", "@page \"/\"\nHome
\n")
+
+ graph = deps_detector_mod.build_dep_graph(tmp_path)
+ home = _key(tmp_path, "Pages/Home.razor")
+ assert "__entrypoint__" in graph[home]["importers"]
+
+ def test_razor_comments_are_ignored(self, tmp_path):
+ """Commented-out markup must not create edges."""
+
+ _csproj(tmp_path)
+ _write(tmp_path, "Components/Widget.razor", "widget
\n")
+ _write(
+ tmp_path,
+ "Pages/Home.razor",
+ "@page \"/\"\n@*\n\n*@\nnothing
\n",
+ )
+
+ graph = deps_detector_mod.build_dep_graph(tmp_path)
+ widget = _key(tmp_path, "Components/Widget.razor")
+ home = _key(tmp_path, "Pages/Home.razor")
+ assert home not in graph[widget]["importers"]
+
+
+# ── Razor Pages and MVC ─────────────────────────────────────────
+
+
+class TestRazorPagesViews:
+ def test_page_model_code_behind_is_linked(self, tmp_path):
+ """Index.cshtml.cs is consumed by Index.cshtml."""
+
+ _csproj(tmp_path, root_namespace="Web")
+ _write(tmp_path, "Pages/Index.cshtml", "@page\n@model IndexModel\nHi
\n")
+ _write(
+ tmp_path,
+ "Pages/Index.cshtml.cs",
+ "namespace Web.Pages;\n\npublic class IndexModel : PageModel { }\n",
+ )
+
+ graph = deps_detector_mod.build_dep_graph(tmp_path)
+ view = _key(tmp_path, "Pages/Index.cshtml")
+ model = _key(tmp_path, "Pages/Index.cshtml.cs")
+ assert view in graph[model]["importers"]
+
+ def test_partial_referenced_by_string_creates_edge(self, tmp_path):
+ """ names a view rather than a type."""
+
+ _csproj(tmp_path, root_namespace="Web")
+ _write(tmp_path, "Pages/Shared/_Card.cshtml", "card
\n")
+ _write(
+ tmp_path,
+ "Pages/Index.cshtml",
+ "@page\n\n",
+ )
+
+ graph = deps_detector_mod.build_dep_graph(tmp_path)
+ card = _key(tmp_path, "Pages/Shared/_Card.cshtml")
+ index = _key(tmp_path, "Pages/Index.cshtml")
+ assert index in graph[card]["importers"]
+
+ def test_layout_referenced_by_string_creates_edge(self, tmp_path):
+ """Layout = "_Layout" names a view rather than a type."""
+
+ _csproj(tmp_path, root_namespace="Web")
+ _write(tmp_path, "Pages/Shared/_Layout.cshtml", "@RenderBody()\n")
+ _write(tmp_path, "Pages/_ViewStart.cshtml", "@{ Layout = \"_Layout\"; }\n")
+
+ graph = deps_detector_mod.build_dep_graph(tmp_path)
+ layout = _key(tmp_path, "Pages/Shared/_Layout.cshtml")
+ view_start = _key(tmp_path, "Pages/_ViewStart.cshtml")
+ assert view_start in graph[layout]["importers"]
+
+ def test_view_component_convention_creates_edge(self, tmp_path):
+ """InvokeAsync("Basket") resolves to BasketViewComponent by convention."""
+
+ _csproj(tmp_path, root_namespace="Web")
+ _write(
+ tmp_path,
+ "ViewComponents/BasketViewComponent.cs",
+ "namespace Web.ViewComponents;\n\n"
+ "public class BasketViewComponent : ViewComponent { }\n",
+ )
+ _write(
+ tmp_path,
+ "Pages/Index.cshtml",
+ "@page\n@await Component.InvokeAsync(\"Basket\")\n",
+ )
+
+ graph = deps_detector_mod.build_dep_graph(tmp_path)
+ component = _key(tmp_path, "ViewComponents/BasketViewComponent.cs")
+ index = _key(tmp_path, "Pages/Index.cshtml")
+ assert index in graph[component]["importers"]
+
+ def test_tag_helper_convention_creates_edge(self, tmp_path):
+ """ resolves to PriceTagTagHelper by convention."""
+
+ _csproj(tmp_path, root_namespace="Web")
+ _write(
+ tmp_path,
+ "TagHelpers/PriceTagTagHelper.cs",
+ "namespace Web.TagHelpers;\n\npublic class PriceTagTagHelper : TagHelper { }\n",
+ )
+ _write(tmp_path, "Pages/Index.cshtml", "@page\n\n")
+
+ graph = deps_detector_mod.build_dep_graph(tmp_path)
+ helper = _key(tmp_path, "TagHelpers/PriceTagTagHelper.cs")
+ index = _key(tmp_path, "Pages/Index.cshtml")
+ assert index in graph[helper]["importers"]
+
+ def test_unused_tag_helper_is_not_linked_from_views(self, tmp_path):
+ """Convention matching must not link a tag helper no view uses."""
+
+ _csproj(tmp_path, root_namespace="Web")
+ _write(
+ tmp_path,
+ "TagHelpers/UnusedTagHelper.cs",
+ "namespace Web.TagHelpers;\n\npublic class UnusedTagHelper : TagHelper { }\n",
+ )
+ _write(tmp_path, "Pages/Index.cshtml", "@page\nnothing custom here
\n")
+
+ graph = deps_detector_mod.build_dep_graph(tmp_path)
+ helper = _key(tmp_path, "TagHelpers/UnusedTagHelper.cs")
+ index = _key(tmp_path, "Pages/Index.cshtml")
+ assert index not in graph[helper]["importers"]
+
+
+# ── Interaction with the shared detectors ───────────────────────
+
+
+class TestRazorAndOrphanDetection:
+ def test_view_files_do_not_appear_orphaned(self, tmp_path):
+ """Views are excluded from the orphan check by the extensions filter."""
+
+ _csproj(tmp_path)
+ _write(tmp_path, "Pages/Home.razor", "@page \"/\"\nHome
\n")
+
+ graph = deps_detector_mod.build_dep_graph(tmp_path)
+ orphans, _ = orphaned_detector_mod.detect_orphaned_files(
+ tmp_path,
+ graph,
+ extensions=[".cs"],
+ options=orphaned_detector_mod.OrphanedDetectionOptions(
+ extra_entry_patterns=[],
+ extra_barrel_names=set(),
+ ),
+ )
+ orphan_files = {e["file"] for e in orphans}
+ assert _key(tmp_path, "Pages/Home.razor") not in orphan_files
+
+ def test_project_without_views_is_unaffected(self, tmp_path):
+ """A view-free project builds the same graph as before."""
+
+ _csproj(tmp_path)
+ _write(
+ tmp_path,
+ "Services/Thing.cs",
+ "namespace App.Services;\n\npublic class Thing { }\n",
+ )
+
+ graph = deps_detector_mod.build_dep_graph(tmp_path)
+ thing = _key(tmp_path, "Services/Thing.cs")
+ assert thing in graph
+ assert graph[thing]["importers"] == set()