Authoring DiagnosticAnalyzer: descriptors, registration actions, concurrency, generated code, testing with the Roslyn testing packages, severity configuration and packaging.
An analyser is a class deriving from DiagnosticAnalyzer with [DiagnosticAnalyzer(LanguageNames.CSharp)]. It declares DiagnosticDescriptors (id, title, message, category, default severity) and in Initialize calls EnableConcurrentExecution(), ConfigureGeneratedCodeAnalysis(...) and registers callbacks such as RegisterSymbolAction, RegisterSyntaxNodeAction or RegisterOperationAction, reporting with context.ReportDiagnostic. It targets netstandard2.0 like a generator. Test it with Microsoft.CodeAnalysis.CSharp.Analyzer.Testing using markup such as {|DEMO001:Run|}. Ship it as a project reference with OutputItemType="Analyzer" or a NuGet package, and let teams tune severity in .editorconfig.
A Roslyn analyser is a class deriving from DiagnosticAnalyzer that registers callbacks (on syntax nodes, symbols or operations) and reports diagnostics when code breaks a rule. Writing one well means: pick the right analysis level (usually operations), make it fast and concurrent-safe, test it thoroughly with the Roslyn testing framework, and ship it as a NuGet package configured through .editorconfig so the whole team gets it in the IDE and CI.
Example rule: "Async methods must pass the CancellationToken they receive to calls that accept one" (a stricter variant of CA2016 for our own APIs), or a domain rule like "Don't call DbContext.SaveChanges() inside loops".
| Level | API | Use for |
|---|---|---|
| Syntax | RegisterSyntaxNodeAction |
Formatting/naming/structure rules |
| Symbol | RegisterSymbolAction |
Declarations (public types must be sealed, naming) |
| Operation | RegisterOperationAction |
Semantic behaviour: calls, assignments, conversions — language-neutral and simpler |
| Compilation start/end | RegisterCompilationStartAction |
Caching symbols, cross-file rules |
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class SaveChangesInLoopAnalyzer : DiagnosticAnalyzer
{
public const string Id = "SGM001";
private static readonly DiagnosticDescriptor Rule = new(
Id, "Avoid SaveChanges inside loops",
"'{0}' is called inside a loop; batch changes and save once after the loop",
"Performance", DiagnosticSeverity.Warning, isEnabledByDefault: true,
helpLinkUri: "https://wiki.internal/analyzers/SGM001");
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [Rule];
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterCompilationStartAction(start =>
{
var dbContext = start.Compilation.GetTypeByMetadataName("Microsoft.EntityFrameworkCore.DbContext");
if (dbContext is null) return; // EF not referenced: nothing to do
start.RegisterOperationAction(ctx =>
{
var invocation = (IInvocationOperation)ctx.Operation;
if (invocation.TargetMethod.Name is not ("SaveChanges" or "SaveChangesAsync")) return;
if (!InheritsFrom(invocation.TargetMethod.ContainingType, dbContext)) return;
for (var parent = invocation.Parent; parent is not null; parent = parent.Parent)
{
if (parent is ILoopOperation)
{
ctx.ReportDiagnostic(Diagnostic.Create(Rule, invocation.Syntax.GetLocation(), invocation.TargetMethod.Name));
return;
}
if (parent is IAnonymousFunctionOperation or ILocalFunctionOperation) return; // stop at lambdas
}
}, OperationKind.Invocation);
});
}
private static bool InheritsFrom(ITypeSymbol? t, INamedTypeSymbol b)
{
for (; t is not null; t = t.BaseType) if (SymbolEqualityComparer.Default.Equals(t, b)) return true;
return false;
}
}
Good practices shown: compilation-start caching of well-known types, early exits, symbol comparisons with SymbolEqualityComparer, concurrent execution, no generated-code analysis, a help link and a clear message.
Use Microsoft.CodeAnalysis.CSharp.Analyzer.Testing (xUnit/NUnit/MSTest adapters):
public class SaveChangesInLoopTests
{
[Fact]
public async Task Reports_SaveChanges_in_foreach() => await new CSharpAnalyzerTest<SaveChangesInLoopAnalyzer, DefaultVerifier>
{
ReferenceAssemblies = ReferenceAssemblies.Net.Net80.AddPackages([new PackageIdentity("Microsoft.EntityFrameworkCore", "8.0.0")]),
TestCode = """
using Microsoft.EntityFrameworkCore;
class Repo(DbContext db)
{
void Save(int[] ids) { foreach (var id in ids) { {|SGM001:db.SaveChanges()|}; } }
}
"""
}.RunAsync();
[Fact]
public async Task No_diagnostic_outside_loop() => ... // negative test
}
Test: positive cases, negative cases (outside loops, inside lambdas, non-EF SaveChanges methods), edge cases (nested loops, while, do), generated code, and performance on large files.
netstandard2.0, reference the minimum Microsoft.CodeAnalysis.CSharp version your team's SDK/IDE supports, EnforceExtendedAnalyzerRules.analyzers/dotnet/cs, set DevelopmentDependency=true so consumers don't get it as a runtime dependency; include a .props/.globalconfig with default severities if desired.Directory.Build.props (or Directory.Packages.props with GlobalPackageReference)..editorconfig (dotnet_diagnostic.SGM001.severity = error), allowing teams to phase in.helpLinkUri.TreatWarningsAsErrors for the rule set once clean.Repeated performance incidents came from SaveChanges inside loops in batch jobs (thousands of round trips). We wrote SGM001 as above, shipped it in our internal analyzer package with severity warning, and the first build flagged 37 occurrences across 12 services. After teams fixed them over two sprints, we raised the severity to error. The incident class stopped, and the same package later gained rules for missing CancellationToken forwarding in our own SDK.
The interviewer checks you can build and roll out team-wide code quality tooling.
Green flags: descriptor and registration, concurrency and generated code settings, right action choice, testing with markup, packaging, .editorconfig severity, gradual rollout.
Red flags: stateful analysers; no tests; enables as error immediately; unaware of built-in CA rules.
Analysers are instantiated once per compilation host and callbacks can run concurrently across files. State must be either immutable or stored per compilation (inside compilation-start actions), never in mutable instance fields shared across compilations. EnableConcurrentExecution signals thread safety.
Operations represent semantics: IInvocationOperation (target method, arguments with conversions), IPropertyReferenceOperation, IAssignmentOperation, ILoopOperation (IForEachLoopOperation, IWhileLoopOperation), IAnonymousFunctionOperation, etc. Walking Parent lets you find context (inside loops, try/catch, lambdas).
isEnabledByDefault, help link, custom tags (WellKnownDiagnosticTags.CompilationEnd for end actions, Unnecessary for fading).RegisterCompilationStartAction to check quickly whether the rule applies (referenced types present).GetSemanticModel calls in syntax actions when operation actions suffice./p:ReportAnalyzer=true in builds reports time per analyser.Users can suppress via #pragma, [SuppressMessage], .editorconfig severity none, or global suppressions. Provide guidance for legitimate exceptions. DiagnosticSuppressors can programmatically suppress other analysers' diagnostics in specific contexts.
.editorconfig severity.ReportAnalyzer.// Analyzers.csproj: netstandard2.0, Microsoft.CodeAnalysis.CSharp (PrivateAssets=all), EnforceExtendedAnalyzerRules
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class AsyncVoidAnalyzer : DiagnosticAnalyzer
{
public const string Id = "DEMO001";
private static readonly DiagnosticDescriptor Rule = new(
id: Id,
title: "Avoid async void methods",
messageFormat: "Method '{0}' is async void; return Task so callers can observe failures",
category: "Reliability",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule);
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSymbolAction(AnalyzeMethod, SymbolKind.Method);
}
private static void AnalyzeMethod(SymbolAnalysisContext context)
{
var method = (IMethodSymbol)context.Symbol;
if (!method.IsAsync || !method.ReturnsVoid) return;
if (IsEventHandler(method)) return; // async void is acceptable for event handlers
context.ReportDiagnostic(Diagnostic.Create(Rule, method.Locations[0], method.Name));
}
private static bool IsEventHandler(IMethodSymbol method)
{
if (method.Parameters.Length != 2) return false;
for (ITypeSymbol? t = method.Parameters[1].Type; t is not null; t = t.BaseType)
if (t.ToDisplayString() == "System.EventArgs") return true;
return false;
}
}
// Tests: Microsoft.CodeAnalysis.CSharp.Analyzer.Testing + xUnit
using Verify = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier<
AsyncVoidAnalyzer, Microsoft.CodeAnalysis.Testing.DefaultVerifier>;
public class AsyncVoidAnalyzerTests
{
[Fact]
public Task Flags_async_void() => Verify.VerifyAnalyzerAsync("""
using System.Threading.Tasks;
class Worker { async void {|DEMO001:Run|}() => await Task.Yield(); }
""");
[Fact]
public Task Allows_event_handlers() => Verify.VerifyAnalyzerAsync("""
using System;
using System.Threading.Tasks;
class Form { async void OnClick(object sender, EventArgs e) => await Task.Yield(); }
""");
}
What happens: instance fields store data across callbacks. Why it is wrong: concurrency bugs and leaks across compilations. Do instead: compilation-start scoped state or immutable data.
What happens: checking method.Name == "SaveChanges" without type checks.
Why it is wrong: false positives on unrelated methods.
Do instead: verify containing types with symbols.
What happens: GetTypeByMetadataName in every operation action.
Why it is wrong: slow.
Do instead: cache in compilation-start actions.
What happens: testing only cases that should report. Why it is wrong: false positives frustrate teams. Do instead: test non-reporting scenarios thoroughly.
What happens: analyzer package flows to consumers' runtime dependencies.
Why it is wrong: pollution and confusion.
Do instead: DevelopmentDependency=true, analyzer folder only.
What happens: builds break across many repos at once. Why it is wrong: teams disable the package. Do instead: start as warnings; raise severity later.
What happens: cryptic diagnostics. Why it is wrong: developers suppress instead of fixing. Do instead: help links and clear messages.
What happens: diagnostics in .g.cs files.
Why it is wrong: noise developers can't fix.
Do instead: ConfigureGeneratedCodeAnalysis(None).
Analysers turn incident lessons into automatic guardrails.
| Rule source | Example analyser |
|---|---|
| Production incident | async void outside event handlers |
| Architecture decision | Domain project must not reference infrastructure namespaces |
| Security review | Ban BinaryFormatter or string-built SQL |
| Performance review | No .Result or .Wait() on tasks in ASP.NET Core code |
Before writing one, seniors check whether the built-in .NET analysers (CA rules) or a well-known package already cover the rule.
A code fix is a class deriving from CodeFixProvider, exported with [ExportCodeFixProvider(LanguageNames.CSharp)] and [Shared]. It lists the diagnostic ids it can fix in FixableDiagnosticIds, and in RegisterCodeFixesAsync finds the syntax at the diagnostic's location and registers a CodeAction that returns a new Document (or Solution). Give each action an equivalenceKey and return WellKnownFixAllProviders.BatchFixer from GetFixAllProvider so users can apply it to a document, project or solution. A safe fix preserves trivia (comments and formatting), adds simplifier and formatter annotations, never changes behaviour silently, and is covered by tests that compare before and after source.
Roslyn is the C# compiler exposed as an API: code becomes syntax trees, a semantic model that knows what every symbol means, and language-neutral operations. A Roslyn analyser is a plug-in that subscribes to those and reports diagnostics in the IDE and in every build, so team rules become compiler warnings or errors instead of review comments. Start with what ships with the SDK: the .NET analysers (CAxxxx) and code style rules (IDExxxx), tuned with AnalysisLevel/AnalysisMode, .editorconfig severities and EnforceCodeStyleInBuild. BannedApiAnalyzers bans specific APIs from a BannedSymbols.txt file with no custom code. When you need a domain rule, write a DiagnosticAnalyzer targeting netstandard2.0, register operation or symbol actions, enable concurrent execution, test it, and ship it as a NuGet package or project reference.
Analysers are plug-ins for the Roslyn compiler. They report diagnostics in the editor and during the build. CA rules (CA1xxx to CA5xxx) are the SDK's code quality analysers, covering design, performance, reliability and security. The .NET analysers are enabled by default for net5.0+ projects (EnableNETAnalyzers), but only a default subset of rules reports as warnings; AnalysisLevel and AnalysisMode set how many apply. IDE rules (IDE0xxx) are code style rules, such as var usage or file-scoped namespaces. They show in the editor but only run in the build when EnforceCodeStyleInBuild is true, which is why style issues often pass CI. Severities for both are set in .editorconfig (or a .globalconfig) with dotnet_diagnostic.<id>.severity. Third-party analysers come in as NuGet packages. For a team, I put the levels in Directory.Build.props, the severities in one root config file, and tighten them step by step.
How to tell it (STAR)
We had [three] incidents from async void crashes. I wrote an analyser with a code fix, rolled it out as a warning, fixed [37] cases and then made it an error. We have not had that incident type since.
Last updated 31 Oct 2026.