From 71dbf2831e6572d9c49dbb6c8df7bd4e6d697e9f Mon Sep 17 00:00:00 2001 From: Daniel Grimes Date: Fri, 31 Jul 2026 09:51:32 +0000 Subject: [PATCH] feat: link Razor and Blazor views into the C# dependency graph The csharp plugin discovered only `.cs`, so `.razor` and `.cshtml` were invisible. Code reachable only from markup therefore looked unreferenced: a `Widget.razor.cs` code-behind partial is reported as an orphaned file with zero importers and a suggestion to delete it, even though deleting it breaks the build. Views are parsed for edges but stay out of the scored extension set, mirroring how the typescript plugin already handles `.svelte`, `.vue` and `.astro`. Scores for existing projects are unchanged. Edges resolve by symbol name rather than by namespace. A view's `@using` says which namespaces are in scope, not which files it depends on, so linking a whole namespace would mark every file in it as live and hide genuinely dead code. Resolved per view: - types the view names, via a type-name index - extension methods it calls, which name no type at the call site - its own code-behind partial (`Widget.razor` -> `Widget.razor.cs`) - components it renders, including components declared in plain C# - partials and layouts referenced by string name - view components and tag helpers, which resolve by naming convention - `@page` marks a view as a routable root, like `Program.cs` - `_Imports.razor` and `_ViewImports.cshtml` usings apply to the subtree Also zones the ambient import files as config and `.razor.g.cs` / `.cshtml.g.cs` as generated, and stops `build_dep_graph` returning early on a project that is all views and no `.cs`. Measured on a ten-project Razor Pages solution: 334 views enter the graph, contributing 1052 edges across 200 `.cs` files, with 136 routable pages marked as roots. No file changed orphan status on that codebase, because the existing namespace matching already linked them. --- desloppify/languages/csharp/_zones.py | 22 +- desloppify/languages/csharp/detectors/deps.py | 141 ++++++- .../csharp/detectors/deps_support_razor.py | 330 ++++++++++++++++ .../csharp/tests/test_csharp_deps_razor.py | 356 ++++++++++++++++++ 4 files changed, 838 insertions(+), 11 deletions(-) create mode 100644 desloppify/languages/csharp/detectors/deps_support_razor.py create mode 100644 desloppify/languages/csharp/tests/test_csharp_deps_razor.py diff --git a/desloppify/languages/csharp/_zones.py b/desloppify/languages/csharp/_zones.py index cba7fbb79..65d7ad5f8 100644 --- a/desloppify/languages/csharp/_zones.py +++ b/desloppify/languages/csharp/_zones.py @@ -14,6 +14,11 @@ "/SceneDelegate.cs", "/WinUIApplication.cs", "/App.xaml.cs", + "/App.razor", + "/Routes.razor", + "/_Imports.razor", + "/_ViewImports.cshtml", + "/_ViewStart.cshtml", "/Properties/", "/Migrations/", ".g.cs", @@ -21,9 +26,22 @@ ] CSHARP_ZONE_RULES = [ - ZoneRule(Zone.GENERATED, [".g.cs", ".designer.cs", "/obj/", "/bin/"]), + ZoneRule( + Zone.GENERATED, + [".g.cs", ".designer.cs", ".razor.g.cs", ".cshtml.g.cs", "/obj/", "/bin/"], + ), ZoneRule(Zone.TEST, [".Tests.cs", "Tests.cs", "Test.cs", "/Tests/", "/test/"]), - ZoneRule(Zone.CONFIG, ["/Program.cs", "/Startup.cs", "/AssemblyInfo.cs"]), + ZoneRule( + Zone.CONFIG, + [ + "/Program.cs", + "/Startup.cs", + "/AssemblyInfo.cs", + "/_Imports.razor", + "/_ViewImports.cshtml", + "/_ViewStart.cshtml", + ], + ), ] + COMMON_ZONE_RULES __all__ = ["CSHARP_ENTRY_PATTERNS", "CSHARP_ZONE_RULES"] diff --git a/desloppify/languages/csharp/detectors/deps.py b/desloppify/languages/csharp/detectors/deps.py index def793096..f053dd27f 100644 --- a/desloppify/languages/csharp/detectors/deps.py +++ b/desloppify/languages/csharp/detectors/deps.py @@ -23,6 +23,18 @@ parse_csproj_references as _parse_csproj_references, parse_project_assets_references as _parse_project_assets_references, ) +from desloppify.languages.csharp.detectors.deps_support_razor import ( + build_component_index as _build_component_index, + build_extension_method_index as _build_extension_method_index, + build_type_index as _build_type_index, + build_view_index as _build_view_index, + code_behind_for as _code_behind_for, + collect_ambient_usings as _collect_ambient_usings, + find_razor_files as _find_razor_files, + inherited_usings as _inherited_usings, + normalize_view_ref as _normalize_view_ref, + parse_razor_metadata as _parse_razor_metadata, +) from desloppify.languages.csharp.detectors.deps_support_render import ( build_graph_from_edge_map as _build_graph_from_edge_map, render_cycles_for_graph as _render_cycles_for_graph, @@ -30,6 +42,7 @@ safe_resolve_graph_path as _safe_resolve_graph_path, ) from desloppify.languages.csharp.extractors import ( + CSHARP_FILE_EXCLUSIONS, find_csharp_files, ) @@ -174,6 +187,94 @@ def _build_dep_graph_roslyn( return _parse_roslyn_graph_payload(payload) +def _link_razor_views( + path: Path, + *, + graph: dict[str, dict], + cs_files: list[str], + razor_files: list[str], + file_to_namespace: dict[str, str | None], + projects: list[Path], + file_to_project: dict[str, Path], + entrypoint_files: set[str], +) -> None: + """Add graph edges contributed by Razor/Blazor views. + + Views are not scored as C# source, but they reference C# that nothing else + references. Without these edges a code-behind partial or a component used + only from markup looks orphaned. + + Edges resolve by type name rather than by whole namespace. A view's + ``@using`` says which namespaces are in scope, not which files it depends + on, so linking the whole namespace would mark every file in it as live and + hide genuinely dead code. + """ + if not razor_files: + return + + file_to_project.update(_map_file_to_project(razor_files, projects)) + ambient = _collect_ambient_usings(razor_files) + component_index = _build_component_index(razor_files) + view_index = _build_view_index(razor_files) + type_index = _build_type_index(cs_files) + extension_index = _build_extension_method_index(cs_files) + + def _link(source: str, target_path: str) -> None: + """Record a view's dependency on one file.""" + target = resolve_path(target_path) + if target == source: + return + graph[source]["imports"].add(target) + graph[target]["importers"].add(source) + + def _link_by_name( + source: str, names: set[str], index: dict[str, set[str]], in_scope: set[str] + ) -> None: + """Link a view to files declaring the named symbols, within scope.""" + for name in names: + for target in index.get(name, ()): + target_ns = file_to_namespace.get(target) + if target_ns and in_scope and target_ns not in in_scope: + continue + _link(source, target) + + for filepath in razor_files: + source = resolve_path(filepath) + graph[source] # ensure entry exists + view = _parse_razor_metadata(filepath) + in_scope = view.usings | _inherited_usings(filepath, ambient) + if view.namespace: + in_scope.add(view.namespace) + + # Link the C# files declaring the types this view actually names, and + # the extension methods it calls, which name no type at the call site. + _link_by_name(source, view.identifiers, type_index, in_scope) + _link_by_name(source, view.invoked_members, extension_index, in_scope) + # Tag helpers and view components are reached by naming convention, so + # their type name never appears literally in the markup. + _link_by_name(source, view.convention_types, type_index, set()) + + # Partials and layouts are named as strings rather than types. + for view_ref in view.view_refs: + referenced = view_index.get(_normalize_view_ref(view_ref)) + if referenced: + _link(source, referenced) + + # A view is the only consumer of its own code-behind partial. + code_behind = _code_behind_for(filepath) + if code_behind: + _link(source, code_behind) + + for component_name in view.component_refs: + defining_view = component_index.get(component_name) + if defining_view: + _link(source, defining_view) + + # A routable page is reachable by URL, so it is a root like Program.cs. + if view.is_routable: + entrypoint_files.add(source) + + def build_dep_graph(path: Path, roslyn_cmd: str | None = None) -> dict[str, dict]: """Build a C# dependency graph compatible with shared graph detectors.""" roslyn_graph = _build_dep_graph_roslyn(path, roslyn_cmd=roslyn_cmd) @@ -183,7 +284,10 @@ def build_dep_graph(path: Path, roslyn_cmd: str | None = None) -> dict[str, dict graph: dict[str, dict] = defaultdict(lambda: {"imports": set(), "importers": set()}) cs_files = find_csharp_files(path) - if not cs_files: + # A Razor class library can be almost entirely views, so the absence of C# + # sources is not the absence of a graph. + razor_files = _find_razor_files(path, tuple(CSHARP_FILE_EXCLUSIONS)) + if not cs_files and not razor_files: return finalize_graph({}) projects = _find_csproj_files(path) @@ -225,15 +329,20 @@ def build_dep_graph(path: Path, roslyn_cmd: str | None = None) -> dict[str, dict if proj is not None: project_to_namespaces[proj].add(ns) - for source, usings in file_to_usings.items(): + def _allowed_namespaces_for(source: str) -> set[str] | None: + """Namespaces a file may reference, limited to its project's references.""" proj = file_to_project.get(source) - allowed_namespaces: set[str] | None = None - if proj is not None: - allowed_projects = {proj} | project_refs.get(proj, set()) - allowed_namespaces = set() - for ap in allowed_projects: - allowed_namespaces.update(project_to_namespaces.get(ap, set())) - + if proj is None: + return None + allowed_projects = {proj} | project_refs.get(proj, set()) + allowed: set[str] = set() + for ap in allowed_projects: + allowed.update(project_to_namespaces.get(ap, set())) + return allowed + + def _link_usings(source: str, usings: set[str]) -> None: + """Add graph edges from one file to every file its usings resolve to.""" + allowed_namespaces = _allowed_namespaces_for(source) for using_ns in usings: for target in _expand_namespace_matches(using_ns, namespace_to_files): if target == source: @@ -248,6 +357,20 @@ def build_dep_graph(path: Path, roslyn_cmd: str | None = None) -> dict[str, dict graph[source]["imports"].add(target) graph[target]["importers"].add(source) + for source, usings in file_to_usings.items(): + _link_usings(source, usings) + + _link_razor_views( + path, + graph=graph, + cs_files=cs_files, + razor_files=razor_files, + file_to_namespace=file_to_namespace, + projects=projects, + file_to_project=file_to_project, + entrypoint_files=entrypoint_files, + ) + # Mark app bootstrap files as referenced roots to avoid orphan false positives. for source in entrypoint_files: graph[source]["importers"].add("__entrypoint__") diff --git a/desloppify/languages/csharp/detectors/deps_support_razor.py b/desloppify/languages/csharp/detectors/deps_support_razor.py new file mode 100644 index 000000000..b9e7cfc49 --- /dev/null +++ b/desloppify/languages/csharp/detectors/deps_support_razor.py @@ -0,0 +1,330 @@ +"""Razor/Blazor view parsing helpers for C# dependency graph building. + +Razor views (``.razor``, ``.cshtml``) are not scored as C# source, but they are +the only place many C# symbols are referenced from. Parsing them here mirrors +the TypeScript plugin's handling of ``.svelte``/``.vue``/``.astro``: the markup +contributes graph edges so code that is reachable only from a view is not +reported as orphaned. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from desloppify.base.discovery.file_paths import resolve_path +from desloppify.base.discovery.source import SourceDiscoveryOptions, find_source_files + +RAZOR_EXTENSIONS = (".razor", ".cshtml") + +# Ambient using files: their directives apply to every view in the directory +# subtree below them, which is how Razor itself resolves them. +_AMBIENT_IMPORT_NAMES = ("_Imports.razor", "_ViewImports.cshtml") + +_RAZOR_USING_RE = re.compile( + r"(?m)^\s*@using\s+(?:static\s+)?([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s*$" +) +_RAZOR_USING_ALIAS_RE = re.compile( + r"(?m)^\s*@using\s+[A-Za-z_]\w*\s*=\s*([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s*$" +) +_RAZOR_NAMESPACE_RE = re.compile( + r"(?m)^\s*@namespace\s+([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*)\s*$" +) +# @inherits/@implements/@model/@attribute carry fully-qualified type names often +# enough to be worth treating as namespace hints. +_RAZOR_TYPE_DIRECTIVE_RE = re.compile( + r"(?m)^\s*@(?:inherits|implements|model|typeparam)\s+" + r"([A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+)" +) +_RAZOR_PAGE_RE = re.compile(r"(?m)^\s*@page\b") + +# Component usage: , , , . +# Razor components are PascalCase; plain HTML elements are lowercase. +_COMPONENT_TAG_RE = re.compile(r"\[\],\.\?]+\s+(\w+)\s*(?:<[^>(]*>)?\s*\(\s*(?:\[[^\]]*\]\s*)*this\s+" +) + +# Member invocations in a view: `@total.ToBadge()`, `@Model.Items.Format()`. +_INVOKED_MEMBER_RE = re.compile(r"\.(\w+)\s*\(") + +# MVC and Razor Pages reference other views by string name, never by type: +# , Html.PartialAsync("_Card"), Layout = "_Layout". +_VIEW_BY_NAME_RE = re.compile( + r"""(?:]*?\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" 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 \"/\"\n

Home

\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*@\n

nothing

\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\n

Hi

\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\n

nothing 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 \"/\"\n

Home

\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()