From 52983addf3665227d1c729f6f437ae7ca8d1fce2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 21:56:53 +0000 Subject: [PATCH 1/6] build: restore and pin the analyzer's Roslyn floor from one source A Dependabot major bump had raised Microsoft.CodeAnalysis.CSharp in the analyzer project to 5.6.0. Since the analyzer is packed inside FirstClassErrors and loaded by each consumer's host compiler, that reference is the minimum Roslyn able to load it: on an older SDK/IDE it now fails to load (CS8032) instead of running. Define the floor once as RoslynFloorVersion in Directory.Build.props, pin Microsoft.CodeAnalysis.CSharp to $(RoslynFloorVersion) (4.8.0), and surface the same value through AssemblyMetadata so the guarding test cannot drift from the pin. Microsoft.CodeAnalysis.Analyzers is a build-time authoring analyzer, not part of the load contract, so it is left free to move. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AbXi2ZEaUFeFWodNxdY6Zk --- Directory.Build.props | 11 +++++++++++ .../FirstClassErrors.Analyzers.csproj | 15 +++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 11d1014e..087ff2cc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -22,4 +22,15 @@ true + + + 4.8.0 + + diff --git a/FirstClassErrors.Analyzers/FirstClassErrors.Analyzers.csproj b/FirstClassErrors.Analyzers/FirstClassErrors.Analyzers.csproj index b61c8228..ad1433b0 100644 --- a/FirstClassErrors.Analyzers/FirstClassErrors.Analyzers.csproj +++ b/FirstClassErrors.Analyzers/FirstClassErrors.Analyzers.csproj @@ -16,13 +16,24 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + From a6bde60702be90125cb8f3d5fcb81697d6e9d653 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 21:56:53 +0000 Subject: [PATCH 2/6] test: enforce the analyzer's Roslyn floor Add RoslynFloorTests: it reads the floor from the analyzer assembly's AssemblyMetadata and fails if any referenced Microsoft.CodeAnalysis* assembly exceeds it. It bounds the whole family (the analyzers use only the language-agnostic API, so Microsoft.CodeAnalysis.CSharp is not necessarily referenced), fails loudly if the family is absent rather than passing vacuously, and compares on major.minor.build so a four-part 4.8.0.0 does not read as newer than the 4.8.0 floor. Uses NFluent, per the test conventions. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AbXi2ZEaUFeFWodNxdY6Zk --- .../RoslynFloorTests.cs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 FirstClassErrors.Analyzers.UnitTests/RoslynFloorTests.cs diff --git a/FirstClassErrors.Analyzers.UnitTests/RoslynFloorTests.cs b/FirstClassErrors.Analyzers.UnitTests/RoslynFloorTests.cs new file mode 100644 index 00000000..d57ed3fa --- /dev/null +++ b/FirstClassErrors.Analyzers.UnitTests/RoslynFloorTests.cs @@ -0,0 +1,56 @@ +using System; +using System.Linq; +using System.Reflection; + +using NFluent; + +namespace FirstClassErrors.Analyzers.UnitTests; + +/// +/// Enforces the analyzer's Roslyn load contract. The analyzer ships inside the FirstClassErrors package +/// and is loaded by each consumer's host compiler, so the Microsoft.CodeAnalysis version it is compiled +/// against is the minimum Roslyn able to load it; a higher one makes it fail to load (CS8032) on older +/// SDKs/IDEs. The floor is defined once in Directory.Build.props and surfaced here through assembly +/// metadata, so the csproj pin and this test can never diverge. +/// +public sealed class RoslynFloorTests { + + [Fact] + public void Analyzer_stays_on_the_supported_Roslyn_floor() { + Assembly analyzerAssembly = typeof(DuplicateErrorCodeAnalyzer).Assembly; + Version floor = ReadFloor(analyzerAssembly); + + // The analyzers use only the language-agnostic API (IOperation), so the compiler emits a reference + // to Microsoft.CodeAnalysis but not necessarily to Microsoft.CodeAnalysis.CSharp — only *used* + // references are recorded. Bound the whole family rather than one assembly name. + AssemblyName[] roslynReferences = analyzerAssembly + .GetReferencedAssemblies() + .Where(reference => reference.Name is not null + && reference.Name.StartsWith("Microsoft.CodeAnalysis", StringComparison.Ordinal)) + .ToArray(); + + // If the family ever disappears from the metadata this test proves nothing: fail loudly rather + // than pass vacuously. + Check.That(roslynReferences).Not.IsEmpty(); + + string[] offenders = roslynReferences + .Where(reference => OnMajorMinorBuild(reference.Version) > floor) + .Select(reference => $"{reference.Name} {reference.Version}") + .ToArray(); + + Check.That(offenders).IsEmpty(); + } + + private static Version ReadFloor(Assembly analyzerAssembly) { + AssemblyMetadataAttribute floor = analyzerAssembly + .GetCustomAttributes() + .Single(metadata => metadata.Key == "RoslynFloorVersion"); + + return OnMajorMinorBuild(Version.Parse(floor.Value!)); + } + + // Roslyn assemblies carry a four-part version (x.y.z.0) while the floor is written as x.y.z; comparing on + // major.minor.build only keeps a raw 4.8.0.0 from reading as newer than the 4.8.0 floor. + private static Version OnMajorMinorBuild(Version? version) => + new(version?.Major ?? 0, version?.Minor ?? 0, version is { Build: >= 0 } ? version.Build : 0); +} From d2b97e62275b2f44990230d96922c76c09804474 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 21:56:53 +0000 Subject: [PATCH 3/6] ci: dogfood the analyzer on the Roslyn floor SDK The unit test checks metadata; only a real build under an old Roslyn proves the analyzer loads. Add a floor job that builds tools/floor-check (a net8.0 consumer reusing the Usage sources with the analyzer wired in) on the .NET 8 SDK, selected via a nested global.json because the root pin plus rollForward: latestFeature never rolls down to a lower major. CS8032 and AD0001 are errors there, so a too-new Roslyn reference fails the load and the build. A follow-up step greps the ReportAnalyzer table (detailed verbosity) for a FCE analyzer type, so the check cannot pass without the analyzer having actually loaded and run. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AbXi2ZEaUFeFWodNxdY6Zk --- .github/workflows/analyzers.yml | 30 +++++++++++++++++++++++++++++ tools/floor-check/FloorCheck.csproj | 29 ++++++++++++++++++++++++++++ tools/floor-check/global.json | 6 ++++++ 3 files changed, 65 insertions(+) create mode 100644 tools/floor-check/FloorCheck.csproj create mode 100644 tools/floor-check/global.json diff --git a/.github/workflows/analyzers.yml b/.github/workflows/analyzers.yml index 73332160..c9d51772 100644 --- a/.github/workflows/analyzers.yml +++ b/.github/workflows/analyzers.yml @@ -43,3 +43,33 @@ jobs: # workflow, which builds and tests the whole solution. - name: Build usage (dogfood analyzers) run: dotnet build FirstClassErrors.Usage/FirstClassErrors.Usage.csproj -c Release + + floor: + name: Dogfood analyzers on the Roslyn floor + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup floor .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Build on the floor SDK + # tools/floor-check/global.json selects the .NET 8 (Roslyn 4.8) SDK: the repo-root global.json pins + # .NET 10 and rollForward: latestFeature never rolls down to a lower major, so building from this + # directory is what makes the CLI pick the nested pin. ReportAnalyzer AND detailed verbosity are both + # required for Roslyn's per-analyzer table to reach the log (it is silent at default verbosity); + # --no-incremental guarantees a real compilation so that table is always produced. The (verbose) log + # is echoed only on failure to keep a green run readable. + working-directory: tools/floor-check + run: dotnet build -c Release --no-incremental -p:ReportAnalyzer=true -v detailed > build.log 2>&1 || { cat build.log; exit 1; } + + - name: Prove the analyzer actually loaded + # A never-loaded analyzer would otherwise leave the build green: CS8032 is emitted only when loading + # is attempted. The assembly name alone appears in ordinary build lines ("FirstClassErrors.Analyzers -> + # ...dll"), so match a FCE analyzer *type* — it exists only in the ReportAnalyzer table, i.e. only if + # the analyzer really loaded and ran. This is what keeps the floor check from passing vacuously. + working-directory: tools/floor-check + run: grep -qE 'FirstClassErrors\.Analyzers\.[A-Za-z]+Analyzer \(FCE[0-9]' build.log diff --git a/tools/floor-check/FloorCheck.csproj b/tools/floor-check/FloorCheck.csproj new file mode 100644 index 00000000..946cb400 --- /dev/null +++ b/tools/floor-check/FloorCheck.csproj @@ -0,0 +1,29 @@ + + + + + net8.0 + enable + enable + false + + $(WarningsAsErrors);CS8032;AD0001 + + + + + + + + + + diff --git a/tools/floor-check/global.json b/tools/floor-check/global.json new file mode 100644 index 00000000..391ba3c2 --- /dev/null +++ b/tools/floor-check/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.100", + "rollForward": "latestFeature" + } +} From 9f0dd40c85456aab150a7a7e89c22f36cd98cba6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 21:56:53 +0000 Subject: [PATCH 4/6] ci: stop Dependabot bumping the analyzer's Roslyn Ignore Microsoft.CodeAnalysis.CSharp and Microsoft.CodeAnalysis.Common in the nuget config: their version is a deliberate compatibility floor, not routine maintenance. Enforcement stays RoslynFloorTests and the floor job; this only stops the recurring bump PRs. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AbXi2ZEaUFeFWodNxdY6Zk --- .github/dependabot.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b904e408..b1ffcbcb 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,6 +9,14 @@ updates: open-pull-requests-limit: 10 commit-message: prefix: "build" + ignore: + # The analyzer's Roslyn floor (RoslynFloorVersion in Directory.Build.props) is a product decision, + # not routine maintenance: bumping Microsoft.CodeAnalysis.* raises the minimum host compiler able to + # load the shipped analyzer and breaks it (CS8032) on older SDKs/IDEs. Real enforcement is + # RoslynFloorTests plus the floor CI job; this only stops Dependabot re-proposing the bump. A single + # nuget config cannot scope the rule to one project, so these also freeze in the analyzer test project. + - dependency-name: "Microsoft.CodeAnalysis.CSharp" + - dependency-name: "Microsoft.CodeAnalysis.Common" - package-ecosystem: "github-actions" directory: "/" From 470762d92a71c96d8f335c213a57a9a7637e22b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 21:56:54 +0000 Subject: [PATCH 5/6] docs: document the analyzers' minimum compiler version Note in the Analyzers section (English and French) that the bundled analyzers need Roslyn 4.8+ (.NET 8 SDK / VS 2022 17.8) to load. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AbXi2ZEaUFeFWodNxdY6Zk --- README.md | 2 ++ doc/README.fr.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/README.md b/README.md index c92775e5..175e1fcf 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,8 @@ FirstClassErrors is especially useful if: FirstClassErrors ships with a set of Roslyn analyzers (rule ids `FCExxx`) **bundled in the NuGet package** — reference the package and they run at build time, no extra install. They catch, before you run anything, the mistakes the runtime or the documentation pipeline would otherwise surface late or silently: duplicate error codes, `[DocumentedBy]` references that don't resolve, documented errors that never reach the catalog, and more. +> **Compiler requirement:** the bundled analyzers are compiled against Roslyn 4.8, so they load in any host from **.NET 8 SDK / Visual Studio 2022 17.8** onward. Older SDKs/IDEs cannot load them. + See the [analyzer rules reference](doc/analyzers/README.md). ## 📚 Next steps diff --git a/doc/README.fr.md b/doc/README.fr.md index 9605a727..aac9787e 100644 --- a/doc/README.fr.md +++ b/doc/README.fr.md @@ -197,6 +197,8 @@ FirstClassErrors est particulièrement utile si : FirstClassErrors est livré avec un ensemble d’analyseurs Roslyn (identifiants de règle `FCExxx`) **inclus dans le package NuGet** — référencez le package et ils s’exécutent au build, sans installation supplémentaire. Ils détectent, avant toute exécution, les erreurs que le runtime ou le pipeline de documentation ne feraient sinon apparaître que tard, voire silencieusement : codes d’erreur dupliqués, références `[DocumentedBy]` qui ne résolvent pas, erreurs documentées qui n’atteignent jamais le catalogue, et plus encore. +> **Prérequis compilateur :** les analyseurs inclus sont compilés contre Roslyn 4.8 ; ils se chargent donc dans tout hôte à partir du **SDK .NET 8 / Visual Studio 2022 17.8**. Les SDK/IDE plus anciens ne peuvent pas les charger. + Voir la [référence des règles d’analyse](analyzers/README.fr.md). ## 📚 Étapes suivantes From 0fafe315d7a80cacff12db7653f07329868e8478 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 22:05:53 +0000 Subject: [PATCH 6/6] ci: pin the floor check to SDK 8.0.100 and loosen the load proof Two robustness fixes to the floor job from PR review: - Pin the floor SDK to exactly 8.0.100 (Roslyn 4.8.0 / VS 17.8) via dotnet-version 8.0.100 and rollForward: disable. With 8.0.x plus latestFeature the CLI could select a newer 8.0 feature band whose Roslyn is above the floor, so the job would stop loading the analyzer on the oldest supported host. - Match the analyzer type in the ReportAnalyzer table without requiring the trailing diagnostic-id list (a Roslyn-version formatting detail). The fully-qualified type name still appears only in that table, never in the build lines, so the proof stays false-positive-safe. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AbXi2ZEaUFeFWodNxdY6Zk --- .github/workflows/analyzers.yml | 12 ++++++++---- tools/floor-check/global.json | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/analyzers.yml b/.github/workflows/analyzers.yml index c9d51772..e945bbe3 100644 --- a/.github/workflows/analyzers.yml +++ b/.github/workflows/analyzers.yml @@ -54,7 +54,10 @@ jobs: - name: Setup floor .NET SDK uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + # Exactly 8.0.100 (Roslyn 4.8.0 == VS 2022 17.8), the oldest supported host. A wildcard like + # 8.0.x would install a later feature band whose bundled Roslyn is newer than the floor, so the + # job would stop testing the actual floor. Paired with rollForward: disable in the nested global.json. + dotnet-version: '8.0.100' - name: Build on the floor SDK # tools/floor-check/global.json selects the .NET 8 (Roslyn 4.8) SDK: the repo-root global.json pins @@ -69,7 +72,8 @@ jobs: - name: Prove the analyzer actually loaded # A never-loaded analyzer would otherwise leave the build green: CS8032 is emitted only when loading # is attempted. The assembly name alone appears in ordinary build lines ("FirstClassErrors.Analyzers -> - # ...dll"), so match a FCE analyzer *type* — it exists only in the ReportAnalyzer table, i.e. only if - # the analyzer really loaded and ran. This is what keeps the floor check from passing vacuously. + # ...dll" / paths), so match a fully-qualified analyzer *type* (`...Analyzer`): it appears only in + # the ReportAnalyzer table, i.e. only if the analyzer really loaded and ran. The pattern deliberately + # does not depend on the trailing diagnostic-id list, which is a Roslyn-version formatting detail. working-directory: tools/floor-check - run: grep -qE 'FirstClassErrors\.Analyzers\.[A-Za-z]+Analyzer \(FCE[0-9]' build.log + run: grep -qE 'FirstClassErrors\.Analyzers\.[A-Za-z]+Analyzer' build.log diff --git a/tools/floor-check/global.json b/tools/floor-check/global.json index 391ba3c2..2244195a 100644 --- a/tools/floor-check/global.json +++ b/tools/floor-check/global.json @@ -1,6 +1,6 @@ { "sdk": { "version": "8.0.100", - "rollForward": "latestFeature" + "rollForward": "disable" } }