From 7509a8d28aa940994a1994e4243c48657a35baf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 20:07:13 +0000 Subject: [PATCH 1/3] docs: add ADR-0022 and state the .NET Framework 4.7.2 library floor The libraries target netstandard2.0 and were advertised as running on .NET Framework 4.6.1+, but 4.6.1 is only the theoretical minimum: the netstandard2.0 facades ship in-box from 4.7.2, and the claim was never verified in CI. Record 4.7.2 as the supported floor and correct the README to match. ADR-0022 refines the incidental 4.6.1 line in ADR-0002 (whose decision is the tooling floor, not the library one) without superseding it, and is recorded as Accepted per the maintainer's decision. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012UcTTqS7hwNHhgxdm8wQU4 --- FirstClassErrors/README.nuget.md | 2 +- ...r-the-library-on-net-framework-4-7-2.fr.md | 168 ++++++++++++++++++ ...loor-the-library-on-net-framework-4-7-2.md | 157 ++++++++++++++++ doc/handwritten/for-maintainers/adr/README.md | 1 + 4 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 doc/handwritten/for-maintainers/adr/0022-floor-the-library-on-net-framework-4-7-2.fr.md create mode 100644 doc/handwritten/for-maintainers/adr/0022-floor-the-library-on-net-framework-4-7-2.md diff --git a/FirstClassErrors/README.nuget.md b/FirstClassErrors/README.nuget.md index 800c3b55..8cec5349 100644 --- a/FirstClassErrors/README.nuget.md +++ b/FirstClassErrors/README.nuget.md @@ -31,7 +31,7 @@ checked at build time and can be extracted into a living error catalog. separate install. They catch, at build time, what would otherwise surface late: duplicate error codes, unresolved `[DocumentedBy]` references, documented errors missing from the catalog, an unused `ToException()` result, and more. -- **Zero runtime dependencies, .NET Standard 2.0.** Runs on .NET Framework 4.6.1+, +- **Zero runtime dependencies, .NET Standard 2.0.** Runs on .NET Framework 4.7.2+, .NET Core 2.0+, .NET 5+ (and Mono / Xamarin / Unity). Nothing added to your dependency graph. diff --git a/doc/handwritten/for-maintainers/adr/0022-floor-the-library-on-net-framework-4-7-2.fr.md b/doc/handwritten/for-maintainers/adr/0022-floor-the-library-on-net-framework-4-7-2.fr.md new file mode 100644 index 00000000..6f9f0333 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0022-floor-the-library-on-net-framework-4-7-2.fr.md @@ -0,0 +1,168 @@ +# ADR-0022 | Fixer le floor .NET Framework de la librairie à 4.7.2 + +🌍 🇬🇧 [English](0022-floor-the-library-on-net-framework-4-7-2.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Accepté +**Date :** 2026-07-19 +**Décideurs :** Reefact + +## Contexte + +Les librairies livrées — `FirstClassErrors`, `FirstClassErrors.Testing` et +`FirstClassErrors.RequestBinder` — ciblent **`netstandard2.0`**. Un assembly +`netstandard2.0` est *consommable* par n'importe quel runtime qui implémente le +standard, et le standard désigne **.NET Framework 4.6.1** comme son minimum sur cette +plateforme. + +Ce minimum 4.6.1 est rétro-ajouté. `netstandard2.0` est sorti après .NET Framework +4.6.1, et sa prise en charge a été greffée après coup : sur 4.6.1 à 4.7.1, la façade +`netstandard.dll` et un ensemble de shims `System.*` sont livrés comme des ressources +NuGet et exigent des *binding redirects* côté consommateur pour se charger. **.NET +Framework 4.7.2 est la première version qui embarque ces façades in-box**, et la +recommandation officielle de Microsoft pour `netstandard2.0` est d'utiliser 4.7.2 ou +ultérieur. + +Le support renforce la même ligne. .NET Framework 4.6, 4.6.1 (et 4.5.2) sont en fin +de support depuis avril 2022 ; 4.6.2 est la plus ancienne encore maintenue ; et +**4.8.1 est la dernière version de .NET Framework** — il n'y aura pas de 4.9. + +Jusqu'ici le produit annonçait, dans `FirstClassErrors/README.nuget.md` et comme une +phrase incidente de la Justification de l'[ADR-0002](0002-floor-the-tooling-runtime.fr.md), +que la librairie « tourne sur .NET Framework 4.6.1+ ». Rien dans la CI n'a jamais +chargé les assemblies sur un runtime .NET Framework : `build-test` exécute la suite sur +.NET 10 et le job `floor` n'exécute que *l'outillage* sur le runtime .NET 8. L'annonce +de compatibilité n'a donc jamais été vérifiée. + +La stack de test est **xUnit v3**, dont la cible .NET Framework la plus basse est +**`net472`** ; il n'existe aucun moyen supporté de faire tourner ces projets de tests +sur un .NET Framework antérieur. La CI garde déjà les deux autres bornes runtime du +produit : le floor .NET 8 de l'outillage ([ADR-0002](0002-floor-the-tooling-runtime.fr.md)) +et, en amont de la publication, la prochaine preview de .NET (`canary.yml`). + +## Décision + +Le floor .NET Framework supporté pour les librairies `netstandard2.0` est **4.7.2**. + +## Justification + +4.7.2 est la plus basse version de .NET Framework sur laquelle la librairie tourne +*sans plomberie côté consommateur* : c'est la première à embarquer les façades +`netstandard2.0` in-box, si bien que la promesse « tourne presque partout » devient +vraie au lieu d'être conditionnée à des *binding redirects*. C'est donc le floor +honnête, là où 4.6.1 était le floor théorique. + +Une annonce de support ne vaut que ce qui la vérifie. La ligne 4.6.1 n'a jamais été +exercée, ce qui est un passif pour une librairie dont la raison d'être est la +diagnosticabilité en production. Fixer le floor à 4.7.2 rend l'annonce *vérifiable à +chaque pull request*, car 4.7.2 est aussi le plus bas framework sur lequel la stack de +test elle-même peut tourner — le même nombre clôt à la fois la question du support et +celle de la vérification. + +4.6.x est le mauvais endroit pour ancrer le garde-fou. 4.6 et 4.6.1 sont en fin de vie, +et la fragilité façade-et-binding-redirects de 4.6.1–4.7.1 rendrait un signal rouge +ambigu — la faute de la librairie, ou celle de la plateforme ? Un floor existe pour +donner un signal sans ambiguïté, et seule 4.7.2 en fournit un avec la stack actuelle. + +Fixer le floor de la librairie à 4.7.2 est le symétrique du floor .NET 8 de +l'outillage : chaque borne runtime supportée est prouvée par son propre job, de sorte +que le produit énonce ses runtimes supportés précisément plutôt que par affirmation. +Cette décision **précise** la phrase incidente « 4.6.1 » de +l'[ADR-0002](0002-floor-the-tooling-runtime.fr.md) sans la remplacer : la décision de +cet ADR est le floor net8 de *l'outillage*, non le floor .NET Framework de la +librairie, qui n'avait pas d'ADR propre jusqu'ici. + +## Alternatives envisagées + +### Continuer d'annoncer .NET Framework 4.6.1+ (statu quo) + +Envisagé parce que c'est le minimum `netstandard2.0` sur le papier et que cela +n'exige aucun changement. + +Rejeté parce que cela n'a jamais été vérifié et ne peut l'être à moindre coût : la +prise en charge de `netstandard2.0` par 4.6.1 est rétro-ajoutée et fragile, les +versions concernées sont largement en fin de vie, et xUnit v3 ne peut pas cibler en +dessous de `net472` — prouver l'annonce demanderait une seconde stack de test et des +*binding redirects* côté consommateur, pour un runtime que presque plus personne ne +devrait déployer. + +### Fixer le floor à 4.6.2 (la plus ancienne 4.6.x encore maintenue) + +Envisagé parce que 4.6.2, contrairement à 4.6/4.6.1, est encore maintenue, ce qui +garderait le plus bas numéro encore supporté. + +Rejeté parce que 4.6.2 précède les façades in-box : elle porte la même fragilité de +*binding redirects* que 4.6.1, et reste sous le floor de xUnit v3 — elle demeure donc +invérifiable avec la stack actuelle, et le signal du garde-fou resterait ambigu. + +### Fixer le floor des librairies sur toute la matrice .NET moderne (net6/net8/… en jambes bloquantes) + +Envisagé comme la réponse conventionnelle « tester sur tout » pour une réassurance +large. + +Rejeté parce que la librairie est un unique assembly `netstandard2.0` et que les +runtimes modernes forment une seule famille CoreCLR : le delta comportemental entre +majors, pour des value objects sans dépendance, est négligeable ; les majors en fin de +vie ne devraient pas être « floorés » du tout ; et une matrice par-major réintroduit +exactement le tapis roulant par-release que l'[ADR-0002](0002-floor-the-tooling-runtime.fr.md) +a rejeté. La seule frontière qui compte est .NET Framework versus .NET moderne, que +`net472` couvre, tandis que le dernier runtime (`build-test`) et la prochaine preview +(`canary.yml`) couvrent déjà l'extrémité moderne. + +## Conséquences + +### Positives + +* Le support .NET Framework annoncé est désormais **vérifié à chaque pull request** par + un job Windows dédié, et non plus seulement affirmé. +* Le floor est **gelé** : 4.8.1 est la dernière version de .NET Framework, donc ce + garde-fou ne poursuit jamais une cible mouvante et n'exige aucun entretien + par-release. +* L'histoire des runtimes supportés du produit est symétrique et précise : un floor + .NET Framework de la librairie (4.7.2) et un floor de l'outillage (.NET 8), chacun + prouvé par son propre job, avec la prochaine preview surveillée en amont. + +### Négatives + +* Les consommateurs figés sur .NET Framework 4.6.1–4.7.1 perdent une annonce de + support qui n'avait jamais été vérifiée ; ils doivent être sur 4.7.2 ou ultérieur. + Accepté : 4.7.2 est le floor `netstandard2.0` pratique et les versions inférieures + sont largement en fin de vie. +* Un petit polyfill `IsExternalInit` réservé aux tests et un chemin de build + conditionné à `net472` sont ajoutés aux projets de tests concernés. Les **librairies + livrées ne sont pas touchées** — elles n'utilisent ni `init` ni records — donc rien + dans le produit ne dépend du polyfill. + +### Risques + +* La jambe `net472` ne tourne que sous Windows ; une régression spécifique à + .NET Framework est invisible sur les jambes Linux jusqu'à l'exécution du job Windows. + Atténué en exécutant le job à chaque pull request. +* `FirstClassErrors.RequestBinder.UnitTests` ne peut pas rejoindre le floor car ses + fixtures lient `DateOnly`, un type .NET 6+ absent de .NET Framework ; RequestBinder + est « flooré » via ses property tests à la place. Accepté : les scénarios exclus + exercent un type qui ne peut de toute façon pas exister sur `net472`. + +## Actions de suivi + +* Indiquer 4.7.2+ dans `FirstClassErrors/README.nuget.md` (fait dans ce changement). +* Ajouter le job `framework-floor` à `ci.yml` et le fichier partagé + `build/Net472TestFloor.props` qui porte la jambe `net472` conditionnée (fait dans ce + changement). +* Faire de `framework-floor` un **status check requis** dans la protection de branche + pour qu'il bloque les merges, conformément à l'intention que le floor soit imposé, et + non pas indicatif. +* Si un mainteneur souhaite réconcilier la phrase incidente « 4.6.1 » de + l'[ADR-0002](0002-floor-the-tooling-runtime.fr.md), y ajouter une note d'errata + renvoyant à cet ADR ; cet ADR fait autorité sur le floor .NET Framework de la + librairie. + +## Références + +* [ADR-0002](0002-floor-the-tooling-runtime.fr.md) — le floor du runtime de + l'outillage ; la décision sœur, sur une borne runtime, que cet ADR précise. +* [ADR-0001](0001-lock-the-analyzer-roslyn-floor.fr.md) — le floor Roslyn de + l'analyseur, la troisième borne d'outillage supporté. +* `FirstClassErrors/README.nuget.md` — l'énoncé de support côté utilisateur. +* `build/Net472TestFloor.props`, le job `framework-floor` dans + `.github/workflows/ci.yml`, et l'exécution des tests de librairies sur la preview + dans `.github/workflows/canary.yml` — là où cette décision est imposée. diff --git a/doc/handwritten/for-maintainers/adr/0022-floor-the-library-on-net-framework-4-7-2.md b/doc/handwritten/for-maintainers/adr/0022-floor-the-library-on-net-framework-4-7-2.md new file mode 100644 index 00000000..c3b5c52c --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0022-floor-the-library-on-net-framework-4-7-2.md @@ -0,0 +1,157 @@ +# ADR-0022 | Floor the library's .NET Framework support at 4.7.2 + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0022-floor-the-library-on-net-framework-4-7-2.fr.md) + +**Status:** Accepted +**Date:** 2026-07-19 +**Decision Makers:** Reefact + +## Context + +The shipped libraries — `FirstClassErrors`, `FirstClassErrors.Testing` and +`FirstClassErrors.RequestBinder` — target **`netstandard2.0`**. A `netstandard2.0` +assembly is *consumable* by any runtime that implements the standard, and the +standard names **.NET Framework 4.6.1** as its minimum on that platform. + +That 4.6.1 minimum is retrofitted. `netstandard2.0` shipped after .NET Framework +4.6.1, and support for it was added back: on 4.6.1 through 4.7.1 the `netstandard.dll` +facade and a set of `System.*` shims are delivered as NuGet assets and require +consumer-side binding redirects to load. **.NET Framework 4.7.2 is the first version +that ships those facades in-box**, and Microsoft's own `netstandard2.0` support +guidance recommends 4.7.2 or later. + +Servicing reinforces the same line. .NET Framework 4.6, 4.6.1 (and 4.5.2) reached +end of support in April 2022; 4.6.2 is the oldest still serviced; and **4.8.1 is the +last .NET Framework** — there will be no 4.9. + +Until now the product advertised, in `FirstClassErrors/README.nuget.md` and as an +incidental line in the Rationale of [ADR-0002](0002-floor-the-tooling-runtime.md), +that the library "runs on .NET Framework 4.6.1+". Nothing in CI ever loaded the +assemblies on a .NET Framework runtime: `build-test` runs the suite on .NET 10 and +the `floor` job runs only the *tooling* on the .NET 8 runtime. The compatibility +claim was therefore never verified. + +The test stack is **xUnit v3**, whose lowest supported .NET Framework target is +**`net472`**; there is no supported way to run these test projects on an earlier +.NET Framework. CI already guards the two other runtime bounds of the product: the +tooling's .NET 8 floor ([ADR-0002](0002-floor-the-tooling-runtime.md)) and, ahead of +release, the next .NET preview (`canary.yml`). + +## Decision + +The supported .NET Framework floor for the `netstandard2.0` libraries is **4.7.2**. + +## Rationale + +4.7.2 is the lowest .NET Framework version on which the library runs *without +consumer-side plumbing*: it is the first to ship the `netstandard2.0` facades in-box, +so the "runs almost everywhere" promise becomes true rather than conditional on +binding redirects. It is therefore the honest floor, where 4.6.1 was the theoretical +one. + +A support claim is only worth what verifies it. The 4.6.1 line was never exercised, +which is a liability for a library whose entire purpose is production diagnosability. +Flooring at 4.7.2 makes the claim *checkable on every pull request*, because 4.7.2 is +also the lowest framework the test stack itself can run on — the same number closes +both the support question and the verification question. + +4.6.x is the wrong place to anchor the guard. 4.6 and 4.6.1 are end-of-life, and the +4.6.1–4.7.1 facade-and-binding-redirect fragility would make a red signal ambiguous — +the library's fault, or the platform's? A floor exists to give an unambiguous signal, +and only 4.7.2 provides one with the current stack. + +Flooring the library at 4.7.2 mirrors the tooling floor at .NET 8: each supported +runtime bound is proven by its own dedicated job, so the product states its supported +runtimes precisely instead of by assertion. This decision **refines** the incidental +"4.6.1" claim in [ADR-0002](0002-floor-the-tooling-runtime.md) without superseding it: +that ADR's decision is the *tooling's* net8 floor, not the library's .NET Framework +floor, which had no ADR of its own until now. + +## Alternatives Considered + +### Keep advertising .NET Framework 4.6.1+ (status quo) + +Considered because it is the `netstandard2.0` minimum on paper and demands no change. + +Rejected because it was never verified and cannot be verified cheaply: 4.6.1's +`netstandard2.0` support is retrofitted and fragile, the versions concerned are +largely end-of-life, and xUnit v3 cannot target below `net472`, so proving the claim +would require a second test stack and consumer binding redirects for a runtime almost +nobody should still deploy. + +### Floor at 4.6.2 (the oldest serviced 4.6.x) + +Considered because 4.6.2, unlike 4.6/4.6.1, is still serviced, so it would keep the +lowest still-supported number. + +Rejected because 4.6.2 predates the in-box facades: it carries the same +binding-redirect fragility as 4.6.1, and it is still below the xUnit v3 floor, so it +remains unverifiable with the current stack — the guard's signal would stay +ambiguous. + +### Floor the libraries across the whole modern .NET matrix (net6/net8/… as blocking legs) + +Considered as the conventional "test on everything" answer for broad reassurance. + +Rejected because the library is a single `netstandard2.0` assembly and the modern +runtimes are one CoreCLR family: the behavioural delta across majors, for +zero-dependency value objects, is negligible; end-of-life majors should not be +floored at all; and a per-major matrix re-introduces exactly the per-release treadmill +that [ADR-0002](0002-floor-the-tooling-runtime.md) rejected. The single valuable +boundary is .NET Framework versus modern .NET, which `net472` covers, while the latest +runtime (`build-test`) and the next preview (`canary.yml`) already cover the modern +end. + +## Consequences + +### Positive + +* The advertised .NET Framework support is now **verified on every pull request** by a + dedicated Windows job, not merely claimed. +* The floor is **frozen**: 4.8.1 is the last .NET Framework, so this guard never + chases a moving target and needs no per-release upkeep. +* The product's supported-runtime story is symmetric and precise: a library + .NET Framework floor (4.7.2) and a tooling floor (.NET 8), each proven by its own + job, with the next preview watched ahead of release. + +### Negative + +* Consumers pinned to .NET Framework 4.6.1–4.7.1 lose a claim of support they never + actually had verified; they must be on 4.7.2 or later. Accepted: 4.7.2 is the + practical `netstandard2.0` floor and the lower versions are largely end-of-life. +* A small, test-only `IsExternalInit` polyfill and a `net472`-conditioned build path + are added to the affected test projects. The **shipped libraries are untouched** — + they use neither `init` nor records — so nothing in the product depends on the + polyfill. + +### Risks + +* The `net472` leg runs on Windows only, so a .NET-Framework-specific regression is + invisible on the Linux legs until the Windows job runs. Mitigated by running the job + on every pull request. +* `FirstClassErrors.RequestBinder.UnitTests` cannot join the floor because its + fixtures bind `DateOnly`, a .NET 6+ type absent from .NET Framework; RequestBinder is + floored through its property tests instead. Accepted: the excluded scenarios exercise + a type that cannot exist on `net472` in the first place. + +## Follow-up Actions + +* State 4.7.2+ in `FirstClassErrors/README.nuget.md` (done in this change). +* Add the `framework-floor` job to `ci.yml` and the shared `build/Net472TestFloor.props` + that carries the gated `net472` leg (done in this change). +* Make `framework-floor` a **required status check** in branch protection so it blocks + merges, matching the intent that the floor is enforced, not advisory. +* Should a maintainer wish to reconcile the incidental "4.6.1" line in + [ADR-0002](0002-floor-the-tooling-runtime.md), add an erratum note there pointing to + this ADR; this ADR is the authority on the library's .NET Framework floor. + +## References + +* [ADR-0002](0002-floor-the-tooling-runtime.md) — the tooling runtime floor; the + sibling runtime-bound decision this ADR refines. +* [ADR-0001](0001-lock-the-analyzer-roslyn-floor.md) — the analyzer's Roslyn floor, + the third supported-tooling bound. +* `FirstClassErrors/README.nuget.md` — the user-facing support statement. +* `build/Net472TestFloor.props`, the `framework-floor` job in + `.github/workflows/ci.yml`, and the library preview run in + `.github/workflows/canary.yml` — where this decision is enforced. diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index 34798908..1ee0d4eb 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -195,3 +195,4 @@ Optional supporting material: | [ADR-0019](0019-document-overridden-binder-errors-in-the-consumers-catalog.md) | Document overridden binder errors in the consumer's own catalog | Accepted | | [ADR-0020](0020-materialize-dummies-only-through-generate.md) | Materialize dummies only through Generate() | Accepted | | [ADR-0021](0021-bind-out-of-dto-arguments-as-peers-through-a-source-agnostic-entry.md) | Bind out-of-DTO arguments as peers through a source-agnostic untyped entry | Proposed | +| [ADR-0022](0022-floor-the-library-on-net-framework-4-7-2.md) | Floor the library's .NET Framework support at 4.7.2 | Accepted | From 366c4ceb415b86c714721354584d4d91df0fdd0f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 20:07:13 +0000 Subject: [PATCH 2/3] build: floor the netstandard2.0 library tests on .NET Framework 4.7.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit netstandard2.0 is only a compile contract; running on the real .NET Framework CLR is what proves the libraries behave there. Add a gated net472 leg to the three test projects that cover the shipped libraries, wired through the shared build/Net472TestFloor.props. EnableNet472Floor keeps net472 out of the ordinary build and the local inner loop; only the opt-in CI job adds it. The leg needs LangVersion=latest (net472 defaults C# to 7.3, which rejects nullable), OutputType=Exe (xUnit v3 on .NET Framework), the reference-assembly pack to compile off-Windows, and an internal IsExternalInit polyfill for the records/init a few test models use. The shipped libraries use none of these and are untouched. The System.Text.Json round-trip test is excluded on net472 (a worker concern that never runs there); RequestBinder reaches the floor through its property tests, since its unit-test fixtures bind DateOnly (net6+). AsEnumerable().Reverse() pins the LINQ overload over the void MemoryExtensions.Reverse(Span) that wins once System.Memory is present. Two ErrorCode/ErrorContextKey blank-input tests pinned the exact ArgumentException.Message, whose parameter suffix .NET Framework renders as "...\nParameter name: x" where modern .NET uses "... (Parameter 'x')"; they now assert the message content and ParamName — the contract, stable on both. The library guards are unchanged. See ADR-0022. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012UcTTqS7hwNHhgxdm8wQU4 --- Directory.Packages.props | 1 + .../FirstClassErrors.PropertyTests.csproj | 5 +- ...sErrors.RequestBinder.PropertyTests.csproj | 7 ++- .../ListBindingInvariantTests.cs | 5 +- FirstClassErrors.UnitTests/ErrorCodeTests.cs | 13 +++-- .../ErrorContextKeyTests.cs | 11 ++-- .../FirstClassErrors.UnitTests.csproj | 12 ++++- build/Net472TestFloor.IsExternalInit.cs | 11 ++++ build/Net472TestFloor.props | 50 +++++++++++++++++++ 9 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 build/Net472TestFloor.IsExternalInit.cs create mode 100644 build/Net472TestFloor.props diff --git a/Directory.Packages.props b/Directory.Packages.props index 54b8d091..f1a268bc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -31,6 +31,7 @@ + diff --git a/FirstClassErrors.PropertyTests/FirstClassErrors.PropertyTests.csproj b/FirstClassErrors.PropertyTests/FirstClassErrors.PropertyTests.csproj index 9c417637..ce92872f 100644 --- a/FirstClassErrors.PropertyTests/FirstClassErrors.PropertyTests.csproj +++ b/FirstClassErrors.PropertyTests/FirstClassErrors.PropertyTests.csproj @@ -1,7 +1,10 @@ + + + - net10.0 enable enable false diff --git a/FirstClassErrors.RequestBinder.PropertyTests/FirstClassErrors.RequestBinder.PropertyTests.csproj b/FirstClassErrors.RequestBinder.PropertyTests/FirstClassErrors.RequestBinder.PropertyTests.csproj index 3c3ac82f..d5d3914f 100644 --- a/FirstClassErrors.RequestBinder.PropertyTests/FirstClassErrors.RequestBinder.PropertyTests.csproj +++ b/FirstClassErrors.RequestBinder.PropertyTests/FirstClassErrors.RequestBinder.PropertyTests.csproj @@ -1,7 +1,12 @@ + + + - net10.0 enable enable false diff --git a/FirstClassErrors.RequestBinder.PropertyTests/ListBindingInvariantTests.cs b/FirstClassErrors.RequestBinder.PropertyTests/ListBindingInvariantTests.cs index 14c32a7e..bde2fa8e 100644 --- a/FirstClassErrors.RequestBinder.PropertyTests/ListBindingInvariantTests.cs +++ b/FirstClassErrors.RequestBinder.PropertyTests/ListBindingInvariantTests.cs @@ -44,7 +44,10 @@ public void ErrorPathsAreExactlyThePositionsOfFailingElements() { public void ReorderingElementsReordersTheirErrorPathsAccordingly() { Prop.ForAll(BinderGen.Slots().ToArbitrary(), slots => { - (string? Raw, bool Fails)[] reversed = slots.Reverse().ToArray(); + // AsEnumerable() pins the LINQ Reverse (a reversed copy). A bare slots.Reverse() binds instead + // to the void, in-place MemoryExtensions.Reverse(Span) on the net472 floor, where + // System.Memory is in the graph (see build/Net472TestFloor.props). + (string? Raw, bool Fails)[] reversed = slots.AsEnumerable().Reverse().ToArray(); return FailingPaths(BindTokens(slots)).SetEquals(PositionsOfFailures(slots)) && FailingPaths(BindTokens(reversed)).SetEquals(PositionsOfFailures(reversed)); diff --git a/FirstClassErrors.UnitTests/ErrorCodeTests.cs b/FirstClassErrors.UnitTests/ErrorCodeTests.cs index 99e90739..1fc7e8ab 100644 --- a/FirstClassErrors.UnitTests/ErrorCodeTests.cs +++ b/FirstClassErrors.UnitTests/ErrorCodeTests.cs @@ -31,10 +31,15 @@ public void CreatingErrorCodeWithValidCodeSucceeds(string code) { [InlineData("")] [InlineData(" ")] public void CreatingErrorCodeWithBlankCodeIsRejected(string? code) { - // Exercise & verify - Check.ThatCode(() => ErrorCode.Create(code!)) - .Throws() - .WithMessage("Error code cannot be null or whitespace. (Parameter 'code')"); + // Exercise + ArgumentException exception = Assert.Throws(() => ErrorCode.Create(code!)); + + // Verify the contract — the message content and the offending parameter — not the + // ArgumentException.Message parameter-suffix formatting, which differs between .NET Framework + // ("...\nParameter name: code") and modern .NET ("... (Parameter 'code')"). The netstandard2.0 + // library runs on both; see the framework-floor job in .github/workflows/ci.yml. + Check.That(exception.Message).Contains("Error code cannot be null or whitespace."); + Check.That(exception.ParamName).IsEqualTo("code"); } [Fact(DisplayName = "Creating the same code twice is allowed and produces equal instances.")] diff --git a/FirstClassErrors.UnitTests/ErrorContextKeyTests.cs b/FirstClassErrors.UnitTests/ErrorContextKeyTests.cs index a3d2447f..4332d470 100644 --- a/FirstClassErrors.UnitTests/ErrorContextKeyTests.cs +++ b/FirstClassErrors.UnitTests/ErrorContextKeyTests.cs @@ -75,10 +75,13 @@ public void AKeyCannotBeCreatedWithANullDescriptionProvider() { [InlineData("")] [InlineData(" ")] public void RegisteringKeyWithBlankNameIsRejected(string? name) { - // Exercise & verify - Check.ThatCode(() => ErrorContextKey.Create(name!)) - .Throws() - .WithMessage("Value cannot be null or whitespace. (Parameter 'name')"); + // Exercise + ArgumentException exception = Assert.Throws(() => ErrorContextKey.Create(name!)); + + // Verify the contract, not the framework-specific parameter-suffix formatting of + // ArgumentException.Message (same net472-floor rationale as ErrorCodeTests). + Check.That(exception.Message).Contains("Value cannot be null or whitespace."); + Check.That(exception.ParamName).IsEqualTo("name"); } [Fact(DisplayName = "Re-declaring a key with the same name and type returns the registered instance.")] diff --git a/FirstClassErrors.UnitTests/FirstClassErrors.UnitTests.csproj b/FirstClassErrors.UnitTests/FirstClassErrors.UnitTests.csproj index 2f8010a6..f1d40488 100644 --- a/FirstClassErrors.UnitTests/FirstClassErrors.UnitTests.csproj +++ b/FirstClassErrors.UnitTests/FirstClassErrors.UnitTests.csproj @@ -1,7 +1,10 @@  + + + - net10.0 enable enable false @@ -32,4 +35,11 @@ + + + + + diff --git a/build/Net472TestFloor.IsExternalInit.cs b/build/Net472TestFloor.IsExternalInit.cs new file mode 100644 index 00000000..f29de95d --- /dev/null +++ b/build/Net472TestFloor.IsExternalInit.cs @@ -0,0 +1,11 @@ +// Compiler-required marker behind C# `init` accessors and records. The .NET Framework 4.7.2 BCL does not +// ship it, so this polyfill is compiled ONLY into net472 test builds (see build/Net472TestFloor.props). It is +// internal, so it never leaves the test assembly and each net472 test assembly gets its own. The shipped +// FirstClassErrors libraries use neither `init` nor records, so nothing in the product relies on this. + +// ReSharper disable once CheckNamespace -- the compiler resolves this type by its exact fully-qualified name. +namespace System.Runtime.CompilerServices { + + internal static class IsExternalInit { } + +} diff --git a/build/Net472TestFloor.props b/build/Net472TestFloor.props new file mode 100644 index 00000000..017ecb1d --- /dev/null +++ b/build/Net472TestFloor.props @@ -0,0 +1,50 @@ + + + + + + net10.0 + + + net10.0;net472 + + + + + latest + + Exe + + + + + + + + + + From 1166132b7f171f269584a46c442330871429c0b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 20:07:13 +0000 Subject: [PATCH 3/3] ci: run the .NET Framework 4.7.2 library floor and preview tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Windows framework-floor job that runs the three net472-enabled test projects on .NET Framework 4.7.2 (EnableNet472Floor plus -f net472), proving the libraries load and pass on that runtime — the guarantee the Linux/net10 legs cannot give. A per-project loop keeps net472 off the net10-only projects a solution-wide -f would otherwise force it onto. Extend the weekly canary to run the library suites on the next .NET preview: the build stays on the .NET 10 SDK and only the test host rolls forward, so a runtime regression that breaks behaviour surfaces before that major ships. Best-effort and non-blocking, like the rest of the canary. See ADR-0022. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012UcTTqS7hwNHhgxdm8wQU4 --- .github/workflows/canary.yml | 34 ++++++++++++++++++++++++++++ .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 53c6e40c..d5fa2aff 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -125,3 +125,37 @@ jobs: exit 1 fi echo "ok: the net8 fce and worker documented a net8 target on the .NET ${newest} preview runtime" + + # Beyond the tooling above, layer BEHAVIOURAL coverage of the netstandard2.0 LIBRARIES on the preview. The fce + # step already proves the library LOADS on the preview runtime (the worker reflects over a netstandard2.0 target + # there); this runs the libraries' own unit and property suites on it, so a runtime regression that breaks + # behaviour — not just loading — turns the scheduled run red before that major ships. The test projects target + # net10.0; only the `dotnet test` execution is wrapped in DOTNET_ROLL_FORWARD, so the net10 test host binds the + # highest installed major (the preview) while the build stays on the .NET 10 SDK. Best-effort and non-blocking + # like the rest of the canary. RequestBinder.UnitTests IS included here (unlike the net472 floor job): DateOnly, + # which its fixtures bind, exists on every modern .NET — it is only absent from .NET Framework. + - name: Test the libraries on the preview runtime + if: steps.preview.outcome == 'success' + shell: bash + run: | + set -euo pipefail + echo "Installed .NET runtimes:" + dotnet --list-runtimes + newest="$(dotnet --list-runtimes | sed -nE 's/^Microsoft\.NETCore\.App ([0-9]+)\..*/\1/p' | sort -n | tail -1)" + if [ "${newest:-0}" -le 10 ]; then + echo "::notice::no runtime newer than the .NET 10 build SDK is installed; skipping the library preview run" + exit 0 + fi + for proj in \ + FirstClassErrors.UnitTests \ + FirstClassErrors.PropertyTests \ + FirstClassErrors.RequestBinder.UnitTests \ + FirstClassErrors.RequestBinder.PropertyTests ; do + echo "::group::$proj on the .NET ${newest} preview" + # Build on the .NET 10 SDK (no roll-forward), then run ONLY the test host under roll-forward so it binds + # the preview major. The env is scoped inline to the test call so it never touches build/evaluation. + dotnet build "$proj/$proj.csproj" -c Release + DOTNET_ROLL_FORWARD=LatestMajor DOTNET_ROLL_FORWARD_TO_PRERELEASE=1 \ + dotnet test "$proj/$proj.csproj" -c Release --no-build --logger "console;verbosity=normal" + echo "::endgroup::" + done diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33d986a7..590b346d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,50 @@ jobs: path: artifacts/coverage/**/coverage.opencover.xml if-no-files-found: error + # build-test above runs the suite on the LATEST .NET (10). This job proves the LIBRARY's OTHER supported + # runtime: that the netstandard2.0 assemblies (FirstClassErrors, FirstClassErrors.Testing, + # FirstClassErrors.RequestBinder) actually load and pass their tests on .NET Framework 4.7.2 — the support + # floor recorded in doc/handwritten/for-maintainers/adr/0022-floor-the-library-on-net-framework-4-7-2.md. + # netstandard2.0 is only a COMPILE contract; running on the real .NET Framework CLR (NLS globalization, the + # netstandard.dll facade, its reflection stack) is what proves the library behaves there — the ordinary net10 + # build cannot. .NET Framework tests run on Windows only (there is no Mono on the Linux legs), and the net472 + # inner build is gated behind EnableNet472Floor (build/Net472TestFloor.props) so it never touches build-test. + framework-floor: + name: Library on the .NET Framework 4.7.2 floor + runs-on: windows-latest + # Restore + build + test of three small projects; cap a hung run like the other jobs. + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + # global.json pins the .NET 10 SDK; that SDK compiles the net472 target. The .NET Framework runtime that + # RUNS net472 assemblies ships on the windows-latest image, and the net472 targeting pack for COMPILATION + # is supplied hermetically by the Microsoft.NETFramework.ReferenceAssemblies package — so no extra install. + - name: Setup .NET (build SDK) + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + with: + dotnet-version: '10.0.x' + + # Only the three library test projects carry the net472 leg (EnableNet472Floor adds it): the tooling test + # projects (Roslyn / GenDoc / Cli) stay net10-only by design, and RequestBinder.UnitTests stays net10-only + # because its fixtures bind DateOnly, a .NET 6+ type absent from net472. RequestBinder is still floored here + # through its property tests. A per-project loop (not a solution-wide -f net472, which would force the TFM + # onto the net10-only projects and fail) keeps the net472 scope exactly these three. + - name: Test the netstandard2.0 libraries on .NET Framework 4.7.2 + shell: bash + run: | + set -euo pipefail + for proj in \ + FirstClassErrors.UnitTests \ + FirstClassErrors.PropertyTests \ + FirstClassErrors.RequestBinder.PropertyTests ; do + echo "::group::$proj (net472)" + dotnet test "$proj/$proj.csproj" -c Release -f net472 -p:EnableNet472Floor=true \ + --logger "console;verbosity=normal" + echo "::endgroup::" + done + # build-test above runs the whole suite on the LATEST .NET (10). This job proves the other end of the # supported range: that the fce tool and its worker, which ship targeting net8.0 (the floor — see # doc/handwritten/for-maintainers/adr/0002-floor-the-tooling-runtime.md), actually run on the .NET 8 RUNTIME. The net8 TFM