📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How do you write a Roslyn analyser, and how do you test it and ship it to a team?

Authoring DiagnosticAnalyzer: descriptors, registration actions, concurrency, generated code, testing with the Roslyn testing packages, severity configuration and packaging.

Senior Coding Advanced analysers code-quality diagnostics roslyn testing

🎯 Key points

  • Derive from DiagnosticAnalyzer and declare DiagnosticDescriptors with stable ids
  • Call EnableConcurrentExecution and ConfigureGeneratedCodeAnalysis in Initialize
  • Choose symbol, syntax or operation actions based on the rule; resolve types once per compilation
  • Analysers must be stateless or thread-safe
  • Test with the Roslyn analyser testing packages using diagnostic markup
  • Ship via analyser project reference or NuGet analyzers/dotnet/cs
  • Roll out as a warning, fix existing cases, then raise severity via .editorconfig

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Choose the rule and the level

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

2. Writing it

[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.

3. Testing

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.

4. Shipping it to a team

  • Project: netstandard2.0, reference the minimum Microsoft.CodeAnalysis.CSharp version your team's SDK/IDE supports, EnforceExtendedAnalyzerRules.
  • Package: pack the DLL into 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.
  • Distribute via an internal feed; reference it centrally from Directory.Build.props (or Directory.Packages.props with GlobalPackageReference).
  • Configure severities in .editorconfig (dotnet_diagnostic.SGM001.severity = error), allowing teams to phase in.
  • Docs: a page per rule (why, how to fix, how to suppress) linked from helpLinkUri.
  • Code fix provider where the fix is mechanical.
  • Version carefully: new rules start as warnings or disabled; breaking severity changes in major versions.
  • CI: build with TreatWarningsAsErrors for the rule set once clean.

5. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Analyser lifecycle and concurrency

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.

2. IOperation tree

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).

3. Diagnostic descriptors

  • ID prefix unique to your organisation (avoid collisions with CA/IDE/third-party).
  • Category, default severity, isEnabledByDefault, help link, custom tags (WellKnownDiagnosticTags.CompilationEnd for end actions, Unnecessary for fading).
  • Message formats with arguments for context.

4. Performance

  • Use RegisterCompilationStartAction to check quickly whether the rule applies (referenced types present).
  • Avoid GetSemanticModel calls in syntax actions when operation actions suffice.
  • /p:ReportAnalyzer=true in builds reports time per analyser.
  • Keep allocations low; avoid LINQ in hot callbacks.

5. Suppressions

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.

Try it yourself

  1. Create an analyser from the template and implement SGM001.
  2. Write positive/negative tests with markup.
  3. Pack and consume it via a local feed with .editorconfig severity.
  4. Measure analyser time with ReportAnalyzer.

💻 Code

// 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(); }
        """);
}

⚠ Common mistakes

1. Mutable shared state in analysers

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.

2. Name-based matching only

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.

3. Looking up symbols per callback

What happens: GetTypeByMetadataName in every operation action. Why it is wrong: slow. Do instead: cache in compilation-start actions.

4. Missing negative tests

What happens: testing only cases that should report. Why it is wrong: false positives frustrate teams. Do instead: test non-reporting scenarios thoroughly.

5. Shipping as a runtime dependency

What happens: analyzer package flows to consumers' runtime dependencies. Why it is wrong: pollution and confusion. Do instead: DevelopmentDependency=true, analyzer folder only.

6. Starting new rules as errors

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.

7. No documentation

What happens: cryptic diagnostics. Why it is wrong: developers suppress instead of fixing. Do instead: help links and clear messages.

8. Analysing generated code

What happens: diagnostics in .g.cs files. Why it is wrong: noise developers can't fix. Do instead: ConfigureGeneratedCodeAnalysis(None).

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says the analyser ships with a code fix Why: Checks code fix authoring
    How to answer

    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.

  2. When: Candidate mentions built-in CA rules Why: Probes the consumer view of Roslyn analysers
    How to answer

    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.

  3. When: Candidate mentions .editorconfig severity Why: Checks analyser configuration in builds
    How to answer

    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.

🛠 Real-world problems: how you handle them

Analyser stopped async void crashes in worker services
Situation
Several incidents in [background worker services] came from exceptions in async void methods crashing the process.
Signals
Process crashes with unhandled exceptions from methods not awaited by any caller.
Investigation
Post-incident reviews found the same pattern in [several] repos.
Resolution
Wrote an analyser (DEMO001) with a code fix to return Task, shipped via an internal NuGet package as a warning, fixed [37] existing cases, then set severity to error.
Prevention
Added the package to the organisation's `Directory.Build.props` template.

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.

↩ Usually asked after

More in Compilation

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me