Roslyn Analyzers in CI
Key Points
- Analyzers are NuGet packages that hook into the C# compiler and surface diagnostics during build and in the IDE — same warnings/errors everywhere.
- Consume, don't author (this topic). Curate a baseline set: Microsoft.CodeAnalysis.NetAnalyzers, StyleCop.Analyzers, SonarAnalyzer.CSharp, Roslynator.Analyzers, Meziantou.Analyzer.
- Configure severity in
.editorconfig(dotnet_diagnostic.CA1822.severity = warning); use Central Package Management (Directory.Packages.props) so every project gets the same versions. - CI gates:
dotnet build /warnaserror,dotnet format --verify-no-changes, SARIF upload to GitHub code scanning. Editor-only analyzers don't break CI — set explicit severities. - Severity escalation is a journey: introduce as
suggestion→ flip towarning→ flip toerroronce the codebase is clean. Don't bulk-suppress and forget. - 💡 The win is consistency. New devs see the same red squigglies the build server enforces — no "works on my machine" lint drift.
Concepts (deep dive)
Authoring vs consuming — different worlds
- Authoring an analyzer: write a
DiagnosticAnalyzer+ optionalCodeFixProvider, package as.nupkgwithanalyzers/dotnet/cs/content. Covered in Source Generators & Analyzers. - Consuming analyzers (this topic): add them to your solution, configure severities, gate CI on diagnostics. This is what 99% of teams need.
The pipeline
┌──────────────┐ PackageReference ┌──────────────────────┐
│ Your project │ ──────────────────► │ Analyzer NuGet (.dll)│
└──────────────┘ └─────────┬────────────┘
│ │
│ Roslyn loads analyzer at compile │
│ and IDE design time │
▼ ▼
csc.exe ──► diagnostics ──► .editorconfig severities
│
▼
warnings / errors / SARIF / build break
Same DLL runs in dotnet build, dotnet build /warnaserror, Visual Studio, Rider, and VS Code's C# extension. There's no separate "lint" step.
The baseline analyzer set
| Package | What it does |
|---|---|
Microsoft.CodeAnalysis.NetAnalyzers | The "CA" rules — built into the SDK; toggle with <EnableNETAnalyzers>true</EnableNETAnalyzers> (default in .NET 5+). Catches API misuse, perf, security. |
StyleCop.Analyzers | "SA" rules — formatting, ordering, naming, doc comments. Opinionated. |
SonarAnalyzer.CSharp | "S" rules from SonarQube/SonarCloud. Bugs, code smells, security hotspots. Free for OSS. |
Roslynator.Analyzers (+ Roslynator.CodeAnalysis.Analyzers, Roslynator.Formatting.Analyzers) | "RCS" rules. Refactorings + style + best practices. Huge rule set. |
Meziantou.Analyzer | "MA" rules — pragmatic checks (tasks/async, IDisposable, security, patterns). |
IDisposableAnalyzers | "IDISP" — leak detection on IDisposable. |
AsyncFixer | "AsyncFixer*" — async/await pitfalls. |
SecurityCodeScan.VS2019 | OWASP-leaning security checks. |
Microsoft.VisualStudio.Threading.Analyzers | "VSTHRD" — sync-over-async, JoinableTaskFactory rules. Mostly for VS extensions; also useful for desktop apps. |
Don't ship them all on day one — pick 2-3 and tune.
Adding analyzers via Central Package Management
Directory.Packages.props in repo root:
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="SonarAnalyzer.CSharp" Version="9.32.0.97167" />
<PackageVersion Include="Roslynator.Analyzers" Version="4.12.4" />
<PackageVersion Include="Meziantou.Analyzer" Version="2.0.165" />
</ItemGroup>
</Project>
Directory.Build.props:
<Project>
<ItemGroup>
<PackageReference Include="StyleCop.Analyzers" PrivateAssets="all" />
<PackageReference Include="SonarAnalyzer.CSharp" PrivateAssets="all" />
<PackageReference Include="Roslynator.Analyzers" PrivateAssets="all" />
<PackageReference Include="Meziantou.Analyzer" PrivateAssets="all" />
</ItemGroup>
</Project>
Every project in the repo automatically gets the analyzers. PrivateAssets="all" keeps them out of the consuming project's transitive graph.
MSBuild props that matter
<PropertyGroup>
<!-- Run NetAnalyzers (CA*) - default true on .NET 5+ -->
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<!-- Run code-style (IDE*) rules in build, not just IDE -->
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<!-- The ratchet: any warning fails the build -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Leak some out as warnings if needed -->
<WarningsNotAsErrors>CA1822;CS0618</WarningsNotAsErrors>
<!-- Analysis level: 'latest', 'latest-recommended', 'preview', or '8.0', '9.0' -->
<AnalysisLevel>latest-recommended</AnalysisLevel>
<AnalysisMode>Recommended</AnalysisMode>
<!-- Generated code skip -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
AnalysisMode values: None, Default, Minimum, Recommended, All. Recommended is a sane starting point; All lights up everything (expect noise).
.editorconfig — the control panel
Per-rule severity overrides live here, so they version with the code and apply to IDE + build identically.
root = true
[*.cs]
# Use 4 spaces.
indent_style = space
indent_size = 4
# C# language conventions
csharp_style_var_for_built_in_types = true:warning
csharp_prefer_braces = true:error
# CA — Microsoft NetAnalyzers
dotnet_diagnostic.CA1822.severity = none # Mark members as static (noisy)
dotnet_diagnostic.CA2007.severity = none # ConfigureAwait — disable in app code
dotnet_diagnostic.CA1305.severity = warning # IFormatProvider
# SA — StyleCop
dotnet_diagnostic.SA1101.severity = none # PrefixLocalCallsWithThis (off)
dotnet_diagnostic.SA1633.severity = none # File header
# S — Sonar
dotnet_diagnostic.S125.severity = warning # Sections of code should not be commented out
# Top-of-file ordering, file-scoped namespace, etc.
dotnet_diagnostic.IDE0161.severity = error # File-scoped namespace
Severity values: none / silent / suggestion / warning / error / default.
Per-folder overrides
# Generated code — relax everything
[**/Migrations/**.cs]
generated_code = true
# Tests — allow Asserts in private methods
[**/*Tests.cs]
dotnet_diagnostic.CA1707.severity = none # Underscores in names (test_method_name)
Suppressions — least-bad to worst
.editorconfigper-folder — versioned, reviewable. Best for "this rule never makes sense in this folder".[SuppressMessage]attribute on a member — local, reviewable, justification required.GlobalSuppressions.cs— generated by IDE for "Suppress in Suppression File". OK but invisible.#pragma warning disable XYZ/restore— last-resort, line-precise. Always include a comment.
[SuppressMessage("Performance", "CA1822:Mark members as static",
Justification = "Required signature for ASP.NET Core action.")]
public IActionResult Get() => Ok();
#pragma warning disable CA2000 // Dispose objects before losing scope
var stream = await response.Content.ReadAsStreamAsync(); // ownership transferred to caller
#pragma warning restore CA2000
⚠️ Bulk-applying <NoWarn>CA1303;CA1822;...</NoWarn> in a project is the worst pattern — it hides everything project-wide with no justification.
dotnet format and --verify-no-changes
dotnet format runs whitespace, code-style (IDE*), and analyzer fixers. In CI:
Exits non-zero if any fixable issue would be modified. Pair with dotnet build /warnaserror for a full lint+build gate.
CI integration: GitHub Actions
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '9.0.x' }
- run: dotnet restore
- run: dotnet format --verify-no-changes --severity warning
- run: |
dotnet build /warnaserror /p:ContinuousIntegrationBuild=true \
-p:ErrorLog="${{ github.workspace }}/analyzer.sarif%3Bversion=2.1"
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: analyzer.sarif
The /p:ErrorLog=...sarif%3Bversion=2.1 flag tells Roslyn to emit a SARIF file. GitHub's code-scanning UI surfaces them as alerts with locations. (%3B is URL-encoded ;.)
Azure DevOps
- task: UseDotNet@2
inputs: { version: '9.0.x' }
- script: dotnet format --verify-no-changes
- script: |
dotnet build /warnaserror \
-p:ErrorLog=$(Build.ArtifactStagingDirectory)/analyzer.sarif%3Bversion=2.1
- task: PublishBuildArtifacts@1
inputs:
pathToPublish: '$(Build.ArtifactStagingDirectory)/analyzer.sarif'
artifactName: 'CodeAnalysisLogs' # Magic name — Azure DevOps SARIF viewer picks it up
Build-time vs editor-only analyzers
Analyzers can declare themselves IDE-only or build-time via DiagnosticDescriptor.CustomTags and RegisterCompilationStartAction. In practice:
- Build-time rules show up in
dotnet buildand IDE. - IDE-only (rare) only appear in design-time. ⚠️ These won't fail CI unless you escalate them in
.editorconfig.
Test a rule's CI behaviour by deliberately violating it on a branch and watching the build.
Severity escalation strategy
For an existing codebase:
- Adopt analyzers with severity
suggestion. No CI break. - Run
dotnet format, fix easy hits, merge. - Promote rules to
warningin.editorconfig. Build is noisy but green. - Set
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>once the warning count is zero. - Use
<WarningsNotAsErrors>for stragglers you can't fix yet.
For a greenfield project: skip to step 4 immediately.
Performance considerations
- Analyzers run on every keystroke in the IDE. Banned APIs, DocumentationDiagnosticAnalyzer, and overly-broad SyntaxNodeActions are notorious slowdowns.
dotnet build -blproduces a.binlog; open in MSBuild Structured Log Viewer to see per-analyzer time.<ReportAnalyzer>true</ReportAnalyzer>dumps per-analyzer ms in build output.- ⚠️ Source generators run before analyzers. Generators that touch every type (e.g. broad
[GenerateShape]) plus heavy analyzers can balloon build time. Disable expensive analyzers on generated files viagenerated_code = truein.editorconfig.
CompilerGeneratedFiles and source-gen interaction
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
Useful for inspecting what generators produced. Analyzers should respect [GeneratedCode] and generated_code = true and skip generated files.
Per-project vs solution-wide rules
- Solution-wide (preferred): one
.editorconfigat repo root, optional per-folder overrides. - Per-project:
.editorconfignext to.csproj. Use sparingly — usually only for tests/migrations. - A child
.editorconfigadds/overrides parent settings unlessroot = trueis set.
Banned APIs
Microsoft.CodeAnalysis.BannedApiAnalyzers reads BannedSymbols.txt and flags use of things like DateTime.Now (use TimeProvider), Newtonsoft.Json (use System.Text.Json), or your team's deprecated helpers.
Brilliant for migrating off legacy APIs without grep-and-shame.
Code: correct vs wrong
❌ Wrong: project-wide NoWarn
No justification, hides everything everywhere, drifts forever.
✅ Correct: severities in .editorconfig with comments
# CA1303 — Pass an IFormatProvider. Off because UI strings are localized via resources.
dotnet_diagnostic.CA1303.severity = none
❌ Wrong: blanket #pragma warning disable at top of file
✅ Correct: scoped suppression with justification
[SuppressMessage("Reliability", "CA2000",
Justification = "Stream ownership transferred to HttpContent.")]
private static HttpContent Wrap(Stream s) => new StreamContent(s);
❌ Wrong: skipping CI lint
✅ Correct: enforce on CI
- run: dotnet format --verify-no-changes --severity warning
- run: dotnet build /warnaserror -p:ErrorLog=analyzer.sarif%3Bversion=2.1
Design patterns for this topic
Pattern 1 — "Repo-root .editorconfig with root = true"
- Intent: one source of truth; every IDE and build picks it up automatically.
Pattern 2 — "Central Package Management for analyzers"
- Intent: every project gets the same analyzer versions; bumping is one-line.
Pattern 3 — "Severity ratchet"
- Intent: suggestion → warning → error over time. Never regress.
Pattern 4 — "BannedSymbols for legacy migration"
- Intent: mechanically prevent re-introduction of
Newtonsoft.Json,DateTime.Now, etc., during a migration.
Pattern 5 — "SARIF upload to code scanning"
- Intent: PR-level visibility of analyzer issues, just like CodeQL alerts.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Analyzers | Free quality lift; IDE+CI parity | Build-time cost; noise on legacy code |
TreatWarningsAsErrors | Hard ratchet | Painful on adoption day |
| StyleCop | Opinionated formatting | Many rules a team will disable |
| Sonar | Strong bug+security ruleset | Some rules overlap with CA |
.editorconfig | Versioned, IDE-portable | Can balloon to 1000 lines |
BannedSymbols | Mechanical migration enforcement | One file to maintain |
dotnet format --verify-no-changes | Fast lint gate | Doesn't catch all analyzer rules — pair with build |
When to use / when to avoid
- Use baseline analyzers from day one of a greenfield project. Adoption pain is zero.
- Use for legacy codebases — but adopt incrementally with
suggestion, noterror. - Use SARIF upload for PR-level review on GitHub or Azure DevOps.
- Avoid ad-hoc per-PR rule disables — they accrete forever.
- Avoid running
Microsoft.CodeAnalysis.NetAnalyzers.AnalysisMode = Allon a brownfield repo without a budget — thousands of new warnings. - Avoid hand-tweaking
GlobalSuppressions.csinstead of fixing the rule, unless the issue truly is a false positive.
Interview Q&A
Q1. Where do you set analyzer severity? .editorconfig via dotnet_diagnostic.<rule_id>.severity = warning|error|none|.... Versioned with code; applied identically by IDE and build.
Q2. EnableNETAnalyzers vs EnforceCodeStyleInBuild? EnableNETAnalyzers enables the CA* analyzers. EnforceCodeStyleInBuild makes the IDE* code-style rules run during build, not just in the editor.
Q3. How do you fail CI on warnings? <TreatWarningsAsErrors>true</TreatWarningsAsErrors> in Directory.Build.props, or dotnet build /warnaserror. Use <WarningsNotAsErrors> for exceptions.
Q4. Suppression precedence? Per-folder .editorconfig ▸ [SuppressMessage] attribute ▸ GlobalSuppressions.cs ▸ #pragma warning disable. Pick the most local you can.
Q5. SARIF — what is it? Static Analysis Results Interchange Format. Roslyn emits it via /p:ErrorLog=foo.sarif%3Bversion=2.1. GitHub code scanning and Azure DevOps render it as PR annotations.
Q6. Common analyzer packages you'd add to a new project? NetAnalyzers (default), StyleCop.Analyzers, SonarAnalyzer.CSharp, Roslynator.Analyzers, Meziantou.Analyzer. Plus BannedApiAnalyzers for migrations.
Q7. Editor-only vs build-time analyzers? Some analyzers run only in the IDE; their diagnostics don't break CI unless escalated in .editorconfig. Verify CI behaviour by deliberately tripping the rule on a branch.
Q8. Why PrivateAssets="all" on analyzer references? Keeps the analyzer DLLs out of the transitive package graph of consumers — your library users don't get your analyzers as a side-effect.
Q9. How do you adopt analyzers on a 5-year-old codebase? Add packages with severities at suggestion. Run dotnet format to fix easy ones. Promote to warning. Once warning count is zero, flip TreatWarningsAsErrors. Use <WarningsNotAsErrors> for stragglers.
Q10. How do you keep analyzers fast? Skip generated files via generated_code = true. Profile with <ReportAnalyzer>true</ReportAnalyzer> and .binlog. Disable expensive rules you don't need.
Q11. dotnet format --verify-no-changes — what does it gate? Whitespace, code-style (IDE*) rules, and analyzer fixers. Exits non-zero on any change — perfect lint gate.
Q12. What's BannedSymbols.txt for? Mechanical enforcement: ban DateTime.Now, Newtonsoft.Json, string.Concat, your own deprecated helpers, etc. Migration teams love it.
Gotchas / common mistakes
- ⚠️ Project-wide
<NoWarn>without comment — hides drift forever. - ⚠️
TreatWarningsAsErrorson day one of a brownfield repo — months of suppression PRs. - ⚠️ Forgetting
PrivateAssets="all"— your analyzer leaks downstream. - ⚠️ Editor-only analyzer rule at
defaultseverity — diag in IDE, silent in CI. - ⚠️ Mismatched analyzer versions across projects — different builds, different warnings. Use Central Package Management.
- ⚠️ Heavy analyzers on generated files — slow build, no value. Use
generated_code = true. - ⚠️
#pragma warning disablewithoutrestore— disabled to end of file. - ⚠️ Ignoring
[GeneratedCode]— analyzer authors should skip generated code; some don't. Suppress per-folder. - ⚠️ Conflicting StyleCop + ReSharper formatters — pick one. Run
dotnet formatas the source of truth.