Modernizing code for newer runtimes¶
stylebro-migrate init --modernize turns on the .NET SDK's own rules that rewrite code into newer C# and newer APIs:
target-typed new(), collection expressions, is not null, file-scoped namespaces, ArgumentNullException.ThrowIfNull,
ranges, and more. StyleBro adds no rules of its own here: the SDK has a fixer for each that dotnet format applies.
What init adds is the choice of which rules are safe in your repository, and the package adds the
multi-target guard, which keeps the newer-API rules out of projects where a target framework
lacks the API.
stylebro-migrate init --modernize # shows the blocks and notes
stylebro-migrate init --modernize --write # adds them to .editorconfig
dotnet format
It's opt-in: plain init doesn't write these rules, and StyleBro's preset doesn't turn them on. The rules go in their own
block in the root .editorconfig, between # BEGIN stylebro-modernize and # END stylebro-modernize. Running the
command again replaces it, and it stays when you run init without --modernize or stylebro-migrate --write. Keys your
root .editorconfig already sets for C# (a rule's severity, csharp_style_namespace_declarations) are left out, so
your settings stay. The CA rules need the .NET analyzers, which are on by default from .NET 5; set
<EnableNETAnalyzers>true</EnableNETAnalyzers> for projects that target only .NET Framework or .NET Standard.
The tiers¶
| Tier | Needs | Rules | Severity |
|---|---|---|---|
| A | C# 7.3 or older, no new API | IDE0016-IDE0020, IDE0029, IDE0030, IDE0041, IDE0054, IDE0180, IDE0240, IDE0241, IDE0250, IDE0270, CA1825, CA1829, CA1834 | warning |
| B | a newer C# version | IDE0028, IDE0031, IDE0062, IDE0074, IDE0078, IDE0083, IDE0090, IDE0150, IDE0161 (with csharp_style_namespace_declarations = file_scoped), IDE0170, IDE0300-IDE0304, IDE0306, IDE0340, IDE0350, IDE0360 |
warning, or suggestion when a multi-targeted project doesn't set LangVersion |
| C | a newer API | CA1510-CA1513, CA1847, CA1850, CA1864, CA1865, CA1872, CA2249, CA2263, IDE0056, IDE0057, IDE0330 | warning; the multi-target guard hides them where a framework lacks the API |
dotnet format fixes warnings, not suggestions (its default --severity is warn), so a suggestion only shows up in
the IDE. The command prints a note for every tier it turned down, with the reason and how to turn it on.
IDE0031 (?.) was tier A until SDK 10: its fix now also writes C# 14's null-conditional assignment
(if (node != null) { node.Count = 2; } -> node?.Count = 2;), and in a net48;net10.0 project without LangVersion
the net48 build failed with CS8370 (probed). IDE0350 (SDK 10, (int x) => x + 1 -> x => x + 1) is tier B because
lambdas with modifiers ((out int r) -> (out r)) need C# 14. It leaves the parentheses out where it can, so it ends
where StyleBro's BRO1136 ((x) => x -> x => x) would, and one dotnet format run is enough (probed). When the SDK
is updated, check tier A the same way.
Examples¶
Tier A:
// Before
if (ReferenceEquals(value, null)) { return; } // IDE0041
int[] empty = new int[0]; // CA1825
builder.Append("a"); // CA1834
count = count + 1; // IDE0054
// After
if (value is null) { return; }
int[] empty = Array.Empty<int>();
builder.Append('a');
count++;
Tier B:
// Before
namespace App
{
class Cache
{
private readonly object gate = new object(); // IDE0090
private int[] sizes = new int[0]; // IDE0300
bool Check(object o) => !(o is string); // IDE0083
}
}
// After
namespace App; // IDE0161
class Cache
{
private readonly object gate = new();
private int[] sizes = [];
bool Check(object o) => o is not string;
}
Tier C:
// Before
if (name == null)
{
throw new ArgumentNullException(nameof(name)); // CA1510
}
var inner = text.Substring(1, text.Length - 2); // IDE0057
var kind = (Kind)Enum.Parse(typeof(Kind), value); // CA2263
// After
ArgumentNullException.ThrowIfNull(name);
var inner = text[1..^1];
var kind = Enum.Parse<Kind>(value);
Multi-targeted projects¶
In a project with <TargetFrameworks>net48;net8.0</TargetFrameworks> every file is compiled once per framework, and
the rules run in each. They check what that compilation has: ArgumentNullException.ThrowIfNull exists for net8.0
and not for net48, so CA1510 reports only in the net8.0 copy, dotnet format applies the fix to the shared file, and
the net48 build breaks. CA1847 is worse: Contains('a') still compiles on net48, but binds to LINQ's
Enumerable.Contains<char> there. Probed on the SDK 10 analyzers: 14 of 15 API rules broke the net48 build.
The language rules have the same problem only when LangVersion isn't set: the SDK then picks a C# version per
framework (7.3 for .NET Framework and netstandard2.0), the rules fire only for the newer one, and the older one gets
CS8370. With <LangVersion>latest</LangVersion> (or any explicit value) in the project or a Directory.Build.props
it imports, every framework uses the same version and the rules are safe. init checks the project file and the
Directory.Build.props files it imports. Single-target projects are always safe: the rules only fire when the
project's framework and C# version have the feature.
For the API rules StyleBro has the multi-target guard, so tier C is at warning in every repository.
The multi-target guard¶
MultiTargetSuppressor (in the StyleBro.Analyzers package) hides a tier C diagnostic when the project targets several
frameworks and one of them lacks the API the rule's fix writes. A hidden diagnostic is neither reported by the build nor
fixed by dotnet format: in a net48;net8.0 project throw new ArgumentNullException(...) stays, while a
single-target net8.0 project in the same repository gets ArgumentNullException.ThrowIfNull. Single-target projects
are left to the rule itself, which only reports when the project's framework has the API.
The package's build targets pass the project's framework list to the compiler as StyleBroTargetFrameworks (the
evaluated TargetFrameworks with , for ;, so a list set in an imported .props counts too; every per-framework
build sees the whole list). Each rule has a minimum: the first .NET (Core) and .NET Standard version with every API its
fix can write.
| Rule | Fix writes | .NET (Core) | .NET Standard |
|---|---|---|---|
| CA1510 | ArgumentNullException.ThrowIfNull |
6.0 | none |
| CA1511 | ArgumentException.ThrowIfNullOrEmpty |
7.0 | none |
| CA1512 | ArgumentOutOfRangeException.ThrowIfNegative, ... |
8.0 | none |
| CA1513 | ObjectDisposedException.ThrowIf |
7.0 | none |
| CA1847 | string.Contains(char) |
2.1 | 2.1 |
| CA1850 | static HashData (SHA/MD5 5.0, HMAC 6.0) |
6.0 | none |
| CA1864 | Dictionary.TryAdd |
2.0 | 2.1 |
| CA1865 | StartsWith(char), EndsWith(char), IndexOf(char, StringComparison) |
2.1 | 2.1 |
| CA1872 | Convert.ToHexString (5.0), ToHexStringLower (9.0) |
9.0 | none |
| CA2249 | string.Contains(string, StringComparison) |
2.1 | 2.1 |
| CA2263 | generic overloads (Enum.Parse<T> 2.0, Enum.GetValues<T> 5.0) |
5.0 | none |
| IDE0056 | a[^1] (System.Index) |
3.0 | 2.1 |
| IDE0057 | s[1..^1] (System.Range) |
3.0 | 2.1 |
| IDE0330 | System.Threading.Lock |
9.0 | none |
.NET Framework has none of them, and a framework the guard doesn't recognize (monoandroid, uap, ...) counts as
lacking everything. A minimum is the newest API the fix can write, so some safe fixes are hidden too (CA1872 in a
net8.0;net9.0 project, where only the lowercase variant needs .NET 9).
What it doesn't see:
- CA2263 covers any method with a generic overload; the table knows the base library's. A package whose generic overload exists for only some of the project's frameworks isn't known.
- Projects without the StyleBro.Analyzers package get no guard.
- It works per build: a framework added later hides the rules again, one removed lets them fix.
- To turn it off for a project, set
StyleBroTargetFrameworksto a single framework (that is howscripts/verify-package.ps1checks that the rules do fire there without it).
A second run¶
Some fixes produce code another rule then rewrites, and dotnet format applies them in one pass in no fixed order:
CA2249 writes s.Contains("x", StringComparison.CurrentCulture), which CA1847 turns into Contains('x', ...) on the
next run; with every rule on, IDE0041 was once left for the next run; fixers write new blocks on one line, which the
formatter wraps on the next run. Run dotnet format until a run changes nothing (usually two).
Left out¶
Opt-in by hand (add dotnet_diagnostic.<id>.severity = warning outside the block), debated or with side effects:
| Rule | Why not by default |
|---|---|
| IDE0290 | Primary constructors: debated style; fields become captured parameters. |
| IDE0066 | Switch statement to switch expression: debated readability. |
| IDE0063 | Simple using declaration: changes when the object is disposed (end of the scope). |
| IDE0305 | x.ToList() to [.. x]: debated readability. |
| IDE0251 | readonly struct members: together with IDE0250 (readonly struct) in one run it adds a redundant readonly to members of the now-readonly struct. Turn it on after a run with IDE0250 if you want it. |
Skipped:
| Rule | Why |
|---|---|
| CA1866, CA1867 | No fixer (the SDK's fixer handles CA1865 only), so dotnet format can't apply them. |
| IDE0038 | No Fix All, so dotnet format can't apply it. |
| CA1845, CA1846 | Span-based performance rewrites, not modernization; they overlap IDE0057. |
| IDE0230 | UTF-8 string literals: niche, and the rewrite ("ab"u8.ToArray()) isn't nicer. |
| IDE0320 | static lambdas: noise. |
| CA1860 | Any() to Count != 0: many find Any() clearer. |