diff --git a/maintainers/adr/0001-lock-the-analyzer-roslyn-floor.fr.md b/maintainers/adr/0001-lock-the-analyzer-roslyn-floor.fr.md new file mode 100644 index 00000000..220eb983 --- /dev/null +++ b/maintainers/adr/0001-lock-the-analyzer-roslyn-floor.fr.md @@ -0,0 +1,193 @@ +# ADR-0001 | Verrouiller le floor Roslyn de l'analyzer + +🌍 🇬🇧 [English](0001-lock-the-analyzer-roslyn-floor.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Accepté +**Date :** 2026-07-10 +**Décideurs :** Reefact + +## Contexte + +`FirstClassErrors.Analyzers` est un analyzer Roslyn livré **intégré dans le +package NuGet `FirstClassErrors`** (à `analyzers/dotnet/cs/`), de sorte que les +consommateurs qui référencent le package obtiennent automatiquement les +diagnostics `FCExxx`, sans installation supplémentaire. + +Un analyzer intégré est chargé par **le compilateur hôte de chaque +consommateur** — le Roslyn fourni avec leur SDK .NET ou leur IDE. La version de +`Microsoft.CodeAnalysis.*` contre laquelle l'analyzer est *compilé* devient donc +le Roslyn **minimum** capable de le charger : + +* si l'analyzer référence un Roslyn **plus récent** que l'hôte, l'hôte refuse de + le charger et émet **`CS8032`** (et l'analyzer ne fait silencieusement rien) ; +* si l'analyzer lève une exception lors du chargement, l'hôte émet **`AD0001`**. + +Une montée de version de routine de la dépendance `Microsoft.CodeAnalysis.*` +élève donc silencieusement le SDK/IDE minimum que chaque consommateur doit +posséder. Cette régression précise s'est produite une fois : l'analyzer a dérivé +jusqu'à exiger Roslyn 5.6. Dependabot propose de telles montées de version +automatiquement, comme n'importe quelle autre mise à jour de dépendance. + +Le contrat de chargement est invisible sur les chaînes d'outils modernes — la CI +sur le dernier SDK, ainsi que l'IDE du mainteneur lui-même, satisfont l'un comme +l'autre n'importe quel floor — de sorte qu'il peut régresser sans aucun signal +rouge. + +L'hôte le plus ancien que FirstClassErrors prend en charge est le **SDK .NET +8.0.100 / Visual Studio 2022 17.8**, dont le compilateur est **Roslyn 4.8**. + +`CS8032` n'est émis que lorsqu'un chargement est *tenté* : un analyzer qui n'est +jamais raccordé à une compilation laisse la build au vert. Et l'analyzer atteint +les consommateurs via un chemin de packaging (`analyzers/dotnet/cs/`) qui peut se +casser indépendamment des références propres de l'analyzer. + +`Microsoft.CodeAnalysis.Analyzers` (5.6.0) est un analyzer d'authoring à la +compilation (`PrivateAssets="all"`), et non une référence à l'exécution de +l'assembly livré, de sorte qu'il n'affecte pas le contrat de chargement. + +## Décision + +Le floor Roslyn de l'analyzer est fixé à **4.8.0** — le Roslyn de l'hôte pris en +charge le plus ancien — déclaré une seule fois comme `RoslynFloorVersion` dans +`Directory.Build.props` et appliqué par quatre garde-fous indépendants. + +## Justification + +La valeur du floor est dictée par le contexte : 4.8.0 est exactement le Roslyn +livré avec l'hôte le plus ancien que nous prétendons prendre en charge (SDK .NET +8.0.100 / VS 2022 17.8). Un floor plus bas n'apporte rien ; un floor plus haut +abandonne silencieusement des hôtes pris en charge. + +Le contrat nécessite plus qu'un simple épinglage de version, car il régresse sans +aucun signal rouge sur les chaînes d'outils modernes. Un épinglage seul +*ressemble* encore à de la maintenance de routine, et une référence trop récente +peut se glisser de trois manières distinctes qu'aucune vérification unique +n'attrape : la *version de référence* peut dériver, l'analyzer livré peut échouer +à se *charger* sur un hôte ancien, et il peut échouer à être *packagé* là où les +consommateurs le cherchent. La décision superpose donc une **défense en +profondeur** — une source unique de vérité et quatre garde-fous, chacun fermant +une brèche que les autres ne peuvent fermer : + +* le floor est déclaré **une seule fois**, comme `RoslynFloorVersion` dans + `Directory.Build.props`, de sorte que l'épinglage, le test et le job CI suivent + une valeur unique et ne peuvent jamais diverger ; +* **l'épinglage** compile l'analyzer contre exactement ce Roslyn, fixant le floor + à sa source ; +* **le test unitaire** (`RoslynFloorTests`) relit le floor depuis les métadonnées + de l'assembly et échoue, rapidement et in-process, si un quelconque assembly + `Microsoft.CodeAnalysis*` référencé le dépasse — attrapant la dérive de + *référence* ; +* **le job CI de floor-check** empaquette la librairie et reconstruit + l'exemple contre l'analyzer empaqueté sous le SDK du floor, prouvant que + l'**artefact livré** à la fois se **charge** et est **packagé** correctement sur + le **compilateur pris en charge le plus ancien** — les deux brèches que le test + unitaire ne peut atteindre ; +* **l'ignore Dependabot** empêche une PR automatisée de jamais proposer la montée + de version, de sorte que relever le floor reste un acte conscient plutôt qu'une + mise à jour entérinée sans examen. + +Deux des garde-fous échouent **bruyamment** (le test unitaire et le job CI) ; +l'épinglage et l'ignore Dependabot fonctionnent silencieusement par construction. +Ensemble, ils satisfont l'exigence posée par le contexte : un garde-fou qui +échoue sur l'hôte le **plus ancien**, sur l'**artefact exact que nous livrons**, +avant qu'un consommateur ne voie jamais `CS8032`. Le compromis accepté est +l'entretien d'un job CI délibérément complexe et d'un projet `tools/floor-check/` +hors-solution ; les mécanismes de ce job — la séparation en deux SDK, la preuve +de chargement et les pièges NuGet qu'il ferme — sont documentés dans la +[référence du workflow `analyzers`](../workflows/analyzers.fr.md), et l'épinglage +et les métadonnées résident dans `FirstClassErrors.Analyzers.csproj`. + +## Alternatives envisagées + +### Suivre le Roslyn courant et laisser passer les montées de version de dépendances (statu quo) + +Envisagée parce que c'est le comportement par défaut, sans effort : les montées +de version de Roslyn arrivent comme des mises à jour de dépendances de routine, +et rien dans la chaîne d'outils ne s'y oppose. + +Rejetée parce que chaque montée de version élève silencieusement le SDK/IDE +minimum que chaque consommateur doit posséder — la dérive vers Roslyn 5.6 s'est +produite exactement de cette manière, sans aucun signal rouge nulle part. + +### Épingler la version de Roslyn, sans garde-fous supplémentaires + +Envisagée comme le correctif minimal : un épinglage d'une ligne arrête la dérive. + +Rejetée parce qu'un simple épinglage *ressemble* encore à de la maintenance de +routine — Dependabot continue de proposer la montée de version, et en accepter +une ne fait rien échouer : aucun test ne relit le floor, et aucune build ne +s'exécute sur l'hôte le plus ancien. La régression reviendrait avec la prochaine +mise à jour bien intentionnée. + +### S'appuyer sur le seul test unitaire (pas de job CI de floor-check) + +Envisagée parce que le test est rapide, in-process, et s'exécute dans le +`dotnet test` ordinaire. + +Rejetée parce qu'elle n'attrape que la dérive de version de *référence*. Elle ne +peut pas prouver que l'artefact livré se **charge** réellement sur l'hôte pris en +charge le plus ancien, ni que l'analyzer est réellement **packagé** à +`analyzers/dotnet/cs/` — deux choses qui peuvent se casser alors que chaque +version de référence reste au floor. + +## Conséquences + +### Positives + +* Le contrat de chargement ne peut pas régresser silencieusement : une référence + Roslyn trop récente fait échouer le test unitaire (rapide) **et** le job de + floor-check (authentique), et un chemin de packaging cassé fait échouer le job + de floor-check. +* Le job de floor-check teste l'*artefact livré* sur l'*hôte pris en charge le + plus ancien*, et non un substitut. +* Le floor est une décision d'une seule ligne, auto-documentée + (`RoslynFloorVersion`). + +### Négatives + +* Deux garde-fous supplémentaires à maintenir au vert, et un projet + `tools/floor-check/` hors-solution avec une configuration NuGet + intentionnellement complexe (documentée dans la [référence du workflow + `analyzers`](../workflows/analyzers.fr.md)). +* Le job de floor-check télécharge le SDK 8.0.100 à chaque exécution (~quelques + secondes). +* Relever le floor est un acte délibéré et en plusieurs étapes (à dessein). + +### Risques + +* La configuration NuGet du floor-check paraît sur-conçue à un lecteur qui ne + connaît pas les pièges qu'elle ferme ; une future « simplification » en + réintroduirait un comme un bug silencieux. Atténuation : chaque subtilité est + consignée dans la [référence du workflow `analyzers`](../workflows/analyzers.fr.md) + et dans les commentaires YAML du workflow lui-même. +* Lorsque le floor est relevé, le SDK du floor épinglé dans `analyzers.yml` et + dans `tools/floor-check/global.json` doit être déplacé à la main ; les oublier + laisse le job valider l'ancien floor. Atténuation : la procédure de relèvement + ci-dessous les liste explicitement. + +## Actions de suivi + +* Aucune dans l'immédiat : les quatre garde-fous (épinglage, `RoslynFloorTests`, + le job de floor-check `analyzers.yml`, l'ignore Dependabot) ont été livrés avec + cette décision. +* Si le floor est un jour relevé : + 1. Monter `` dans `Directory.Build.props`. + 2. Mettre à jour le SDK du floor dans `analyzers.yml` (`dotnet-version` et le + `tools/floor-check/global.json` imbriqué) vers le SDK dont le Roslyn + correspond au nouveau floor. + 3. Mettre à jour la note d'exigence de compilateur du README / + `doc/README.fr.md`. + 4. Remplacer cet ADR (nouveau floor, nouveau SDK/IDE minimum). + + L'épinglage, le test unitaire et l'ignore Dependabot ne nécessitent aucun + changement — ils suivent tous `$(RoslynFloorVersion)` ou les identifiants de + package. + +## Références + +* Façonné par #69 (verrouillage initial) et #74 / #75 / #77 (durcissement du + floor-check). +* [ADR-0002](0002-floor-the-tooling-runtime.fr.md) — le floor du runtime + d'outillage, le pendant à l'exécution de cette décision à la compilation. +* [référence du workflow `analyzers`](../workflows/analyzers.fr.md) — le job de + floor-check, structurellement. diff --git a/maintainers/adr/0001-lock-the-analyzer-roslyn-floor.md b/maintainers/adr/0001-lock-the-analyzer-roslyn-floor.md index 283cc7c5..c32dade1 100644 --- a/maintainers/adr/0001-lock-the-analyzer-roslyn-floor.md +++ b/maintainers/adr/0001-lock-the-analyzer-roslyn-floor.md @@ -1,5 +1,7 @@ # ADR-0001 | Lock the analyzer's Roslyn floor +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0001-lock-the-analyzer-roslyn-floor.fr.md) + **Status:** Accepted **Date:** 2026-07-10 **Decision Makers:** Reefact diff --git a/maintainers/adr/0002-floor-the-tooling-runtime.fr.md b/maintainers/adr/0002-floor-the-tooling-runtime.fr.md new file mode 100644 index 00000000..0409c951 --- /dev/null +++ b/maintainers/adr/0002-floor-the-tooling-runtime.fr.md @@ -0,0 +1,221 @@ +# ADR-0002 | Fixer le floor du runtime de l'outillage à la plus ancienne LTS supportée + +🌍 🇬🇧 [English](0002-floor-the-tooling-runtime.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Accepté +**Date :** 2026-07-13 +**Décideurs :** Reefact + +## Contexte + +FirstClassErrors livre deux types d'artefacts très différents : + +* la **librairie** (`FirstClassErrors`, `FirstClassErrors.Testing`) cible + **`netstandard2.0`**. Une librairie netstandard est consommée par + *n'importe quel* runtime qui implémente le standard — .NET Framework 4.6.1+, + .NET Core 2.0+, .NET 5–10+, Mono/Unity — de sorte que la question + « fonctionne presque partout » est déjà résolue, une seule fois, par le TFM, + et ne nécessite rien ici. +* l'**outillage** (`FirstClassErrors.Cli` — l'outil .NET `fce` — plus + `FirstClassErrors.GenDoc` et `FirstClassErrors.GenDoc.Worker`, qu'il charge + in-process et lance comme processus enfant) est une **application exécutable + dépendante du framework**. Son TFM est un **minimum strict** : une application + dépendante du framework ne peut jamais s'exécuter sur un runtime **plus + ancien** que son TFM, et le **roll-forward ne va jamais que vers le haut, + jamais vers le bas**. + +Le TFM de l'outillage décide donc *quels consommateurs peuvent exécuter `fce` +tout court*. Il était à `net10.0`, ce qui signifiait qu'un atelier dont le +runtime installé le plus récent est .NET 8 pouvait référencer la librairie +mais **ne pouvait pas exécuter le générateur de documentation** — alors même que +la librairie qu'il documente est `netstandard2.0`. + +Il existe une seconde contrainte, plus subtile. Le worker charge l'assembly +**cible** via `Assembly.LoadFrom` (voir +`FirstClassErrors.GenDoc.Worker/Program.cs`). Cette cible peut être compilée +pour n'importe quel runtime choisi par le consommateur, de sorte que le +**processus** worker doit s'exécuter sur un runtime `>=` à celui de la cible. +C'est un problème de *roll-forward*, pas un problème de *nombre de TFM*, et les +deux sont faciles à confondre. + +Les politiques de roll-forward se comportent comme suit : la politique `Minor` +par défaut ne franchit jamais une version majeure ; `Major` monte à la version +majeure suivante uniquement lorsque la majeure demandée est *absente* ; +`LatestMajor` se lie toujours à la **plus haute** majeure **installée**. + +.NET 8 est le plus ancien .NET encore supporté par Microsoft (sa date d'EOL est +le 2026-11-10), et c'est le floor que l'analyzer énonce déjà : +[ADR-0001](0001-lock-the-analyzer-roslyn-floor.fr.md) épingle Roslyn 4.8, le +compilateur du SDK .NET 8.0.100. + +La CI compile sur le dernier SDK .NET publié (actuellement .NET 10), et les +runners GitHub embarquent plusieurs runtimes côte à côte. + +## Décision + +L'outillage (`FirstClassErrors.Cli`, `FirstClassErrors.GenDoc`, +`FirstClassErrors.GenDoc.Worker`) cible uniquement **`net8.0`** — le plus ancien +.NET encore supporté par Microsoft — et couvre tous les runtimes plus récents +avec le roll-forward, et non une matrice de cibles. + +## Justification + +Le floor réduit l'histoire du support à une seule phrase : *FirstClassErrors +supporte .NET 8 et au-delà pour son outillage et son analyzer ; la librairie +elle-même est `netstandard2.0` et descend jusqu'à .NET Framework 4.6.1.* Le +floor de l'outillage et le floor de l'analyzer (ADR-0001) énoncent le **même** +minimum, de sorte que le produit énonce **un seul** chiffre de support. + +Le roll-forward couvre tous les runtimes plus récents, ajusté par processus : + +| Projet | `RollForward` | Pourquoi | +|---|---|---| +| `FirstClassErrors.Cli` (`fce`) | `Major` | Le front-end a seulement besoin de *s'exécuter*. `Major` monte le build net8 à la majeure suivante lorsque .NET 8 est absent, de sorte qu'une machine qui n'a que .NET 10 l'exécute (monte 8→10). Sans lui, la politique `Minor` par défaut ne franchit jamais une majeure et `fce` échouerait à démarrer sur la machine courante « .NET plus récent, pas de .NET 8 ». | +| `FirstClassErrors.GenDoc.Worker` | `LatestMajor` | Le worker doit **surclasser la cible qu'il charge**. `Major` ne monte que lorsque la majeure demandée est *absente*, de sorte que sur une machine qui embarque **à la fois** .NET 8 et .NET 10, un worker net8 se lierait à 8 et échouerait à charger une cible net10. `LatestMajor` se lie toujours à la **plus haute** majeure **installée**, de sorte que le worker peut documenter une cible compilée pour n'importe quel runtime présent. | +| `FirstClassErrors.GenDoc` | — | Chargé in-process par `fce` ; le runtime est choisi par le runtimeconfig de la CLI, de sorte qu'une librairie ne fixe aucune politique. | + +`latest` reste sur les trois projets afin que le floor +net8 ne borne que la **surface BCL et le runtime cible**, et non le C# que la +source peut utiliser (les projets `netstandard2.0` font déjà exactement cela). + +Ce design évite aussi un cycle répétitif à chaque version : une nouvelle version +.NET (net11, net12, …) ne requiert **aucun rebuild, aucun changement de code, +aucune re-publication** — le roll-forward exécute dessus les binaires `net8.0` +existants — et un runtime *au-dessus* du floor qui atteint son EOL ne requiert +rien, parce que nous ne le ciblons pas. Seul le floor LTS lui-même atteignant +son EOL appelle un bump, une ligne par projet, selon une cadence à peu près +biennale (voir Actions de suivi). + +La décision est sûre à maintenir parce que le floor peut être imposé sur les +deux axes sur lesquels il peut régresser, et chaque axe dispose d'un garde-fou : + +* **La surface d'API dérive au moment du build.** Parce que les projets + *ciblent* `net8.0`, chaque build de CI (sur le SDK .NET 10) les compile contre + le reference pack net8, de sorte qu'une API propre à `net10` ne peut pas se + glisser silencieusement — elle casse le build ordinaire, sans qu'un job dédié + soit nécessaire. C'est pourquoi le floor de l'outillage est moins coûteux à + garder que le floor Roslyn de l'analyzer, qui est invisible sur une CI moderne + et nécessite `tools/floor-check` (ADR-0001). +* **L'exécution runtime régresse au moment de l'exécution.** Le job `floor` dans + `ci.yml` exécute l'outillage net8 livré sur le runtime .NET 8 lui-même, + prouvant que la CLI et le worker démarrent et documentent effectivement une + vraie cible net8 à cet endroit — la garantie que le build ne peut pas donner. + La seule surface qu'il ne peut pas couvrir, le roll-forward vers une majeure + **pas encore publiée**, est surveillée à l'avance par le `canary.yml` + hebdomadaire, qui exécute le même outillage sur la prochaine preview .NET et + avertit le mainteneur avant que cette majeure ne sorte. + +Les mécaniques des deux jobs — les overrides de roll-forward qui épinglent +l'exécution au runtime voulu, et pourquoi `Usage` est multi-ciblé pour leur +fournir une cible — sont documentées dans la +[référence du workflow `ci`](../workflows/ci.fr.md) ; les réglages `RollForward` +par projet vivent dans les trois csprojs de l'outillage. + +## Alternatives envisagées + +### Garder l'outillage sur `net10.0` (statu quo) + +Envisagé parce que c'était l'état existant : cibler le runtime le plus récent +est la voie de moindre résistance et ne nécessite aucun ajustement de +roll-forward. + +Rejeté parce que le TFM est un minimum strict pour une application dépendante du +framework : un atelier dont le runtime installé le plus récent est .NET 8 +pourrait référencer la librairie `netstandard2.0` mais ne pourrait pas +exécuter le générateur de documentation qui la documente. + +### Multi-cibler l'outillage (`net8.0;net10.0`) + +Envisagé comme la façon conventionnelle de servir plusieurs runtimes à la fois. + +Rejeté parce que : + +* le roll-forward permet déjà à un seul build `net8.0` de s'exécuter sur + 8 / 9 / 10 / 11+, de sorte qu'un second TFM achète une portée que nous avons + déjà ; +* un générateur de documentation n'a aucun besoin d'API BCL propres à `net10` ; +* une matrice met l'outillage sur une cadence « ajouter en haut, retirer en + bas » à chaque version, et **réintroduit le piège du worker** : le build bas + de la matrice est précisément celui qui ne peut pas charger une cible au TFM + plus élevé. + +Un seul build floor + les deux réglages de roll-forward est strictement plus +simple et a la même portée. + +## Conséquences + +### Positives + +* Tout consommateur sur **.NET 8 ou plus récent** peut exécuter `fce`, pas + seulement ceux sur le runtime le plus récent. +* **Un seul** artefact d'outillage livré ; aucune matrice de TFM par version à + maintenir. +* Le floor de l'outillage et le floor de l'analyzer énoncent le **même** minimum + (.NET 8), de sorte que l'histoire du support tient en une seule phrase. +* Vérifié de bout en bout : un `fce` `net8.0` documente un assembly cible + **`net10`** sur une machine qui n'a **que** le runtime .NET 10 — `fce` monte + 8→10 (`Major`) et le worker se lie à la plus haute majeure (`LatestMajor`) + pour charger la cible net10. +* Gardé en CI sur toute la plage : `build-test` exécute la suite sur le dernier + .NET publié (10) ; le job `floor` exécute l'outillage livré sur le runtime + .NET 8 ; et `canary.yml` l'exécute sur la prochaine preview .NET (voir + Justification, et la [référence du workflow `ci`](../workflows/ci.fr.md)). +* Aucun remaniement de code dû au mouvement des versions .NET ; tout au plus un + bump de TFM d'une ligne environ une fois tous les deux ans. + +### Négatives + +* `fce` ne peut pas s'exécuter sur une machine dont le runtime le plus récent + est antérieur à .NET 8 (par exemple un .NET 6/7 en EOL, ou uniquement + .NET Framework). Accepté : ces consommateurs **utilisent** toujours la + librairie `netstandard2.0` dans leur application ; exécuter un *outil* de + dev/CI sur un runtime actuellement supporté est un prérequis raisonnable (un + SDK .NET moderne est déjà présent partout où l'on compile du .NET moderne). + +### Risques + +* `LatestMajor` sur le worker va, sur une machine où une **preview** de la + prochaine majeure est installée, se lier à cette preview. Ce n'est un risque + que pour les machines qui optent pour les previews, et `canary.yml` est + précisément l'alerte précoce que cette liaison fonctionne toujours avant que + cette majeure ne sorte. +* Une régression de roll-forward contre une majeure pas encore publiée est + attrapée par le canary hebdomadaire, pas par une barrière de pull request — + par conception, puisqu'une preview peut être non publiée ou instable. + +## Actions de suivi + +* **Quand le floor LTS atteint son EOL** (.NET 8 → 2026-11-10 ; hygiène plutôt + que fonction — le roll-forward maintient le build net8 fonctionnel sur les + runtimes plus récents après l'EOL — selon une cadence à peu près biennale) : + 1. Changer `` de `net8.0` vers le nouveau floor dans les trois + csprojs de l'outillage : `FirstClassErrors.Cli`, `FirstClassErrors.GenDoc`, + `FirstClassErrors.GenDoc.Worker`. Laisser les réglages `RollForward` tels + quels. + 2. Monter le nouveau floor dans le `` de l'exemple `Usage` + (afin que le job floor de la CI ait toujours une cible sur le nouveau floor) + et dans le job `floor` de `ci.yml` (`dotnet-version` `8.0.x` → le runtime du + nouveau floor). + 3. Mettre à jour la note de runtime dans `FirstClassErrors.Cli/README.nuget.md`. + 4. Remplacer cet ADR (nouveau floor, nouveau runtime minimum). + 5. Optionnellement, retirer les overrides `latest` + si le C# par défaut du nouveau floor est déjà la version que vous voulez. + + Garder ceci aligné avec le floor de l'analyzer (ADR-0001) maintient vrai + l'énoncé de support unique « .NET N et au-delà » du produit. +* **Quand la majeure preview du canary atteint la GA :** `canary.yml` épingle la + majeure preview qu'il cible (`dotnet-version: 11.0.x`, qualité `preview`) ; + montez-la à la suivante (`12.0.x`, …) pour que le canary continue de regarder + une version en avant. Rien ne casse si vous oubliez : `build-test` récupère la + majeure nouvellement publiée comme « latest », et le canary cesse simplement de + trouver une preview plus récente que le SDK de build et termine ses exécutions + en neutre jusqu'au bump. + +## Références + +* [ADR-0001](0001-lock-the-analyzer-roslyn-floor.fr.md) — le floor Roslyn de + l'analyzer, le pendant build-time de cette décision run-time. +* [référence du workflow `ci`](../workflows/ci.fr.md) — le job `floor`, + structurellement. +* `FirstClassErrors.GenDoc.Worker/Program.cs` — l'appel `Assembly.LoadFrom` + derrière la contrainte de roll-forward du worker. diff --git a/maintainers/adr/0002-floor-the-tooling-runtime.md b/maintainers/adr/0002-floor-the-tooling-runtime.md index 22f1a260..24ac82df 100644 --- a/maintainers/adr/0002-floor-the-tooling-runtime.md +++ b/maintainers/adr/0002-floor-the-tooling-runtime.md @@ -1,5 +1,7 @@ # ADR-0002 | Floor the tooling runtime at the oldest supported LTS +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0002-floor-the-tooling-runtime.fr.md) + **Status:** Accepted **Date:** 2026-07-13 **Decision Makers:** Reefact diff --git a/maintainers/adr/0003-unify-outcome-value-mapping-under-then.fr.md b/maintainers/adr/0003-unify-outcome-value-mapping-under-then.fr.md new file mode 100644 index 00000000..551c38cc --- /dev/null +++ b/maintainers/adr/0003-unify-outcome-value-mapping-under-then.fr.md @@ -0,0 +1,68 @@ +# ADR-0003 | Unifier la transformation de valeur d'Outcome sous Then + +🌍 🇬🇧 [English](0003-unify-outcome-value-mapping-under-then.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Accepté +**Date :** 2026-07-15 +**Décideurs :** Reefact + +## Contexte + +* `Outcome` et `Outcome` sont le type de résultat succès-ou-échec de la librairie ; ils se composent au travers d'une petite surface fluide. +* Deux opérations de composition existaient sous des noms distincts : `Then` enchaînait avec une fonction pouvant elle-même échouer (elle retourne un `Outcome`), et `To` transformait une valeur en cas de succès avec une fonction ne pouvant pas échouer (elle retourne une valeur simple). Ce sont les opérations *bind* et *map* de la programmation fonctionnelle. +* L'objectif affiché de la librairie est une API nommée pour l'**intention**, non pour la mécanique de la programmation fonctionnelle ; la FAQ défend ce choix précis lorsqu'elle oppose `Outcome` à un `Result` générique. +* Le fait qu'une étape de composition puisse échouer est déjà exprimé par le type de retour de la fonction que l'appelant fournit : une fonction retournant un `Outcome` peut échouer ; une fonction retournant une valeur ne le peut pas. +* C# interdit la surcharge sur le seul type de retour, et sa résolution de surcharge sélectionne le type de paramètre le plus spécifique lorsque plusieurs candidats s'appliquent. La distinction map/bind est donc imposée par le système de types à chaque site d'appel, indépendamment du nom de la méthode. +* La librairie est en pré-version : non publiée sur NuGet, sans consommateurs externes, de sorte qu'un renommage cassant n'entraîne aujourd'hui aucun coût de migration en aval. + +## Décision + +L'opération de transformation de valeur sur `Outcome` est exposée sous forme de surcharge de `Then` plutôt que comme une méthode `To` distincte. + +## Justification + +* Conserver deux noms fait remonter la mécanique map/bind de la programmation fonctionnelle dans l'API, ce qui contredit l'objectif de nommer les opérations pour l'intention. Un unique `Then` se lit comme « ensuite, fais ceci » — l'intention réelle de l'appelant — quel que soit le type de fonction qui suit. +* L'information que les noms `To`/`Then` encodaient — *cette étape peut-elle échouer ?* — est redondante avec le type de retour de la fonction fournie, que le lecteur voit déjà au site d'appel. Abandonner le second verbe ne perd aucune information que le code ne porte pas déjà. +* Fusionner les deux sous un seul nom est sûr : C# sélectionne la surcharge de transformation de valeur pour une fonction retournant une valeur et la surcharge de liaison pour une fonction retournant un `Outcome`, de sorte qu'un résultat s'aplatit toujours et ne s'imbrique jamais en `Outcome>`. Cette résolution est déterministe et a été vérifiée stable à travers les versions du langage C# avec lesquelles les consommateurs compilent (voir la pull request et ses tests de verrouillage de résolution). +* Le statut de pré-version signifie que le coût du changement — un renommage cassant — est payé maintenant, alors qu'il n'y a aucun consommateur, plutôt que plus tard. + +## Alternatives envisagées + +### Conserver `To` et `Then` comme opérations distinctes (statu quo) + +Envisagée parce qu'elle reflète la nomenclature établie `map`/`bind` familière aux praticiens de la programmation fonctionnelle et garde chaque opération sans ambiguïté par elle-même. + +Rejetée parce qu'elle expose précisément la mécanique que la librairie masque délibérément, et la distinction échec/sans-échec qu'elle encode est déjà portée par le type de retour de la fonction fournie — le second nom ajoute du vocabulaire sans ajouter d'information. + +### Conserver `To` comme alias déprécié de `Then` + +Envisagée comme une voie de migration plus douce et non cassante. + +Rejetée parce qu'il n'y a aucun consommateur à migrer, et qu'un alias préserverait la dualité map/bind que la décision vise à supprimer. + +## Conséquences + +### Positives + +* La surface de composition est un unique verbe nommé pour l'intention, cohérent avec le reste de l'API. +* Les sites d'appel se lisent comme une intention unique, qu'une étape puisse échouer ou non. +* La distinction map/bind reste imposée là où c'est important — par le système de types à chaque appel — sans risque d'outcomes imbriqués. + +### Négatives + +* `To` est supprimée : un changement cassant de l'API publique. +* L'opération map ne peut plus être nommée distinctement à un site d'appel ; un lecteur déduit « ne peut pas échouer » du type de retour de la fonction fournie plutôt que du verbe. +* Obtenir délibérément un `Outcome>` imbriqué via la surcharge de transformation n'est plus possible (jamais un usage prévu). + +### Risques + +* Un changement futur dans la résolution de surcharge de C# pourrait, en principe, modifier quelle surcharge est sélectionnée. Atténué par des tests de verrouillage qui vérifient la résolution et échouent — à la compilation et à l'exécution — si jamais elle régresse. + +## Actions de suivi + +* Mettre à jour la documentation destinée aux utilisateurs (guide Outcome, Concepts fondamentaux, Schémas d'utilisation) pour utiliser `Then` pour la transformation de valeur. + +## Références + +* Pull request : [#127](https://github.com/Reefact/first-class-errors/pull/127) +* FAQ — « Pourquoi ne pas utiliser un `Result` générique ? » (la justification intention-plutôt-que-mécanique). diff --git a/maintainers/adr/0003-unify-outcome-value-mapping-under-then.md b/maintainers/adr/0003-unify-outcome-value-mapping-under-then.md index 18eb8a39..15753113 100644 --- a/maintainers/adr/0003-unify-outcome-value-mapping-under-then.md +++ b/maintainers/adr/0003-unify-outcome-value-mapping-under-then.md @@ -1,5 +1,7 @@ # ADR-0003 | Unify Outcome value mapping under Then +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0003-unify-outcome-value-mapping-under-then.fr.md) + **Status:** Accepted **Date:** 2026-07-15 **Decision Makers:** Reefact diff --git a/maintainers/adr/0004-check-every-pull-request-against-the-adr-base.fr.md b/maintainers/adr/0004-check-every-pull-request-against-the-adr-base.fr.md new file mode 100644 index 00000000..6e284568 --- /dev/null +++ b/maintainers/adr/0004-check-every-pull-request-against-the-adr-base.fr.md @@ -0,0 +1,149 @@ +# ADR-0004 | Contrôler chaque pull request au regard de la base d'ADR + +🌍 🇬🇧 [English](0004-check-every-pull-request-against-the-adr-base.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Accepté +**Date :** 2026-07-15 +**Décideurs :** Reefact + +## Contexte + +Le dépôt consigne les décisions importantes sous forme d'ADR dans `maintainers/adr/`, +mais rien ne confronte un changement à cette base au moment où le changement est +réalisé. C'est dans une pull request que les nouvelles décisions entrent dans la base +de code : elle peut embarquer une décision qui n'est jamais consignée, remplacer une +décision que porte un ADR existant sans le dire, ou contredire un ADR accepté sans que +personne ne le remarque. + +Les invariants durs et mécaniques sont déjà gardés ailleurs : la règle `class` des +value objects, le floor Roslyn de l'analyzer (ADR-0001) et le floor du runtime de +l'outillage (ADR-0002) sont garantis par des tests unitaires et des jobs CI qui +échouent de façon déterministe. Ce qu'aucun test n'exprime, c'est la question plus +subtile que le lecteur d'un diff doit malgré tout se poser : y a-t-il ici une décision +d'architecture, et s'accorde-t-elle avec ce qui a déjà été décidé ? + +Le travail sur le dépôt est en grande partie produit au cours de sessions de codage +Claude Code, qui chargent `CLAUDE.md` (et, lorsqu'on le leur demande, `AGENTS.md`) +comme instructions et détiennent, au sein de la session, l'intégralité du diff, la base +d'ADR et le raisonnement qui a produit le changement. Il est aussi ouvert à des +contributeurs qui n'utilisent pas Claude Code. Un workflow GitHub Actions peut exécuter +un modèle sur déclenchement manuel, comme le fait déjà le workflow `changelog`. Un tel +appel de modèle a un coût par requête, et le modèle est référencé par un alias flottant, +de sorte que son verdict n'est pas reproductible. + +Le mainteneur (`Reefact`) est la seule autorité qui merge une pull request et qui +accepte un ADR ; aucun agent ne merge, et un ADR est immuable une fois accepté (une +décision est réexaminée au moyen d'un ADR de remplacement). + +## Décision + +Chaque pull request est contrôlée au regard de la base d'ADR sous la forme d'une +recommandation consultative et non bloquante — automatiquement au sein d'une session de +codage Claude Code et sur déclenchement manuel pour les contributeurs sans Claude Code +— un agent rédigeant tout ADR comme `Proposé` et le mainteneur étant seul à l'accepter, +le remplacer ou le déprécier. + +## Justification + +Le contrôle a sa place au niveau de la pull request parce que c'est là que les décisions +entrent dans la base de code ; se demander « faut-il consigner ceci ? » pendant que le +contexte est encore frais, c'est ce dont la base d'ADR a besoin et qu'elle n'obtient pas +encore. + +Il est consultatif, jamais bloquant, parce que les décisions qu'il fait émerger relèvent +du jugement, et non de conditions qu'une machine tranche : les invariants durs qui +*peuvent* être tranchés mécaniquement sont déjà verrouillés par les tests et la CI, et +conditionner un merge à l'opinion d'un modèle contredirait la règle selon laquelle le +mainteneur seul merge. + +La voie automatique s'exécute au sein d'une session Claude Code parce que l'agent qui +s'y trouve est le mieux placé pour effectuer le contrôle — il détient déjà le diff, la +base d'ADR et la raison pour laquelle le changement a été fait, de sorte que le contrôle +ajoute peu au travail que la session accomplit déjà, avec plus de contexte que n'en +pourrait porter aucun appel séparé. + +Le workflow manuel existe pour qu'un contributeur sans Claude Code reste couvert ; le +garder manuel — comme le workflow jumeau `changelog` — apporte cette couverture sans +transformer un verdict de LLM non reproductible en une barrière autonome sur chaque pull +request. + +Un agent rédige et propose ; il ne fixe jamais le statut d'un ADR. Cela maintient le +mainteneur comme autorité de décision, en cohérence avec « aucun agent ne merge » et +avec l'immuabilité des ADR. + +## Alternatives envisagées + +### Un contrôle automatique par modèle sur chaque pull request dans la CI + +Envisagé parce qu'il couvrirait chaque pull request — rédigée par un humain ou par un +agent, avec Claude Code ou non — de façon déterministe et sans que personne ait à penser +à le lancer. + +Rejeté parce qu'un appel de modèle autonome sur chaque pull request entraîne un coût par +requête, introduit un contrôle non déterministe sur une surface quasi obligatoire, et +duplique — avec moins de contexte — ce qu'une session Claude Code accomplit déjà ; la +couverture qu'il apporterait est assurée à la place par le contrôle en session, complété +par le déclenchement manuel. + +### Encoder l'invariant de chaque ADR dans un champ vérifiable par machine + +Envisagé parce qu'un invariant net et déclaré rendrait la détection de conflits plus +fiable qu'un raisonnement sur de la prose. + +Rejeté parce que les invariants durs qui se prêtent à une vérification mécanique sont +déjà garantis par les tests et la CI, qui le font mieux et de façon déterministe ; et +ajouter un champ de spécification à un ADR contredit le principe selon lequel un ADR est +un enregistrement de décision, non une spécification, ce qui éroderait la lisibilité par +un humain qui fait tout l'intérêt du format. + +### S'en remettre à la mémoire, sans aucun contrôle + +Envisagé parce que c'est le statu quo sans effort. + +Rejeté parce que c'est précisément la faille que la base d'ADR existe pour combler : des +décisions embarquées dans une pull request restent alors non consignées, en remplacent +silencieusement une précédente, ou contredisent un ADR accepté. + +## Conséquences + +### Positives + +* La question « y a-t-il une décision à consigner ? » est posée sur chaque pull request, + pendant que le contexte qui a produit le changement est encore frais. +* La rédaction est peu coûteuse : l'agent en session dispose déjà de tout ce dont il a + besoin. +* Rien ne bloque un merge ; le mainteneur conserve l'autorité exclusive sur le statut des + ADR. +* Les contributeurs sans Claude Code disposent d'un recours de première classe. + +### Négatives + +* Le contrôle en session est fait au mieux : c'est une consigne que l'agent suit, non une + barrière dure. +* La couverture d'une pull request ouverte sans Claude Code dépend du fait que quelqu'un + déclenche le workflow. +* Le verdict consultatif est non déterministe — il utilise un alias de modèle flottant — + et n'est donc pas reproductible. + +### Risques + +* Un agent saute le contrôle en session. Atténuation : l'essentiel figure dans `CLAUDE.md` + (chargé de façon fiable), un élément de checklist est présent sur chaque pull request, + et le workflow manuel constitue une voie indépendante. +* Les fausses alertes habituent l'équipe à ignorer le contrôle. Atténuation : le prompt + est fortement orienté vers le silence sur les changements de routine. + +## Actions de suivi + +* Aucune qui soit bloquante. Si la consigne en session s'avère peu fiable en pratique, + ajouter un hook Claude Code au périmètre étroit et non bloquant qui exécute le même + contrôle — à ne pas construire de manière préventive. + +## Références + +* `AGENTS.md` — « Architecture decisions » (la procédure de l'agent). +* `CLAUDE.md` — l'essentiel par session, inséré en ligne. +* [référence du workflow `adr-check`](../workflows/adr-check.fr.md). +* [ADR-0001](0001-lock-the-analyzer-roslyn-floor.fr.md), [ADR-0002](0002-floor-the-tooling-runtime.fr.md) + — des exemples des invariants durs que ce contrôle laisse délibérément aux tests et à + la CI. diff --git a/maintainers/adr/0004-check-every-pull-request-against-the-adr-base.md b/maintainers/adr/0004-check-every-pull-request-against-the-adr-base.md index 15905e47..f9544494 100644 --- a/maintainers/adr/0004-check-every-pull-request-against-the-adr-base.md +++ b/maintainers/adr/0004-check-every-pull-request-against-the-adr-base.md @@ -1,5 +1,7 @@ # ADR-0004 | Check every pull request against the ADR base +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0004-check-every-pull-request-against-the-adr-base.fr.md) + **Status:** Accepted **Date:** 2026-07-15 **Decision Makers:** Reefact diff --git a/maintainers/adr/0005-reserve-the-plain-factory-name-for-the-outcome-returning-variant.fr.md b/maintainers/adr/0005-reserve-the-plain-factory-name-for-the-outcome-returning-variant.fr.md new file mode 100644 index 00000000..4e6e2a2a --- /dev/null +++ b/maintainers/adr/0005-reserve-the-plain-factory-name-for-the-outcome-returning-variant.fr.md @@ -0,0 +1,146 @@ +# ADR-0005 | Réserver le nom de fabrique simple à la variante retournant un Outcome + +🌍 🇬🇧 [English](0005-reserve-the-plain-factory-name-for-the-outcome-returning-variant.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Accepté +**Date :** 2026-07-16 +**Décideurs :** Reefact + +## Contexte + +FirstClassErrors existe pour rendre explicite le chemin d'échec d'une opération : +une erreur est une valeur qu'un appelant retourne et inspecte +(`Outcome`/`Outcome`), et non une exception qui circule de manière invisible. +La levée d'exception reste prise en charge comme commodité optionnelle — le README +la présente comme un choix (« vous choisissez comment elle circule ») — mais +retourner un `Outcome` est le chemin par défaut et de première classe de la +librairie. + +Les opérations de type fabrique se présentent donc couramment en deux variantes +sur les mêmes entrées : l'une retourne `Outcome`, l'autre lève une exception en +cas d'échec (par exemple `Temperature.FromKelvin` / `TryFromKelvin`, celle qui +lève étant implémentée comme la variante Outcome suivie de `GetResultOrThrow()`). +C# ne peut pas surcharger sur le seul type de retour, si bien que les deux +variantes doivent porter des noms distincts — l'une d'elles doit être marquée. + +La BCL de .NET possède déjà une convention omniprésente pour l'une de ces formes. +`TryXxx` signifie `bool TryXxx(..., out T result)` : elle retourne un `bool`, +fournit sa valeur via un paramètre `out`, et se lit en ligne dans un `if` +(`if (int.TryParse(s, out var n))`). Les développeurs, les IDE et les analyzers +présument tous cette forme exacte à la vue d'un préfixe `Try`. Deux faits en +découlent : + +* **Forme.** Un `TryXxx` dans cette librairie retourne `Outcome` — il + emprunte le nom sans la forme `bool`+`out`, si bien qu'il ne peut pas être + utilisé en ligne dans un `if` et rompt discrètement l'attente que le nom + établit. +* **Disposition.** Dans la BCL, le nom *simple* est celui qui lève (`Parse`) et le + nom *marqué* est celui qui est sûr (`TryParse`). Le code actuel suit cette + disposition (`FromKelvin` lève, `TryFromKelvin` est sûr) — plaçant le nom + simple, non marqué, sur le chemin qui lève, c'est-à-dire le chemin que cette + librairie traite comme l'exception plutôt que comme le défaut. + +`Outcome` expose déjà `GetResultOrThrow()`, si bien que « lever en cas +d'échec » dispose d'un vocabulaire établi dans la librairie. + +## Décision + +La variante retournant un Outcome porte le nom de fabrique simple (`FromKelvin`, +retournant `Outcome`) ; la variante qui lève est marquée d'un suffixe `OrThrow` +(`FromKelvinOrThrow`). + +## Justification + +* **Un seul principe, appliqué au défaut de cette librairie.** La convention qui + mérite d'être conservée de la BCL n'est pas le mot `Try` mais la règle + sous-jacente : *la variante qui s'écarte du défaut dominant porte le marqueur.* + Le défaut de la BCL est de lever, donc sa variante sûre est marquée (`Try`) ; le + défaut de cette librairie est de retourner un `Outcome`, donc sa variante qui + lève est l'écart et prend le marqueur (`OrThrow`). Même règle, référence opposée + — ce qui est précisément la raison pour laquelle réutiliser `Try` ici serait + incohérent, et non un simple conflit de noms. +* **L'appel risqué est celui qui est visible.** Toute la thèse de la librairie + est qu'une exception est le mode d'échec qui se cache au site d'appel. `OrThrow` + la remet dans le nom : `FromKelvinOrThrow(k)` annonce qu'elle peut lever ; + `FromKelvin(k)` annonce qu'elle retourne une valeur à inspecter. Le nom simple et + facile d'accès est celui qui est sûr. +* **Aucune attente empruntée.** Retirer `Try` supprime la fausse promesse d'une + forme `bool`+`out` en ligne que la méthode n'a jamais eue. +* **Réutilise le vocabulaire existant.** `OrThrow` fait écho à `GetResultOrThrow()`, + et la fabrique qui lève est littéralement `Xxx(...).GetResultOrThrow()` — le nom + reflète l'implémentation au lieu d'introduire un nouveau terme. + +## Alternatives envisagées + +Le véritable choix porte sur *laquelle des deux variantes porte le marqueur*, et +non simplement sur le mot à utiliser. + +### Marquer la variante sûre, laisser celle qui lève simple (la disposition du statu quo) + +C'est la disposition de la BCL : le nom simple lève, le nom marqué retourne un +`Outcome`. Quel que soit le marqueur choisi pour le côté sûr — `Try` (statu quo), +`XxxOrError`, `AttemptXxx`, `XxxSafe` — la disposition partage un même défaut : +elle laisse l'appel qui *lève* non marqué, de sorte que le seul appel qui peut +échouer de manière invisible porte le nom le plus court et le plus semblable à un +défaut. Pour une librairie dont la raison d'être est de rendre explicite le +chemin d'échec, c'est à l'envers. `Try` entre de plus en conflit avec la forme de +la BCL ; les autres évitent le conflit mais pas le défaut sous-jacent. + +### Marquer la variante qui lève avec un mot différent (`XxxThrows`, `XxxUnsafe`) + +Même disposition que la décision, suffixe différent. `OrThrow` est préféré +uniquement parce qu'il existe déjà dans la surface de l'API (`GetResultOrThrow`) ; +introduire un synonyme fragmenterait le vocabulaire. + +## Conséquences + +### Positives + +* Le mode d'échec de chaque fabrique est lisible à partir de son nom au site + d'appel : un `FromKelvin` simple retourne un `Outcome` à inspecter, tandis que + `FromKelvinOrThrow` annonce d'emblée qu'il peut lever. +* Le nom simple, celui vers lequel on se tourne le plus, appartient désormais au + chemin de première classe de la librairie, si bien que la variante vers + laquelle un appelant se dirige par défaut est celle qui est inspectable. +* Aucune méthode n'annonce la forme `Try`/`out` de la BCL qu'elle n'implémente pas. + +### Négatives + +* Les fabriques existantes dans `FirstClassErrors.Usage` doivent être renommées : + les `TryFromKelvin` / `TryFromCelsius` retournant un Outcome perdent le préfixe + `Try`, et les `FromKelvin` / `FromCelsius` qui lèvent prennent le suffixe + `OrThrow`. Leurs sites d'appel (par exemple l'initialiseur `AbsoluteZero`) et + leurs résumés de documentation XML — la formulation « Attempts to create… » — + doivent être mis à jour en conséquence. +* Les exemples de documentation qui utilisent le préfixe `Try` (`GettingStarted`, + `UsagePatterns`, en EN comme en FR) doivent être reformulés avec les nouveaux + noms. +* Il s'agit d'un renommage cassant de membres publics. La librairie est en + pré-version et non publiée sur NuGet, sans consommateurs externes, si bien + qu'elle n'entraîne aujourd'hui aucun coût de migration en aval. + +### Risques + +* Les documentations anglaise et française doivent être renommées de concert ; un + passage partiel laisserait la documentation canonique et sa traduction décrire + des API différentes. +* Tant que la convention n'est pas appliquée par l'outillage, elle ne tient que par + la revue et la documentation, si bien qu'une nouvelle méthode `TryXxx` retournant + un `Outcome` peut se réintroduire inaperçue. + +## Actions de suivi + +* Renommer `TryFromKelvin` / `TryFromCelsius` avec le nom simple, ajouter les + variantes `OrThrow` correspondantes, et mettre à jour leurs sites d'appel et + leurs résumés de documentation XML. +* Reformuler de concert les exemples de documentation EN + FR qui utilisent le + préfixe `Try` (`GettingStarted`, `UsagePatterns`). +* L'application de la convention par un analyzer est reportée à une révision + ultérieure. + +## Références + +* `README.md` — la formulation « vous choisissez comment elle circule » qui fait de + `Outcome` le chemin par défaut. +* ADR-0003 — décision antérieure connexe sur le nommage de l'API Outcome (contexte + uniquement ; pas un précédent pour ce choix). diff --git a/maintainers/adr/0005-reserve-the-plain-factory-name-for-the-outcome-returning-variant.md b/maintainers/adr/0005-reserve-the-plain-factory-name-for-the-outcome-returning-variant.md index a42bdcc9..6cfc09ff 100644 --- a/maintainers/adr/0005-reserve-the-plain-factory-name-for-the-outcome-returning-variant.md +++ b/maintainers/adr/0005-reserve-the-plain-factory-name-for-the-outcome-returning-variant.md @@ -1,5 +1,7 @@ # ADR-0005 | Reserve the plain factory name for the Outcome-returning variant +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0005-reserve-the-plain-factory-name-for-the-outcome-returning-variant.fr.md) + **Status:** Accepted **Date:** 2026-07-16 **Decision Makers:** Reefact diff --git a/maintainers/adr/0006-supply-arbitrary-test-values-from-a-seedable-source.fr.md b/maintainers/adr/0006-supply-arbitrary-test-values-from-a-seedable-source.fr.md new file mode 100644 index 00000000..415d8859 --- /dev/null +++ b/maintainers/adr/0006-supply-arbitrary-test-values-from-a-seedable-source.fr.md @@ -0,0 +1,187 @@ +# ADR-0006 | Fournir les valeurs de test arbitraires depuis une source unique à graine + +🌍 🇬🇧 [English](0006-supply-arbitrary-test-values-from-a-seedable-source.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Accepté +**Date :** 2026-07-16 +**Décideurs :** Reefact + +## Contexte + +Le package compagnon `FirstClassErrors.Testing` existe pour que les tests +portant sur les erreurs et les outcomes se lisent comme des tests portant sur +des valeurs. Il livre déjà deux points d'injection de test — une horloge +gelable et des identifiants d'instance gelables — qui partagent un même +contrat : les substitutions sont délimitées par `using`, disposables, et +locales au contexte (adossées à `AsyncLocal`), de sorte qu'elles ne fuient +jamais entre des tests s'exécutant en parallèle. + +Un test a presque toujours besoin d'entrées sur lesquelles il ne fait **pas** +d'assertion : un code d'erreur, un message de diagnostic ou un message court, +un instant d'occurrence, un identifiant d'instance. Deux faits découlent de la +manière dont ces entrées sont fournies aujourd'hui. + +* **Lisibilité.** Un littéral choisi à la main (`ErrorCode.Create("PAYMENT_DECLINED")`) + se lit comme significatif — « cette valeur compte pour le test » — même + lorsque le test ne la vérifie jamais. Le lecteur ne peut pas distinguer une + valeur ayant fait l'objet d'une assertion d'une valeur incidente. +* **Surajustement.** Une constante fixe réutilisée dans toute une suite peut + faire passer un test pour la mauvaise raison (il se trouve qu'elle correspond + à cette unique constante). Une valeur qui varie d'une exécution à l'autre fait + apparaître un tel couplage. + +Les librairies établies résolvent cela avec un dispositif de « valeur +anonyme / arbitraire » (AutoFixture, Bogus, l'utilitaire `Any` de GOOS, les +générateurs FsCheck). En adopter un ici doit satisfaire deux contraintes +strictes de ce package : + +* **Zéro dépendance d'exécution tierce.** La promesse affichée du package est + qu'il « n'ajoute rien à vos dépendances de production ». Un package de support + de test livré qui prendrait une dépendance sur AutoFixture/Bogus/FsCheck + imposerait cette dépendance, de manière transitive, à chaque consommateur. +* **Sûreté des tests parallèles sur .NET Standard 2.0.** Les exécuteurs de + tests exécutent les classes de test de manière concurrente ; un unique + `System.Random` partagé et mutable n'est pas thread-safe et produirait des + interférences entre tests et des valeurs non reproductibles. + `System.Random.Shared` n'existe pas sur la cible netstandard2.0. + +Reproduire un échec qui a utilisé une valeur arbitraire nécessite une +**graine** : sans elle, une exécution en échec ne peut pas être rejouée. + +## Décision + +`FirstClassErrors.Testing` fournit les valeurs de test arbitraires au travers +d'une source pseudo-aléatoire unique, sans dépendance et locale au contexte, +dont le déterminisme est optionnel (opt-in) au travers d'un exécuteur +`Reproducibly` qui ensemence une exécution, signale la graine en cas d'échec, +et la rejoue à la demande ; les points d'injection de l'horloge et de +l'identifiant d'instance gagnent des variantes `UseAny` sans graine qui puisent +dans cette même source. + +## Justification + +* **Préserve la promesse zéro dépendance.** Une source de première partie + construite sur `System.Random` n'ajoute aucune référence de package, si bien + que la promesse selon laquelle le package n'ajoute rien aux dépendances d'un + consommateur tient — la contrainte qui écarte AutoFixture/Bogus/FsCheck. +* **Sûr en parallèle en réutilisant l'idiome propre au package.** Stocker la + source dans un `AsyncLocal` donne à chaque contexte d'exécution son propre + générateur, ce qui est exactement le contrat « ne fuit jamais entre tests + parallèles » que les points d'injection de l'horloge et de l'identifiant + d'instance respectent déjà. La nouvelle surface a la même forme que la surface + qu'elle côtoie, plutôt que d'être un second mécanisme sans rapport. +* **Arbitraire par défaut, reproductible à la demande.** Un défaut sans graine + fait varier les valeurs d'une exécution à l'autre, ce qui est précisément ce + qui met au jour le surajustement ; envelopper un test sensible aux valeurs + dans `Reproducibly` fixe une graine, la signale lorsque le corps échoue, et la + rejoue lorsqu'on lui en fournit une — de sorte qu'une exécution en échec est + récupérable sans forcer chaque test à gérer une graine. Parce que la graine + réside sur l'exécution qui détient le flux d'exécution, les portées autonomes + `UseAny` de l'horloge et de l'identifiant d'instance n'ont besoin d'aucune + surcharge à graine propre : exécutées à l'intérieur de `Reproducibly`, elles + héritent de la graine. +* **Le nom porte l'intention.** Recourir à une valeur explicitement + *arbitraire* se lit comme « cette entrée est incidente », ce qui est la + distinction qu'un littéral choisi à la main ne peut pas faire. Les variantes + `UseAny` étendent la famille existante `UseFixed` / `UseSequential` sur les + mêmes points d'injection, de sorte que l'horloge et les identifiants gagnent + la même expression « la valeur est ici sans importance » qu'offre la fabrique + de valeurs. + +## Alternatives envisagées + +### Dépendre d'AutoFixture, Bogus ou FsCheck + +Ce sont les outils matures et bien compris pour les données de test +arbitraires. Rejeté parce que chacun est une dépendance d'exécution tierce +qu'un package de support de test livré pousserait sur chaque consommateur, +brisant la promesse zéro dépendance du package ; leur machinerie plus large de +graphes d'objets et de générateurs dépasse également ce dont a besoin un +utilitaire petit et ciblé du type « donne-moi une valeur sur laquelle je ne +fais pas d'assertion ». + +### Une source statique partagée sans localité de contexte + +L'implémentation la plus simple — un unique `Random` statique derrière la +façade. Rejeté parce qu'elle n'est pas sûre sous exécution parallèle des tests : +les tirages concurrents interfèrent, les valeurs ne sont pas reproductibles, et +le couplage entre tests sans rapport est exactement ce que les points +d'injection existants du package ont été conçus pour éviter. + +### Un générateur basé sur instance (`new …(seed)`) comme surface primaire + +La forme de style AutoFixture, où un test construit un générateur et l'appelle. +Rejeté comme surface primaire parce qu'elle fait circuler l'état du générateur à +travers chaque test et diverge de l'idiome établi du package `Use*` / portée +disposable ; elle reste disponible comme ajout futur possible pour les appelants +qui veulent un objet explicite, mais ce n'est pas la forme que le package met en +avant. + +### Ajouter des fabriques de test aux value objects eux-mêmes + +Placer « fabrique-en une arbitraire » sur `ErrorCode`, `Error`, et consorts dans +la librairie cœur. Rejeté parce que cela mêle une préoccupation de test à des +types de production et livrerait une surface réservée aux tests dans le package +principal. + +## Conséquences + +### Positives + +* Une entrée incidente est lisible comme incidente sur le site d'appel, et une + exécution en échec est reproductible une fois qu'une graine est fixée. +* Aucune nouvelle dépendance n'atteint les consommateurs, et la nouvelle surface + réutilise le contrat existant du package — sûr en parallèle, à portée + disposable — plutôt que d'en inventer un second. + +### Négatives + +* Une nouvelle surface publique sur un package livré, qui doit être maintenue et + tenue documentée en anglais et en français. +* L'utilitaire d'énumération générique peut encore renvoyer une valeur + sentinelle (telle que `Unknown`) ; les appelants qui ont besoin d'une valeur + *significative* doivent utiliser les utilitaires dédiés par énumération qui + l'excluent. +* `ErrorContextKey` est délibérément laissé en dehors de la première surface : + les clés vivent dans un registre à l'échelle du processus sans réinitialisation + publique, de sorte que forger des clés arbitraires accumulerait de l'état + global au fil d'une exécution. Les tests nécessitant une clé arbitraire + restent sans réponse tant que cela n'a pas été conçu. + +### Risques + +* Le défaut sans graine puise dans un générateur par contexte ; si deux + contextes démarrent par coïncidence à partir de la même graine, leurs valeurs + « arbitraires » coïncident. C'est inoffensif (les valeurs ne font pas l'objet + d'assertions) et c'est atténué en dérivant la graine par défaut d'un + identifiant neuf par contexte. +* Reproduire un échec exige que l'auteur ait enveloppé le test dans + `Reproducibly`, et ne tient que tant que la séquence d'appels `Any` du corps + est déterministe ; un test ordinaire non enveloppé qui échoue sur une valeur + arbitraire n'est toujours pas rejouable à partir de sa seule sortie. +* Tant qu'elle n'est pas imposée par l'outillage, l'habitude « valeur + arbitraire ⇒ utiliser la source, pas un littéral » ne tient que par la revue + et la documentation. + +## Actions de suivi + +* Documenter la surface dans le guide de test, en anglais et en français, de + manière synchronisée. +* Envisager une conception pour des valeurs `ErrorContextKey` arbitraires qui + respecte le registre à l'échelle du processus. +* Envisager un adaptateur optionnel de framework de test (par exemple un + `[ReproducibleFact]` xUnit) afin que la graine soit exposée automatiquement, + sans envelopper chaque corps dans `Reproducibly`. +* Envisager un générateur basé sur instance si les appelants demandent un objet + explicite. +* Envisager d'extraire le moteur de valeurs générique dans un utilitaire + autonome et agnostique aux erreurs si un second consommateur apparaît ; il est + maintenu séparable en interne de la surface spécifique aux erreurs à cette fin. + +## Références + +* `doc/ArbitraryTestValues.en.md` — le guide où la nouvelle surface est + documentée. +* ADR-0005 — décision de nommage antérieure dans le même esprit (un nom devrait + annoncer ce que fait un appel) ; contexte seulement, pas un précédent pour ce + choix. diff --git a/maintainers/adr/0006-supply-arbitrary-test-values-from-a-seedable-source.md b/maintainers/adr/0006-supply-arbitrary-test-values-from-a-seedable-source.md index 8db840cf..d5805685 100644 --- a/maintainers/adr/0006-supply-arbitrary-test-values-from-a-seedable-source.md +++ b/maintainers/adr/0006-supply-arbitrary-test-values-from-a-seedable-source.md @@ -1,5 +1,7 @@ # ADR-0006 | Supply arbitrary test values from a single seedable source +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0006-supply-arbitrary-test-values-from-a-seedable-source.fr.md) + **Status:** Accepted **Date:** 2026-07-16 **Decision Makers:** Reefact diff --git a/maintainers/adr/0007-name-the-binder-terminals-new-and-create.fr.md b/maintainers/adr/0007-name-the-binder-terminals-new-and-create.fr.md new file mode 100644 index 00000000..715db6dc --- /dev/null +++ b/maintainers/adr/0007-name-the-binder-terminals-new-and-create.fr.md @@ -0,0 +1,155 @@ +# ADR-0007 | Nommer les terminaux du binder New et Create + +🌍 🇬🇧 [English](0007-name-the-binder-terminals-new-and-create.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Accepté +**Date :** 2026-07-16 +**Décideurs :** Reefact + +## Contexte + +* `FirstClassErrors.RequestBinder` lie chaque propriété d'un DTO de requête en un value + object, puis un **terminal** assemble les valeurs liées dans la commande (ou requête) + et retourne `Outcome`. +* Les consommateurs assemblent cette commande sous deux formes qui diffèrent selon que + l'étape d'assemblage peut elle-même échouer : un **constructeur total** + (`new Command(...)`) ne peut pas échouer, parce que chaque champ a déjà été validé un + par un pendant le binding ; une **fabrique validante** (`Command.Create(...)` + retournant `Outcome`) peut encore échouer, parce qu'elle applique une règle + inter-champs — comme le fait que le check-out soit postérieur au check-in — qu'aucun + champ isolé ne pourrait vérifier à lui seul. +* C# interdit la surcharge sur le seul type de retour, et sa résolution de surcharge ne + traverse pas la conversion implicite `TCommand` → `Outcome`. Deux surcharges + de terminal portant le même nom — l'une prenant un assembleur retournant `TCommand`, + l'autre prenant un assembleur retournant `Outcome` — sont donc ambiguës pour + une lambda qui retourne `Outcome` : les deux sont applicables avec un + `TCommand` inféré différent, et le compilateur rejette l'appel. +* Un terminal unique ne prenant qu'un assembleur retournant `TCommand` force un + consommateur dont la fabrique retourne `Outcome` à imbriquer le résultat sous + la forme `Outcome>`, et ne laisse aucun endroit où exécuter une règle + inter-champs. +* La librairie réserve déjà le nom de fabrique simple à la variante retournant un + `Outcome` (ADR-0005) ; dans tout le code, les fabriques `Parse` / `Create` des value + objects retournent `Outcome`, si bien que « une fabrique au nom simple restitue un + `Outcome` » constitue un vocabulaire établi. +* L'assembleur reçoit un `BindingScope`, un `readonly ref struct` ; un ref struct ne peut + pas être un argument de type générique, si bien qu'un assembleur ne peut pas être un + `Func<>` et doit être un type delegate nommé. +* La librairie est en pré-version, non publiée sur NuGet et sans consommateurs + externes, si bien qu'un choix de nommage sur cette nouvelle API n'entraîne aucun coût + de migration en aval. + +## Décision + +Le request binder expose deux terminaux aux noms distincts — `New`, qui prend un +constructeur total retournant la commande, et `Create`, qui prend une fabrique validante +retournant `Outcome` dont le résultat est aplati — plutôt qu'un terminal unique +ou deux surcharges portant le même nom. + +## Justification + +* Des noms distincts donnent à chaque terminal exactement une signature, de sorte que + l'ambiguïté de surcharge que deux terminaux portant le même nom soulèveraient pour une + lambda retournant `Outcome` ne peut pas survenir : le problème est supprimé au + niveau du nom plutôt que contourné au point d'appel. +* Conserver deux terminaux — plutôt qu'un seul terminal exclusivement `Outcome` — permet + à un constructeur total de rester total : le cas courant construit la commande + directement sans l'envelopper dans un `Outcome` de succès, tandis que `Create` aplatit + le cas validant afin que l'`Outcome` d'une fabrique ne soit jamais imbriqué. + Cela répond aux deux défaillances que décrit le Contexte (l'outcome imbriqué et la règle + inter-champs sans endroit où s'exécuter). +* Nommer chaque terminal d'après l'assembleur qu'il prend rend le choix auto-sélectif : un + consommateur qui écrit un `new` se tourne vers `New`, celui qui écrit une fabrique + `.Create` se tourne vers `Create`. Le nom réutilise la distinction constructeur/fabrique + que tout développeur C# possède déjà, si bien qu'il ne nécessite aucune recherche + distincte. +* Le moyen mnémotechnique est cohérent avec l'ADR-0005 plutôt qu'en tension avec lui : + l'ADR-0005 réserve le nom de fabrique simple à la variante retournant un `Outcome`, et + `Create` est ici précisément le terminal retournant un `Outcome`. L'axe de l'ADR-0005 + (retourner un `Outcome` versus lever une exception) est orthogonal à celui-ci (un + assembleur à valeur nue versus un assembleur retournant un `Outcome`) ; aucun des deux + terminaux ne lève d'exception, si bien qu'aucun marqueur `OrThrow` ne s'applique. +* `Create` retourne l'échec de la fabrique inchangé plutôt que de le ré-envelopper, parce + qu'une règle inter-champs est une préoccupation métier que la fabrique possède, tandis + que l'enveloppe du binder regroupe les échecs de binding d'arguments. Garder les deux + canaux séparés permet à un appelant de distinguer un argument malformé d'une combinaison + valide rejetée. +* Le statut de pré-version signifie que le nommage est fixé maintenant, alors qu'il n'y a + aucun consommateur à migrer. + +## Alternatives envisagées + +### Un terminal unique prenant un assembleur retournant un Outcome + +Envisagé parce que c'est la surface la plus réduite — un nom unique, l'aplatissement +couvrant les deux cas dès lors que l'appelant enveloppe un constructeur total dans un +`Outcome` de succès. + +Rejeté parce qu'il force le cas courant, qui ne peut pas échouer, à envelopper chaque +construction dans un `Outcome` de succès, exposant une plomberie de résultat dont le cas +total n'a par ailleurs pas besoin. + +### Deux surcharges `Build` portant le même nom + +Envisagé parce que la surcharge est la façon idiomatique, en C#, d'accepter deux formes +d'arguments sous un même verbe. + +Rejeté parce qu'une lambda retournant `Outcome` est applicable aux deux +surcharges avec un `TCommand` inféré différent, que la résolution de surcharge n'en préfère +aucune, et que l'appel ne compile donc pas. + +### `Build` et `BuildValidated` (un nom nu, un suffixé) + +Envisagé comme des noms distincts qui évitent déjà l'ambiguïté. + +Rejeté comme asymétrique : un verbe nu à côté d'une variante suffixée se lit comme « le +vrai et un cas particulier », alors que les deux sont des pairs portant sur des formes +d'assembleur différentes. + +### Paires symétriques privilégiant la sémantique (`Assemble` / `Validate`, `BuildFrom` / `BuildThrough`) + +Envisagé parce que les mots énoncent la sémantique peut-échouer / ne-peut-pas-échouer pour +un lecteur qui découvre l'API. + +Rejeté au profit de `New` / `Create`, qui optimisent plutôt pour le développeur qui écrit +l'appel : le nom correspond au constructeur ou à la fabrique déjà en main, réutilisant une +convention existante plutôt que d'enseigner un nouveau vocabulaire. + +## Conséquences + +### Positives + +* L'ambiguïté de surcharge est impossible : un nom, une signature chacun. +* Un constructeur total reste non enveloppé, et une fabrique validante se compose sans + `Outcome` imbriqué. +* Le nom du terminal est un moyen mnémotechnique d'usage lié au constructeur ou à la + fabrique que le consommateur écrit déjà, et réutilise la convention + `Create`-retourne-`Outcome` du code (ADR-0005). +* Les échecs de binding d'arguments et l'échec inter-champs d'une fabrique restent + distinguables au point d'appel. + +### Négatives + +* `New` et `Create` sont des quasi-synonymes en anglais, si bien que « seul `Create` peut + échouer » est porté par la convention constructeur/fabrique plutôt que par les mots ; un + lecteur peu familier de la convention doit consulter la documentation de l'API. +* `New` est un identifiant de méthode inhabituel — valide en C# (seul le `new` en + minuscules est le mot-clé) mais nécessitant un échappement (`[New]`) pour être appelé + depuis VB.NET. +* Deux terminaux publics et deux types delegate d'assembleur publics à documenter plutôt + qu'un seul. + +### Risques + +* Sans outillage qui l'impose, un terminal ultérieur pour une troisième forme d'assembleur + pourrait s'écarter de la convention et diluer le moyen mnémotechnique ; atténué pour + l'instant par cette ADR et par la revue. + +## Références + +* ADR-0005 — réserver le nom de fabrique simple à la variante retournant un Outcome, la + convention que `Create` réutilise ici. +* ADR-0003 — unifier le mapping de valeur d'Outcome sous `Then`, du contexte sur le + nommage de la surface Outcome pour l'intention plutôt que pour la mécanique. +* Pull request #126 — la fonctionnalité de request binder à laquelle ce terminal + appartient. diff --git a/maintainers/adr/0007-name-the-binder-terminals-new-and-create.md b/maintainers/adr/0007-name-the-binder-terminals-new-and-create.md index 7541d69b..41a10817 100644 --- a/maintainers/adr/0007-name-the-binder-terminals-new-and-create.md +++ b/maintainers/adr/0007-name-the-binder-terminals-new-and-create.md @@ -1,7 +1,9 @@ # ADR-0007 | Name the binder terminals New and Create -**Status:** Proposed -**Date:** 2026-07-15 +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0007-name-the-binder-terminals-new-and-create.fr.md) + +**Status:** Accepted +**Date:** 2026-07-16 **Decision Makers:** Reefact ## Context diff --git a/maintainers/adr/README.md b/maintainers/adr/README.md index c528156d..76f53e58 100644 --- a/maintainers/adr/README.md +++ b/maintainers/adr/README.md @@ -35,7 +35,12 @@ need editing. * One file per decision, under `maintainers/adr/`, named `NNNN-short-title.md` — a four-digit sequence number and a lowercase, kebab-case title: `0001-lock-the-analyzer-roslyn-floor.md`. -* ADRs are written in **English**, like all repository content. +* ADRs are written in **English** — the canonical version — with a French + translation kept alongside as `NNNN-short-title.fr.md`, matching the rest of + the repository's bilingual documentation. The English version is + authoritative; the French one is a convenience for the project's maintainers + and follows it. Each file carries a language banner linking to its + counterpart. * Every ADR follows the format below; [`template.md`](template.md) is a copy-ready skeleton. @@ -175,5 +180,5 @@ Optional supporting material: | [ADR-0004](0004-check-every-pull-request-against-the-adr-base.md) | Check every pull request against the ADR base | Accepted | | [ADR-0005](0005-reserve-the-plain-factory-name-for-the-outcome-returning-variant.md) | Reserve the plain factory name for the Outcome-returning variant | Accepted | | [ADR-0006](0006-supply-arbitrary-test-values-from-a-seedable-source.md) | Supply arbitrary test values from a single seedable source | Accepted | -| [ADR-0007](0007-name-the-binder-terminals-new-and-create.md) | Name the binder terminals New and Create | Proposed | +| [ADR-0007](0007-name-the-binder-terminals-new-and-create.md) | Name the binder terminals New and Create | Accepted | | [ADR-0008](0008-bind-nullable-value-type-properties-through-a-struct-constrained-overload.md) | Bind nullable value-type properties through a struct-constrained overload | Proposed |