From bb2076e7a1393390d02fe5b6ec499caf4bb15a0f Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Mon, 26 Oct 2026 10:01:54 +0100 Subject: [PATCH 1/8] Add Nullable.Extended.Analyzer to the StandardAnalyzers module A null-forgiving operator silences a nullable warning on an invariant that no type states, and Buildvana SDK reported none of them. Unless a `// !` comment above an operator states the invariant, the analyzer reports it. `UseNullableExtendedAnalyzer` follows `UseStyleCopAnalyzers`: on by default, and off for a `Microsoft.Build.NoTargets` project. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/sdk-modules/standard-analyzers.md | 46 ++++++++- ...Module.UseNullableExtendedAnalyzer.targets | 10 ++ .../Modules/StandardAnalyzers/Module.targets | 16 ++++ src/Buildvana.Sdk/Sdk/PackageVersions.props | 1 + .../Buildvana.Sdk.Tests.csproj | 1 + .../StandardAnalyzersModuleTargetsTests.cs | 93 +++++++++++++++++++ 7 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 src/Buildvana.Sdk/Modules/StandardAnalyzers/Module.UseNullableExtendedAnalyzer.targets create mode 100644 tests/Buildvana.Sdk.Tests/StandardAnalyzersModuleTargetsTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 40818522..50f72372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,7 @@ The patch number restarted from 1, below the 2.0.x versions already published. - **BREAKING CHANGE**: Buildvana SDK raises BVSDK1006 on a project whose [SDK does not layer on `Microsoft.NET.Sdk`](docs/introduction.md#project-types), where such a project used to build. - **BREAKING CHANGE**: A file-based app is a [project type of its own](docs/internal-use-properties.md#project-type), `BV_IsFileBasedAppProject`, so `BV_IsExeProject` is `false` for it. - **BREAKING CHANGE**: `bv` no longer reads `.config/dotnet-tools.json`, and stops with an error that [names the move](docs/directory-structure.md#migration-from-configdotnet-toolsjson) when the home directory holds one. +- **BREAKING CHANGE**: The `StandardAnalyzers` module adds [Nullable.Extended.Analyzer](docs/sdk-modules/standard-analyzers.md#usenullableextendedanalyzer-property), so a null-forgiving operator without a justification comment no longer compiles without a warning. - The [`ReleaseAssetList` module](docs/sdk-modules/release-asset-list.md#generatereleaseassetlist-property) writes no release asset list for a file-based app. - On GitHub, `bv release` refuses [two assets with the same file name](docs/tool-commands/release.md#publishing-on-github) before it pushes the commits. - A [`notice:` level](docs/command-line.md#verbosity), shown from `minimal` verbosity up, sits between `warning:` and `info:`. diff --git a/docs/sdk-modules/standard-analyzers.md b/docs/sdk-modules/standard-analyzers.md index d163996f..9625a192 100644 --- a/docs/sdk-modules/standard-analyzers.md +++ b/docs/sdk-modules/standard-analyzers.md @@ -1,6 +1,6 @@ # `StandardAnalyzers` module -This module turns on the code quality and code style analyzers of the .NET SDK, StyleCop, and the public API analyzers. +This module turns on the code quality and code style analyzers of the .NET SDK, StyleCop, the public API analyzers, and Nullable.Extended.Analyzer. It sets stricter defaults than the .NET SDK, and adds the `stylecop.json` file and the public and internal API files to the compilation. --- @@ -13,6 +13,7 @@ It sets stricter defaults than the .NET SDK, and adds the `stylecop.json` file a - [Code analysis properties](#code-analysis-properties) - [`UseStyleCopAnalyzers` property](#usestylecopanalyzers-property) - [`UsePublicApiAnalyzers` property](#usepublicapianalyzers-property) + - [`UseNullableExtendedAnalyzer` property](#usenullableextendedanalyzer-property) - [`UseTfmSpecificPublicApiFiles` property](#usetfmspecificpublicapifiles-property) - [`UseTfmSpecificInternalApiFiles` property](#usetfmspecificinternalapifiles-property) - [Usage](#usage) @@ -20,13 +21,14 @@ It sets stricter defaults than the .NET SDK, and adds the `stylecop.json` file a - [`stylecop.json` file](#stylecopjson-file) - [Public API files](#public-api-files) - [Internal API files](#internal-api-files) + - [Null-forgiving operators](#null-forgiving-operators) - [Diagnostics](#diagnostics) --- ## Configuration -The module is active in every project, and each of the three analyzer sets has a switch of its own. +The module is active in every project, and each of the four analyzer sets has a switch of its own. Configure the rules of every set in an `.editorconfig` file or a `.globalconfig` file, as the [.NET documentation](https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/configuration-files) describes. A severity in an `.editorconfig` file reaches only a diagnostic located in a source file. So configure RS0052, RS0053, RS0054, and RS0058 in a `.globalconfig` file, because they report a line of an internal API file, or no location. @@ -62,6 +64,12 @@ Set it to `true` to run the public API analyzers in a project, or to `false` to The default is `true` for a [library project](../internal-use-properties.md#project-type), and `false` for every other project. A value MSBuild reads as true, such as `yes`, counts as `true`, and any other value counts as `false`. +### `UseNullableExtendedAnalyzer` property + +Set it to `false` to leave Nullable.Extended.Analyzer out of a project. +The default is `true`, except for a project that uses the `Microsoft.Build.NoTargets` SDK. +A value MSBuild reads as true, such as `yes`, counts as `true`, and any other value counts as `false`. + ### `UseTfmSpecificPublicApiFiles` property Set it to `true` to keep one pair of [public API files](#public-api-files) per target framework, or to `false` to keep one pair for the project. @@ -80,10 +88,17 @@ A value MSBuild reads as true, such as `yes`, counts as `true`, and any other va ### Package references -The module adds a `PackageReference` to `StyleCop.Analyzers` when `UseStyleCopAnalyzers` is `true`, and one to `Microsoft.CodeAnalysis.PublicApiAnalyzers` when `UsePublicApiAnalyzers` is `true`. +The module adds a `PackageReference` to each analyzer package whose switch is `true`: + +| Package | Switch | +| ------------------------------------------- | ----------------------------- | +| `StyleCop.Analyzers` | `UseStyleCopAnalyzers` | +| `Microsoft.CodeAnalysis.PublicApiAnalyzers` | `UsePublicApiAnalyzers` | +| `Nullable.Extended.Analyzer` | `UseNullableExtendedAnalyzer` | + Buildvana SDK pins the version of each package, and `dotnet list package` shows the reference as auto-referenced, with the `(A)` mark. Each reference has `PrivateAssets="All"`, so a project that references this one, and a NuGet package this one produces, get no dependency on the analyzers. -A `PackageReference` of yours to either package replaces the module's reference, and under central package management a `PackageVersion` item sets its version. +A `PackageReference` of yours to one of the packages replaces the module's reference, and under central package management a `PackageVersion` item sets its version. The code quality and code style analyzers ship with the .NET SDK, and need no package. @@ -149,6 +164,29 @@ Without `require_api_files`, RS0051 reports every internal type and member of a With `require_api_files` set to `true`, a project with neither file of a pair gets no diagnostic for that kind of API. A project with one file of the internal pair still gets RS0058. +### Null-forgiving operators + +Nullable.Extended.Analyzer reports every null-forgiving operator (`!`) as a warning. +The operator silences a nullable warning on the strength of an invariant the compiler cannot check, and the analyzer makes each such invariant visible. +Four rules report the operator, one per context: + +| Rule | Context | +| ------ | -------------------------------------------------------------------- | +| NX0001 | Any context the other three rules do not cover | +| NX0002 | On a `null` or `default` literal | +| NX0003 | Inside a lambda expression | +| NX0004 | On a `null` or `default` literal in an init-only property assignment | + +A comment on the line above the statement or declaration clears the warning, when its text starts with `!` and a space: + +```csharp +// ! The dictionary was filled from the same keys a moment ago. +var value = lookup.GetValueOrDefault(key)!; +``` + +The [Nullable.Extended documentation](https://github.com/tom-englert/Nullable.Extended) describes the analyzer and its code fixes. +Set the severity of each rule in a `.editorconfig` file or a `.globalconfig` file, as [Configuration](#configuration) says. + --- ## Diagnostics diff --git a/src/Buildvana.Sdk/Modules/StandardAnalyzers/Module.UseNullableExtendedAnalyzer.targets b/src/Buildvana.Sdk/Modules/StandardAnalyzers/Module.UseNullableExtendedAnalyzer.targets new file mode 100644 index 00000000..0c48c6d0 --- /dev/null +++ b/src/Buildvana.Sdk/Modules/StandardAnalyzers/Module.UseNullableExtendedAnalyzer.targets @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/src/Buildvana.Sdk/Modules/StandardAnalyzers/Module.targets b/src/Buildvana.Sdk/Modules/StandardAnalyzers/Module.targets index ac1a3f0b..76f22479 100644 --- a/src/Buildvana.Sdk/Modules/StandardAnalyzers/Module.targets +++ b/src/Buildvana.Sdk/Modules/StandardAnalyzers/Module.targets @@ -54,4 +54,20 @@ + + + true + false + + + + true + + + false + + + + diff --git a/src/Buildvana.Sdk/Sdk/PackageVersions.props b/src/Buildvana.Sdk/Sdk/PackageVersions.props index fba6a863..b357b4d4 100644 --- a/src/Buildvana.Sdk/Sdk/PackageVersions.props +++ b/src/Buildvana.Sdk/Sdk/PackageVersions.props @@ -8,6 +8,7 @@ --> + diff --git a/tests/Buildvana.Sdk.Tests/Buildvana.Sdk.Tests.csproj b/tests/Buildvana.Sdk.Tests/Buildvana.Sdk.Tests.csproj index 91f8b35e..e08b7799 100644 --- a/tests/Buildvana.Sdk.Tests/Buildvana.Sdk.Tests.csproj +++ b/tests/Buildvana.Sdk.Tests/Buildvana.Sdk.Tests.csproj @@ -39,6 +39,7 @@ + diff --git a/tests/Buildvana.Sdk.Tests/StandardAnalyzersModuleTargetsTests.cs b/tests/Buildvana.Sdk.Tests/StandardAnalyzersModuleTargetsTests.cs new file mode 100644 index 00000000..ce13fc35 --- /dev/null +++ b/tests/Buildvana.Sdk.Tests/StandardAnalyzersModuleTargetsTests.cs @@ -0,0 +1,93 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Reflection; +using Buildvana.Core.Testing; +using Microsoft.Build.Definition; +using Microsoft.Build.Evaluation; + +// Evaluates the real Module.targets of the StandardAnalyzers module, imported by a project of a temporary home +// directory that states the project type properties Buildvana SDK computes before the module runs. +// Evaluation-only: no targets are executed, so assertions are limited to properties and items. +internal sealed class StandardAnalyzersModuleTargetsTests +{ + private const string NullableExtendedAnalyzer = "Nullable.Extended.Analyzer"; + + [Test] + public async Task Evaluate_ByDefault_ReferencesNullableExtendedAnalyzer() + { + using var home = new TempHome(); + var result = Evaluate(home, isNoTargetsProject: false, string.Empty); + await Assert.That(result.UseNullableExtendedAnalyzer).IsEqualTo("true"); + await Assert.That(result.PrivateAssets).IsEqualTo("All"); + } + + [Test] + public async Task Evaluate_NoTargetsProject_LeavesNullableExtendedAnalyzerOut() + { + using var home = new TempHome(); + var result = Evaluate(home, isNoTargetsProject: true, string.Empty); + await Assert.That(result.UseNullableExtendedAnalyzer).IsEqualTo("false"); + await Assert.That(result.PrivateAssets).IsNull(); + } + + // A no-target project defaults the property to false, so a value that stores 'true' is the one set. + [Test] + [Arguments("true")] + [Arguments("yes")] + [Arguments("On")] + [Arguments("!false")] + public async Task Evaluate_ValueMSBuildReadsAsTrue_StoresTrue(string value) + { + using var home = new TempHome(); + var result = Evaluate(home, isNoTargetsProject: true, value); + await Assert.That(result.UseNullableExtendedAnalyzer).IsEqualTo("true"); + await Assert.That(result.PrivateAssets).IsEqualTo("All"); + } + + [Test] + [Arguments("false")] + [Arguments("no")] + [Arguments("maybe")] + public async Task Evaluate_ValueOtherThanTrue_StoresFalse(string value) + { + using var home = new TempHome(); + var result = Evaluate(home, isNoTargetsProject: false, value); + await Assert.That(result.UseNullableExtendedAnalyzer).IsEqualTo("false"); + await Assert.That(result.PrivateAssets).IsNull(); + } + + // The PrivateAssets metadata of the module's reference to Nullable.Extended.Analyzer, or null when the module + // adds no reference. + private static (string UseNullableExtendedAnalyzer, string? PrivateAssets) Evaluate( + TempHome home, + bool isNoTargetsProject, + string useNullableExtendedAnalyzer) + { + var projectPath = home.GetFullPath("Test.proj"); + var projectText = $""" + + + {home.RootPath}{Path.DirectorySeparatorChar} + {(isNoTargetsProject ? "true" : "false")} + false + {useNullableExtendedAnalyzer} + + + + """; + home.WriteFile("Test.proj", projectText); + + using var collection = new ProjectCollection(); + var project = Project.FromFile(projectPath, new ProjectOptions { ProjectCollection = collection }); + var reference = project.GetItems("BV_PackageReference") + .FirstOrDefault(static item => item.EvaluatedInclude == NullableExtendedAnalyzer); + return (project.GetPropertyValue("UseNullableExtendedAnalyzer"), reference?.GetMetadataValue("PrivateAssets")); + } + + private static string GetRealTargetsPath() + => typeof(StandardAnalyzersModuleTargetsTests).Assembly + .GetCustomAttributes() + .First(static attribute => attribute.Key == "RealStandardAnalyzersModuleTargetsPath") + .Value!; +} From 2cdc84d6cae6d1be1c8f61d0fc4cd872340ab0c6 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Mon, 26 Oct 2026 12:08:14 +0100 Subject: [PATCH 2/8] Test every switch and default of the StandardAnalyzers module Only `UseNullableExtendedAnalyzer` had tests in `StandardAnalyzersModuleTargetsTests`. A regression in `EnableNETAnalyzers`, `AnalysisLevel`, `AnalysisMode`, `EnforceCodeStyleInBuild`, `UseStyleCopAnalyzers`, `UsePublicApiAnalyzers`, or the search for `stylecop.json` in `Module.UseStyleCopAnalyzers.targets` would pass the build. `EnableNETAnalyzers` and the three `Use*` properties share one pattern of defaults and stored values, so one matrix of tests covers all four. Co-Authored-By: Claude Opus 5.5 --- .../StandardAnalyzersModuleTargetsTests.cs | 266 +++++++++++++++--- 1 file changed, 228 insertions(+), 38 deletions(-) diff --git a/tests/Buildvana.Sdk.Tests/StandardAnalyzersModuleTargetsTests.cs b/tests/Buildvana.Sdk.Tests/StandardAnalyzersModuleTargetsTests.cs index ce13fc35..d318a0a0 100644 --- a/tests/Buildvana.Sdk.Tests/StandardAnalyzersModuleTargetsTests.cs +++ b/tests/Buildvana.Sdk.Tests/StandardAnalyzersModuleTargetsTests.cs @@ -5,86 +5,276 @@ using Buildvana.Core.Testing; using Microsoft.Build.Definition; using Microsoft.Build.Evaluation; +using Microsoft.Build.Execution; // Evaluates the real Module.targets of the StandardAnalyzers module, imported by a project of a temporary home // directory that states the project type properties Buildvana SDK computes before the module runs. // Evaluation-only: no targets are executed, so assertions are limited to properties and items. internal sealed class StandardAnalyzersModuleTargetsTests { + private const string ProjectDirectory = "src/Test"; + private const string EnableNETAnalyzers = "EnableNETAnalyzers"; + private const string UseStyleCopAnalyzers = "UseStyleCopAnalyzers"; + private const string UsePublicApiAnalyzers = "UsePublicApiAnalyzers"; + private const string UseNullableExtendedAnalyzer = "UseNullableExtendedAnalyzer"; + private const string StyleCopAnalyzers = "StyleCop.Analyzers"; + private const string PublicApiAnalyzers = "Microsoft.CodeAnalysis.PublicApiAnalyzers"; private const string NullableExtendedAnalyzer = "Nullable.Extended.Analyzer"; + // A project that is neither a no-target project nor a library project. + private const string OrdinaryProject = """ + false + false + """; + + private const string NoTargetsProject = """ + true + false + """; + + private const string LibraryProject = """ + false + true + """; + [Test] - public async Task Evaluate_ByDefault_ReferencesNullableExtendedAnalyzer() + public async Task Evaluate_ByDefault_SetsTheCodeAnalysisDefaults() { using var home = new TempHome(); - var result = Evaluate(home, isNoTargetsProject: false, string.Empty); - await Assert.That(result.UseNullableExtendedAnalyzer).IsEqualTo("true"); - await Assert.That(result.PrivateAssets).IsEqualTo("All"); + var project = Evaluate(home, OrdinaryProject); + await Assert.That(project.GetPropertyValue(EnableNETAnalyzers)).IsEqualTo("true"); + await Assert.That(project.GetPropertyValue("AnalysisLevel")).IsEqualTo("latest"); + await Assert.That(project.GetPropertyValue("AnalysisMode")).IsEqualTo("All"); + await Assert.That(project.GetPropertyValue("EnforceCodeStyleInBuild")).IsEqualTo("true"); } [Test] - public async Task Evaluate_NoTargetsProject_LeavesNullableExtendedAnalyzerOut() + public async Task Evaluate_AnalysisLevelAndAnalysisModeSet_KeepsBoth() { using var home = new TempHome(); - var result = Evaluate(home, isNoTargetsProject: true, string.Empty); - await Assert.That(result.UseNullableExtendedAnalyzer).IsEqualTo("false"); - await Assert.That(result.PrivateAssets).IsNull(); + const string properties = OrdinaryProject + "9.0Recommended"; + var project = Evaluate(home, properties); + await Assert.That(project.GetPropertyValue("AnalysisLevel")).IsEqualTo("9.0"); + await Assert.That(project.GetPropertyValue("AnalysisMode")).IsEqualTo("Recommended"); } - // A no-target project defaults the property to false, so a value that stores 'true' is the one set. [Test] - [Arguments("true")] - [Arguments("yes")] - [Arguments("On")] - [Arguments("!false")] - public async Task Evaluate_ValueMSBuildReadsAsTrue_StoresTrue(string value) + [Arguments("false")] + [Arguments("no")] + [Arguments("Off")] + [Arguments("!true")] + public async Task Evaluate_EnforceCodeStyleInBuildMSBuildReadsAsFalse_StoresFalse(string value) { using var home = new TempHome(); - var result = Evaluate(home, isNoTargetsProject: true, value); - await Assert.That(result.UseNullableExtendedAnalyzer).IsEqualTo("true"); - await Assert.That(result.PrivateAssets).IsEqualTo("All"); + var project = Evaluate(home, OrdinaryProject + $"{value}"); + await Assert.That(project.GetPropertyValue("EnforceCodeStyleInBuild")).IsEqualTo("false"); } [Test] - [Arguments("false")] - [Arguments("no")] + [Arguments("true")] [Arguments("maybe")] - public async Task Evaluate_ValueOtherThanTrue_StoresFalse(string value) + public async Task Evaluate_EnforceCodeStyleInBuildOtherThanFalse_StoresTrue(string value) + { + using var home = new TempHome(); + var project = Evaluate(home, OrdinaryProject + $"{value}"); + await Assert.That(project.GetPropertyValue("EnforceCodeStyleInBuild")).IsEqualTo("true"); + } + + [Test] + public async Task Evaluate_OrdinaryProject_ReferencesStyleCopAndNullableExtendedAnalyzer() + { + using var home = new TempHome(); + var project = Evaluate(home, OrdinaryProject); + await Assert.That(project.GetPropertyValue(UseStyleCopAnalyzers)).IsEqualTo("true"); + await Assert.That(project.GetPropertyValue(UsePublicApiAnalyzers)).IsEqualTo("false"); + await Assert.That(project.GetPropertyValue(UseNullableExtendedAnalyzer)).IsEqualTo("true"); + await Assert.That(GetReferences(project)).IsEquivalentTo([StyleCopAnalyzers, NullableExtendedAnalyzer]); + } + + [Test] + public async Task Evaluate_NoTargetsProject_TurnsEveryAnalyzerOff() + { + using var home = new TempHome(); + var project = Evaluate(home, NoTargetsProject); + await Assert.That(project.GetPropertyValue(EnableNETAnalyzers)).IsEqualTo("false"); + await Assert.That(project.GetPropertyValue(UseStyleCopAnalyzers)).IsEqualTo("false"); + await Assert.That(project.GetPropertyValue(UsePublicApiAnalyzers)).IsEqualTo("false"); + await Assert.That(project.GetPropertyValue(UseNullableExtendedAnalyzer)).IsEqualTo("false"); + await Assert.That(GetReferences(project)).IsEmpty(); + } + + [Test] + public async Task Evaluate_LibraryProject_ReferencesEveryAnalyzerPackage() + { + using var home = new TempHome(); + var project = Evaluate(home, LibraryProject); + await Assert.That(project.GetPropertyValue(UsePublicApiAnalyzers)).IsEqualTo("true"); + string[] expected = [StyleCopAnalyzers, PublicApiAnalyzers, NullableExtendedAnalyzer]; + await Assert.That(GetReferences(project)).IsEquivalentTo(expected); + } + + // A no-target project that is not a library project defaults every switch to false, so a value that stores 'true' + // is the one set. + [Test] + [MatrixDataSource] + public async Task Evaluate_SwitchMSBuildReadsAsTrue_StoresTrue( + [Matrix(EnableNETAnalyzers, UseStyleCopAnalyzers, UsePublicApiAnalyzers, UseNullableExtendedAnalyzer)] + string name, + [Matrix("true", "yes", "On", "!false")] string value) + { + using var home = new TempHome(); + var project = Evaluate(home, NoTargetsProject + $"<{name}>{value}"); + await Assert.That(project.GetPropertyValue(name)).IsEqualTo("true"); + } + + // A library project defaults every switch to true, so a value that stores 'false' is the one set. + [Test] + [MatrixDataSource] + public async Task Evaluate_SwitchOtherThanTrue_StoresFalse( + [Matrix(EnableNETAnalyzers, UseStyleCopAnalyzers, UsePublicApiAnalyzers, UseNullableExtendedAnalyzer)] + string name, + [Matrix("false", "no", "maybe")] string value) + { + using var home = new TempHome(); + var project = Evaluate(home, LibraryProject + $"<{name}>{value}"); + await Assert.That(project.GetPropertyValue(name)).IsEqualTo("false"); + } + + [Test] + [Arguments(UseStyleCopAnalyzers, StyleCopAnalyzers)] + [Arguments(UsePublicApiAnalyzers, PublicApiAnalyzers)] + [Arguments(UseNullableExtendedAnalyzer, NullableExtendedAnalyzer)] + public async Task Evaluate_SwitchTrue_ReferencesThePackageWithPrivateAssets(string name, string package) + { + using var home = new TempHome(); + var project = Evaluate(home, NoTargetsProject + $"<{name}>true"); + await Assert.That(GetReferences(project)).IsEquivalentTo([package]); + var reference = project.GetItems("BV_PackageReference").Single(); + await Assert.That(reference.GetMetadataValue("PrivateAssets")).IsEqualTo("All"); + } + + [Test] + [Arguments(UseStyleCopAnalyzers, StyleCopAnalyzers)] + [Arguments(UsePublicApiAnalyzers, PublicApiAnalyzers)] + [Arguments(UseNullableExtendedAnalyzer, NullableExtendedAnalyzer)] + public async Task Evaluate_SwitchFalse_LeavesThePackageOut(string name, string package) { using var home = new TempHome(); - var result = Evaluate(home, isNoTargetsProject: false, value); - await Assert.That(result.UseNullableExtendedAnalyzer).IsEqualTo("false"); - await Assert.That(result.PrivateAssets).IsNull(); + var project = Evaluate(home, LibraryProject + $"<{name}>false"); + await Assert.That(GetReferences(project)).DoesNotContain(package); } - // The PrivateAssets metadata of the module's reference to Nullable.Extended.Analyzer, or null when the module - // adds no reference. - private static (string UseNullableExtendedAnalyzer, string? PrivateAssets) Evaluate( - TempHome home, - bool isNoTargetsProject, - string useNullableExtendedAnalyzer) + [Test] + public async Task Evaluate_StyleCopJsonInTheHomeDirectory_AddsItAsAdditionalFile() { - var projectPath = home.GetFullPath("Test.proj"); + using var home = new TempHome(); + home.WriteFile("stylecop.json", "{}"); + var project = Evaluate(home, OrdinaryProject); + await Assert.That(GetAdditionalFiles(project)).IsEquivalentTo([(home.GetFullPath("stylecop.json"), "stylecop.json")]); + } + + [Test] + public async Task Evaluate_StyleCopJsonNearerTheProject_WinsOverTheOneInTheHomeDirectory() + { + using var home = new TempHome(); + home.WriteFile("stylecop.json", "{}"); + home.WriteFile($"{ProjectDirectory}/stylecop.json", "{}"); + var project = Evaluate(home, OrdinaryProject); + var expected = home.GetFullPath($"{ProjectDirectory}/stylecop.json"); + await Assert.That(GetAdditionalFiles(project)).IsEquivalentTo([(expected, "stylecop.json")]); + } + + [Test] + public async Task Evaluate_DotStyleCopJsonAlone_AddsItAsStyleCopJson() + { + using var home = new TempHome(); + home.WriteFile("src/.stylecop.json", "{}"); + var project = Evaluate(home, OrdinaryProject); + await Assert.That(GetAdditionalFiles(project)).IsEquivalentTo([(home.GetFullPath("src/.stylecop.json"), "stylecop.json")]); + } + + // The search for .stylecop.json starts only when no stylecop.json exists, however far up. + [Test] + public async Task Evaluate_StyleCopJsonAndNearerDotStyleCopJson_AddsStyleCopJson() + { + using var home = new TempHome(); + home.WriteFile("stylecop.json", "{}"); + home.WriteFile($"{ProjectDirectory}/.stylecop.json", "{}"); + var project = Evaluate(home, OrdinaryProject); + await Assert.That(GetAdditionalFiles(project)).IsEquivalentTo([(home.GetFullPath("stylecop.json"), "stylecop.json")]); + } + + // The home directory is the src subdirectory of the temporary directory, and the file is above it. + [Test] + [Arguments("stylecop.json")] + [Arguments(".stylecop.json")] + public async Task Evaluate_FileAboveTheHomeDirectory_AddsNothing(string fileName) + { + using var home = new TempHome(); + home.WriteFile(fileName, "{}"); + var properties = OrdinaryProject + $"{home.GetFullPath("src")}{Path.DirectorySeparatorChar}"; + var project = Evaluate(home, properties); + await Assert.That(project.GetPropertyValue("StyleCopJsonPath")).IsEmpty(); + await Assert.That(GetAdditionalFiles(project)).IsEmpty(); + } + + [Test] + public async Task Evaluate_StyleCopJsonListedByTheProject_AddsItOnceAsAdditionalFile() + { + using var home = new TempHome(); + home.WriteFile($"{ProjectDirectory}/stylecop.json", "{}"); + const string items = """ + + + """; + var project = Evaluate(home, OrdinaryProject, items); + var expected = home.GetFullPath($"{ProjectDirectory}/stylecop.json"); + await Assert.That(GetAdditionalFiles(project)).IsEquivalentTo([(expected, "stylecop.json")]); + await Assert.That(project.GetItems("None")).IsEmpty(); + } + + [Test] + public async Task Evaluate_UseStyleCopAnalyzersFalse_AddsNoStyleCopJson() + { + using var home = new TempHome(); + home.WriteFile("stylecop.json", "{}"); + var project = Evaluate(home, OrdinaryProject + "false"); + await Assert.That(GetAdditionalFiles(project)).IsEmpty(); + } + + // The project is in src/Test, and the home directory is the temporary directory, unless the properties set + // HomeDirectory. + private static ProjectInstance Evaluate(TempHome home, string properties, string items = "") + { + var projectPath = home.GetFullPath($"{ProjectDirectory}/Test.proj"); var projectText = $""" {home.RootPath}{Path.DirectorySeparatorChar} - {(isNoTargetsProject ? "true" : "false")} - false - {useNullableExtendedAnalyzer} + {properties} + + {items} + """; - home.WriteFile("Test.proj", projectText); + home.WriteFile($"{ProjectDirectory}/Test.proj", projectText); using var collection = new ProjectCollection(); - var project = Project.FromFile(projectPath, new ProjectOptions { ProjectCollection = collection }); - var reference = project.GetItems("BV_PackageReference") - .FirstOrDefault(static item => item.EvaluatedInclude == NullableExtendedAnalyzer); - return (project.GetPropertyValue("UseNullableExtendedAnalyzer"), reference?.GetMetadataValue("PrivateAssets")); + return ProjectInstance.FromFile(projectPath, new ProjectOptions { ProjectCollection = collection }); } + private static string[] GetReferences(ProjectInstance project) + => [.. project.GetItems("BV_PackageReference").Select(static item => item.EvaluatedInclude)]; + + // Each additional file as its full path and its Link metadata. + private static (string Path, string Link)[] GetAdditionalFiles(ProjectInstance project) + => [ + .. project.GetItems("AdditionalFiles") + .Select(static item => (item.GetMetadataValue("FullPath"), item.GetMetadataValue("Link"))), + ]; + private static string GetRealTargetsPath() => typeof(StandardAnalyzersModuleTargetsTests).Assembly .GetCustomAttributes() From af874bc352e83819ab31972fabb36b644767eed7 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Mon, 26 Oct 2026 20:34:13 +0100 Subject: [PATCH 3/8] Remove the .DotSettings warning from the .globalconfig section headers The StyleCop and Public API sections of `.globalconfig` warned against setting severities in `.DotSettings` files. Only ReSharper reads those files, so the warning belongs in the ReSharper section of `.editorconfig` alone. Co-Authored-By: Claude Opus 5.5 --- .globalconfig | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.globalconfig b/.globalconfig index 886a4cd6..e0888c68 100644 --- a/.globalconfig +++ b/.globalconfig @@ -234,8 +234,7 @@ dotnet_naming_style.begins_with_i.capitalization = pascal_case # ============================================================================================== # STYLECOP ANALYZERS # ---------------------------------------------------------------------------------------------- -# This is the single source of truth for StyleCop.Analyzers severities. -# DO NOT set severities in .DotSettings files, as precedence rules are unclear. +# This is the single source of truth for StyleCop.Analyzers settings and severities. # Ordering and grouping follows the categories listed in the official documentation. # See https://github.com/DotNetAnalyzers/StyleCopAnalyzers/tree/master/documentation # ---------------------------------------------------------------------------------------------- @@ -517,7 +516,6 @@ dotnet_diagnostic.SA1654.severity = warning # Properly encode generic brackets # PUBLIC API ANALYZERS # ---------------------------------------------------------------------------------------------- # This is the single source of truth for Public API Analyzer severities. -# DO NOT set severities in .DotSettings files, as precedence rules are unclear. # We only list non-default severities here. # See https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/Microsoft.CodeAnalysis.PublicApiAnalyzers.md # ---------------------------------------------------------------------------------------------- From 9841db86d465c1095a6bc31f02d9abfe26399b5e Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Mon, 26 Oct 2026 20:35:15 +0100 Subject: [PATCH 4/8] Add NullableExtendedAnalyzer configuration to .globalconfig Also temporarily disable the analyzer in `Common.props`, lest the solution fail to build. Co-Authored-By: Claude Opus 5.5 --- .claude/scratchpad/Common.props | 6 ++++++ .globalconfig | 15 +++++++++++++++ Common.props | 3 +++ 3 files changed, 24 insertions(+) diff --git a/.claude/scratchpad/Common.props b/.claude/scratchpad/Common.props index 7e24bef9..1c08ac47 100644 --- a/.claude/scratchpad/Common.props +++ b/.claude/scratchpad/Common.props @@ -31,6 +31,12 @@ false false + + false + false + + + false From 4dbd5681d8b40b569abfb89d8c1056c54a1868a6 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Mon, 26 Oct 2026 20:56:43 +0100 Subject: [PATCH 5/8] Add the Nullable.Extended.Mcp server to dotnet-tools.json and .mcp.json Dealing with null-forgiving operators is easier with a specialized MCP server. Co-Authored-By: Claude Opus 5.5 --- .mcp.json | 17 +++++++++++++++++ dotnet-tools.json | 6 ++++++ 2 files changed, 23 insertions(+) create mode 100644 .mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..d28ba1e9 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "nullable-extended": { + "type": "stdio", + "command": "dotnet", + "args": [ + "tool", + "run", + "nullable-extended-mcp", + "--root", + ".", + "--solution", + "Buildvana.slnx" + ] + } + } +} diff --git a/dotnet-tools.json b/dotnet-tools.json index a029fe81..aaaa9323 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -7,6 +7,12 @@ "commands": [ "bv" ] + }, + "nullable.extended.mcp": { + "version": "1.18.2", + "commands": [ + "nullable-extended-mcp" + ] } } } From b27cdf71b11235da8529debb28d42651ef58d468 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Mon, 26 Oct 2026 21:32:06 +0100 Subject: [PATCH 6/8] Pin Nullable.Extended.Analyzer at 1.18.2 It was still at 1.18.0, while `Nullable.Extended.Mcp` was at 1.18.2. They are versioned together, so they're better kept at the same version. Co-Authored-By: Claude Opus 5.5 --- .globalconfig | 2 +- src/Buildvana.Sdk/Sdk/PackageVersions.props | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.globalconfig b/.globalconfig index 2c7b1404..facbd78d 100644 --- a/.globalconfig +++ b/.globalconfig @@ -544,7 +544,7 @@ dotnet_diagnostic.RS0058.severity = warning # Missing shipped or unshipped inter # Every rule is listed, defaults included. # See https://github.com/tom-englert/Nullable.Extended # ---------------------------------------------------------------------------------------------- -# Last update: 2026-10-26 (Nullable.Extended.Analyzer 1.18.0) +# Last update: 2026-10-26 (Nullable.Extended.Analyzer 1.18.2) # ============================================================================================== dotnet_diagnostic.NX0001.severity = warning # Find general usages of the NullForgiving operator diff --git a/src/Buildvana.Sdk/Sdk/PackageVersions.props b/src/Buildvana.Sdk/Sdk/PackageVersions.props index b357b4d4..5cca52ce 100644 --- a/src/Buildvana.Sdk/Sdk/PackageVersions.props +++ b/src/Buildvana.Sdk/Sdk/PackageVersions.props @@ -8,7 +8,7 @@ --> - + From 28a3a901a31afda01ec21f331c472103ad5004e3 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Mon, 26 Oct 2026 22:29:00 +0100 Subject: [PATCH 7/8] Stop limiting NX0002 and NX0004 to literals in standard-analyzers.md The rules table in docs/sdk-modules/standard-analyzers.md said that NX0002 covers a `null` or `default` literal, and NX0004 an init-only property assignment. In Nullable.Extended.Analyzer, `IsNullOrDefaultExpression` also matches `default(T)`, and `IsInitOnlyPropertyAssignment` matches only the initializer of a property with an `init` accessor. The analyzer checks both rules before NX0003, so `null!` inside a lambda expression gets NX0002. Co-Authored-By: Claude Opus 5.5 --- docs/sdk-modules/standard-analyzers.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/sdk-modules/standard-analyzers.md b/docs/sdk-modules/standard-analyzers.md index 9625a192..8647871b 100644 --- a/docs/sdk-modules/standard-analyzers.md +++ b/docs/sdk-modules/standard-analyzers.md @@ -170,12 +170,12 @@ Nullable.Extended.Analyzer reports every null-forgiving operator (`!`) as a warn The operator silences a nullable warning on the strength of an invariant the compiler cannot check, and the analyzer makes each such invariant visible. Four rules report the operator, one per context: -| Rule | Context | -| ------ | -------------------------------------------------------------------- | -| NX0001 | Any context the other three rules do not cover | -| NX0002 | On a `null` or `default` literal | -| NX0003 | Inside a lambda expression | -| NX0004 | On a `null` or `default` literal in an init-only property assignment | +| Rule | Context | +| ------ | ---------------------------------------------------------------------------------------------- | +| NX0001 | Any context the other three rules do not cover | +| NX0002 | On `null`, `default`, or `default(T)` | +| NX0003 | Inside a lambda expression, on any other operand | +| NX0004 | On `null`, `default`, or `default(T)` in the initializer of a property with an `init` accessor | A comment on the line above the statement or declaration clears the warning, when its text starts with `!` and a space: From 2a0ab4659986282ece39a61e69a4dc26cec7075c Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Mon, 26 Oct 2026 22:29:43 +0100 Subject: [PATCH 8/8] Comment NX0001 to NX0004 in .globalconfig with message, not title The comments in .globalconfig held the titles of NX0001 to NX0004, which no build output shows. A search for the text of a warning found no line there. The four rules share one message. Co-Authored-By: Claude Opus 5.5 --- .globalconfig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.globalconfig b/.globalconfig index facbd78d..7e5abb1d 100644 --- a/.globalconfig +++ b/.globalconfig @@ -547,10 +547,10 @@ dotnet_diagnostic.RS0058.severity = warning # Missing shipped or unshipped inter # Last update: 2026-10-26 (Nullable.Extended.Analyzer 1.18.2) # ============================================================================================== -dotnet_diagnostic.NX0001.severity = warning # Find general usages of the NullForgiving operator -dotnet_diagnostic.NX0002.severity = warning # Find usages of the NullForgiving operator on null or default expression -dotnet_diagnostic.NX0003.severity = warning # Find usages of the NullForgiving operator inside lambda expressions -dotnet_diagnostic.NX0004.severity = warning # Find usages of the NullForgiving operator on null or default expression at init only property +dotnet_diagnostic.NX0001.severity = warning # Instance of NullForgiving operator without justification detected +dotnet_diagnostic.NX0002.severity = warning # Instance of NullForgiving operator without justification detected +dotnet_diagnostic.NX0003.severity = warning # Instance of NullForgiving operator without justification detected +dotnet_diagnostic.NX0004.severity = warning # Instance of NullForgiving operator without justification detected # ============================================================================================== # RESHARPER INSPECTIONS