Packaging, publishing and consuming Roslyn analyzers

Blazor Analyzer Feature Image5

We have a tested analyzer. Now we ship it. A Roslyn analyzer is packaged differently from a normal library: the assembly goes to a special folder inside the package so the compiler knows to load it as an analyzer rather than to reference it as a library.

The full list of articles about the Roslyn analyzers is:

The full source code of the analyzer is available on GitHub.

The magic folder: analyzers/dotnet/cs

When NuGet restores a package, any assembly under analyzers/dotnet/cs/ is handed to the C# compiler as an analyzer — not added to your bin, not referenced by your code. That path is the whole trick. So packaging an analyzer means: don’t put the assembly in lib/, do put it in analyzers/dotnet/cs/.

Add this packaging block to PSC.Analyzers.Blazor.csproj:

<PropertyGroup>
  <IsPackable>true</IsPackable>
  <IncludeBuildOutput>false</IncludeBuildOutput>
  <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
  <IncludeSymbols>false</IncludeSymbols>
  <DevelopmentDependency>true</DevelopmentDependency>
  <Version>1.0.0</Version>
  <Authors>PSC</Authors>
  <Description>Roslyn analyzers enforcing PSC Blazor conventions.</Description>
  <PackageTags>roslyn;analyzers;blazor</PackageTags>
</PropertyGroup>

<!-- Place the analyzer assembly in the well-known analyzer path. -->
<ItemGroup>
  <None Include="$(OutputPath)$(AssemblyName).dll"
        Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>

Each line earns its place:

  • IncludeBuildOutput=false — stops NuGet putting the DLL in lib/. We place it ourselves in the line below.
  • None Include=… PackagePath="analyzers/dotnet/cs" — puts the built assembly exactly where the compiler looks for analyzers.
  • SuppressDependenciesWhenPacking=true — your Microsoft.CodeAnalysis.* references are provided by the compiler; they must not become package dependencies. This also avoids the NU5128 packaging warning.
  • DevelopmentDependency=true — marks the package as build-time only, so it doesn’t flow transitively to your consumers.

Create the package

dotnet pack src/PSC.Analyzers.Blazor -c Release -o ./nupkgs

Verify the layout — open the .nupkg (it’s a zip) and you should see:

PSC.Analyzers.Blazor.nuspec
analyzers/dotnet/cs/PSC.Analyzers.Blazor.dll

No lib/ folder, no dependencies in the nuspec. That’s a correct analyzer package.

Publish it

To a private Azure Artifacts feed:

dotnet nuget push ./nupkgs/*.nupkg \
  --source "https://pkgs.dev.azure.com/<org>/<project>/_packaging/<feed>/nuget/v3/index.json" \
  --api-key az

Or to nuget.org:

dotnet nuget push ./nupkgs/*.nupkg --source https://api.nuget.org/v3/index.json --api-key <key>

In CI you’d run dotnet pack then dotnet nuget push on merges to main, usually stamping a build-number version like 1.0.$(BuildId) via -p:Version=.

Consume it

In any project, reference the package like a normal analyzer. PrivateAssets="all" keeps it from leaking to downstream consumers of your assembly:

<PackageReference Include="PSC.Analyzers.Blazor" Version="1.0.*" PrivateAssets="all" />

Restore, and PSC001 immediately lights up in the editor and in dotnet build. No code change, no config — the rule is just on.

To apply it to an entire solution at once, put the reference in a Directory.Build.props at the repository root:

<Project>
  <ItemGroup>
    <PackageReference Include="PSC.Analyzers.Blazor" Version="1.0.*" PrivateAssets="all" />
  </ItemGroup>
</Project>

Every project under that folder now gets the analyzer automatically.

From warning to build-breaker

By default PSC001 is a warning. Two ways to make it enforce:

Per repo, via .editorconfig (fine-grained, the idiomatic choice):

[*.cs]
dotnet_diagnostic.PSC001.severity = error

Behind a toggle, via WarningsAsErrors — handy when you want a gentle adoption period, then flip the switch in CI:

<PropertyGroup Condition="'$(PscAnalyzersAsErrors)' == 'true'">
  <WarningsAsErrors>$(WarningsAsErrors);PSC001</WarningsAsErrors>
</PropertyGroup>
dotnet build /p:PscAnalyzersAsErrors=true

Shipping a new version

When you release, update the release-tracking files from Part 1: move the PSC001 row from AnalyzerReleases.Unshipped.md into AnalyzerReleases.Shipped.md under a version heading:

## Release 1.0.0

### New Rules

Rule ID | Category   | Severity | Notes
--------|------------|----------|-----------------------------------------
PSC001  | PSC.Blazor | Warning  | Component parameter should be PascalCase

Bump the version, pack, push. Consumers pick it up on their next restore.

Wrapping up the series

Across four parts we went from an empty folder to a published, tested analyzer:

  1. Setup — a netstandard2.0 library with Roslyn as a private dependency.
  2. The rule — a DiagnosticAnalyzer that resolves Blazor types from the compilation and reports on [Parameter] properties that aren’t PascalCase.
  3. Tests — in-memory compilations with inline markup, positive and negative.
  4. Ship — the analyzers/dotnet/cs package layout, publishing, and consuming.

The same pattern scales to any rule you can express semantically. Want a “use EventCallback, not Action” rule, or “don’t block on async with .Result”? Add another DiagnosticAnalyzer, another test class, another row in the release notes — and your team’s conventions enforce themselves, right in the editor.

Related posts

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.