The blog of Sam Beynon

I bid you a humble welcome to the ramblings of my mind.

Make C# Safer By Making the Compiler Meaner - The Knowledge In Your Head Doesn't Scale

2026-04-10

Recently, I decided to spend some time with Haskell.

Suffice to say, It was a rough and humbling experience.

Coming from a procedural and object-oriented background, I found myself buried under unfamiliar concepts - currying, monads, type systems that felt less like guardrails and more like a brick wall.

But once I got something to compile, something interesting happened: It worked. Not "it seems fine." Not "we'll find out in QA." - It just… worked.

And that got me thinking.

In Haskell - and in languages like Rust - the compiler doesn't just check syntax. It actively pushes you away from entire classes of bugs. The kind we all know about. The kind we still ship anyway. The kind that show up at 4pm on a Friday as a NullReferenceException from code nobody has touched in months.

In C#, we don't really have that. The compiler tells us our types line up and our syntax is valid - but beyond that (for the most part), we rely on memory. Experience. Pain.

And that's a problem.

Because the most valuable knowledge we have as developers - the things that actually prevent bugs - lives in our heads. And things that live in your head don't freakin' scale, some poor junior developer is going to hit this issue when you could have prevented it.

So what am I trying to solve?

Currently, if I work on any C# project, the onus is on my memory to remember what kinds of bugs can arise from the common, everyday stuff we implement. The compiler tells us very little beyond "your types are correct and your syntax is valid" - which is a bit like a spell checker telling you your words are real English but having no opinion on whether the sentence makes any sense. Sometimes an IDE might offer a useful suggestion, but more often than not we're left to our own experiences and memories of painful encounters to guide us.

And that's fine for me, mostly. I've been bitten by enough production bugs to have a mental catalogue of things to watch for. But you know what? That catalogue lives in my head, and the problem with things that live in your head is that they don't scale. I can't paste my experience into a PR review. I can't install my memories into a junior developer's IDE.

Which brings me to the second problem - not everyone has had these painful encounters yet. A junior developer who hasn't yet experienced the "bliss" of a NullReferenceException at 4pm on a Friday because someone added a nullable field three sprints ago, shouldn't have to learn that lesson the hard way (Well, they probably should - we all know, the best way to improve, is to first suffer through a problem that needs fixing). The knowledge of "this pattern might make me cry on a random Friday" shouldn't be gatekept behind years of production incidents. It should be encoded into the tooling, available to everyone working on the codebase from day one, guiding them toward correctness without them needing to know why it matters yet. They'll learn the why in time, but in the meantime, the compiler has their back.

And then there's the elephant in the room - LLMs. Whether we like it or not, AI-generated code is a reality in most codebases now, and it introduces a genuinely new problem. An LLM doesn't have memories of painful production incidents. It doesn't read your team's wiki. It doesn't know your conventions unless you've very carefully prompted it, and even then it might decide to ignore them and do something weird and wacky instead. Code review has always been our safety net for catching these issues, but code review doesn't scale when a significant chunk of your PRs are AI-assisted and the volume of code being produced is higher than ever. Analyzers do scale. They don't get tired, they don't miss things because it's Friday afternoon, and they apply the same rules to every line of code regardless of who or what wrote it.

Okay, sure, but what can we do about it?

Well, we can utilise some new magic - the magic of a Roslyn analyser, and while these aren't new new, they are new enough. I've done some work with the source generators before and they were quite cool but what i want, is to enhance the development experience to the point of "If the compiler says it works, it probably works". This is probably a goal that i'll never achieve, but I can crawl toward it one bug at a time and if I happen to reduce the bug count in my own software when i'm feeling tired and not focusing as much as i probably should be or when i just want to get onto that really cool task that's waiting for me.

So here's what I'm going to do, I'm going to create a Nuget package that bundles all my analyser's together (Once i've built them, that is) so that I can use them in my own projects and if others benefit from it, then so be it - No skin off my back!

Which brings me to the ultimate question "What can I actually solve", And i've sat down and had a think, what are common errors that are solved by languages like Haskell, and Rust by enforcing things at the compilation level (As annoying as that may be to some) and here's a boring list of what i've come up with. Though I think it's important to note at this point that I will be releasing posts relating to these over time and discussing each one in depth to both explain why these are issues and how we actually solve them and then hopefully using that information to provide useful and contextual error messages.

My (boring list) Catalogue of "Things the Compiler Should Moan At You For"

Feel free to skim this, as I noted before, i'll be deep diving into them as I add the functionality to my project.

Async & Concurrency

Type Safety & Primitive Obsession

Mutation & Invariant Violations

Enumeration & Deferred Execution

Closures & Capture

Transactions & Consistency

Architectural

Polymorphism & Contracts

Equality & Identity

And you know what? I'm sure there are plenty more, and I look forward to finding them and hopefully being able to solve them in the same way, though static analysis can only go so far, I relish the chance to try.

Right, so that's what we want to catch. Now let's talk about how.

So, I've glossed over the fact that i'm discussing Roslyn analyzers, but haven't actually explained what they bloody well are, and frankly, if you've never worked on anything using the Roslyn API's before - it's a bit of a shock! Definitely looks and feels like a whole lot of magic. Rest assured, it's not! But goodness me are they awesome!

The Roslyn compiler is the current C# compiler, it has been for quite a while now! And via it's API's it actually exposes a boat load of really cool compile-time accessible stuff such as the syntax tree, semantic model, symbol resolution, type information... You know, pretty much every damn thing you could want, when writing static analysis! And the best part of all this, it allows us to add our own custom classes into the compile-time pipeline, that lets us inspect the code being compiled and emit some juicy warnings, errors and other diagnostic level information when our code finds something we want to throw away.

Most importantly of all, it's not a separate linting step, it is actually bootstrapped into the actual compilation itself and it runs in real time within your IDE, which means we get our wonderful, pleasant and familiar red and yellow squiggles while we're actively making fools of ourselves with bad code. But it does mean, that the feedback loop is immediate, you don't have to click "build" just to see your errors, they're there, in front of you, as you type them!

Analyzers can also ship with "code fix providers" which hook into that lovely little lightbulb menu to give you clickable, actionable fixes to the issue that is reported which essentially allows our IDE fix the problem for you. This is, primarily, the difference between "The compiler wants me to suffer" and "Oh my, the compiler is making me suffer, but is actually fixing the issues for me".

Project setup

Right, enough jabbering, let's put all this nonsense to good use and build something. I'm going to assume you've got a relatively modern .NET SDK installed and an IDE you're comfortable with (I use Rider, but VS works just as well for this).

I'm gonna go ahead and do this via CLI, simply to make it nice and agnostic for everyone and to ensure that the templates aren't missing anything annoying.

dotnet new sln -n Expedition.Analyzers
dotnet new classlib -n Expedition.Analyzers -f netstandard2.0
dotnet new classlib -n Expedition.Analyzers.CodeFixes -f netstandard2.0
dotnet new xunit -n Expedition.Analyzers.Tests
dotnet sln add Expedition.Analyzers Expedition.Analyzers.CodeFixes Expedition.Analyzers.Tests
An important note here: Analyzers target netstandard2.0. This is because they run inside the compiler process and various IDE hosts which have specific runtime requirements. Don't be tempted to target net8.0 or net9.0, it won't work properly and you'll get very confusing errors that don't tell you why.
Trust me on this one, I lost time to it so you don't have to.

Now we need the relevant NuGet packages on our analyzer project:

cd Expedition.Analyzers
dotnet add package Microsoft.CodeAnalysis.CSharp -v 4.8.0
dotnet add package Microsoft.CodeAnalysis.Analyzers -v 3.3.4

That second package is a meta-analyzer that helps you write analyzers correctly. An analyzer for your analyzers, if you will. It'll warn you if you do things like use APIs that aren't available in the analyzer hosting environment, which saves you from some really confusing runtime failures down the line.

And for our test project:

cd ../Expedition.Analyzers.Tests
dotnet add package Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit
dotnet add reference ../Expedition.Analyzers/Expedition.Analyzers.csproj

Now, there's one very important thing we need to configure in the analyzer .csproj. Analyzers aren't normal dependencies - they need to be placed in a specific analyzers/dotnet/cs folder within the NuGet package so that the consuming project loads them into the compiler pipeline rather than treating them as a runtime reference. Without this, your analyzer will happily install as a NuGet package and then do absolutely nothing, which is a fantastic way to waste an afternoon wondering why it's not working (ask me how I know).

<PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    <IsRoslynComponent>true</IsRoslynComponent>
    <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
    <IncludeBuildOutput>false</IncludeBuildOutput>
</PropertyGroup>

<ItemGroup>
    <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true"
          PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>

EnforceExtendedAnalyzerRules is particularly nice - it'll flag any API usage that isn't safe within the analyzer hosting environment so you find out at build time rather than at "why is the IDE crashing" time.

Diagnostic ID conventions

Before we write a single line of analyzer code, let's establish the diagnostic ID scheme. Every rule needs a unique ID, and these are what consumers will use in .editorconfig to configure severity, suppress specific rules, or turn them off entirely. Getting this right from the start saves pain later, so we'll do it now and stick with it throughout.

I'm using the prefix EXP (for Expedition, because I apparently cannot stop using that name across my projects) followed by a four digit number, grouped by category:

Which means consumers can configure everything per-rule in their .editorconfig:

# Make async void an error, because we really mean it
dotnet_diagnostic.EXP0001.severity = error

# Primitive obsession is just a suggestion while we migrate
dotnet_diagnostic.EXP0100.severity = suggestion

This granularity is critical for adoption. If someone installed it today, and then suddenly had 1000's of new errors that they can't turn off... They'll just uninstall it. But by providing this mechanism we can turn everything into warnings/suggestions and slowly promote them up the chain as they or their team become more familiar with the rules. In my opinion this is the difference between a tool the team is happy to have and a tool the team will gladly tell to bugger off.

The naive approach and why we won't use it

Now, we all think the most obvious way to build multiple analyzers is to create a separate DiagnosticAnalyzer class for each rule. Want to catch async void? New class, derives from DiagnosticAnalyzer, registers its own SyntaxNodeAction. Missing CancellationToken? Same thing. Too many parameters? Same thing again.

For a handful of rules, this is fine. But we're planning to build a lot of these, and here's the problem: Roslyn loads every DiagnosticAnalyzer in your assembly and each one independently registers its own callbacks. If you've got 30 analyzers and 15 of them care about MethodDeclaration nodes, that's 15 separate callbacks firing for every single method in the codebase. 15 walks over the same data. Multiply that by the number of methods in a real project and you're adding annoyingly meaningful overhead to compilation and IDE responsiveness, which is the opposite of what we want. The whole point is to make the development experience better, not to make the IDE cry us a river every time someone opens a file.

On top of that, some of our later analyzers - things like transaction boundary detection - will need to build up state across the entire compilation. They need to reason about relationships between methods, not just individual methods in isolation. If each of those registers its own CompilationStartAction and builds its own representation of the codebase independently, we're duplicating expensive work for no reason.

So instead of 30 separate analyzers, we're going to build a composition model. One DiagnosticAnalyzer that acts as the host. Individual rules that declare what they need. The host does a single registration pass, groups rules by what they care about, and dispatches efficiently. Adding a new rule becomes "implement an interface and drop the class in" - no boilerplate, no duplicated tree walks, no touching the host.

The composition model

Let's start with what a rule actually needs to declare. At its simplest, a rule says "I care about this kind of syntax node" and "here's what I do when I see one." But we also need to support rules that operate at the symbol level (types, methods, properties), and rules that need compilation-wide analysis for the more complex stuff we'll tackle later in the series.

So we'll define a set of interfaces that rules can implement based on what level of analysis they need:

using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;

namespace Expedition.Analyzers.Infrastructure;

/// <summary>
/// Base interface for all analysis rules. Every rule must provide
/// its supported diagnostics so the host can aggregate them.
/// </summary>
public interface IAnalysisRule
{
    ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; }
}

/// <summary>
/// A rule that inspects individual syntax nodes.
/// Declares which SyntaxKinds it cares about so the host
/// can batch registrations efficiently.
/// </summary>
public interface ISyntaxNodeRule : IAnalysisRule
{
    ImmutableArray<SyntaxKind> SyntaxKinds { get; }
    void Analyze(SyntaxNodeAnalysisContext context);
}

/// <summary>
/// A rule that inspects symbols (types, methods, properties, etc).
/// Declares which SymbolKinds it cares about.
/// </summary>
public interface ISymbolRule : IAnalysisRule
{
    ImmutableArray<SymbolKind> SymbolKinds { get; }
    void Analyze(SymbolAnalysisContext context);
}

/// <summary>
/// A rule that needs compilation-wide analysis. Used for rules
/// that reason about relationships between methods, call chains,
/// or cross-type patterns like transaction boundary violations.
/// </summary>
public interface ICompilationRule : IAnalysisRule
{
    void Initialize(CompilationStartAnalysisContext context);
}

The design here is that each interface declares what the rule needs to inspect. ISyntaxNodeRule exposes a SyntaxKinds property that tells the host exactly which syntax node kinds the rule cares about. This is what enables efficient batching - the host doesn't register every node kind for every rule, it groups rules by the kinds they need and registers each kind exactly once.

ICompilationRule is deliberately more open-ended. Rules that implement this get handed the CompilationStartAnalysisContext directly and are free to register whatever they need within it. This is because compilation-level analysis - building call graphs, tracking state across types - is inherently more complex and less amenable to a one-size-fits-all dispatch pattern. The host still manages discovery and diagnostic aggregation, but the rule owns its own registration. We'll see exactly why this matters when we get to the transaction boundary analyzer.

Now let's build the host that wires all of this together:

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;

namespace Expedition.Analyzers.Infrastructure;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ExpeditionAnalyzer : DiagnosticAnalyzer
{
    private readonly ImmutableArray<IAnalysisRule> _rules;

    private readonly Dictionary<SyntaxKind, ImmutableArray<ISyntaxNodeRule>> _syntaxRulesByKind;
    private readonly Dictionary<SymbolKind, ImmutableArray<ISymbolRule>> _symbolRulesByKind;
    private readonly ImmutableArray<ICompilationRule> _compilationRules;

    public ExpeditionAnalyzer()
    {
        _rules = DiscoverRules();

        _syntaxRulesByKind = _rules.OfType<ISyntaxNodeRule>()
            .SelectMany(rule => rule.SyntaxKinds.Select(kind => (kind, rule)))
            .GroupBy(pair => pair.kind)
            .ToDictionary(
                group => group.Key,
                group => group.Select(pair => pair.rule).ToImmutableArray());

        _symbolRulesByKind = _rules.OfType<ISymbolRule>()
            .SelectMany(rule => rule.SymbolKinds.Select(kind => (kind, rule)))
            .GroupBy(pair => pair.kind)
            .ToDictionary(
                group => group.Key,
                group => group.Select(pair => pair.rule).ToImmutableArray());

        _compilationRules = _rules.OfType<ICompilationRule>().ToImmutableArray();
    }

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
        _rules.SelectMany(r => r.SupportedDiagnostics).ToImmutableArray();

    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();

        // Register each unique SyntaxKind exactly once
        foreach (var kind in _syntaxRulesByKind.Keys)
        {
            var rulesForKind = _syntaxRulesByKind[kind];
            context.RegisterSyntaxNodeAction(
                nodeContext =>
                {
                    foreach (var rule in rulesForKind)
                    {
                        rule.Analyze(nodeContext);
                    }
                },
                kind);
        }

        // Register each unique SymbolKind exactly once
        foreach (var kind in _symbolRulesByKind.Keys)
        {
            var rulesForSymbolKind = _symbolRulesByKind[kind];
            context.RegisterSymbolAction(
                symbolContext =>
                {
                    foreach (var rule in rulesForSymbolKind)
                    {
                        rule.Analyze(symbolContext);
                    }
                },
                kind);
        }

        // Compilation rules get their own start action
        if (_compilationRules.Any())
        {
            context.RegisterCompilationStartAction(compilationContext =>
            {
                foreach (var rule in _compilationRules)
                {
                    rule.Initialize(compilationContext);
                }
            });
        }
    }

    private static ImmutableArray<IAnalysisRule> DiscoverRules()
    {
        var ruleType = typeof(IAnalysisRule);
        var rules = ruleType.Assembly.GetTypes()
            .Where(t => !t.IsAbstract && !t.IsInterface && ruleType.IsAssignableFrom(t))
            .Select(t =>
            {
                try { return (IAnalysisRule)Activator.CreateInstance(t); }
                catch { return null; }
            })
            .Where(rule => rule != null)
            .ToImmutableArray();

        return rules!;
    }
}

So there's quite a lot going on here, let's walk through the important bits.

The constructor is where the composition happens. DiscoverRules() uses reflection to find every class in the assembly that implements IAnalysisRule and instantiates it. This means that adding a new rule is literally just creating a new class that implements one of our interfaces - no registration code, no wiring, no modification of the host. Drop the class in, rebuild, and it's active. The host discovers it automatically.

Now, you might be raising an eyebrow at reflection in an analyzer. And honestly, fair enough. But this
reflection runs exactly once, in the constructor, when the analyzer is first loaded by the compiler. It's
not in any hot path. The actual analysis callbacks are just iterating over pre-built immutable arrays,
which is about as fast as you can get.

After discovery, we build the dispatch lookup tables. _syntaxRulesByKind is a dictionary that maps each SyntaxKind to the array of rules that care about it. If three rules all care about MethodDeclaration, they end up in the same array under the same key. In Initialize, we register each unique SyntaxKind exactly once, and the callback iterates over just the rules that are relevant. Fifteen rules that care about method declarations? One registration, one callback, one loop. Not fifteen separate registrations with fifteen separate callbacks.

The same pattern applies to symbol rules. And for compilation rules, we give them a single shared CompilationStartAction where each rule gets to do its own registration on the CompilationStartAnalysisContext. This is important because compilation-level rules often need to register their own RegisterOperationAction or RegisterSyntaxNodeAction within the compilation context so they can capture shared state - like building up a picture of which methods call which other methods. We'll see exactly why this flexibility matters when we get to the more complex analyzers later in the series.

SupportedDiagnostics aggregates across all discovered rules. This is required by Roslyn - the host must declare every diagnostic ID that any of its rules might emit, otherwise the diagnostics get silently dropped. Since each rule exposes its own SupportedDiagnostics, we just flatten them all into a single array.

Two other things worth noting in Initialize - ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None) tells our analyzer to skip generated code. Source generator output, designer files, that sort of thing. You almost always want this, because flagging problems in code that a human didn't write (and can't fix directly) is just noise. And EnableConcurrentExecution() tells Roslyn it's safe to run our analyzer in parallel across multiple syntax nodes, which is a nice performance win. This works because our rules are stateless - they don't share mutable data between invocations. And if you ever find yourself wanting shared mutable state in a syntax node callback... well, that would be a bit ironic given what we're trying to detect, wouldn't it?

Shared helpers

While we're building infrastructure, there are a few utility methods that are going to come up repeatedly across different rules. Rather than duplicating them in every rule class, let's put them somewhere shared now. These are the kind of checks that almost every analyzer ends up needing, and I'd rather build them once than copy-paste them later:

using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace Expedition.Analyzers.Infrastructure;

public static class RoslynExtensions
{
    /// <summary>
    /// Checks whether a method symbol returns a Task or Task<T>.
    /// Useful for identifying async methods and flagging async-related issues.
    /// </summary>
    public static bool ReturnsTask(this IMethodSymbol method)
    {
        var returnType = method.ReturnType;
        return returnType.Name == "Task"
               && returnType.ContainingNamespace?.ToDisplayString() == "System.Threading.Tasks";
    }

    /// <summary>
    /// Checks whether a method symbol has a CancellationToken parameter.
    /// </summary>
    public static bool AcceptsCancellationToken(this IMethodSymbol method)
    {
        return method.Parameters.Any(p =>
            p.Type.Name == "CancellationToken"
            && p.Type.ContainingNamespace?.ToDisplayString() == "System.Threading");
    }

    /// <summary>
    /// Gets the method symbol from a method declaration syntax node
    /// via the semantic model.
    /// </summary>
    public static IMethodSymbol? GetMethodSymbol(
        this SyntaxNodeAnalysisContext context,
        MethodDeclarationSyntax methodDeclaration)
    {
        return context.SemanticModel.GetDeclaredSymbol(methodDeclaration) as IMethodSymbol;
    }

    /// <summary>
    /// Checks whether a symbol has a specific attribute applied to it.
    /// </summary>
    public static bool HasAttribute(this ISymbol symbol, string fullyQualifiedAttributeName)
    {
        return symbol.GetAttributes()
            .Any(attr => attr.AttributeClass?.ToDisplayString() == fullyQualifiedAttributeName);
    }

    /// <summary>
    /// Checks whether a type is a mutable collection type
    /// (List, Dictionary, HashSet, etc). Useful for detecting
    /// stored mutable references and exposed internal collections.
    /// </summary>
    public static bool IsMutableCollectionType(this ITypeSymbol type)
    {
        var name = type.Name;
        return name is "List" or "Dictionary" or "HashSet" or "SortedSet"
                   or "Queue" or "Stack" or "LinkedList" or "SortedDictionary"
                   or "SortedList" or "ObservableCollection"
               && type.ContainingNamespace?.ToDisplayString() is
                   "System.Collections.Generic" or "System.Collections.ObjectModel";
    }
}

These are intentionally small and focused. ReturnsTask will be used by the async void analyzer and the CancellationToken analyzer. AcceptsCancellationToken will be used by the CancellationToken propagation rule. HasAttribute will show up when we need to check for things like event handler attributes. IsMutableCollectionType will be used by both the stored mutable reference and the returned internal collection rules. As we add more rules throughout the series, this file will grow, but the principle is that every addition should be something that at least two rules need. If only one rule uses a helper, it belongs in that rule's class, not here.

Our first rule

Right, time for the payoff. Let's write our first rule and plug it into the composition model. I'm deliberately picking something simple for this - a rule that flags methods with more than 5 parameters - because I want the focus to be on proving the infrastructure works, not on complex analysis logic. We'll get to the gnarly stuff starting in the next post.

Think of this as the "hello world" of the series. The goal is to see our first custom squiggle in the IDE and confirm the whole pipeline works end to end.

using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using Expedition.Analyzers.Infrastructure;

namespace Expedition.Analyzers.Rules;

public class TooManyParametersRule : ISyntaxNodeRule
{
    private const int MaxParameters = 5;

    private static readonly DiagnosticDescriptor Descriptor = new(
        "EXP0900",
        title: "Method has too many parameters",
        messageFormat: "Method '{0}' has {1} parameters (maximum {2})",
        category: "Design",
        defaultSeverity: DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: "Methods with many parameters are harder to use correctly " +
                     "and may indicate a need for a parameter object.");

    public ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
        ImmutableArray.Create(Descriptor);

    public ImmutableArray<SyntaxKind> SyntaxKinds =>
        ImmutableArray.Create(SyntaxKind.MethodDeclaration);

    public void Analyze(SyntaxNodeAnalysisContext context)
    {
        var methodDeclaration = (MethodDeclarationSyntax)context.Node;
        var parameterCount = methodDeclaration.ParameterList.Parameters.Count;

        if (parameterCount <= MaxParameters) return;

        context.ReportDiagnostic(Diagnostic.Create(
            Descriptor,
            methodDeclaration.Identifier.GetLocation(),
            methodDeclaration.Identifier.Text,
            parameterCount,
            MaxParameters));
    }
}

Now compare this to what a standalone DiagnosticAnalyzer would look like. There's no base class, no Initialize method, no registration boilerplate. The rule declares what it cares about (SyntaxKind.MethodDeclaration), provides its diagnostic metadata, and implements the analysis. That's it. The ExpeditionAnalyzer host discovers this class at startup, sees that it wants MethodDeclaration nodes, and registers it into the dispatch table automatically.

And this is the pattern for every rule going forward. The next post will add three new rule classes for async and concurrency issues. The one after that will add primitive obsession detection. Each one is a focused, self-contained class with no awareness of the other rules and no knowledge of the hosting infrastructure. They just implement the interface and exist.

To really drive home how little friction there is, here's the shape of what the async void rule will look like in the next post (without the full implementation):

public class AsyncVoidRule : ISyntaxNodeRule
{
    private static readonly DiagnosticDescriptor Descriptor = new(
        "EXP0001", ...);

    public ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
        ImmutableArray.Create(Descriptor);

    public ImmutableArray<SyntaxKind> SyntaxKinds =>
        ImmutableArray.Create(SyntaxKind.MethodDeclaration);

    public void Analyze(SyntaxNodeAnalysisContext context)
    {
        // Check if async method returns void
        // Report diagnostic if so
    }
}

New class in the Rules folder. No other files change. The host picks it up. Done.

Testing

Now, there's a slight wrinkle with testing that's worth addressing. The Roslyn test framework's AnalyzerVerifier expects a DiagnosticAnalyzer, and our individual rules aren't analyzers anymore - they're implementations of ISyntaxNodeRule. But our tests need to run against the composed host, because that's what actually runs in the real world.

So our tests target ExpeditionAnalyzer but verify specific diagnostic IDs:

using Verify = Microsoft.CodeAnalysis.CSharp.Testing.XUnit.AnalyzerVerifier<  
    Expedition.Analyzers.Infrastructure.ExpeditionAnalyzer>;  
  
namespace Expedition.Analyzers.Tests.Rules;  
  
public class TooManyParametersRuleTests  
{  
    [Fact]  
    public async Task MethodWithFiveParameters_NoDiagnostic()  
    {        var test = @"  
            class TestClass            {                void Method(int a, int b, int c, int d, int e) { }            }";  
  
        await Verify.VerifyAnalyzerAsync(test);  
    }  
    [Fact]  
    public async Task MethodWithSixParameters_ReportsDiagnostic()  
    {        var test = @"  
            class TestClass            {                void {|#0:Method|}(int a, int b, int c, int d, int e, int f) { }            }";  
  
        var expected = Verify.Diagnostic("EXP0900")  
            .WithLocation(0)  
            .WithArguments("Method", 6, 5);  
  
        await Verify.VerifyAnalyzerAsync(test, expected);  
    }  
    [Fact]  
    public async Task MethodWithExactlyMaxParameters_NoDiagnostic()  
    {        var test = @"  
            class TestClass            {                void Method(int a, int b, int c, int d, int e) { }            }";  
  
        await Verify.VerifyAnalyzerAsync(test);  
    }}

The testing framework takes a little getting used to but once you understand the conventions it's quite elegant. The {|#0:Method|} markup in the test source is a location marker - it tells the test framework "I expect a diagnostic at position #0, covering the span Method". WithArguments verifies the format string was populated with the values we expect. And calling VerifyAnalyzerAsync with no expected diagnostics asserts that the analyzer stays quiet, which is just as important to test as the cases where it fires.

Note that Verify.Diagnostic("EXP0900") targets a specific diagnostic ID rather than a specific rule class. Since ExpeditionAnalyzer aggregates all rules, we need to be explicit about which diagnostic we're expecting. This is actually a better pattern anyway because it mirrors how consumers interact with the rules - via their IDs in .editorconfig, not their class names.

I'd also recommend organising your test classes to mirror the rule classes. One test class per rule,
kept in a folder structure that matches the rules. When you've got 20 rules in the project, you'll
be glad you did this from the start rather than having to untangle a mess later.

If these tests pass (and they should!), then the pipeline is working end to end. You've got a composition model that discovers rules automatically, a shared set of helpers, a rule plugged into the system, and tests proving it works. Everything from here on is just writing more rule classes and dropping them into the Rules folder.

At this point I'd encourage you to reference the analyzer in an actual test project and write a method
with 6 parameters just to see the squiggle appear in your IDE. It's the equivalent of seeing
"Hello World" print to the console for the first time. Screenshot it. Enjoy it.

Making it helpful, not just annoying

So we've got the squiggle. The compiler is now moaning at you for having too many parameters. But here's the thing - a diagnostic that just tells you something is wrong without offering to help is, frankly, a bit useless. It's like someone pointing at a mess and saying "that's messy" while keeping their hands in their pockets. What we want is the lightbulb - the code fix that says "here's what you could do about it."

For our too-many-parameters rule, the natural fix is to extract those parameters into a dedicated type. And in modern C#, we've got a nice choice to offer: a record (which gives you immutability, structural equality, and a concise syntax) or a class (for when you need something more traditional). So let's offer both.

Code fix providers are separate from analyzers in Roslyn's architecture. They live in the CodeFixes project and they register against specific diagnostic IDs rather than being part of the composition model. This is actually a clean separation - the analyzer finds the problem, the code fix offers the solution. They don't need to know about each other beyond the diagnostic ID that connects them.

First, make sure the code fixes project has the right packages:

cd Expedition.Analyzers.CodeFixes
dotnet add package Microsoft.CodeAnalysis.CSharp.Workspaces -v 4.8.0
dotnet add package Microsoft.CodeAnalysis.CSharp.CodeFix.Testing.XUnit
dotnet add reference ../Expedition.Analyzers/Expedition.Analyzers.csproj

And here's the code fix provider:

using System.Collections.Immutable;  
using System.Composition;  
using System.Linq;  
using System.Threading;  
using System.Threading.Tasks;  
using Microsoft.CodeAnalysis;  
using Microsoft.CodeAnalysis.CodeActions;  
using Microsoft.CodeAnalysis.CodeFixes;  
using Microsoft.CodeAnalysis.CSharp;  
using Microsoft.CodeAnalysis.CSharp.Syntax;  
using Microsoft.CodeAnalysis.Editing;  
  
namespace Expedition.Analyzers.CodeFixes;  
  
[ExportCodeFixProvider(LanguageNames.CSharp), Shared]  
public class TooManyParametersCodeFix : CodeFixProvider  
{  
    public override ImmutableArray<string> FixableDiagnosticIds =>  
        ImmutableArray.Create("EXP0900");  
  
    public override FixAllProvider? GetFixAllProvider() =>  
        WellKnownFixAllProviders.BatchFixer;  
  
    public override async Task RegisterCodeFixesAsync(CodeFixContext context)  
    {        var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken);  
        if (root == null) return;  
  
        var diagnostic = context.Diagnostics[0];  
        var diagnosticSpan = diagnostic.Location.SourceSpan;  
  
        var methodDeclaration = root.FindToken(diagnosticSpan.Start)  
            .Parent?  
            .AncestorsAndSelf()  
            .OfType<MethodDeclarationSyntax>()  
            .FirstOrDefault();  
  
        if (methodDeclaration == null) return;  
  
        context.RegisterCodeFix(  
            CodeAction.Create(  
                title: "Extract parameters to record",  
                createChangedDocument: ct =>  
                    ExtractParametersAsync(context.Document, methodDeclaration, useRecord: true, ct),  
                equivalenceKey: "ExtractToRecord"),  
            diagnostic);  
        context.RegisterCodeFix(  
            CodeAction.Create(  
                title: "Extract parameters to class",  
                createChangedDocument: ct =>  
                    ExtractParametersAsync(context.Document, methodDeclaration, useRecord: false, ct),  
                equivalenceKey: "ExtractToClass"),  
            diagnostic);    }  
    private static async Task<Document> ExtractParametersAsync(  
        Document document,  
        MethodDeclarationSyntax methodDeclaration,  
        bool useRecord,  
        CancellationToken cancellationToken)  
    {        var semanticModel = await document.GetSemanticModelAsync(cancellationToken);  
        if (semanticModel == null) return document;  
  
        var methodName = methodDeclaration.Identifier.Text;  
        var parameterTypeName = $"{methodName}Parameters";  
  
        var parameters = methodDeclaration.ParameterList.Parameters;  
  
        // Build the properties/parameters for the new type  
        var members = parameters.Select(param =>  
        {  
            var typeSyntax = param.Type!;  
            var propertyName = char.ToUpperInvariant(param.Identifier.Text[0])  
                               + param.Identifier.Text.Substring(1);  
  
            if (useRecord)  
            {                // Record parameters are defined in the primary constructor  
                return (MemberDeclarationSyntax)null!; // Handled differently for records  
            }  
  
            // Class property  
            return SyntaxFactory.PropertyDeclaration(typeSyntax, propertyName)  
                .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword))  
                .AddAccessorListAccessors(  
                    SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration)  
                        .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)),  
                    SyntaxFactory.AccessorDeclaration(SyntaxKind.SetAccessorDeclaration)  
                        .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)));  
        }).ToArray();  
  
        TypeDeclarationSyntax newType;  
  
        if (useRecord)  
        {            // Build record with primary constructor: record ProcessOrderParameters(Guid CustomerId, Guid OrderId);  
            var recordParameters = parameters.Select(param =>  
            {  
                var propertyName = char.ToUpperInvariant(param.Identifier.Text[0])  
                                   + param.Identifier.Text.Substring(1);  
                return SyntaxFactory.Parameter(SyntaxFactory.Identifier(propertyName))  
                    .WithType(param.Type!);  
            });  
            var parameterList = SyntaxFactory.ParameterList(  
                SyntaxFactory.SeparatedList(recordParameters));  
  
            newType = SyntaxFactory.RecordDeclaration(  
                    SyntaxFactory.Token(SyntaxKind.RecordKeyword),  
                    parameterTypeName)                .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword))  
                .WithParameterList(parameterList)  
                .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken));  
        }        else  
        {  
            newType = SyntaxFactory.ClassDeclaration(parameterTypeName)  
                .AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword))  
                .AddMembers(members.Where(m => m != null).ToArray());  
        }  
        // Create the new single parameter for the method  
        var newParameter = SyntaxFactory.Parameter(  
                SyntaxFactory.Identifier("parameters"))  
            .WithType(SyntaxFactory.ParseTypeName(parameterTypeName));  
  
        var newParameterList = SyntaxFactory.ParameterList(  
            SyntaxFactory.SingletonSeparatedList(newParameter));  
  
        // Replace the method's parameter list  
        var newMethodDeclaration = methodDeclaration  
            .WithParameterList(newParameterList);  
  
        var root = await document.GetSyntaxRootAsync(cancellationToken);  
        if (root == null) return document;  
  
        // Detect the line ending style used in the existing document  
        var eol = root.DescendantTrivia()  
            .FirstOrDefault(t => t.IsKind(SyntaxKind.EndOfLineTrivia));  
        var endOfLine = eol != default  
            ? SyntaxFactory.EndOfLine(eol.ToString())  
            : SyntaxFactory.LineFeed;  
  
        // Find the containing type to add our new type as a sibling  
        var containingType = methodDeclaration.Parent;  
        if (containingType == null) return document;  
  
        var newRoot = root.ReplaceNode(methodDeclaration, newMethodDeclaration);  
  
        // Re-find the containing type in the new tree  
        var newContainingType = newRoot.DescendantNodes()  
            .OfType<TypeDeclarationSyntax>()  
            .FirstOrDefault(t => t.Identifier.Text ==  
                (containingType as TypeDeclarationSyntax)?.Identifier.Text);  
  
        if (newContainingType != null)  
        {            // Insert the new type before the containing type  
            newRoot = newRoot.InsertNodesBefore(newContainingType,  
                new[] { newType.NormalizeWhitespace()  
                    .WithLeadingTrivia(endOfLine)  
                    .WithTrailingTrivia(endOfLine, endOfLine) });  
        }  
        return document.WithSyntaxRoot(newRoot);  
    }}

Now I'll be honest, there's a fair amount going on here and it's not the prettiest code in the world. Code fix providers involve a lot of syntax tree manipulation which is inherently fiddly. But let's break down what it's actually doing.

RegisterCodeFixesAsync is called by the IDE whenever the cursor is on a diagnostic that matches one of our FixableDiagnosticIds. We find the MethodDeclarationSyntax that triggered the diagnostic and register two code actions - one for extracting to a record, one for extracting to a class. Each action gets a title (which is what appears in the lightbulb menu) and a delegate that produces the changed document.

ExtractParametersAsync does the actual work. It takes the method's parameters, generates a new type with properties matching those parameters, creates a new method signature with a single parameter of that new type, and inserts everything into the syntax tree. For the record version, we use C#'s primary constructor syntax so you get the nice concise record ProcessOrderParameters(Guid CustomerId, Guid OrderId); form. For the class version, we generate full properties with getters and setters.

One thing you'll notice is that the code fix doesn't update the method body to use the new parameter object. So `customerId` references inside the method would need to become `parameters.CustomerId`.

Doing this automatically is possible but it adds a lot of complexity for a first pass. For now, the compiler will tell you about the broken references after the fix is applied, which is honestly fine - you'd want to review those usages anyway. We can always make it smarter later.

The GetFixAllProvider returning WellKnownFixAllProviders.BatchFixer is a nice touch - it means if you've got multiple methods with too many parameters, you can fix them all at once rather than one at a time. The IDE will show a "Fix all occurrences" option in the lightbulb menu.

So what does the experience actually look like? You write a method with 6 parameters, you get a squiggle, you click the lightbulb, and you see:

Click one, and the method signature is rewritten to accept a single parameter object. That's the compiler not just telling you something is wrong, but actively helping you fix it. And that's the difference between a tool people tolerate and a tool people actually like using.

The code fixes project also needs the correct packaging configuration in its .csproj, similar to the
analyzer project. The PackagePath is the same `analyzers/dotnet/cs` folder - Roslyn discovers both
analyzers and code fix providers from the same location.
<PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <IsRoslynComponent>true</IsRoslynComponent>
    <IncludeBuildOutput>false</IncludeBuildOutput>
</PropertyGroup>

<ItemGroup>
    <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true"
          PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>

Testing the code fix

We can test the code fix in a similar way to the analyzer, but using CodeFixVerifier instead:

using Microsoft.CodeAnalysis.CSharp.Testing;  
using Microsoft.CodeAnalysis.Testing;  
  
namespace Expedition.Analyzers.Tests.CodeFixes;  
using Verify = Microsoft.CodeAnalysis.CSharp.Testing.XUnit.CodeFixVerifier<  
    Expedition.Analyzers.Infrastructure.ExpeditionAnalyzer,  
    Expedition.Analyzers.CodeFixes.TooManyParametersCodeFix>;  
  
public class TooManyParametersCodeFixTests  
{  
    [Fact]  
    public async Task ExtractToRecord_CreatesRecordAndUpdatesSignature()  
    {        var test = @"  
class TestClass  
{  
    void {|#0:ProcessOrder|}(int a, int b, int c, int d, int e, int f) { }}";  
  
        var fixedCode = @"  
public record ProcessOrderParameters(int A, int B, int C, int D, int E, int F);  
  
  
class TestClass  
{  
    void ProcessOrder(ProcessOrderParameters parameters) { }}";  
  
        var expected = Verify.Diagnostic("EXP0900")  
            .WithLocation(0)  
            .WithArguments("ProcessOrder", 6, 5);  
  
        var codeFixTest = new CSharpCodeFixTest<  
            Infrastructure.ExpeditionAnalyzer,  
            Expedition.Analyzers.CodeFixes.TooManyParametersCodeFix,  
            DefaultVerifier>  
        {            TestCode = test,  
            FixedCode = fixedCode,  
            ReferenceAssemblies = ReferenceAssemblies.Net.Net80,  
        };  
  
        codeFixTest.ExpectedDiagnostics.Add(expected);  
        await codeFixTest.RunAsync();  
    }}

The CodeFixVerifier takes both the analyzer and the code fix provider as type parameters, which ties them together for the test. You provide the original source with the diagnostic, the expected diagnostic, and the source you expect after the fix is applied. The framework runs the analyzer, confirms the diagnostic, applies the code fix, and verifies the output matches. It's a really nice way to test the entire flow end-to-end.

A word of caution here - whitespace and formatting in these tests can be extremely finicky. The
fixed code needs to match what the code fix actually produces, including indentation and line breaks.
If a test is failing and the diagnostic output looks correct, check the whitespace first. It's
almost always the whitespace.

Seeing it in action (in Rider / VS)

So we've got tests proving everything works, but there's nothing quite like seeing the squiggle and lightbulb in your actual IDE. The question is: how do you get your locally-built analyzer running in Rider (or Visual Studio) without having to publish a NuGet package first?

The answer is surprisingly simple. You add a project to the solution that references the analyzer project — but not as a normal dependency. You reference it as an analyzer. This is done with some specific metadata on the project reference:

dotnet new console -n Expedition.Analyzers.Playground
dotnet sln add Expedition.Analyzers.Playground

And then in the playground's .csproj:

<ItemGroup>
    <ProjectReference Include="..\Expedition.Analyzers\Expedition.Analyzers.csproj"
                      OutputItemType="Analyzer"
                      ReferenceOutputAssembly="false" />
    <ProjectReference Include="..\Expedition.Analyzers.CodeFixes\Expedition.Analyzers.CodeFixes.csproj"
                      OutputItemType="Analyzer"
                      ReferenceOutputAssembly="false" />
</ItemGroup>

OutputItemType="Analyzer" tells the build system to load the DLL into the compiler pipeline as an analyzer rather than treating it as a runtime dependency. ReferenceOutputAssembly="false" means the playground can't reference the analyzer's types directly — it's purely loaded into the compiler. This is exactly what happens when someone installs the NuGet package, we're just doing it locally via project reference.

Now open any .cs file in the playground project, write a method with 6 parameters, and Rider should show the squiggle and lightbulb immediately. Click the lightbulb and you'll see our "Extract parameters to record" and "Extract parameters to class" options right there. That's the full loop working — diagnostic, squiggle, lightbulb, fix — all running locally against your in-development analyzer.

Compiler warning

![Compiler warning](/csharp-analyserp-1/Pasted image 20260411005406.png)

Lightbulb menu

![Lightbulb menu](/csharp-analyserp-1/Pasted image 20260411005423.png)

After clicking "Extract parameters to record"

![After clicking "Extract parameters to record" ](/csharp-analyserp-1/Pasted image 20260411005553.png)

After clicking "Extract parameters to class"

![After clicking "Extract parameters to class"](/csharp-analyserp-1/Pasted image 20260411005619.png)

One little tidbit with Rider specifically — it sometimes caches analyzer state aggressively. If you change your analyzer code and rebuild but the squiggles don't update, try "File → Invalidate Caches" and restart. 

It's bloody annoying but it's a known Rider quirk with local analyzer development. You won't hit this issue once the analyzer is distributed as a NuGet package, it's purely a local development friction.

The nice thing about keeping a playground project in the solution is that it doubles as a manual testing ground for the entire series. As we add rules in each post, you can write intentionally bad code in the playground and watch the squiggles light up. It's satisfying in a slightly weird way, writing code you know is wrong just to see the compiler moan at you for it.

Where we stand

So let's take stock of what we've built.

We've got a solution structure set up for analyzer development, targeting netstandard2.0 with the correct NuGet packaging configuration so it'll actually work when we ship it. We've got a diagnostic ID scheme that maps to our categories and gives consumers full control over severity per-rule. We've got a composition model where a single host discovers, registers, and dispatches to individual rules, so that adding a new rule is just implementing an interface. We've got shared utilities that'll save us from duplicating common Roslyn queries across rules. We've got our first rule plugged in and tested. We've got a code fix provider that doesn't just complain about the problem but offers to fix it for you. And we've got a playground project to see it all working in the IDE with real squiggles and lightbulbs — the full loop from problem to solution.

The infrastructure might feel like a lot of setup for one simple rule, and honestly it is. But the payoff comes in every subsequent post. From here on, we're not fighting boilerplate or worrying about registration or duplicating tree walks. We're just writing focused rule classes and dropping them in. And for rules where a code fix makes sense, we know exactly how to wire that up too.

Go on, open the playground, write a method with 6 parameters, and enjoy your first custom squiggle.
You've earned it. Screenshot it. Enjoy it.

Right now, the compiler's definition of "correct C#" is "it's syntactically valid and the types resolve." We haven't moved that needle much yet with a single parameter-count rule, but the machinery is in place and the friction of adding each new rule is as low as we can make it.

Thanks for reading, I hope you found this informative and are looking forward to the next post as much as I!