Skip to content

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 to warning → flip to error once 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 + optional CodeFixProvider, package as .nupkg with analyzers/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

  1. .editorconfig per-folder — versioned, reviewable. Best for "this rule never makes sense in this folder".
  2. [SuppressMessage] attribute on a member — local, reviewable, justification required.
  3. GlobalSuppressions.cs — generated by IDE for "Suppress in Suppression File". OK but invisible.
  4. #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:

dotnet format --verify-no-changes --severity warning

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 build and 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

        time →
NEW       MATURE              FROZEN
suggestion → warning → warning → error
                   add to /warnaserror

For an existing codebase:

  1. Adopt analyzers with severity suggestion. No CI break.
  2. Run dotnet format, fix easy hits, merge.
  3. Promote rules to warning in .editorconfig. Build is noisy but green.
  4. Set <TreatWarningsAsErrors>true</TreatWarningsAsErrors> once the warning count is zero.
  5. 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 -bl produces 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 via generated_code = true in .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 .editorconfig at repo root, optional per-folder overrides.
  • Per-project: .editorconfig next to .csproj. Use sparingly — usually only for tests/migrations.
  • A child .editorconfig adds/overrides parent settings unless root = true is 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.

T:Newtonsoft.Json.JsonConvert;Use System.Text.Json
M:System.DateTime.get_Now;Use TimeProvider

Brilliant for migrating off legacy APIs without grep-and-shame.


Code: correct vs wrong

❌ Wrong: project-wide NoWarn

<PropertyGroup>
  <NoWarn>CA1303;CA1822;CA2007;SA1101;SA1633</NoWarn>
</PropertyGroup>

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

#pragma warning disable CA2000, CA1822, SA1633
namespace MyApp;

✅ 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

- run: dotnet build       # warnings ignored, drift wins

✅ 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, not error.
  • 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 = All on a brownfield repo without a budget — thousands of new warnings.
  • Avoid hand-tweaking GlobalSuppressions.cs instead 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.
  • ⚠️ TreatWarningsAsErrors on day one of a brownfield repo — months of suppression PRs.
  • ⚠️ Forgetting PrivateAssets="all" — your analyzer leaks downstream.
  • ⚠️ Editor-only analyzer rule at default severity — 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 disable without restore — 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 format as the source of truth.

Further reading