Skip to content

feat: link Razor and Blazor views into the C# dependency graph - #682

Open
grinidx wants to merge 1 commit into
peteromallet:mainfrom
grinidx:feat/csharp-razor-blazor-views
Open

feat: link Razor and Blazor views into the C# dependency graph#682
grinidx wants to merge 1 commit into
peteromallet:mainfrom
grinidx:feat/csharp-razor-blazor-views

Conversation

@grinidx

@grinidx grinidx commented Jul 31, 2026

Copy link
Copy Markdown

Problem

The csharp plugin discovers only .cs (languages/csharp/__init__.py, extensions=[".cs"]), so .razor and .cshtml are invisible to it. Code reachable only from markup therefore looks unreferenced.

Minimal Blazor repro - a component with a code-behind partial:

src/App/Components/Widget.razor        <div>@Label - @Describe()</div>
src/App/Components/Widget.razor.cs     public partial class Widget { ... }
src/App/Components/Pages/Home.razor    @page "/"  ...  <Widget Label="Basket" />
$ desloppify --lang csharp detect orphaned

Orphaned files: 1 files, 11 LOC

File                                                  LOC
src/App/Components/Widget.razor.cs                    11

Widget.razor.cs is not dead. It is the code-behind for Widget.razor, which Home.razor renders. Deleting it breaks the build. The scan also reported "3 production files" for a 6-file project, because the three views were never seen.

Approach

Views are parsed for edges but stay out of the scored extension set, mirroring how the typescript plugin already handles .svelte, .vue and .astro (languages/typescript/detectors/deps/__init__.py, _FRAMEWORK_EXTENSIONS). Scores for existing projects are unchanged; this only adds graph edges.

Edges resolve by symbol name, not by namespace. This is the main design decision. My first attempt fed a view's @using into expand_namespace_matches, and it silently marked genuinely dead code as live: @using App.Services linked every file in that namespace, and since _Imports.razor broadcasts usings to a whole directory subtree, one ambient import would have marked entire namespaces live across an app. A @using says which namespaces are in scope, not which files are depended on. So the view edges go through a type-name index instead, with the using set used only to constrain scope.

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, Index.cshtml -> Index.cshtml.cs)
  • components it renders, including components declared in plain C# rather than a .razor file
  • partials and layouts referenced by string name (<partial name="_Card" />, Layout = "_Layout")
  • view components and tag helpers, which resolve by naming convention (InvokeAsync("Basket") -> BasketViewComponent, <price-tag /> -> PriceTagTagHelper)
  • @page marks a view as a routable root, like Program.cs
  • _Imports.razor and _ViewImports.cshtml usings apply to the directory 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 (a Razor class library).

Testing

17 new tests in languages/csharp/tests/test_csharp_deps_razor.py, following the structure of TestFrameworkFiles in test_ts_deps.py.

They include negative controls, which I would point a reviewer at first: a type no view names, an unused tag helper, and a never-included partial all stay unlinked. Those tests are what caught the namespace-granularity mistake above, and a second real bug where build_dep_graph returned early on a view-only project so none of this code ran.

Full suite: 6686 passed, 164 skipped. Six failures are pre-existing and identical on main (test_cli.py::TestStatePath, test_helpers.py::test_state_path_from_lang_arg, test_bash_unused_imports.py). ruff lint gate, mypy, and the import-linter contracts are all clean.

Measured effect on real codebases

On a ten-project Razor Pages solution (388k LOC, 334 views): 334 views enter the graph, contributing 1052 edges across 200 .cs files, with 136 routable pages marked as roots. On a small Blazor app: 5 views, 4 edges.

No file changed orphan status on either, and I would rather say so than imply otherwise. On those codebases the existing .cs namespace matching already links everything the views reach, so the false positive above does not surface there. I confirmed this by measuring rather than assuming: with expand_namespace_matches experimentally tightened, the view edges still rescue exactly zero files. The bug is real and reproducible, but its blast radius is projects where the namespace heuristic does not already over-link.

That interaction is worth its own discussion and I will open a separate issue for it, since the underlying cause turns out to be that the C# graph cannot resolve fully-qualified type references at all. Nothing in this PR depends on that being resolved.

Known limits

  • a view referenced through a runtime string (Html.PartialAsync(someVariable)) cannot be resolved statically
  • types reached purely by reflection or DI string keys are not linked
  • @inject IFooService links the interface, not the implementation, which is normally DI-registered from .cs anyway

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant