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:
- Build a Roslyn Analyzer from scratch
- Writing your first diagnostic
- Unit testing your analyzer
- Packaging, publishing and consuming Roslyn analyzers
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 inlib/. 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— yourMicrosoft.CodeAnalysis.*references are provided by the compiler; they must not become package dependencies. This also avoids theNU5128packaging 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:
- Setup — a
netstandard2.0library with Roslyn as a private dependency. - The rule — a
DiagnosticAnalyzerthat resolves Blazor types from the compilation and reports on[Parameter]properties that aren’t PascalCase. - Tests — in-memory compilations with inline markup, positive and negative.
- Ship — the
analyzers/dotnet/cspackage 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.