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