What Changed in Static Analysis: .NET 5–11 Analyzers, From TSLint to ESLint 10, TypeScript 7 and Next-Generation Linters
Analyzers have been part of the SDK since .NET 5, and every release brings new rules: the same code gave zero warnings with the .NET 10 SDK and four with the .NET 11 SDK (CA2026, CA2027, CA1877, CA2028). The post covers the .NET 6–11 rule history, CA2028 missing source-generated regexes, why automatic .eslintrc to flat config migration isn't enough with angular-eslint 22, TypeScript 7 holding back typescript-eslint, Oxlint and Biome results on Bookstore, and Sonar's new names.
Version note: The code in this post was built and run with .NET 10 (SDK 10.0.402) and .NET 11 (SDK 11.0.100); on the JavaScript side ESLint 10.15, @eslint/migrate-config 3.0.4, Oxlint 1.93 (oxlint-tsgolint 7.0.2007) and Biome 2.6.7 were run on Node.js 24. The Sonar information comes from the SonarQube Community Build 26.11 setup in the fifth post and from the documentation.
Guess First
Bookstore, the online bookstore used as the shared example across this site's series, has an old helper class that reads catalog files from suppliers. It was written years ago and still compiles without problems on today's .NET:
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Bookstore.Catalog;
// Reads a supplier's catalog file; written the way older .NET code often looks.
internal static partial class CatalogImport
{
private static readonly Regex IsbnField = new(@"^isbn:(97[89]\d{10})$", RegexOptions.Compiled);
[GeneratedRegex(@"^isbn:(97[89]\d{10})$")]
private static partial Regex IsbnFieldGenerated { get; }
public static string? IsbnFromField(string field)
{
if (IsbnField.IsMatch(field))
{
return IsbnField.Match(field).Groups[1].Value;
}
return null;
}
public static string? IsbnFromFieldGenerated(string field)
{
if (IsbnFieldGenerated.IsMatch(field))
{
return IsbnFieldGenerated.Match(field).Groups[1].Value;
}
return null;
}
public static string TitleFromJson(string json) =>
JsonDocument.Parse(json).RootElement.GetProperty("title").GetString() ?? "";
public static string CoverPath(string root, string isbn) =>
Path.Combine(Path.Combine(root, "covers"), isbn + ".jpg");
public static async Task<bool> DownloadWithTimeoutAsync(Task download)
{
Task finished = await Task.WhenAny(download, Task.Delay(TimeSpan.FromSeconds(5)));
return finished == download;
}
}
The project targets net10.0 and uses <AnalysisLevel>latest-recommended</AnalysisLevel>. How many warnings do you get when you build it with the .NET 10 SDK? How many with the .NET 11 SDK, without changing a single character of code? And since the two IsbnFrom... methods make the same mistake, are both caught?
.NET 5: Analyzers Move into the SDK
To understand today's setup we need to go back a little. In the .NET Framework era, code analysis came from two separate worlds. The old FxCop was a tool that analyzed the compiled assembly after the build; its rules were managed with .ruleset XML files. With Roslyn, these rules moved to analyzers that run on source code, and for a while they were distributed as the Microsoft.CodeAnalysis.FxCopAnalyzers NuGet package.
.NET 5 (November 2020) brought that package into the SDK. Microsoft.CodeAnalysis.NetAnalyzers started running without extra installation in every project targeting net5.0 or later, and the AnalysisLevel property was born. The rule set file was replaced first by .editorconfig and then, with .NET 6, by .globalconfig, whose precedence can be set with global_level. The file extension of the config files the SDK ships for each level carries this change too: according to Microsoft's documentation, the level files for .NET 7 and earlier are .editorconfig, later ones .globalconfig. The .ruleset migration we saw in the second post is a leftover of this history.
.NET 6–10: What Came with Each Release?
The primary source for which rule was added in which release is the AnalyzerReleases.Shipped.md file in the dotnet/sdk repo. What each release turns into warnings in "Recommended" mode we worked out by comparing the SDK's own analysislevel_<version>_recommended.globalconfig files. The notable ones for a web application like Bookstore:
| Release | Notable new rules | What it means for Bookstore |
|---|---|---|
| .NET 6 | CA1848, CA2253, CA2254 (logging templates), CA2017 (parameter count mismatch, warning by default) | Structured logging rules; the interpolated log line in the first post came from these |
| .NET 7 | CA1849 (sync call instead of async), CA1851 (multiple enumeration), CA1852, CA1854 | The app.Run() finding in Program.cs |
| .NET 8 | CA1510–CA1513 (throw helpers), CA1859–CA1870 (performance), CA2021 (warning by default) | Together with .NET 6, the release that added the most rules to Recommended mode: 17 rules |
| .NET 9 | CA1515 (make types internal), CA2022 (Stream.Read), CA2263–CA2265 | The CA1515 that showed up 39 times in All mode in the first post |
| .NET 10 | CA1873 (expensive log argument), CA1874/CA1875 (Regex), CA2023, CA2024, CA2025 | CA1873 on the log line in the first post |
| .NET 11 | CA1517, CA1876, CA1877, CA2026, CA2027, CA2028, CA2267 | Below |
One detail to keep in mind when reading the table: the release in which a rule was added to the SDK and the mode in which it is enabled by default are different things. For example, CA1515 came with .NET 9, but its default severity is "Disabled"; it only runs in All mode. Whether a new rule breaks your build depends on the AnalysisLevel and mode you pinned in the second post.
The .NET 11 Rules
The answer to the guess:
$ dotnet --version
10.0.402
$ dotnet build
0 Warning(s)
$ dotnet --version
11.0.100
$ dotnet build
CatalogImport.cs(16,13): warning CA2028: Redundant call to 'Regex.IsMatch' before 'Regex.Match'. Use 'Regex.Match' and check 'Match.Success' instead.
CatalogImport.cs(33,9): warning CA2026: Use 'JsonElement.Parse' instead of 'JsonDocument.Parse(...).RootElement' to avoid resource leaks
CatalogImport.cs(36,9): warning CA1877: Multiple consecutive Path.Combine operations can be collapsed into a single operation
CatalogImport.cs(40,54): warning CA2027: Using Task.WhenAny with Task.Delay may result in a timer continuing to run after the operation completes, wasting resources
4 Warning(s)
Zero warnings on .NET 10, four on .NET 11. All four are real problems. CA2026: JsonDocument rents memory from a pool and must be disposed; taking RootElement and dropping the document doesn't give that memory back. CA2027: when the operation finishes, the Task.Delay timer keeps running for another five seconds; on a busy endpoint that means piled-up timers. CA1877's suggestion is a single Path.Combine(root, "covers", isbn + ".jpg"). Some of these rules don't have a page on Microsoft Learn yet at the time of writing; we verified their IDs and descriptions from the config files of SDK 11.0.100 and from AnalyzerReleases.Unshipped.md in the dotnet/sdk repo.
The answer to the third question is in the output too: CA2028 appeared only on line 16, for the regex called through a static field. IsbnFromFieldGenerated, which makes the same mistake through a [GeneratedRegex] property, stayed silent. We saw the same thing in the second post while writing Bookstore's Isbn class: the rule doesn't recognize the source-generated version that exposes the regex as a method or a property. False negatives like this are common in the first release of a new rule, which is why "the analyzer is silent, so the code is clean" is the wrong conclusion.
Remember that Bookstore is still on the .NET 10 SDK at level 10.0-recommended. To prepare the move to .NET 11 we fixed the code first; Isbn.Normalize now calls Match once:
// Normalizes "ISBN 978-0-441-17271-9" to "9780441172719"; returns null for anything else.
public static string? Normalize(string input)
{
Match match = Isbn13Pattern.Match(input.Trim());
if (!match.Success)
{
return null;
}
string digits = string.Concat(match.Groups.Values.Skip(1).Select(g => g.Value));
return digits.Length == 13 ? digits : null;
}
In this state, building Bookstore with the .NET 11 SDK and -p:AnalysisLevel=11.0-recommended gave zero warnings and zero errors. The upgrade pull request now only has to change global.json and the AnalysisLevel line.
From TSLint to ESLint 10
The history on the Angular side is messier. For years the Angular CLI came with TSLint; TSLint was declared deprecated in 2019, and from Angular CLI 12 on, new projects came without lint configured. Projects created back then and upgraded ever since may still have a tslint.json; if no tool reads it, it's a dead file. The migration was done with angular-eslint's schematics; convert-tslint-to-eslint is still in the schematic list of 22.6.
Then came ESLint's own migration. ESLint 9 (April 2024) made flat config the default but still supported the old .eslintrc through an environment variable; ESLint 10 (February 2026) removed it completely. The release notes of angular-eslint 22.0 also say it dropped support for ESLint 8 and the eslintrc format. With a typical .eslintrc.json from the Angular 17 days, ESLint 10 says:
$ npx eslint src
Oops! Something went wrong! :(
ESLint: 10.15.0
ESLint couldn't find an eslint.config.* file.
The ESLint team's migration tool converts the file:
$ npx @eslint/migrate-config .eslintrc.json
Migrating .eslintrc.json
Wrote new config to ./eslint.config.mjs
You will need to install the following packages to use the new config:
- @eslint/js
- @eslint/eslintrc
The generated file wraps the old extends entries with FlatCompat: compat.extends("plugin:@angular-eslint/recommended", ...). After installing the packages and running it:
$ npx eslint src
Oops! Something went wrong! :(
ESLint: 10.15.0
ESLint couldn't find the config "plugin:@angular-eslint/recommended" to extend from. Please check that the name of the config is correct.
FlatCompat is a bridge for using configs written in the old format in the new one; but angular-eslint 22 no longer publishes configs in the old format, so there's nothing on the other side of the bridge. The right migration takes the automatic output as a starting point and replaces the old names with the packages' flat config equivalents: angular.configs.tsRecommended instead of plugin:@angular-eslint/recommended, angular.configs.templateRecommended instead of plugin:@angular-eslint/template/recommended, tseslint.configs.recommended instead of plugin:@typescript-eslint/recommended. The result is exactly the eslint.config.js from the fourth post. overrides blocks become array elements with a files field, and ignorePatterns becomes globalIgnores; the tool got this part right.
TypeScript 6.0 and TypeScript 7
TypeScript 6.0 (March 2026) shipped as the last TypeScript written in JavaScript and changed the defaults; what matters most to us is that strict is now on by default. In the fourth post we saw the compiler do null checks in a file without a tsconfig. The cheapest layer of the static analysis spectrum, the type checker, is thus active even if nobody turns it on.
TypeScript 7.0 (July 2026) is the compiler rewritten in Go. It's fast, but 7.0 has no programmatic API. Since typescript-eslint's type-aware rules depend on that API, as we saw in the fourth post even the latest typescript-eslint limits TypeScript to <6.1.0, and Angular 22.2 wants >=6.0 <6.1. The TypeScript 7.1 beta (October 2026) comes with a new API; until that API is stable and typescript-eslint moves to it, Angular projects stay on TypeScript 6.0. The practical consequence: in an Angular project, the decision "let's move to TypeScript 7" today depends not on the compiler but on the lint and framework toolchain.
Next-Generation Linters: Oxlint and Biome
Oxlint leads the tools filling this gap. It's written in Rust, and version 1.0 was released in June 2025. For type-aware rules it uses a separate engine: oxlint-tsgolint, built on TypeScript 7's native compiler. According to the Oxc team's post of 22 July 2026, type-aware linting became stable on that date, supports 59 of typescript-eslint's 61 type-aware rules, and was measured 12 to 18 times faster than ESLint with typescript-eslint on four large codebases. The interesting part is that the tool brings its own TypeScript: it worked on Bookstore's TypeScript 6.0 project.
We put back the faulty buyNow and clearCart from the fourth post and tried it. With the default rules Oxlint reported nothing; in type-aware mode the default set found the two floating promises:
$ npx oxlint --type-aware src
src/app/catalog/book-detail-page.ts:29:5: warning typescript(no-floating-promises): Promises must be awaited, add void operator to ignore.
src/app/catalog/book-detail-page.ts:30:5: warning typescript(no-floating-promises): Promises must be awaited, add void operator to ignore.
When we explicitly asked for the other rules ESLint had found, it caught those too:
$ npx oxlint --type-aware -A all -D typescript/no-floating-promises -D typescript/no-misused-promises -D typescript/no-unsafe-assignment src
src/app/catalog/book-detail-page.ts:29:5: error typescript(no-floating-promises): Promises must be awaited, add void operator to ignore.
src/app/cart/cart-page.ts:31:13: error typescript(no-unsafe-assignment): Unsafe assignment of an any value.
src/app/catalog/book-detail-page.ts:30:5: error typescript(no-floating-promises): Promises must be awaited, add void operator to ignore.
src/app/cart/cart-page.ts:37:51: error typescript(no-misused-promises): Promise returned in function argument where a void return was expected.
But Oxlint has no counterpart to angular-eslint: it doesn't lint Angular templates, and prefer-control-flow or the accessibility template rules aren't there. In Bookstore's small client the time difference isn't meaningful either; type-aware Oxlint took 0.51 seconds, ESLint 0.94 seconds. The difference shows in large codebases.
Biome combines a linter and a formatter in a single tool. With 2.6.7, biome lint src on Bookstore's client gave 7 warnings (useImportType, suggesting import type for imports used only as types) and 9 errors. All 9 errors are the same rule:
src/app/catalog/catalog-page.html:23:9 lint/a11y/useValidAnchor ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
× Provide a href attribute for the a element.
> 23 │ <a class="card-link" [routerLink]="['/books', book.isbn]">{{ book.title }}</a>
Angular's routerLink directive adds the href itself at runtime; Biome doesn't know that, so all nine links in Bookstore are false positives. It didn't see the floating promises with default settings either. A concrete example showing that Biome doesn't replace ESLint for Angular templates today.
So, instead of ESLint or next to it? Bookstore's decision is next to it. ESLint and angular-eslint, with their template rules and mature ecosystem, stay the main check in CI. Oxlint can be considered as a fast first filter where seconds matter, such as the editor and pre-commit in a large monorepo; to keep the two tools from reporting the same rule twice, eslint-plugin-oxlint, published by the Oxlint team, switches off on the ESLint side the rules Oxlint covers. Biome is an option for projects willing to replace the formatter too and that don't weigh Angular templates heavily.
Sonar: New Names and the Versioning Scheme
SonarSource announced on 29 October 2024 that it was renaming its products, and the new names reached the products in December: SonarQube → SonarQube Server, SonarCloud → SonarQube Cloud, SonarLint → SonarQube for IDE, SonarQube Community Edition → SonarQube Community Build. Around the same time Server moved to a year-based version scheme; the first LTA was 2025.1, and today's LTA is 2026.5. The Community Build ships monthly with a year.month version number; we used 26.11 in the fifth post.
Community Build 26.10 also changed the built-in quality profiles: the "Sonar way" profile was renamed "Sonar way comprehensive", and the default profile is now the narrower "Sonar way core". Projects created before this change keep their old profiles, but a new installation has noticeably fewer default rules; in the fifth post we saw 121 versus 321 rules for C#. Language support is updated with releases too: the C# analyzer in our setup has three rules tagged csharp14. All three guard against identifiers named extension, partial or scoped, words whose meaning C# 14 changed, being interpreted differently by the new compiler (S8368, S8380, S8381).
In Production
Do upgrades in separate pull requests, and measure them. The SDK, AnalysisLevel, typescript-eslint's strict set, the SonarQube version and its profile: each can bring new findings without any code change. A pull request that compares the "before and after" number of findings by rule ID for each of them protects the team's trust in the tool.
Don't blindly trust the first release of a new rule. Gaps like CA2028 not seeing the source-generated regex are common in first releases. If a rule matters, try to trigger it in your own code; if it doesn't fire, either write your own analyzer or add it to the review checklist.
Compare the set of findings when switching tools. Before moving from ESLint to Oxlint or Biome, run both tools on the same commit and read the difference; without that comparison, Biome's nine routerLink false positives would have eaten up the team's first day.
Common Pitfalls
Treating the automatic migration output as finished. @eslint/migrate-config converted the file's structure correctly but didn't produce a working config; the plugins' flat config equivalents had to be written by hand.
Bringing TypeScript 7 into an Angular project too early. The latest tag on npm points to 7.0; in an Angular 22 project, npm install typescript@latest comes with peer dependency warnings and leaves the range the Angular compiler and typescript-eslint support. TypeScript 7.0 can be used today in a standalone TypeScript library, not in an Angular project.
Not deleting the old tslint.json. A lint config that no tool reads gives the team the impression that a check exists when it doesn't.
Assuming Sonar's new default profile is the old one. Looking for the name "Sonar way" after installation is pointless; if you want the complexity or unused parameter rules, choose the profile deliberately.
A Small Experiment
★☆☆ Build your own .NET project with the .NET 11 SDK and -p:AnalysisLevel=11.0-recommended. How many of the new warnings are CA2026, CA2027 or CA2028? Note the cost of fixing each one.
★★☆ Convert the .eslintrc.json of an old Angular project with @eslint/migrate-config, then make it work by replacing the FlatCompat lines with the flat configs of angular-eslint and typescript-eslint. Compare the number of findings before and after the migration on the same commit.
★★★ Put Oxlint with --type-aware next to ESLint in your project and switch off the overlapping rules in ESLint with eslint-plugin-oxlint. Run only Oxlint in pre-commit and both in CI. How did the times change in a large project, and which findings showed up in only one of them?
The series ends here. On Bookstore we saw what static analysis finds and what it can't, managing .NET analyzers by level and severity, writing our own rule, type-aware linting in Angular, and a quality gate focused on new code. None of these tools replaces tests or review; but they free the attention of both from mechanical mistakes and point it at the real question: does the code do the right thing?