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

.NET Analyzers: AnalysisLevel, CA Rules, .globalconfig, WarningsAsErrors and Rolling Out Gradually in a Legacy Project

Mert Özen Nov 14, 2026 15 min 12 views
.NET Analyzers: AnalysisLevel, CA Rules, .globalconfig, WarningsAsErrors and Rolling Out Gradually in a Legacy Project

Pinning AnalysisLevel to a release such as 10.0-recommended stops an SDK upgrade from silently enabling new rules and breaking CI; in Bookstore the .NET 11 SDK gave a CA2028 error with latest and passed cleanly with the pinned level. The post covers the roles of .globalconfig and .editorconfig, CA1309 and a coupon that fails on the Turkish i, CA2100 and a real SQL injection, targeted WarningsAsErrors, justified suppressions, and SARIF with pull request annotations.

Version note: The code in this post was built and run with .NET 10 (SDK 10.0.402); the SDK upgrade experiment used the .NET 11 SDK 11.0.100, the suppression check used StyleCop.Analyzers 1.3.180, and the app ran against SQL Server 2025. The GitHub Actions workflow was validated with actionlint 1.7.12 and was not run on GitHub.

Guess First

In the first post we added a price import endpoint to Bookstore, the online bookstore used as the shared example across this site's series, and saw that the default analyzer settings hid five of seven diagnostics. The team learns the lesson: <AnalysisLevel>latest-recommended</AnalysisLevel> goes into Directory.Build.props, the resulting warnings are cleaned up, and TreatWarningsAsErrors is already on. Along the way the import gets a small helper that normalizes numbers written as "ISBN 978-0-441-17271-9":

using System.Text.RegularExpressions;

namespace Bookstore.Catalog;

public static class Isbn
{
    private static readonly Regex Isbn13Pattern = new(
        @"^(?:ISBN[- ]?)?(97[89])[- ]?(\d{1,5})[- ]?(\d+)[- ]?(\d+)[- ]?(\d)$", RegexOptions.Compiled);

    // Normalizes "ISBN 978-0-441-17271-9" to "9780441172719"; returns null for anything else.
    public static string? Normalize(string input)
    {
        string candidate = input.Trim();
        if (Isbn13Pattern.IsMatch(candidate))
        {
            Match match = Isbn13Pattern.Match(candidate);
            string digits = string.Concat(match.Groups.Values.Skip(1).Select(g => g.Value));
            return digits.Length == 13 ? digits : null;
        }

        return null;
    }
}

The build is clean and CI is green. A month later .NET 11 ships and Emre opens a one-line pull request: the SDK version in global.json goes from 10.0.100 to 11.0.100. Nothing changes in the code, and the project still targets net10.0. What does CI say? And which single line would have kept this pull request from turning red without any code change?

The SDK's Analyzers and AnalysisLevel

Since .NET 5, Microsoft's code quality analyzers (Microsoft.CodeAnalysis.NetAnalyzers) ship inside the SDK; they run in every project targeting net5.0 or later without an extra package. Two MSBuild properties decide which rules run at which level. AnalysisLevel says which .NET release the rule set belongs to, and AnalysisMode says how much of that set is enabled. The mode values, in increasing order, are None, Default, Minimum, Recommended and All. The two can be combined into a single property: 10.0-recommended, latest-all and so on. According to Microsoft's documentation, if you set both, the compound AnalysisLevel value wins.

The defaults explain the table we saw in the first post. The default AnalysisLevel is latest, and the default mode is Default. In .NET 10 the Default mode enables 29 rules as warnings or errors, and almost all of them are about narrow topics such as interop, stackalloc or file-based programs. Looking at the SDK's own config files (sdk/<version>/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_10_recommended.globalconfig), we see that Recommended mode makes 145 rules warnings; in the same file for .NET 11 the number is 151.

The answer to the guess is here. latest means "the newest rule set of the SDK I have". Move to SDK 11 and the set changes too:

$ dotnet --version
11.0.100
$ dotnet build Bookstore.slnx
src/Bookstore.Api/Catalog/Isbn.cs(14,13): error CA2028: Redundant call to 'Regex.IsMatch' before 'Regex.Match'. Use 'Regex.Match' and check 'Match.Success' instead.
    0 Warning(s)
    1 Error(s)

CA2028 is a new rule that comes with .NET 11: running the regex once with IsMatch and then again with Match is wasteful; calling Match once and checking Success is enough. The rule is right, but it is not what this pull request is about. The fix is to pin the level:

<AnalysisLevel>10.0-recommended</AnalysisLevel>
$ dotnet build Bookstore.slnx
    0 Warning(s)
    0 Error(s)

Same SDK 11, same code, zero errors. According to the documentation, when you upgrade the SDK with a pinned level you still get bug fixes for existing rules, but no new rule is enabled and no existing one is disabled. An SDK upgrade is a compiler and runtime change; raising the rule set is a code quality decision. Doing them in separate pull requests makes both review and rollback easier. Pinning has a cost too: you miss the good rules that latest would bring automatically. So you need a recurring task such as "raise the level after every major SDK"; in the sixth post we go through the rules .NET 11 brings one by one.

Per category: security rules

Every property has a per-category counterpart: AnalysisLevelSecurity, AnalysisLevelPerformance, AnalysisModeGlobalization and so on. Bookstore is happy with Recommended in general but wants every rule in the security category. We first tried the obvious route, -p:AnalysisModeSecurity=All: not a single new warning appeared. The reason is the precedence rule above. When AnalysisLevel is a compound value, the category level is derived from it by default and overrides the mode. The right property is AnalysisLevelSecurity:

$ dotnet build src/Bookstore.Api -p:AnalysisLevelSecurity=10.0-all
src/Bookstore.Api/Catalog/CouponCode.cs(13,32): warning CA5394: Random is an insecure random number generator. Use cryptographically secure random number generators when randomness is required for security.
src/Bookstore.Api/Reports/ReportsModule.cs(24,9): warning CA2100: Review if the query string passed to 'string DbCommand.CommandText' in 'GetRevenue', accepts any user input

We deal with these two findings below. Roslyn's security category consists of pattern-based rules that look inside a single method; they don't follow user input across methods to a dangerous call. That is the job of SAST tools; in the DevSecOps series, "SAST: Finding Vulnerabilities in Source Code with CodeQL, Semgrep and .NET Security Analyzers" compares these rules with CodeQL and Semgrep.

.globalconfig or .editorconfig?

Rule severity can be written in two kinds of files, and they serve different purposes. .editorconfig is file and folder based: section headers such as [*.cs] and [tests/**/*.cs] select which files a rule applies to, and the file deeper in the tree wins. A global AnalyzerConfig file has no section headers, starts with is_global = true, and applies to all source files of the project. If it is named exactly .globalconfig, it is added automatically to every C# project in its folder and subfolders; with any other name you add it with the GlobalAnalyzerConfigFiles item.

The conflict rules are spelled out on Microsoft Learn: if the same key is in both an .editorconfig and a global file, .editorconfig wins. If two global files conflict, the one with the higher global_level wins; for files named .globalconfig the default is 100. The config files the SDK adds for AnalysisLevel are global configs too, but they come with global_level = -100, so every line in our .globalconfig overrides them. Bookstore's split:

  • .globalconfig: project-wide rule decisions and their reasons, read in one file during code review.
  • .editorconfig: path-based exceptions only. Style rules live in this file too, but they belong to "Encoding C# Conventions in .editorconfig" in the Coding Conventions series; the two series look at the same file from different angles.

When we moved to latest-recommended, two of the errors were path based: CA1861 in EF Core migrations and CA1707 for underscores in test method names. We handle both without touching the code, only in the folders concerned:

root = true

# Code style rules are not here: see the Coding Conventions series.
# This file only holds path-based analysis settings; project-wide rules live in .globalconfig.

[src/Bookstore.Api/Data/Migrations/*.cs]
generated_code = true

[tests/**/*.cs]
# Underscores in test names are a Bookstore convention: Placing_an_order_reserves_stock
dotnet_diagnostic.CA1707.severity = none

generated_code = true marks the migration files as code generated by dotnet ef, and analyzers skip them. Use it only for files that really are generated and not edited by hand; marking the whole Data folder would take real code out of analysis too.

Rules Chosen for Bookstore

The Recommended set is a good start, but the real work for a team is deciding which rule breaks the build. Bookstore's criterion is the one from the first post: has this rule caught, or would it catch, a real bug in our code? The result is .globalconfig:

is_global = true

# Bookstore code analysis rules. The rule set comes from Directory.Build.props
# (AnalysisLevel = 10.0-recommended); this file only records our decisions.

# Bugs we have actually shipped: these break the build.
# CA2016: forward the CancellationToken
dotnet_diagnostic.CA2016.severity = error
# CA1305: culture-dependent parsing and formatting
dotnet_diagnostic.CA1305.severity = error
# CA1309: ordinal comparison for codes, slugs and ISBNs
dotnet_diagnostic.CA1309.severity = error
# CA2100: SQL text built from input
dotnet_diagnostic.CA2100.severity = error
# CA5394: Random for codes and tokens
dotnet_diagnostic.CA5394.severity = error
# EF1002 / EF1003: interpolated or concatenated string in FromSqlRaw/ExecuteSqlRaw
dotnet_diagnostic.EF1002.severity = error
dotnet_diagnostic.EF1003.severity = error

# Stays a warning: visible in the IDE and in the pull request, does not break the build.
# CA1848: LoggerMessage source generator
dotnet_diagnostic.CA1848.severity = warning

# Off on purpose.
# CA2007: no SynchronizationContext in ASP.NET Core
dotnet_diagnostic.CA2007.severity = none

We saw CA2016 and CA1305 in the first post. In Bookstore, CA1309 shows up in campaign coupons. Built with the Recommended set, this code breaks the build:

// Campaign coupon codes such as "WINTER25" or generated ones such as "K7QX2MPA9R".
public static class CouponCode
{
    private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";

    public static string NewCode()
    {
        char[] code = new char[10];
        for (int i = 0; i < code.Length; i++)
        {
            code[i] = Alphabet[Random.Shared.Next(Alphabet.Length)];
        }
        return new string(code);
    }

    public static bool Matches(string input, string code) =>
        string.Equals(input.Trim(), code, StringComparison.CurrentCultureIgnoreCase);
}
src/Bookstore.Api/Catalog/CouponCode.cs(19,43): error CA1309: Use ordinal string comparison

The answer to "the comparison is already case-insensitive, why ordinal?" is in Turkish. We ran the same comparison under two cultures:

en-US: CurrentCultureIgnoreCase=True, OrdinalIgnoreCase=True
tr-TR: CurrentCultureIgnoreCase=False, OrdinalIgnoreCase=True

In Turkish the uppercase of "i" is "İ", so on a server running with the tr-TR culture, a customer typing "winter25" doesn't match "WINTER25". A coupon code is not linguistic text but an identifier, and identifiers are compared ordinally. The Random.Shared in the same file is CA5394: Random is a predictable generator, and a predictable coupon code means discounts nobody paid for. The fixed version:

public static string NewCode() => RandomNumberGenerator.GetString(Alphabet, 10);

public static bool Matches(string input, string code) =>
    string.Equals(input.Trim(), code, StringComparison.OrdinalIgnoreCase);

CA2100 and CA5394 are not in the Recommended set, so we make them errors explicitly in .globalconfig; AnalysisLevelSecurity keeps the rest of the security category on as warnings. The code CA2100 catches is the back office's revenue-by-category report, written with ADO.NET outside EF Core:

command.CommandText = """
    SELECT COALESCE(SUM(i.LineTotal), 0)
    FROM ordering.OrderItems i
    JOIN catalog.Books b ON b.Id = i.BookId
    JOIN catalog.Categories c ON c.Id = b.CategoryId
    WHERE c.Slug = '
    """ + category + "'";

The rule's message asks whether it "accepts any user input"; let's test the answer. On a database with one order, switching the rule off temporarily with NoWarn and running it:

$ curl "http://localhost:5080/api/admin/reports/revenue?category=classics"
{"category":"classics","revenue":499.80}  HTTP 200
$ curl "http://localhost:5080/api/admin/reports/revenue?category=nothing'%20OR%20'1'='1"
{"category":"nothing' OR '1'='1","revenue":988.80}  HTTP 200

A category that doesn't exist returned the total revenue. The parameterized version:

command.CommandText = """
    SELECT COALESCE(SUM(i.LineTotal), 0)
    FROM ordering.OrderItems i
    JOIN catalog.Books b ON b.Id = i.BookId
    JOIN catalog.Categories c ON c.Id = b.CategoryId
    WHERE c.Slug = @category
    """;
DbParameter parameter = command.CreateParameter();
parameter.ParameterName = "@category";
parameter.Value = category;
command.Parameters.Add(parameter);

decimal revenue = Convert.ToDecimal(await command.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture);
{"category":"classics","revenue":499.80}  HTTP 200
{"category":"nothing' OR '1'='1","revenue":0.00}  HTTP 200

The CultureInfo.InvariantCulture on the last line is no accident either: CA1305 had flagged Convert.ToDecimal(object) in the same build. If the query can stay inside EF Core, db.Database.SqlQuery<decimal>($"...") turns the interpolated string into parameters by itself; when you do need to drop down to ADO.NET, the rule forces you to use parameters.

We left CA1848 as a warning. Turning the log line from the first post into a source-generated method is easy:

[LoggerMessage(Level = LogLevel.Information, Message = "Price import finished: {Updated} updated, {Unknown} unknown")]
private static partial void LogImportFinished(ILogger logger, int updated, int unknown);

But making it mandatory for every log line would create more work than it is worth on hundreds of lines that are not on a hot path. Seeing it as a warning in the pull request is enough. We turned CA2007 off explicitly; the Recommended set doesn't enable it anyway, but having the line in the file says "we know about this and it is off on purpose". For a team writing a library, the decision would be the opposite.

TreatWarningsAsErrors or Targeted Errors?

Bookstore started with TreatWarningsAsErrors. Two options and their tradeoffs:

The first option is to make every warning an error. Its advantage is simplicity: the build is either clean or broken, and warnings don't pile up. Its disadvantage is that everything you don't control breaks the build too: a rule brought by a new SDK, an API a package marked [Obsolete], or the NU1903 that NuGetAudit, covered in the DevSecOps series, produces for a security advisory published overnight. CI breaks without any change to the code, and the team usually reaches for the bluntest fix, NoWarn.

The second option is to pick, one by one, which diagnostics break the build. Bookstore does this in two places: severity = error in .globalconfig for CA rules, and MSBuild's WarningsAsErrors for the compiler's nullable warnings:

<Project>
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>

    <!-- Rule set pinned to the .NET 10 release; raise it in its own pull request. -->
    <AnalysisLevel>10.0-recommended</AnalysisLevel>
    <AnalysisLevelSecurity>10.0-all</AnalysisLevelSecurity>

    <!-- Only these warnings break the build; severities of CA rules live in .globalconfig. -->
    <WarningsAsErrors>$(WarningsAsErrors);nullable</WarningsAsErrors>

    <!-- In CI, write every diagnostic to a SARIF 2.1 file per project. -->
    <ErrorLog Condition="'$(CI)' == 'true'">$(MSBuildThisFileDirectory)artifacts/sarif/$(MSBuildProjectName).sarif,version=2.1</ErrorLog>
  </PropertyGroup>
</Project>

nullable is a special value: it covers all of the compiler's nullable warnings (such as CS8602) in one word. The book.Price line from the first post still gives error CS8602 with this setting. Making the severity an error in .globalconfig differs from WarningsAsErrors in one way: the rule shows red in the IDE too, so the developer sees it without waiting for the build.

The cost of this option is that warnings can pile up. Bookstore keeps them visible with SARIF and pull request annotations, and tracks the warning count on the dashboard in the fifth post. If the team's discipline is weak, the first option is safer; in a legacy codebase the second option is the only realistic way to migrate gradually. A middle ground: targeted errors in the local build, -warnaserror in CI. According to the documentation, CodeAnalysisTreatWarningsAsErrors=false keeps that flag from affecting CA rules if you want.

Kinds of Suppression and Requiring a Justification

The endpoint that picks four random books for the home page's "featured" section broke the build with the new security setting:

src/Bookstore.Api/Catalog/CatalogModule.cs(84,9): error CA5394: Random is an insecure random number generator. Use cryptographically secure random number generators when randomness is required for security.

This is a false positive: which book appears in the shop window is not a secret and grants nobody anything. There are five ways to suppress it, with very different scopes:

WayScopeCan you write a justification?
#pragma warning disable CA5394A range of linesOnly as a comment
[SuppressMessage]Member, typeYes, Justification
GlobalSuppressions.cs (assembly-level SuppressMessage)The targeted member or namespaceYes
severity = none in .editorconfig / .globalconfigFolder or projectAs a comment
NoWarnProjectNo

Bookstore's rule: for a single false positive, SuppressMessage with a non-empty Justification; if a rule is meaningless across the whole project, none in the config with a comment next to it. #pragma and NoWarn are rejected in code review.

// Four random books for the home page; a different selection on every request.
[SuppressMessage("Security", "CA5394:Do not use insecure randomness",
    Justification = "Picks books to show on the home page; the result is public and grants nothing.")]
private static async Task<Ok<BookSummary[]>> GetFeatured(BookstoreContext db, CancellationToken ct)
{
    BookSummary[] books = await db.Books.AsNoTracking()
        .Select(b => new BookSummary(b.Isbn, b.Title, b.Author.Name, b.Category.Name, b.Price, b.PublishedYear))
        .ToArrayAsync(ct);
    Random.Shared.Shuffle(books);
    return TypedResults.Ok(books[..Math.Min(4, books.Length)]);
}

The SDK doesn't stop you from leaving Justification empty. If you want a tool to enforce it, rule SA1404 in the StyleCop.Analyzers package does exactly that. Since the package comes with hundreds of style rules, we switched off the other categories and enabled only this rule (seven lines such as dotnet_analyzer_diagnostic.category-StyleCop.CSharp.MaintainabilityRules.severity = none plus dotnet_diagnostic.SA1404.severity = error), then emptied the justification:

src/Bookstore.Api/Catalog/CatalogModule.cs(81,9): error SA1404: Code analysis suppression should have justification
CSC : warning SA0001: XML comment analysis is disabled due to project configuration

The second line is the informational warning the package gives in projects where XML documentation generation is off; it can be switched off in the config too. We cover third-party analyzers in detail in the next post; for now Bookstore keeps this rule on its review checklist.

SARIF and Pull Request Annotations

Nobody reads the build log; the warning has to appear next to the line in the pull request. Roslyn can produce SARIF for that. The ErrorLog line in the Directory.Build.props above writes a separate file per project when the CI environment variable is true; GitHub Actions sets that variable itself. On the first try the build broke with this error:

CSC : error CS0016: Could not write to output file '.../artifacts/sarif/Bookstore.Api.sarif' -- 'Could not find a part of the path '.../artifacts/sarif/Bookstore.Api.sarif'.'

The compiler doesn't create the folder; the workflow needs a mkdir first. One result from the file (we only shortened the path):

{
  "ruleId": "CA1849",
  "ruleIndex": 168,
  "level": "warning",
  "message": {
    "text": "'WebApplication.Run(string?)' synchronously blocks. Await 'WebApplication.RunAsync(string?)' instead."
  },
  "locations": [
    {
      "physicalLocation": {
        "artifactLocation": {
          "uri": "file:///.../src/Bookstore.Api/Program.cs"
        },
        "region": {
          "startLine": 48,
          "startColumn": 1,
          "endLine": 48,
          "endColumn": 10
        }
      }
    }
  ]
}

The workflow uses the upload-sarif action, which uploads the results to GitHub code scanning. The action's checkout_path input makes absolute paths in the SARIF relative to the repo, so the file:/// paths are not a problem:

name: dotnet-analysis

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-dotnet@v6
        with:
          global-json-file: global.json
      # The compiler does not create the SARIF folder (CS0016)
      - run: mkdir -p artifacts/sarif
      - run: dotnet build Bookstore.slnx
      - name: Upload analyzer results
        if: ${{ !cancelled() }}
        uses: github/codeql-action/upload-sarif@v4
        with:
          sarif_file: artifacts/sarif
          category: dotnet-analyzers

if: ${{ !cancelled() }} matters: the results should be uploaded when the build breaks too, otherwise you see no annotations exactly when you need them most. Code scanning is free for public repos; private repos need GitHub's Code Security product, a licensing split we covered in the DevSecOps series. You can also add the workflow as a separate job to the pipeline from the ".NET CI" post in the GitHub Actions series.

From .ruleset to .globalconfig

In projects left over from the .NET Framework era, rules usually live in a .ruleset XML file wired up with the CodeAnalysisRuleSet property. It still works in SDK 10; we tried it, and CA1849 with Action="Error" in the rule set broke the build. But Microsoft considers rule sets deprecated, and if a rule set and a config file give the same rule different severities, which one wins is undefined. The migration is mechanical:

<RuleSet Name="Bookstore rules" Description="Legacy rule set" ToolsVersion="17.0">
  <Rules AnalyzerId="Microsoft.CodeAnalysis.NetAnalyzers" RuleNamespace="Microsoft.CodeAnalysis.NetAnalyzers">
    <Rule Id="CA1849" Action="Error" />
    <Rule Id="CA2007" Action="None" />
    <Rule Id="CA1848" Action="Info" />
  </Rules>
</RuleSet>
is_global = true
dotnet_diagnostic.CA1849.severity = error
dotnet_diagnostic.CA2007.severity = none
dotnet_diagnostic.CA1848.severity = suggestion

The Action values map as Error → error, Warning → warning, Info → suggestion, Hidden → silent, None → none. Visual Studio has a command that converts a rule set to .editorconfig; still review the output line by line, because old rule sets often contain FxCop rules that no longer exist. When the migration is done, delete the CodeAnalysisRuleSet line; keeping both makes the undefined precedence above permanent.

In Production

Raise the level with measurements. Before moving from 10.0-recommended to 11.0-recommended, take two builds with the same SDK, count the new warnings by rule ID, and in a separate pull request either fix them or switch them off with a reason. The single CA2028 from the guess is what that pull request is about.

Make sure the IDE and CI see the same rules. All settings are in Directory.Build.props, .globalconfig and .editorconfig, that is, in the repo. Machine-specific settings, such as Visual Studio's "Analyze and Code Cleanup" options, don't exist in CI; something that isn't red in the IDE but breaks in CI, or the other way round, means the team isn't using these files.

Watch the size of the SARIF. In All mode, Bookstore.Api alone produced 90 results. GitHub has SARIF upload limits; in a legacy project producing thousands of results, sort out the severities first, then upload.

Common Pitfalls

Assuming AnalysisModeSecurity works. If AnalysisLevel is a compound value, the category mode has no effect; we saw that by trying it. Use AnalysisLevel<Category> with a compound value for a category.

Giving generated_code a wide glob. A section such as [src/**/Data/**.cs] counts the hand-written BookstoreContext as "generated" too and takes it out of analysis.

Adding the Microsoft.CodeAnalysis.NetAnalyzers package next to the SDK. The package exists to decouple analyzers from the SDK and tie them to their own version; when you add it, the SDK's analyzers are switched off. If the package is older than the SDK's analyzers, the build warns. Unless you have a reason, pin the version with AnalysisLevel and don't add the package.

Forgetting to create the SARIF folder. CS0016 is not an analyzer finding but the compiler failing to write a file; the build breaks and no annotations are uploaded.

A Small Experiment

★☆☆ Open the SDK's analysislevel_10_recommended.globalconfig and, among the rules that are warnings, find the three your code violates most. For each, write the "error, warning or none" decision and its reason as a .globalconfig line.

★★☆ Rewrite Isbn.Normalize, which triggers CA2028, with a single Match call and match.Success, then confirm that a latest-recommended build with the .NET 11 SDK passes cleanly. Does the rule still fire when you turn the regex into a [GeneratedRegex] property?

★★★ Migrate the .ruleset file of a legacy project in your team to .globalconfig. Before and after the migration, produce SARIF with -p:AnalysisLevel=latest-all and write a small script that compares the results in the two files by rule ID. If there is a difference, what causes it?

We now manage the SDK's analyzers by level, severity and CI output. But rules specific to Bookstore, such as "use TimeProvider, not DateTime.UtcNow, in domain code", are not in the SDK. In the next post we look at third-party analyzer packages, their effect on build time, and writing our own Roslyn analyzer with a code fix and tests.