Mert Özen Further With Every Line
Testing & Quality Static Analysis — #03

Third-Party Analyzers and Writing Your Own Roslyn Analyzer: BannedApiAnalyzers, Popular Packages, DiagnosticAnalyzer and CodeFix

Mert Özen Nov 14, 2026 14 min 11 views
Third-Party Analyzers and Writing Your Own Roslyn Analyzer: BannedApiAnalyzers, Popular Packages, DiagnosticAnalyzer and CodeFix

If a rule only says 'don't use this API', BannedApiAnalyzers is enough; if you need a code fix, team-specific logic or a context-aware message, you write your own Roslyn analyzer. In Bookstore, Meziantou, Roslynator and SonarAnalyzer added 87 warnings and 14 times the analysis time but didn't catch DateTime.UtcNow by default. The post builds a TimeProvider rule with an operation-callback analyzer, a code fix in a separate assembly, Microsoft.CodeAnalysis.Testing tests and a NuGet package.

Version note: The code in this post was built and run with .NET 10 (SDK 10.0.402); the analyzers were written against Microsoft.CodeAnalysis.CSharp 4.14.0 and tested with Microsoft.CodeAnalysis.Testing 1.1.4 and xUnit.net v3 4.0.3. Packages compared: Meziantou.Analyzer 3.0.340, Roslynator.Analyzers 5.0.1, SonarAnalyzer.CSharp 10.36.0 and Microsoft.CodeAnalysis.BannedApiAnalyzers 5.6.0.

Guess First

In Bookstore, the online bookstore used as the shared example across this site's series, someone is writing a back-office job that deletes carts nobody has touched for 30 days. Ayşe's pull request:

using Bookstore.Data;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Ordering;

// Carts untouched for 30 days are deleted by a back-office job.
public sealed class CartCleanup(BookstoreContext db, TimeProvider clock)
{
    public static readonly TimeSpan MaxAge = TimeSpan.FromDays(30);

    public Task<int> DeleteExpiredAsync(CancellationToken ct)
    {
        DateTime cutoff = DateTime.UtcNow - MaxAge;
        return db.Carts.Where(c => c.UpdatedAtUtc < cutoff).ExecuteDeleteAsync(ct);
    }
}

Bookstore's rule is clear: get the time from TimeProvider, because tests must be able to control the clock. clock is injected but unused; the code reads the system clock directly. What does the build say with the settings from the second post? If we added the three most widely used analyzer packages, Meziantou.Analyzer, Roslynator and SonarAnalyzer.CSharp, which of them would catch this line?

Third-Party Packages: What They Bring and What They Cost

First, the current state. None of the SDK's rules forbids reading the system clock; the only thing the build says is an indirect hint:

src/Bookstore.Api/Ordering/CartCleanup.cs(7,67): warning CS9113: Parameter 'clock' is unread.

The compiler notices that the primary constructor parameter is never read. Useful, but it doesn't state the actual rule; if someone used clock on another line, the warning would disappear too. Now we add the three packages with Central Package Management. PrivateAssets="all" matters for analyzer packages: the package is used only at compile time and doesn't flow as a dependency to other projects that reference Bookstore.

<Project>
  <ItemGroup>
    <PackageReference Include="Meziantou.Analyzer" PrivateAssets="all" />
    <PackageReference Include="Roslynator.Analyzers" PrivateAssets="all" />
    <PackageReference Include="SonarAnalyzer.CSharp" PrivateAssets="all" />
  </ItemGroup>
</Project>

With this Directory.Build.targets, building the clean Bookstore from before CartCleanup gave 87 warnings:

PackageVersion, last releaseWarnings in buildAnalyzer time
Meziantou.Analyzer3.0.340, 14 November 2026850.129 s
Roslynator.Analyzers5.0.1, 4 October 202600.108 s
SonarAnalyzer.CSharp10.36.0, 11 November 202620.524 s
The SDK's NetAnalyzers (for comparison)SDK 10.0.40200.033 s

All three are actively maintained; the release dates in the table come from NuGet. That is a selection criterion: every Roslyn release brings new syntax, and an analyzer that is no longer maintained either raises false alarms or crashes on new C# features. Of Meziantou's 85 warnings, 47 are MA0004 (ConfigureAwait(false) on awaited tasks) and 31 are MA0048 (the file name must match the type inside it). Most Roslynator rules are suggestions shown only in the IDE by default, which is why they don't appear in the build. And the answer to the question: none of the three caught the DateTime.UtcNow in CartCleanup with default settings.

To be fair: SonarAnalyzer has a rule for exactly this. S6354 ("Use a testable (date) time provider instead") is off by default; enable it in .globalconfig and it catches the line:

src/Bookstore.Api/Ordering/CartCleanup.cs(13,27): warning S6354: Use a testable (date) time provider instead.

So before writing your own analyzer, check the rule lists of existing packages. Bookstore will still write its own rule, for reasons we'll see below: a code fix, team-specific logic that can be attached to the rule, and not carrying a package that takes 64% of all analysis time for a single rule.

Rule Overlaps and Build Time

Several analyzer packages can report the same problem under different IDs. Three examples showed up in Bookstore:

src/Bookstore.Api/Catalog/Isbn.cs(7,51): warning MA0009: Regular expressions should not be vulnerable to Denial of Service attacks
src/Bookstore.Api/Catalog/Isbn.cs(7,51): warning S6444: Pass a timeout to limit the execution time.
src/Bookstore.Api/Program.cs(48,1): warning S6966: Await RunAsync instead.

The first two lines are the same line, the same column and the same advice: give the regex a timeout. The third is Sonar's counterpart of CA1849, which we saw in All mode in the first post. MA0004 is more interesting: we switched CA2007 off on purpose in the second post because it is meaningless in an ASP.NET Core application; Meziantou brought the same rule back in 47 places under a different ID. Adding a package doesn't mean accepting its rule set; every package needs a decision list like the one in the second post: which rule is an error, which is a warning, and which is none because it duplicates another package.

The compiler has its own report for measuring time: -p:ReportAnalyzer=true with detailed verbosity:

$ dotnet build src/Bookstore.Api --no-incremental -p:ReportAnalyzer=true -v:d
         Total analyzer execution time: 0.812 seconds.
         Time (s)    %   Analyzer
         0.524   64   SonarAnalyzer.CSharp, Version=10.36.0.0
         0.129   15   Meziantou.Analyzer, Version=3.0.340.0
         0.108   13   Roslynator.CSharp.Analyzers, Version=5.0.1.0
         0.033    4   Microsoft.CodeAnalysis.NetAnalyzers, Version=10.0.402.0

Without the packages, the same measurement was 0.056 seconds. In Bookstore's small API project the difference isn't noticeable, but the ratio matters: analysis time went up about 14 times, and two thirds of it comes from a single package. The report also breaks the time down per rule; Sonar's most expensive rule in Bookstore was S5344 (password storage). In a large solution this breakdown tells you which rule you gain the most from by switching off.

The No-Code Route: BannedApiAnalyzers

Microsoft's BannedApiAnalyzers package bans the APIs listed in a text file. In the "Root Cause Analysis" post of the Debugging and Problem Solving series we used this package to ban the two-parameter Math.Round overload, which does banker's rounding. For the clock rule, BannedSymbols.txt:

P:System.DateTime.Now;Use an injected TimeProvider: clock.GetLocalNow()
P:System.DateTime.UtcNow;Use an injected TimeProvider: clock.GetUtcNow()
P:System.DateTime.Today;Use an injected TimeProvider: clock.GetLocalNow().Date
P:System.DateTimeOffset.Now;Use an injected TimeProvider: clock.GetLocalNow()
P:System.DateTimeOffset.UtcNow;Use an injected TimeProvider: clock.GetUtcNow()
<ItemGroup>
  <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" PrivateAssets="all" />
  <AdditionalFiles Include="BannedSymbols.txt" />
</ItemGroup>
src/Bookstore.Api/Ordering/CartCleanup.cs(13,27): warning RS0030: The symbol 'DateTime.UtcNow' is banned in this project: Use an injected TimeProvider: clock.GetUtcNow()

The P: at the start of each line means property; the format is C#'s XML documentation IDs. Five lines, zero code. When a rule only says "don't use this API", this should be the first choice. Its limits are clear too. There is no code fix; the developer fixes it by hand. The rule is on or off per project; you can't express logic such as "allowed in this namespace". The message is fixed; it doesn't adapt to the code's context. If one of these limits gets in your way, it's time for your own analyzer.

Your Own Analyzer: DiagnosticAnalyzer

Analyzers run inside the compiler; since Visual Studio, Rider and dotnet build use the same compiler, the rule behaves the same everywhere. That's why the analyzer project targets netstandard2.0 and references the Microsoft.CodeAnalysis.CSharp package for the compiler's API:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <!-- Analyzers are loaded by the compiler, so they target netstandard2.0. -->
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    <IsRoslynComponent>true</IsRoslynComponent>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all" />
  </ItemGroup>

  <ItemGroup>
    <AdditionalFiles Include="AnalyzerReleases.Shipped.md" />
    <AdditionalFiles Include="AnalyzerReleases.Unshipped.md" />
  </ItemGroup>

</Project>

Microsoft.CodeAnalysis.Analyzers brings analyzers for analyzer authors: rules starting with RS that catch, for example, an analyzer holding non-thread-safe state or using a forbidden API. The two AnalyzerReleases files are for release tracking, which records the release in which each rule ID shipped. EnforceExtendedAnalyzerRules forbids APIs an analyzer can't use inside the compiler's environment, such as the file system or environment variables. The analyzer:

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

namespace Bookstore.Analyzers;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class UseTimeProviderAnalyzer : DiagnosticAnalyzer
{
    public const string DiagnosticId = "BS0001";

    private static readonly DiagnosticDescriptor Rule = new(
        id: DiagnosticId,
        title: "Use TimeProvider instead of the system clock",
        messageFormat: "'{0}' reads the system clock; use an injected TimeProvider",
        category: "Reliability",
        defaultSeverity: DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: "Bookstore code gets the current time from TimeProvider so that tests can control the clock.");

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

    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();
        context.RegisterCompilationStartAction(start =>
        {
            ImmutableHashSet<IPropertySymbol> clockProperties = FindClockProperties(start.Compilation);
            if (clockProperties.IsEmpty)
            {
                return;
            }

            start.RegisterOperationAction(
                operation => AnalyzePropertyReference(operation, clockProperties),
                OperationKind.PropertyReference);
        });
    }

    private static ImmutableHashSet<IPropertySymbol> FindClockProperties(Compilation compilation)
    {
        ImmutableHashSet<IPropertySymbol>.Builder builder =
            ImmutableHashSet.CreateBuilder<IPropertySymbol>(SymbolEqualityComparer.Default);
        AddProperties(builder, compilation.GetSpecialType(SpecialType.System_DateTime), "Now", "UtcNow", "Today");
        AddProperties(builder, compilation.GetTypeByMetadataName("System.DateTimeOffset"), "Now", "UtcNow");
        return builder.ToImmutable();
    }

    private static void AddProperties(
        ImmutableHashSet<IPropertySymbol>.Builder builder, INamedTypeSymbol? type, params string[] names)
    {
        if (type is null)
        {
            return;
        }

        foreach (string name in names)
        {
            foreach (IPropertySymbol property in type.GetMembers(name).OfType<IPropertySymbol>())
            {
                builder.Add(property);
            }
        }
    }

    private static void AnalyzePropertyReference(
        OperationAnalysisContext context, ImmutableHashSet<IPropertySymbol> clockProperties)
    {
        IPropertyReferenceOperation reference = (IPropertyReferenceOperation)context.Operation;
        if (!clockProperties.Contains(reference.Property))
        {
            return;
        }

        string name = $"{reference.Property.ContainingType.Name}.{reference.Property.Name}";
        context.ReportDiagnostic(Diagnostic.Create(Rule, reference.Syntax.GetLocation(), name));
    }
}

Three decisions deserve attention. The first is the kind of callback. A syntax callback looks at the text of the code: you'd search for the text DateTime.UtcNow and miss a bare UtcNow written with using static System.DateTime;, or a type alias. An operation callback sees the compiler's semantic model: "a reference to this property", regardless of how it was written. The second is symbol comparison. We find the properties once, at the start of the compilation, and keep them in a set with SymbolEqualityComparer.Default; comparing name strings on every operation would be both slow and fragile. The third is EnableConcurrentExecution and ConfigureGeneratedCodeAnalysis: the first lets the analyzer run in parallel, the second skips generated code. Microsoft.CodeAnalysis.Analyzers warns about an analyzer that doesn't call them.

CodeFixProvider

On the first try we put the code fix into the same project and added the Microsoft.CodeAnalysis.CSharp.Workspaces package:

Bookstore.Analyzers/UseTimeProviderAnalyzer.cs(8,2): warning RS1038: This compiler extension should not be implemented in an assembly containing a reference to Microsoft.CodeAnalysis.Workspaces. The Microsoft.CodeAnalysis.Workspaces assembly is not provided during command line compilation scenarios, so references to it could cause the compiler extension to behave unpredictably.

The warning is right: during dotnet build the compiler doesn't load the Workspaces assembly, so the analyzer and the code fix must live in separate assemblies. The code fix project references the analyzer project; the code fix itself:

[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UseTimeProviderCodeFix)), Shared]
public sealed class UseTimeProviderCodeFix : CodeFixProvider
{
    public override ImmutableArray<string> FixableDiagnosticIds =>
        ImmutableArray.Create(UseTimeProviderAnalyzer.DiagnosticId);

    public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;

    public override async Task RegisterCodeFixesAsync(CodeFixContext context)
    {
        SyntaxNode? root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
        SemanticModel? model = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false);
        if (root is null || model is null)
        {
            return;
        }

        if (root.FindNode(context.Span, getInnermostNodeForTie: true) is not MemberAccessExpressionSyntax access
            || ClockCall(model, access) is not { } call)
        {
            return;
        }

        // Only offer a fix when a TimeProvider is already in scope (field, property, parameter or local).
        INamedTypeSymbol? timeProvider = model.Compilation.GetTypeByMetadataName("System.TimeProvider");
        string? clock = model.LookupSymbols(access.SpanStart)
            .Where(s => s is IFieldSymbol or IPropertySymbol or IParameterSymbol or ILocalSymbol)
            .Where(s => SymbolEqualityComparer.Default.Equals(TypeOf(s), timeProvider))
            .Select(s => s.Name)
            .OrderBy(n => n, StringComparer.Ordinal)
            .FirstOrDefault();
        if (clock is null)
        {
            return;
        }

        string replacement = $"{clock}.{call}";
        context.RegisterCodeFix(
            CodeAction.Create(
                title: $"Use '{replacement}'",
                createChangedDocument: _ => Task.FromResult(context.Document.WithSyntaxRoot(
                    root.ReplaceNode(access, SyntaxFactory.ParseExpression(replacement).WithTriviaFrom(access)))),
                equivalenceKey: nameof(UseTimeProviderCodeFix)),
            context.Diagnostics);
    }

    private static string? ClockCall(SemanticModel model, MemberAccessExpressionSyntax access) =>
        model.GetSymbolInfo(access).Symbol is IPropertySymbol property
            ? (property.ContainingType.Name, property.Name) switch
            {
                ("DateTime", "UtcNow") => "GetUtcNow().UtcDateTime",
                ("DateTime", "Now") => "GetLocalNow().DateTime",
                ("DateTime", "Today") => "GetLocalNow().Date",
                ("DateTimeOffset", "UtcNow") => "GetUtcNow()",
                ("DateTimeOffset", "Now") => "GetLocalNow()",
                _ => null,
            }
            : null;

    private static ITypeSymbol? TypeOf(ISymbol symbol) => symbol switch
    {
        IFieldSymbol field => field.Type,
        IPropertySymbol property => property.Type,
        IParameterSymbol parameter => parameter.Type,
        ILocalSymbol local => local.Type,
        _ => null,
    };
}

The fix is deliberately narrow. If there is no TimeProvider in scope it offers nothing, because baking TimeProvider.System into the code defeats the purpose of the rule; the right fix is to get a TimeProvider through dependency injection, and that is a design decision. The only thing a code fix can do automatically is write a change whose decision has already been made. LookupSymbols also sees primary constructor parameters; that is exactly how the clock in CartCleanup is found.

Testing with Microsoft.CodeAnalysis.Testing

Plugging the analyzer into Bookstore and trying it by hand is slow and incomplete. The Roslyn team's testing library compiles source code in memory and compares the expected diagnostics and the code fix result. In the markup syntax, {|BS0001:...|} says that exactly that diagnostic is expected on that span:

public sealed class UseTimeProviderTests
{
    [Fact]
    public async Task UtcNow_is_replaced_with_the_injected_clock()
    {
        const string before = """
            using System;

            public sealed class CartCleanup(TimeProvider clock)
            {
                public DateTime Cutoff() => {|BS0001:DateTime.UtcNow|}.AddDays(-30);
            }
            """;
        const string after = """
            using System;

            public sealed class CartCleanup(TimeProvider clock)
            {
                public DateTime Cutoff() => clock.GetUtcNow().UtcDateTime.AddDays(-30);
            }
            """;

        await VerifyFixAsync(before, after);
    }

    [Fact]
    public async Task Using_static_is_reported_too()
    {
        const string source = """
            using System;
            using static System.DateTime;

            public static class Clock
            {
                public static DateTime Now() => {|BS0001:UtcNow|};
            }
            """;

        await VerifyFixAsync(source, source);
    }

    [Fact]
    public async Task Other_DateTime_members_are_allowed()
    {
        const string source = """
            using System;

            public static class Dates
            {
                public static DateTime AsUtc(DateTime value) => DateTime.SpecifyKind(value, DateTimeKind.Utc);
                public static DateTime Epoch => DateTime.UnixEpoch;
            }
            """;

        await VerifyFixAsync(source, source);
    }

    private static Task VerifyFixAsync(string source, string fixedSource)
    {
        CSharpCodeFixTest<UseTimeProviderAnalyzer, UseTimeProviderCodeFix, DefaultVerifier> test = new()
        {
            TestCode = source,
            FixedCode = fixedSource,
            ReferenceAssemblies = ReferenceAssemblies.Net.Net100,
        };
        return test.RunAsync(TestContext.Current.CancellationToken);
    }
}

The file has two more tests: DateTimeOffset.Now becoming time.GetLocalNow() through a field, and a diagnostic with no fix offered when there is no TimeProvider in scope. A FixedCode equal to TestCode means "nothing should change when the fix is applied". ReferenceAssemblies.Net.Net100 explicitly chooses which .NET version's reference assemblies the test code is compiled against, so the tests run against the net10.0 Bookstore targets rather than the library's default. DefaultVerifier is not tied to a test framework; the tests run on xUnit.net v3 like Bookstore's other tests:

$ dotnet test --project tests/Bookstore.Analyzers.Tests
Test run summary: Passed!
  total: 5
  failed: 0
  succeeded: 5
  skipped: 0

The third test documents the boundary of the rule: members that don't read the clock, such as the DateTime.SpecifyKind in BookstoreContext, are allowed. An analyzer's false positives are caught in these tests, not in the team's build.

Wiring It into Bookstore and Packaging for NuGet

To use the analyzer in a project of the same solution, it is enough to mark the ProjectReference as an analyzer:

<ItemGroup>
  <!-- Bookstore's own rules run in the compiler; the assembly is not referenced by the app. -->
  <ProjectReference Include="..\Bookstore.Analyzers\Bookstore.Analyzers.csproj"
                    OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
  <ProjectReference Include="..\Bookstore.Analyzers.CodeFixes\Bookstore.Analyzers.CodeFixes.csproj"
                    OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>

We added the reference only to Bookstore.Api; the "no system clock in domain code" rule doesn't cover the test projects. After adding dotnet_diagnostic.BS0001.severity = error to .globalconfig, Ayşe's pull request gives:

src/Bookstore.Api/Ordering/CartCleanup.cs(13,27): error BS0001: 'DateTime.UtcNow' reads the system clock; use an injected TimeProvider
src/Bookstore.Api/Ordering/CartCleanup.cs(7,67): warning CS9113: Parameter 'clock' is unread.

The code fix shows up as a light bulb in the IDE; on the command line it can be applied with dotnet format:

$ dotnet format analyzers Bookstore.slnx --diagnostics BS0001 --severity warn -v n
Formatting code files in workspace 'Bookstore.slnx'.
Formatted code file 'src/Bookstore.Api/Ordering/CartCleanup.cs'.
Format complete in 3174ms.
DateTime cutoff = clock.GetUtcNow().UtcDateTime - MaxAge;

Both BS0001 and CS9113 are gone. Sharing the rule with other repos needs a NuGet package. The layout of an analyzer package is standard: the DLLs go under analyzers/dotnet/cs/, not under lib/. We set up a separate packaging project for this: IncludeBuildOutput=false, DevelopmentDependency=true, and a small MSBuild target that puts the two DLLs in the right folder:

$ dotnet pack src/Bookstore.Analyzers.Package -o artifacts/packages
  Successfully created package 'artifacts/packages/Bookstore.CodeAnalysis.1.0.0.nupkg'.
$ unzip -l artifacts/packages/Bookstore.CodeAnalysis.1.0.0.nupkg
_rels/.rels
Bookstore.CodeAnalysis.nuspec
analyzers/dotnet/cs/Bookstore.Analyzers.CodeFixes.dll
analyzers/dotnet/cs/Bookstore.Analyzers.dll
[Content_Types].xml
package/services/metadata/core-properties/nuget.psmdcp

When we added the package from a local folder to a separate project and built a Shipping class that used DateTime.Now, we got warning BS0001: 'DateTime.Now' reads the system clock. We also tried making the package ID Bookstore.Analyzers; since the solution has a project with that name, restore failed with "Ambiguous project name", so the package is called Bookstore.CodeAnalysis.

In Production

Keep the Roslyn version low. The Microsoft.CodeAnalysis.CSharp version the analyzer references can't be newer than the compiler that loads it. We tried it: after bumping the package to 5.12.0 and building with SDK 10.0.402, the build succeeded, but with this line:

CSC : warning CS9057: Analyzer assembly 'src/Bookstore.Analyzers/bin/Debug/netstandard2.0/Bookstore.Analyzers.dll' cannot be used because it references version '5.12.0.0' of the compiler, which is newer than the currently running version '5.9.0.0'.

That is the dangerous part: the build is green even though BS0001 is set to error, because the analyzer was never loaded, and the only trace is a warning. Bookstore chose 4.14.0, which is older than the compiler in the .NET 10 SDK and in the IDEs the team uses. If you distribute the analyzer to other teams, choose it based on the oldest IDE and SDK you support, and make CS9057 an error in CI.

Watch IDE performance. Visual Studio and Rider run analyzers in the background after every keystroke. A package that takes 0.5 seconds in the build feels like editor lag in a large solution. You can set the scope of Visual Studio's background analysis (open files only or the whole solution); but first find the expensive rule with the ReportAnalyzer breakdown.

Version your own analyzer too. When you change a rule's behavior (banning a new property, say), it has the same effect as the SDK upgrade in the second post: builds break in repos whose code hasn't changed. Release tracking files and semantic versioning exist for this.

Common Pitfalls

The clash between netstandard2.0 and Nullable. Bookstore's Directory.Build.props turns on nullable for all projects, while the default C# version of netstandard2.0 is 7.3. When LangVersion was missing in the packaging project, the build broke with error CS8630: Invalid 'nullable' value: 'Enable' for C# 7.3.

Not seeing the transitive dependencies a test package brings in. Microsoft.CodeAnalysis.Testing pulls in System.Security.Cryptography.Pkcs 9.0.6 through the NuGet client libraries, and NuGetAudit reported NU1903 for it. Turning on Central Package Management's transitive pinning is the first fix that comes to mind; but because it applies to every project, it clashed with the Roslyn 5.0 dependency of Microsoft.EntityFrameworkCore.Design and restore gave NU1107. The fix was to reference the patched version directly, only in the test project.

Confusing analyzers with source generators. Both use the same Roslyn API and are packaged the same way, but a generator produces code while an analyzer only reports. If you're thinking "let's generate this code instead of writing it", see "Source Generators in C#" in the Advanced C# series; don't try to generate code inside an analyzer.

Putting the code fix in place of the analyzer. A code fix only runs in the IDE and in dotnet format; the analyzer is what catches the violation in the build. A code fix without tests can turn code into something that doesn't compile; that's why half of the tests above compare the "code after the fix".

A Small Experiment

★☆☆ Build your own project with -p:ReportAnalyzer=true -v:d and find the three most expensive analyzers and the three most expensive rules. If one of those rules is of no use to your team, switch it off and measure again.

★★☆ Add a second rule next to BS0001: BS0002 should report uses of TimeProvider.System outside Program.cs. Hint: context.Operation.Syntax.SyntaxTree.FilePath. Write the tests first for both.

★★★ Protect Bookstore's module boundary with an analyzer: code in the Bookstore.Ordering namespace may use only ICatalogService and BookInfo from Bookstore.Catalog. Can BannedApiAnalyzers do this? If not, which callback should your analyzer use?

With that, SDK rules, third-party packages and rules specific to Bookstore are all managed in a single .globalconfig on the .NET side. In the next post we move to the Angular client: ESLint 10's flat config, typescript-eslint's rules that use type information, and angular-eslint's template rules.