From 05d485785eab4b92211eef1d9439f6317335642e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 13:07:36 +0000 Subject: [PATCH 1/4] docs: add ADR-0043 on gating pull requests on the mutation score MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the decision to require a mutation-score threshold on every pull request, measured over the mutants of the files it changed, for the five libraries the repository ships — enforced by two independent gates, split along the boundary JustDummies is expected to leave through. The context section holds what measurement established before the decision: Stryker's default VSTest runner reports every mutant as survived on this xUnit v3 test bed, its Microsoft Testing Platform runner works but is preview, that runner's coverage-based selection misclassifies killed mutants as uncovered, and the cost is one test-suite run per mutant. That cost is also why one library ships without a bar: JustDummies' sweep runs well past an hour, no score was ever measured for it, and the follow-up actions name the weekly sweep as what must supply one. Drafted as Proposed; the maintainer decides whether it is accepted. --- ...ts-on-the-mutation-score-of-the-diff.fr.md | 311 ++++++++++++++++++ ...uests-on-the-mutation-score-of-the-diff.md | 287 ++++++++++++++++ doc/handwritten/for-maintainers/adr/README.md | 1 + 3 files changed, 599 insertions(+) create mode 100644 doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.fr.md create mode 100644 doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md diff --git a/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.fr.md b/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.fr.md new file mode 100644 index 00000000..5c9b11ce --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.fr.md @@ -0,0 +1,311 @@ +# ADR-0043 | Conditionner les pull requests au score de mutation de ce qu'elles modifient + +🌍 🇬🇧 [English](0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md) · 🇫🇷 Français (ce fichier) + +**Statut :** Proposé +**Date :** 2026-07-27 +**Décideurs :** Reefact + +## Contexte + +FirstClassErrors livre cinq bibliothèques — `FirstClassErrors`, +`FirstClassErrors.Testing`, `FirstClassErrors.RequestBinder`, `JustDummies` et +`JustDummies.Xunit` — dont le produit *est* la sémantique : quelle erreur une +fabrique retourne, quelle branche un `Outcome` emprunte, quelle valeur une +contrainte admet. Un défaut à cet endroit n'est pas un plantage que le +consommateur voit venir ; c'est une réponse fausse délivrée avec assurance. + +Le dépôt impose déjà deux signaux de qualité automatiques sur chaque pull +request : toute la suite de tests sur deux plateformes (`ci`), et la quality gate +SonarQube Cloud, couverture de lignes et de branches comprise (`sonar`). La +couverture enregistre qu'une ligne a été **exécutée** par un test. Elle ne peut +pas enregistrer si un test aurait **remarqué** que cette ligne était fausse : une +suite qui appelle une méthode sans rien affirmer de son résultat obtient la même +couverture qu'une suite qui épingle tous les cas. + +Les tests de mutation mesurent exactement cette différence. L'outil réécrit la +bibliothèque un petit changement à la fois, rejoue la suite contre chaque +réécriture, et signale celles que la suite laisse passer. Sur .NET, cet outil est +Stryker.NET ; il n'existe pas d'alternative maintenue. + +Quatre faits sur son exécution dans ce dépôt ont été établis par la mesure avant +que cette décision soit prise : + +* **Le runner VSTest par défaut de Stryker ne fonctionne pas sur ce banc de + tests.** Tous les projets de tests sont ici en xUnit v3, et un projet de tests + xUnit v3 est un exécutable que l'adaptateur VSTest lance dans un processus fils + — hors de portée des crochets in-process dont Stryker se sert à la fois pour + capturer la couverture et pour *activer* un mutant. Le run va au bout, annonce + un nombre de tests plausible, et score **0 %** : tous les mutants sont + rapportés survivants, y compris des mutants que la suite tue de façon + démontrable quand la même modification est appliquée à la main. Un barrage bâti + sur ce runner serait rouge en permanence et ne prouverait rien. +* **Le runner Microsoft Testing Platform de Stryker fonctionne**, parce qu'il + lance lui-même l'exécutable de tests. Il est marqué *preview* par ses auteurs. +* **Sa sélection de mutants par la couverture n'est pas encore fiable.** + Sélection activée, des mutants que la suite tue effectivement sont classés non + couverts et comptés contre le score ; sélection désactivée — chaque mutant + confronté à toute la suite — la même population score nettement plus haut, et + ce résultat correspond à ce que donne l'application des mutations à la main. La + désactiver ne coûte pratiquement rien ici, car les suites sont rapides. +* **Le coût est d'environ une exécution de la suite par mutant.** Cela représente + environ une seconde par mutant pour ces bibliothèques : le balayage complet + d'une bibliothèque se compte en minutes, celui des cinq est trop long pour être + attendu à chaque push. Ne sélectionner que les mutants touchés par un + changement ramène le cas courant au coût fixe de l'analyse et du build. + +Le mode diff de Stryker sélectionne les mutants par **fichier** modifié, pas par +ligne modifiée ; il n'y a pas de granularité à la ligne. Une modification d'une +ligne dans un gros fichier met donc tous les mutants de ce fichier sur le +barrage. + +Tout survivant n'est pas un défaut : certains mutants sont *équivalents*, ils +changent le code sans changer le comportement observable, et aucun test ne peut +les tuer. Un seuil à 100 % est donc inatteignable par principe. + +Deux des cinq bibliothèques — `JustDummies` et son adaptateur xUnit v3 — sont +déjà tenues à l'écart de toute référence aux trois autres +([ADR-0011](0011-host-dummies-as-a-standalone-package.fr.md)) et sont destinées à +migrer vers un dépôt à elles. + +Les runs ont lieu sur des runners hébergés par GitHub : quatre vCPU, un plafond +de six heures par job. Un check ne peut être rendu obligatoire sur `main` que par +la protection de branche, qui nomme les checks un par un — une matrice fournit un +nom de check par branche. + +## Décision + +Toute pull request ciblant `main` doit franchir un seuil de score de mutation, +mesuré par Stryker.NET sur les mutants des fichiers qu'elle modifie, pour chacune +des cinq bibliothèques que le dépôt livre — imposé par deux barrages +indépendants, découpés le long de la frontière de dépôt à venir. + +## Justification + +Le barrage bouche exactement le trou que les signaux existants laissent ouvert. +`ci` prouve que la suite passe ; `sonar` prouve que le code a été exécuté. Ni +l'un ni l'autre ne distingue un test qui épingle un comportement d'un test qui ne +fait que le traverser — et cette distinction est toute la qualité d'une +bibliothèque dont le produit est sa sémantique. Les tests de mutation sont le +seul signal automatique qui la mesure. + +Le rendre **obligatoire plutôt que consultatif** est l'objet de la décision, pas +un détail aggravant. Un rapport consultatif, sur un dépôt maintenu par une seule +personne, est un rapport que personne ne lit ; la pratique qu'il vise à installer +— écrire l'assertion, pas seulement l'appel — ne survit que si le merge en +dépend. Le dépôt traite déjà ainsi ses autres invariants : le cliquet de +warnings, la convention de commit et les floors supportés sont imposés, pas +suggérés. + +Cantonner le barrage à **ce que la pull request modifie** est ce qui rend +l'obligation abordable. Le modèle de coût est linéaire en nombre de mutants, et +ce nombre est proportionnel au code mesuré ; mesurer le diff maintient une pull +request ordinaire au coût fixe de l'analyse et du build, là où tout mesurer, à +chaque fois, mettrait des dizaines de minutes sur chaque push pour du code que +l'auteur n'a pas touché. Le compromis accepté est que la granularité au fichier +du mode diff fait rapporter le score du fichier entier pour une petite +modification, si bien qu'une pull request peut avoir à répondre de manques +préexistants dans un fichier qu'elle n'a fait qu'effleurer. C'est un coût réel, +et c'est la bonne direction pour l'erreur : elle pousse à la hausse la couverture +des fichiers les plus faibles au contact, et le mainteneur peut toujours écarter +une branche. + +Restreindre le périmètre aux **cinq bibliothèques livrées** applique le même +argument de coût dans l'autre sens. L'outillage `fce` et les analyseurs Roslyn ne +sont liés dans l'application de personne ; leurs tests pilotent des compilations +Roslyn et lancent des processus, leur coût par mutant est donc d'un ordre de +grandeur supérieur, et leur comportement est déjà tenu de bout en bout par des +workflows dédiés. Les inclure multiplierait le coût du barrage là où un défaut +est le moins cher à remarquer. + +L'imposer par **deux barrages plutôt qu'un** ne coûte rien aujourd'hui et achète +la migration. Les packages JustDummies sont déjà isolés du reste par construction, +et ils partent ; une matrice unique devrait être réécrite, et son entrée de check +obligatoire renégociée, précisément au moment où la partie la moins intéressante +d'une séparation de dépôt devrait être sa CI. Deux barrages font de cette étape un +déplacement de fichier. Ils laissent aussi les deux barres évoluer séparément — ce +qui est nécessaire, puisque les bibliothèques se situent à des niveaux de maturité +de test visiblement différents et qu'une barre unique devrait être calée sur la +plus faible des deux. + +Épingler le moteur de mutation compte pour la même raison que le floor Roslyn de +l'analyseur ([ADR-0001](0001-lock-the-analyzer-roslyn-floor.md)) : un moteur plus +récent invente de nouveaux mutants, et le score bougerait sans qu'une ligne de +code change. Un seuil n'a de sens que face à un générateur figé. + +Le statut **preview** du runner dont dépend le barrage est la principale +faiblesse de la décision, et elle est acceptée en connaissance de cause : +l'alternative est l'absence totale de signal de mutation, puisque le runner +supporté ne sous-estime pas — il rapporte zéro. L'atténuation tient à ce que le +mode de défaillance est bruyant et non silencieux : une régression du runner qui +cesserait d'activer les mutants ramènerait tous les scores à zéro et ferait +échouer le barrage, au lieu de le laisser passer discrètement. + +Enfin, le seuil est fixé **sous 100 %** parce que les mutants équivalents rendent +100 % inatteignable, et parce que c'est un *score* qui est contrôlé, pas +l'absence de survivants : c'est le rapport, et non le code de sortie, que le +mainteneur lit pour décider si un survivant est une assertion manquante ou un +mutant équivalent. + +Chaque bibliothèque reçoit son **propre** seuil, dérivé de son score mesuré et +non d'une cible choisie dans l'abstrait. Un chiffre unique pour cinq +bibliothèques devrait être soit assez bas pour la plus faible — et donc sans +mordant pour celles déjà au sommet —, soit assez haut pour la plus forte, et donc +rouge dès le premier jour pour les autres. Le dériver par bibliothèque fait du +barrage un cliquet : il interdit la régression par rapport au niveau déjà atteint, +passe à l'introduction, et ne peut que monter. Le prix de ce choix, c'est que la +barre est basse là où le banc de tests est faible — précisément là où le barrage +serait le plus utile ; relever ces seuils à mesure que le balayage hebdomadaire +révèle de la marge est l'usage prévu du cliquet, et c'est délibérément une +décision du mainteneur plutôt qu'un automatisme. + +## Alternatives envisagées + +### Publier le score de mutation sans faire échouer le build + +Envisagée parce qu'elle ne porte aucun risque : aucune pull request n'est jamais +bloquée par un outil en preview, et le chiffre est quand même publié. + +Rejetée parce qu'elle ne change rien. Les invariants de qualité du dépôt sont +tous imposés plutôt que suggérés, précisément parce qu'un signal qu'il ne coûte +rien d'ignorer finit ignoré. La décision qui mérite d'être consignée ici, c'est +qu'une pull request doit répondre des assertions qu'elle n'a pas écrites ; un +rapport consultatif ne formule pas cette exigence. + +### Muter chaque bibliothèque en entier sur chaque pull request + +Envisagée parce que c'est la mesure honnête : un score sur toute la bibliothèque +est comparable d'un run à l'autre, et il ne peut pas être contourné en +n'effleurant que les bords d'un fichier. + +Rejetée pour deux raisons. La première est le coût : le coût par mutant est une +exécution complète de la suite de la bibliothèque, si bien qu'un balayage complet +des cinq se compte en dizaines de minutes au mieux — payées à chaque push, +essentiellement pour re-mesurer du code que personne n'a changé. La seconde suffit +à elle seule : **un score sur toute la bibliothèque est bien trop insensible pour +servir de barrage**. La plus grosse bibliothèque porte plusieurs centaines de +mutants : un comportement nouvellement ajouté et non affirmé déplace donc son +score d'une fraction de pour-cent — bien en dessous de tout seuil qui ne serait +pas lui-même du bruit. Le score cantonné au diff est sensible précisément parce +que son dénominateur est petit : une poignée de mutants neufs, dont un survit, +c'est une chute visible. Le balayage hebdomadaire récupère le chiffre sur toute la +bibliothèque là où il a sa place — comme tendance, pas comme barrage. + +### Relever l'exigence de couverture SonarQube Cloud à la place + +Envisagée parce que la quality gate existe déjà, est déjà obligatoire, et ne +rapporte déjà que sur le code neuf — le mécanisme même dont cette décision a +besoin. + +Rejetée parce qu'elle mesure autre chose. La couverture ne peut pas descendre +sous 100 % pour une ligne qu'un test exécute sans rien en affirmer ; relever le +pourcentage exigé achète des lignes exécutées, pas du comportement épinglé. Les +deux signaux sont complémentaires, et celui-ci n'a pas de substitut. + +### Conserver le runner VSTest par défaut de Stryker + +Envisagée parce que c'est la configuration supportée et non-preview, et que +préférer un composant en preview dans un check obligatoire n'est pas une décision +à prendre à la légère. + +Rejetée parce qu'elle ne fonctionne pas du tout sur ce banc de tests. Vérifié par +la mesure : elle rapporte tous les mutants comme survivants, y compris des +mutants qui cassent la suite de façon démontrable quand on les applique à la +main. La choisir reviendrait soit à un barrage rouge en permanence, soit à un +seuil assez bas pour ne rien vouloir dire. + +### Étendre le barrage à l'outillage et aux analyseurs + +Envisagée par souci d'uniformité — une règle unique couvrant tout le dépôt se +défend mieux qu'une liste de cinq projets. + +Rejetée parce que le coût est concentré exactement là où la valeur est la plus +faible. Les tests d'analyseurs et de générateur compilent du code et lancent des +processus ; leurs suites sont les lentes, donc leurs mutants sont les chers. Leur +comportement visible de l'extérieur est déjà tenu par les jobs `analyzers`, +`gendoc-docs` et le `floor` de `ci`, et un défaut s'y manifeste par un build +cassé ou un document faux, pas par une réponse silencieusement fausse dans +l'application d'un consommateur. + +## Conséquences + +### Positives + +* Une pull request qui ajoute du comportement sans ajouter l'assertion qui + l'épingle est refusée automatiquement, sur le code qu'elle modifie, avant la + revue. +* Les fichiers les moins bien testés s'améliorent au contact : en toucher un met + tout son score de mutation sur le barrage. +* Le balayage hebdomadaire donne aux parties non touchées des bibliothèques une + tendance que rien d'autre dans la chaîne ne produit. +* Le diagnostic est concret. Un barrage en échec nomme le mutant survivant, son + fichier et sa ligne — il dit quelle assertion manque, pas seulement qu'un + chiffre est trop bas. + +### Négatives + +* Une pull request qui touche un gros fichier faiblement couvert paie pour les + manques préexistants de ce fichier, pas seulement pour son propre changement. +* Deux checks obligatoires de plus sur le chemin critique de chaque merge, et une + pièce mobile de plus à maintenir en état — l'épinglage de l'outil, le mode du + runner et les seuils doivent tous être entretenus délibérément. +* Les deux workflows sont des fichiers quasi identiques. Tant que la séparation + n'a pas eu lieu, un correctif sur l'un est un correctif sur l'autre, et rien ne + l'impose. +* Les mutants équivalents rendent une partie de la distance restante jusqu'à + 100 % inatteignable : le seuil relève donc du jugement, pas d'un calcul. + +### Risques + +* **Le runner Microsoft Testing Platform est en preview.** Une régression pourrait + déplacer les scores d'une version du moteur à l'autre. Atténué par l'épinglage + du moteur dans le manifeste d'outils, qui fait de la montée de version un acte + délibéré, et par le caractère bruyant du mode de défaillance : un runner qui + cesse d'activer les mutants ramène tous les scores à zéro. +* **Sa sélection par la couverture est désactivée** : le coût est donc d'une + exécution complète de la suite par mutant. C'est supportable aujourd'hui parce + que ces suites sont rapides ; une suite nettement plus lente rendrait le + balayage, puis le barrage, coûteux. +* **Les seuils sont calibrés sur les scores d'aujourd'hui.** Une bibliothèque + ajoutée plus tard avec un banc de tests plus faible échouerait au barrage à son + premier contact plutôt qu'à son introduction. +* **`JustDummies` part sans seuil de score.** Son balayage est trop long pour + servir de calibration interactive : cette bibliothèque est donc barrée sur tout + sauf sur un score, jusqu'à ce que le premier balayage hebdomadaire en fournisse + un. +* **Les pull requests de tests seuls sélectionnent aussi des mutants**, via les + fichiers de tests qu'elles modifient : une pull request qui n'ajoute que des + tests peut donc être barrée. + +## Actions de suivi + +* Rendre les deux checks agrégés — `Mutation gate` et `JustDummies mutation gate` + — obligatoires sur `main` dans la protection de branche ; un workflow ne peut + pas se rendre obligatoire lui-même. +* Quand JustDummies migrera dans son propre dépôt, emporter son workflow, ses deux + configurations et le manifeste d'outil tels quels, puis repointer le champ + `solution` ; la page de référence du workflow porte la check-list. +* Réexaminer le choix du runner quand le support Microsoft Testing Platform de + Stryker sortira de preview, et réactiver la sélection par la couverture quand + elle classera correctement les mutants couverts. +* Relire les seuils après chaque montée de version du moteur, et après tout ajout + d'une bibliothèque au périmètre. +* Fixer le seuil de `JustDummies` à partir du premier balayage hebdomadaire. Son + balayage complet est trop long pour être exécuté interactivement : aucun score + n'a donc été mesuré pour elle et sa barre est pour l'instant désactivée — la + seule bibliothèque que le barrage ne tient pas encore à un score. + +## Références + +* [Référence du workflow `mutation`](../workflows/mutation.fr.md) — comment la + décision est mise en œuvre, et les réglages qu'elle expose. +* [ADR-0001](0001-lock-the-analyzer-roslyn-floor.fr.md) — le précédent en matière + d'épinglage d'une version d'outil qui, sinon, déplacerait seule un résultat + mesuré. +* [ADR-0040](0040-split-the-justdummies-test-bed-between-example-and-property-suites.fr.md) + — le découpage du banc de tests dont les deux suites alimentent ce barrage. +* [stryker-net#3117](https://github.com/stryker-mutator/stryker-net/issues/3117) + — le signalement amont du runner VSTest de Stryker face à xUnit v3. +* [stryker-net#3629](https://github.com/stryker-mutator/stryker-net/issues/3629) + — la limitation amont de l'analyse de couverture sous le runner Microsoft + Testing Platform. diff --git a/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md b/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md new file mode 100644 index 00000000..ebe865d0 --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md @@ -0,0 +1,287 @@ +# ADR-0043 | Gate pull requests on the mutation score of what they changed + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0043-gate-pull-requests-on-the-mutation-score-of-the-diff.fr.md) + +**Status:** Proposed +**Date:** 2026-07-27 +**Decision Makers:** Reefact + +## Context + +FirstClassErrors ships five libraries — `FirstClassErrors`, +`FirstClassErrors.Testing`, `FirstClassErrors.RequestBinder`, `JustDummies` and +`JustDummies.Xunit` — whose product *is* their semantics: which error a factory +returns, which branch an `Outcome` takes, which value a constraint admits. A +defect there is not a crash a consumer can see coming; it is a wrong answer +delivered confidently. + +The repository already enforces two automated quality signals on every pull +request: the full test suite on two platforms (`ci`), and SonarQube Cloud's +quality gate including line and branch coverage (`sonar`). Coverage records that +a line was **executed** by a test. It cannot record whether any test would have +**noticed** that line being wrong: a suite that calls a method and asserts +nothing about the result scores the same coverage as one that pins every case. + +Mutation testing measures exactly that difference. The tool rewrites the library +one small change at a time, re-runs the suite against each rewrite, and reports +the rewrites the suite still passes on. On .NET the tool is Stryker.NET; there is +no maintained alternative. + +Four facts about running it on this repository were established by measurement +before this decision was taken: + +* **Stryker's default VSTest runner does not work on this test bed.** Every test + project here is xUnit v3, and an xUnit v3 test project is an executable the + VSTest adapter launches as a child process — out of reach of the in-process + hooks Stryker uses both to capture coverage and to *activate* a mutant. The + run completes, reports a plausible-looking number of tests, and scores **0 %**: + every mutant is reported as survived, including mutants the suite demonstrably + kills when the same edit is applied by hand. A gate built on that runner would + be permanently red and would prove nothing. +* **Stryker's Microsoft Testing Platform runner does work**, because it launches + the test executable itself. It is flagged *preview* by its authors. +* **Its coverage-based mutant selection is not trustworthy yet.** With selection + enabled, mutants the suite does kill are classified as uncovered and counted + against the score; with it disabled — every mutant tested against the whole + suite — the same population scores materially higher and matches what running + the mutations by hand shows. Disabling it also costs almost nothing here, + because the suites are fast. +* **The cost is roughly one test-suite run per mutant.** That is about a second + per mutant for these libraries, which makes a full sweep of one library a + matter of minutes and a full sweep of all five too long to sit in front of on + every push. Selecting only the mutants a change touches brings the common case + down to the fixed cost of analysis and build. + +Stryker's diff mode selects mutants per changed **file**, not per changed line; +there is no line granularity. A one-line edit to a large file therefore puts that +whole file's mutants on the gate. + +Not every survivor is a defect: some mutants are *equivalent*, changing the code +without changing observable behaviour, and no test can kill them. A threshold of +100 % is therefore not reachable in principle. + +Two of the five libraries — `JustDummies` and its xUnit v3 adapter — are already +kept free of any reference to the other three +([ADR-0011](0011-host-dummies-as-a-standalone-package.md)) and are intended to +move to a repository of their own. + +Runs happen on GitHub-hosted runners: four vCPU, a six-hour cap per job. A check +can only be made mandatory on `main` through branch protection, which names +checks individually — a matrix contributes one check name per leg. + +## Decision + +Every pull request targeting `main` must clear a mutation-score threshold, +measured by Stryker.NET over the mutants of the files it changed, for each of the +five libraries the repository ships — enforced by two independent gates, split +along the prospective repository boundary. + +## Rationale + +The gate closes the specific hole the existing signals leave open. `ci` proves +the suite passes; `sonar` proves the code was executed. Neither can distinguish a +test that pins behaviour from a test that merely visits it, and that distinction +is the whole quality of a library whose product is its semantics. Mutation +testing is the only automated signal that measures it. + +Making it **mandatory rather than advisory** is the point of the decision, not an +aggravating detail. An advisory report on a repository maintained by one person +is a report nobody reads; the practice it is meant to install — write the +assertion, not just the call — only survives if merging depends on it. The +repository already treats its other invariants this way: the warning ratchet, the +commit convention, the support floors are all enforced, not suggested. + +Scoping the gate to **what the pull request changed** is what makes mandatory +affordable. The cost model is linear in the number of mutants, and the number of +mutants is proportional to the code under measurement; measuring the diff keeps a +typical pull request at the fixed cost of analysis and build, while measuring +everything, every time, would put tens of minutes on every push for code the +author did not touch. The trade-off accepted is that the file-level granularity +of the diff mode makes a small edit to a large file report that whole file's +score, so a pull request can be asked to answer for pre-existing gaps in a file +it merely touched. That is a real cost, and it is the acceptable direction of +error: it pushes coverage of the weakest files up on contact, and the maintainer +can always waive a leg. + +Restricting the scope to the **five shipped libraries** follows the same cost +argument in the other direction. The `fce` tooling and the Roslyn analyzers are +not linked into anyone's application; their tests drive Roslyn compilations and +spawn processes, so their per-mutant cost is an order of magnitude higher, and +their behaviour is already pinned end to end by dedicated workflows. Including +them would multiply the gate's cost for the part of the codebase where a defect +is cheapest to notice. + +Enforcing it through **two gates rather than one** costs nothing today and buys +the move. The JustDummies packages are already isolated from the rest by design, +and they are leaving; a single shared matrix would have to be edited, and its +required-check entry renegotiated, at exactly the moment when the least +interesting part of a repository split should be its CI. Two gates make that step +a file move. They also let the two bars move independently — which they must, +since the libraries sit at visibly different levels of test maturity, and one bar +would have to be set at the weaker of the two. + +Pinning the mutation engine matters for the same reason the analyzer's Roslyn +floor is pinned ([ADR-0001](0001-lock-the-analyzer-roslyn-floor.md)): a newer +engine invents new mutants, and the score would move without a line of code +changing. A threshold is only meaningful against a fixed generator. + +The **preview** status of the runner the gate depends on is the decision's main +weakness, and it is accepted knowingly: the alternative is no mutation signal at +all, because the supported runner does not merely under-report on this test bed — +it reports zero. The mitigation is that the failure mode is loud rather than +silent: a runner regression that stopped activating mutants would take every +score to zero and fail the gate, not quietly pass it. + +Finally, the threshold is set **below 100 %** because equivalent mutants make +100 % unreachable, and it gates a *score*, not the absence of survivors: the +report, not the exit code, is what a maintainer reads to decide whether a +survivor is a missing assertion or an equivalent mutant. + +Each library gets its **own** threshold, derived from its own measured score +rather than from a target chosen in the abstract. A single number across five +libraries would have to be either low enough for the weakest — and therefore +toothless for the ones already at the top — or high enough for the strongest, and +therefore red on day one for the rest. Deriving it per library makes the gate a +ratchet: it forbids regression from where each library already stands, clears on +introduction, and only ever moves up. The cost of that choice is that the bar is +low where the test bed is weak, which is exactly where the gate would be most +useful; raising those thresholds as the weekly sweep shows headroom is the way +the ratchet is meant to be used, and it is deliberate that doing so is a +maintainer's decision rather than an automatic one. + +## Alternatives Considered + +### Report the mutation score without failing the build + +Considered because it carries none of the risk: no pull request is ever blocked +by a preview-status tool, and the number is still published. + +Rejected because it changes nothing. The repository's existing quality +invariants are all enforced rather than suggested, precisely because a signal +that costs nothing to ignore is ignored. The decision worth recording here is +that a pull request must answer for the assertions it did not write; an advisory +report does not make that demand. + +### Mutate every library in full on every pull request + +Considered because it is the honest measurement: a score over the whole library +is comparable between runs, and it cannot be gamed by touching a file's edges. + +Rejected on two counts. The first is cost: the per-mutant cost is a full run of +the library's suite, so a complete sweep of the five libraries is a matter of +tens of minutes at best — paid on every push, mostly to re-measure code nobody +changed. The second is decisive on its own: **a whole-library score is far too +insensitive to gate on**. The largest library carries several hundred mutants, so +one newly added, unasserted behaviour moves its score by a fraction of a +percent — well below any threshold that is not itself noise. The diff-scoped +score is sensitive precisely because its denominator is small: a handful of new +mutants, one of which survives, is a visible drop. The weekly sweep recovers the +whole-library number where it belongs — as a trend, not as a gate. + +### Raise the SonarQube Cloud coverage requirement instead + +Considered because the quality gate already exists, is already mandatory, and +already reports on new code only — the mechanism this decision needs. + +Rejected because it measures the wrong thing. Coverage cannot fall below 100 % +for a line that a test executes and asserts nothing about; raising the required +percentage buys more executed lines, not more pinned behaviour. The two signals +are complements, and this one has no substitute. + +### Keep Stryker's default VSTest runner + +Considered because it is the supported, non-preview configuration, and preferring +a preview component in a required check is not a decision to take lightly. + +Rejected because it does not work on this test bed at all. Verified by +measurement: it reports every mutant as survived, including mutants that +demonstrably break the suite when applied by hand. Choosing it would mean either +a permanently red gate or a threshold low enough to be meaningless. + +### Extend the gate to the tooling and the analyzers + +Considered for uniformity — a single rule covering the whole repository is easier +to defend than a list of five projects. + +Rejected because the cost is concentrated exactly where the value is lowest. +Analyzer and generator tests compile code and spawn processes; their suites are +the slow ones, so their mutants are the expensive ones. Their externally visible +behaviour is already pinned by the `analyzers`, `gendoc-docs` and `ci` floor +jobs, and a defect there surfaces as a broken build or a wrong document, not as a +silently wrong answer inside a consumer's application. + +## Consequences + +### Positive + +* A pull request that adds behaviour without adding the assertion that pins it is + refused automatically, on the code it changed, before review. +* The weakest-tested files improve on contact: touching one puts its whole + mutation score on the gate. +* The weekly sweep gives the untouched parts of the libraries a trend line that + nothing else in the pipeline produces. +* The diagnosis is concrete. A failing gate names the surviving mutant, its file + and its line — it says which assertion is missing, not merely that a number is + too low. + +### Negative + +* A pull request touching a large, weakly covered file pays for that file's + pre-existing gaps, not only for its own change. +* Two more required checks on the critical path of every merge, and one more + moving part to keep working — the tool pin, the runner mode and the thresholds + all have to be maintained deliberately. +* The two workflows are near-identical files. Until the split happens, a fix to + one is a fix to both, and nothing enforces that. +* Equivalent mutants make part of the remaining distance to 100 % unreachable, so + the threshold is a judgement call rather than a derived value. + +### Risks + +* **The Microsoft Testing Platform runner is preview.** A regression in it could + change scores between engine versions. Mitigated by pinning the engine in the + tool manifest, so an upgrade is a deliberate act, and by the failure mode being + loud: a runner that stops activating mutants takes every score to zero. +* **Its coverage-based selection is disabled**, so the cost is one full suite run + per mutant. That is affordable today because these suites are fast; a + substantially slower suite would make the sweep, and eventually the gate, + expensive. +* **The thresholds are calibrated against today's scores.** A library added later + with a weaker test bed would fail the gate on its first contact rather than on + its introduction. +* **`JustDummies` ships without a score threshold.** Its sweep is too long to + calibrate against interactively, so that library is gated on everything except + a score until the first weekly sweep supplies one. +* **Test-only pull requests select mutants too**, through the test files they + change, so a pull request that only adds tests can still be gated. + +## Follow-up Actions + +* Mark both aggregated checks — `Mutation gate` and `JustDummies mutation gate` — + as required on `main` in the branch protection; a workflow cannot make itself + mandatory. +* When JustDummies moves to its own repository, take its workflow, its two + configurations and the tool manifest across unchanged and repoint the + `solution` field; the workflow reference page carries the checklist. +* Revisit the runner choice when Stryker's Microsoft Testing Platform support + leaves preview, and re-enable coverage-based selection when it classifies + covered mutants correctly. +* Re-read the thresholds after each engine upgrade, and after any library is + added to the scope. +* Set the `JustDummies` threshold from the first weekly sweep. Its full sweep is + too long to run interactively, so no score was measured for it and its bar is + currently disabled — the one library the gate does not yet hold to a score. + +## References + +* [`mutation` workflow reference](../workflows/mutation.en.md) — how the decision + is implemented, and the knobs it exposes. +* [ADR-0001](0001-lock-the-analyzer-roslyn-floor.md) — the precedent for pinning + a tool version that would otherwise move a measured result on its own. +* [ADR-0040](0040-split-the-justdummies-test-bed-between-example-and-property-suites.md) + — the test-bed split whose two suites both feed this gate. +* [stryker-net#3117](https://github.com/stryker-mutator/stryker-net/issues/3117) + — the upstream report of Stryker's VSTest runner mishandling xUnit v3. +* [stryker-net#3629](https://github.com/stryker-mutator/stryker-net/issues/3629) + — the upstream limitation of coverage analysis under the Microsoft Testing + Platform runner. diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index ec581398..125aa306 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -225,3 +225,4 @@ Optional supporting material: | [ADR-0040](0040-split-the-justdummies-test-bed-between-example-and-property-suites.md) | Split the JustDummies test bed between an example suite and a property suite | Accepted | | [ADR-0041](0041-draw-flag-enum-combinations-behind-an-opt-in.md) | Draw flag-enum combinations behind an opt-in | Accepted | | [ADR-0042](0042-serialize-draws-on-a-random-source.md) | Serialize draws on a random source, and scope reproducibility to the draw sequence | Proposed | +| [ADR-0043](0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md) | Gate pull requests on the mutation score of what they changed | Proposed | From 283df032c8ac7e464b4e58257f94601988388ffc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 13:07:37 +0000 Subject: [PATCH 2/4] ci: gate pull requests on the mutation score of the diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two mutation workflows running Stryker.NET: `mutation` over the three FirstClassErrors libraries, `justdummies-mutation` over the two JustDummies packages. On a pull request each matrix leg mutates only the files the diff touched — `--since` against the fork point resolved with `git merge-base` — and fails when the score falls under that library's threshold. Each workflow aggregates its matrix into one stable check name to mark as required on `main`; a weekly schedule sweeps every mutant, advisory by construction. Both paths render the surviving mutants into the run summary. The two workflows are deliberate near-duplicates rather than one shared matrix. JustDummies already holds no reference to FirstClassErrors and is headed for a repository of its own, so the split keeps that move a file move, and it lets the two quality bars sit at different heights. Two settings are load-bearing rather than tuning, and both were established by measurement. `test-runner: mtp` is required: Stryker's default VSTest runner cannot activate a mutant in an xUnit v3 test project, so it runs to completion and reports every mutant as survived — including mutants that break the suite when the same edit is applied by hand. `coverage-analysis: off` is for accuracy rather than speed: the Microsoft Testing Platform runner's coverage selection still classifies killed mutants as uncovered, which understates the score. Four of the five thresholds are derived from that library's measured full-sweep score, so those gates start green and only ever ratchet up. JustDummies is the exception: a few thousand mutants over a heavy suite put its sweep well past an hour and no score was measured for it, so rather than invent a bar its score gate is left off — its leg still runs, still fails on a broken build or a failing suite, and still lists its survivors, but does not yet refuse a pull request over a score. The weekly sweep is what supplies the missing figure. The engine is pinned in a tool manifest: a newer Stryker invents new mutants and would move every score on its own. See ADR-0043 for the decision. --- .config/dotnet-tools.json | 13 ++ .github/workflows/justdummies-mutation.yml | 257 ++++++++++++++++++++ .github/workflows/mutation.yml | 258 +++++++++++++++++++++ .gitignore | 4 + build/stryker/binder.json | 21 ++ build/stryker/core.json | 21 ++ build/stryker/justdummies-xunit.json | 20 ++ build/stryker/justdummies.json | 21 ++ build/stryker/testing.json | 20 ++ 9 files changed, 635 insertions(+) create mode 100644 .config/dotnet-tools.json create mode 100644 .github/workflows/justdummies-mutation.yml create mode 100644 .github/workflows/mutation.yml create mode 100644 build/stryker/binder.json create mode 100644 build/stryker/core.json create mode 100644 build/stryker/justdummies-xunit.json create mode 100644 build/stryker/justdummies.json create mode 100644 build/stryker/testing.json diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 00000000..95cf0ed4 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-stryker": { + "version": "4.16.0", + "commands": [ + "dotnet-stryker" + ], + "rollForward": false + } + } +} diff --git a/.github/workflows/justdummies-mutation.yml b/.github/workflows/justdummies-mutation.yml new file mode 100644 index 00000000..b73a184f --- /dev/null +++ b/.github/workflows/justdummies-mutation.yml @@ -0,0 +1,257 @@ +name: justdummies-mutation + +# Mutation testing for the JustDummies libraries, kept in a workflow of their own rather than as two +# more legs of `mutation.yml`. JustDummies is destined for a repository of its own (it is already a +# standalone, error-agnostic package — ADR-0011), and this file is written so that the move is a +# FILE MOVE, not an edit: take this workflow, `build/stryker/justdummies*.json`, +# `.config/dotnet-tools.json` and the reference page, then repoint the `solution` field in the two +# configs at the new solution. Nothing here refers to a FirstClassErrors project. +# +# The mechanism is identical to `mutation.yml`; keep the two in step when you change one. + +on: + pull_request: + branches: + - main + # Weekly full sweep. The pull-request legs only mutate what a pull request touched, so nothing ever + # re-measures the parts of the libraries no one has edited: this is the run that does. Offset from + # `mutation`'s slot so the two sweeps do not contend for runners. + schedule: + - cron: '47 3 * * 1' + workflow_dispatch: + +# Cancel superseded runs on the same branch / PR. +concurrency: + group: justdummies-mutation-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Least privilege: this workflow only checks out, builds and runs tests, so the token +# needs nothing beyond read access to the repository contents. +permissions: + contents: read + +env: + DOTNET_NOLOGO: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' + +jobs: + # The gate. One leg per shipped library, each mutating ONLY what the pull request changed + # (Stryker's `--since`), so the cost follows the diff and not the size of the library. + # + # `gate` below aggregates these legs into the single check to mark as required on `main`. + changed: + name: Mutate the diff (${{ matrix.name }}) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + # A leg whose library the pull request did not touch finishes in ~2 min (analysis, build, initial + # test run, mutant generation). The cap covers the worst realistic case instead: `JustDummies` is + # the largest library in the repository, and Stryker selects per changed FILE, so touching one of + # its bigger generators puts that whole file's mutants on the gate. + timeout-minutes: 60 + strategy: + # Run both legs to completion: a survivor in the adapter is worth seeing even when the + # generator is red. + fail-fast: false + matrix: + # The two JustDummies packages this repository SHIPS: the generator and its xUnit v3 adapter + # (ADR-0039). The FirstClassErrors libraries live in `mutation.yml`. See ADR-0043. + include: + - name: justdummies + config: build/stryker/justdummies.json + - name: justdummies-xunit + config: build/stryker/justdummies-xunit.json + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + # Stryker's `--since` diffs the working tree against a commit, so the full history must be + # present; a shallow clone would leave that commit unreachable. + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.0.x' + + - name: Restore the local tools + # Pins dotnet-stryker through .config/dotnet-tools.json, so CI and a maintainer's machine run + # the same mutation engine — a newer engine invents new mutants and would move every score on + # its own, without a line of code changing. + run: dotnet tool restore + + # `pull_request` checks out the merge commit, so what this branch actually changed is measured + # from the FORK POINT — not from the base branch tip, which may have moved on since the branch + # was cut and would drag every file changed on `main` in the meantime into the "changed" set. + - name: Resolve the fork point + id: base + # A real commit SHA, not a rev expression: `--since:HEAD` is rejected outright by Stryker + # ("No branch or tag or commit found with given target"). `set -e` makes a failed merge-base + # fail the step, rather than handing Stryker an empty target that fails it later and vaguer. + run: | + set -euo pipefail + base=$(git merge-base '${{ github.event.pull_request.base.sha }}' HEAD) + echo "fork point: $base" + echo "sha=$base" >> "$GITHUB_OUTPUT" + + - name: Mutate the changed files + # The runner mode, the thresholds and the reporters all live in the config file, so this is + # the same command a maintainer runs locally. `--break-at` is deliberately NOT passed here: + # overriding the threshold from the YAML is what would let CI and a local run disagree. + # + # Stryker exits 0 — "unable to calculate a mutation score" — when the diff touches none of + # this library's sources, which is the common case for a leg nobody is working on. + run: >- + dotnet stryker --config-file ${{ matrix.config }} + --since:${{ steps.base.outputs.sha }} + --output artifacts/mutation/${{ matrix.name }} + + - name: Summarise the surviving mutants + # The score alone does not say what to fix. This turns the JSON report into the actionable + # half — file, line and the kind of rewrite that went unnoticed — in the run summary, so a + # failing gate can be diagnosed without downloading the artifact. `if: always()` because the + # interesting case is the failing one. + if: always() + run: | + set -euo pipefail + report="artifacts/mutation/${{ matrix.name }}/reports/mutation-report.json" + if [ ! -f "$report" ]; then + echo "${{ matrix.name }}: the diff selected no mutant" | tee -a "$GITHUB_STEP_SUMMARY" + exit 0 + fi + # Stryker writes a report even when it selects nothing, so "no mutant" has to be read from + # the report's contents, not from the file's absence: count the mutants that were actually + # put to the test, i.e. neither filtered out nor rejected by the compiler. + tested=$(jq '[.files[].mutants[] + | select(.status != "Ignored" and .status != "CompileError")] | length' "$report") + rows=$(jq -r --arg ws "$GITHUB_WORKSPACE/" ' + .files | to_entries[] | .key as $f | .value.mutants[] + | select(.status == "Survived" or .status == "Timeout" or .status == "NoCoverage") + | "| \(.status) | \($f | ltrimstr($ws)):\(.location.start.line) | \(.mutatorName) |" + ' "$report") + { + echo "### Mutation — ${{ matrix.name }}" + echo + if [ "$tested" -eq 0 ]; then + echo "This pull request changed nothing this library is mutated from." + elif [ -z "$rows" ]; then + echo "All $tested mutants were killed." + else + echo "| Status | Location | Mutation |" + echo "| --- | --- | --- |" + echo "$rows" + fi + } | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload the mutation report + # Always: the report is most useful precisely when the run failed the threshold — the HTML + # view shows each surviving mutant in its source, which the summary table cannot. + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: mutation-report-${{ matrix.name }} + path: artifacts/mutation/${{ matrix.name }}/reports/ + # A leg that selected no mutant writes no report; that is a pass, not a packaging failure. + if-no-files-found: ignore + + # The single check to mark as required on `main` for the JustDummies packages. Separate from + # `mutation.yml`'s gate on purpose: the two are independent quality bars for what will become two + # repositories, and marking them required is one branch-protection entry each. + gate: + name: JustDummies mutation gate + if: always() && github.event_name == 'pull_request' + needs: changed + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check the mutation legs + # `needs.changed.result` is the matrix's aggregate: 'success' only when every leg succeeded. + # 'skipped' is accepted so the job stays green when the whole matrix is skipped by design. + run: | + result='${{ needs.changed.result }}' + echo "mutation legs: $result" + if [ "$result" != "success" ] && [ "$result" != "skipped" ]; then + echo "::error::the JustDummies mutation gate failed — see the 'Mutate the diff' legs and their reports" + exit 1 + fi + + # The weekly sweep: every mutant of both JustDummies packages, not just the ones a pull request + # touched. Advisory by construction — `--break-at 0` disables the threshold — because its job is to + # publish a trend, not to turn `main` red on a Monday morning over code nobody changed. + full: + name: Full sweep (${{ matrix.name }}) + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + # The sweep runs the library's whole test suite once per mutant, and `JustDummies` carries a few + # thousand of them — this is the longest job in the repository, and the reason the sweep is + # weekly and the gate is diff-scoped. + timeout-minutes: 180 + strategy: + fail-fast: false + matrix: + include: + - name: justdummies + config: build/stryker/justdummies.json + - name: justdummies-xunit + config: build/stryker/justdummies-xunit.json + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.0.x' + + - name: Restore the local tools + run: dotnet tool restore + + - name: Mutate everything + run: >- + dotnet stryker --config-file ${{ matrix.config }} + --break-at 0 + --output artifacts/mutation/${{ matrix.name }} + + - name: Summarise the surviving mutants + # Same table as the gate legs produce. On the sweep it is the deliverable, not a diagnosis + # aid: this is the list of behaviours nothing in the suite asserts, library by library. + if: always() + run: | + set -euo pipefail + report="artifacts/mutation/${{ matrix.name }}/reports/mutation-report.json" + if [ ! -f "$report" ]; then + echo "${{ matrix.name }}: no report produced" | tee -a "$GITHUB_STEP_SUMMARY" + exit 0 + fi + # Stryker writes a report even when it selects nothing, so "no mutant" has to be read from + # the report's contents, not from the file's absence: count the mutants that were actually + # put to the test, i.e. neither filtered out nor rejected by the compiler. + tested=$(jq '[.files[].mutants[] + | select(.status != "Ignored" and .status != "CompileError")] | length' "$report") + rows=$(jq -r --arg ws "$GITHUB_WORKSPACE/" ' + .files | to_entries[] | .key as $f | .value.mutants[] + | select(.status == "Survived" or .status == "Timeout" or .status == "NoCoverage") + | "| \(.status) | \($f | ltrimstr($ws)):\(.location.start.line) | \(.mutatorName) |" + ' "$report") + { + echo "### Mutation sweep — ${{ matrix.name }}" + echo + if [ "$tested" -eq 0 ]; then + echo "No mutant was selected — check the run's Stryker output." + elif [ -z "$rows" ]; then + echo "All $tested mutants were killed." + else + echo "| Status | Location | Mutation |" + echo "| --- | --- | --- |" + echo "$rows" + fi + } | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload the mutation report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: mutation-report-full-${{ matrix.name }} + path: artifacts/mutation/${{ matrix.name }}/reports/ + # The sweep always produces a report; nothing to upload means the run never got that far. + if-no-files-found: error diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 00000000..9dc6309b --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,258 @@ +name: mutation + +# Mutation testing for the FirstClassErrors libraries. JustDummies has its own, identical workflow +# — `justdummies-mutation.yml` — because it is destined for a repository of its own: the split has to +# be a file move, not an edit of a shared matrix. Keep the two in step when you change one. + +on: + pull_request: + branches: + - main + # Weekly full sweep. The pull-request legs only mutate what a pull request touched, so nothing ever + # re-measures the parts of the libraries no one has edited: this is the run that does. + schedule: + - cron: '23 3 * * 1' + workflow_dispatch: + +# Cancel superseded runs on the same branch / PR. +concurrency: + group: mutation-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Least privilege: this workflow only checks out, builds and runs tests, so the token +# needs nothing beyond read access to the repository contents. +permissions: + contents: read + +env: + DOTNET_NOLOGO: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' + +jobs: + # The gate. One leg per shipped library, each mutating ONLY what the pull request changed + # (Stryker's `--since`), so the cost follows the diff and not the size of the library. + # + # `gate` below aggregates these legs into the single check to mark as required on `main`. + changed: + name: Mutate the diff (${{ matrix.name }}) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + # A leg whose library the pull request did not touch finishes in ~2 min (analysis, build, initial + # test run, mutant generation). The cap covers the worst realistic case instead: a pull request + # touching one of the large files — Stryker selects per changed FILE, so `Outcome.cs` alone puts + # ~280 mutants on the gate, each costing one run of the library's test suite. + timeout-minutes: 60 + strategy: + # Run every leg to completion: a survivor in the binder is worth seeing even when core is red. + fail-fast: false + matrix: + # The three FirstClassErrors libraries this repository SHIPS. The `fce` tooling and the Roslyn + # analyzers are deliberately out of scope: their tests drive Roslyn compilations and spawn + # processes, so the per-mutant cost is an order of magnitude higher, and their behaviour is + # already pinned end to end by the `analyzers`, `gendoc-docs` and `ci` (floor) workflows. + # JustDummies and JustDummies.Xunit live in `justdummies-mutation.yml`. See ADR-0043. + include: + - name: core + config: build/stryker/core.json + - name: testing + config: build/stryker/testing.json + - name: binder + config: build/stryker/binder.json + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + # Stryker's `--since` diffs the working tree against a commit, so the full history must be + # present; a shallow clone would leave that commit unreachable. + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.0.x' + + - name: Restore the local tools + # Pins dotnet-stryker through .config/dotnet-tools.json, so CI and a maintainer's machine run + # the same mutation engine — a newer engine invents new mutants and would move every score on + # its own, without a line of code changing. + run: dotnet tool restore + + # `pull_request` checks out the merge commit, so what this branch actually changed is measured + # from the FORK POINT — not from the base branch tip, which may have moved on since the branch + # was cut and would drag every file changed on `main` in the meantime into the "changed" set. + - name: Resolve the fork point + id: base + # A real commit SHA, not a rev expression: `--since:HEAD` is rejected outright by Stryker + # ("No branch or tag or commit found with given target"). `set -e` makes a failed merge-base + # fail the step, rather than handing Stryker an empty target that fails it later and vaguer. + run: | + set -euo pipefail + base=$(git merge-base '${{ github.event.pull_request.base.sha }}' HEAD) + echo "fork point: $base" + echo "sha=$base" >> "$GITHUB_OUTPUT" + + - name: Mutate the changed files + # The runner mode, the thresholds and the reporters all live in the config file, so this is + # the same command a maintainer runs locally. `--break-at` is deliberately NOT passed here: + # overriding the threshold from the YAML is what would let CI and a local run disagree. + # + # Stryker exits 0 — "unable to calculate a mutation score" — when the diff touches none of + # this library's sources, which is the common case for a leg nobody is working on. + run: >- + dotnet stryker --config-file ${{ matrix.config }} + --since:${{ steps.base.outputs.sha }} + --output artifacts/mutation/${{ matrix.name }} + + - name: Summarise the surviving mutants + # The score alone does not say what to fix. This turns the JSON report into the actionable + # half — file, line and the kind of rewrite that went unnoticed — in the run summary, so a + # failing gate can be diagnosed without downloading the artifact. `if: always()` because the + # interesting case is the failing one. + if: always() + run: | + set -euo pipefail + report="artifacts/mutation/${{ matrix.name }}/reports/mutation-report.json" + if [ ! -f "$report" ]; then + echo "${{ matrix.name }}: the diff selected no mutant" | tee -a "$GITHUB_STEP_SUMMARY" + exit 0 + fi + # Stryker writes a report even when it selects nothing, so "no mutant" has to be read from + # the report's contents, not from the file's absence: count the mutants that were actually + # put to the test, i.e. neither filtered out nor rejected by the compiler. + tested=$(jq '[.files[].mutants[] + | select(.status != "Ignored" and .status != "CompileError")] | length' "$report") + rows=$(jq -r --arg ws "$GITHUB_WORKSPACE/" ' + .files | to_entries[] | .key as $f | .value.mutants[] + | select(.status == "Survived" or .status == "Timeout" or .status == "NoCoverage") + | "| \(.status) | \($f | ltrimstr($ws)):\(.location.start.line) | \(.mutatorName) |" + ' "$report") + { + echo "### Mutation — ${{ matrix.name }}" + echo + if [ "$tested" -eq 0 ]; then + echo "This pull request changed nothing this library is mutated from." + elif [ -z "$rows" ]; then + echo "All $tested mutants were killed." + else + echo "| Status | Location | Mutation |" + echo "| --- | --- | --- |" + echo "$rows" + fi + } | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload the mutation report + # Always: the report is most useful precisely when the run failed the threshold — the HTML + # view shows each surviving mutant in its source, which the summary table cannot. + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: mutation-report-${{ matrix.name }} + path: artifacts/mutation/${{ matrix.name }}/reports/ + # A leg that selected no mutant writes no report; that is a pass, not a packaging failure. + if-no-files-found: ignore + + # The single check to mark as required on `main` for the FirstClassErrors libraries. A matrix + # produces one check per leg, and the set of leg names would have to be re-declared in the branch + # protection every time the matrix changes; this job collapses them into one stable name. + # `if: always()` is what makes it run after a failed leg — without it the job would be skipped, and + # GitHub reports a skipped required check as success. + gate: + name: Mutation gate + if: always() && github.event_name == 'pull_request' + needs: changed + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check the mutation legs + # `needs.changed.result` is the matrix's aggregate: 'success' only when every leg succeeded. + # 'skipped' is accepted so the job stays green when the whole matrix is skipped by design. + run: | + result='${{ needs.changed.result }}' + echo "mutation legs: $result" + if [ "$result" != "success" ] && [ "$result" != "skipped" ]; then + echo "::error::the mutation gate failed — see the 'Mutate the diff' legs and their reports" + exit 1 + fi + + # The weekly sweep: every mutant of every FirstClassErrors library, not just the ones a pull request + # touched. Advisory by construction — `--break-at 0` disables the threshold — because its job is to + # publish a trend, not to turn `main` red on a Monday morning over code nobody changed. + full: + name: Full sweep (${{ matrix.name }}) + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + # The sweep runs the library's whole test suite once per mutant. `core` carries a few hundred of + # them, which is the reason the sweep is weekly and the gate is diff-scoped. + timeout-minutes: 180 + strategy: + fail-fast: false + matrix: + include: + - name: core + config: build/stryker/core.json + - name: testing + config: build/stryker/testing.json + - name: binder + config: build/stryker/binder.json + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.0.x' + + - name: Restore the local tools + run: dotnet tool restore + + - name: Mutate everything + run: >- + dotnet stryker --config-file ${{ matrix.config }} + --break-at 0 + --output artifacts/mutation/${{ matrix.name }} + + - name: Summarise the surviving mutants + # Same table as the gate legs produce. On the sweep it is the deliverable, not a diagnosis + # aid: this is the list of behaviours nothing in the suite asserts, library by library. + if: always() + run: | + set -euo pipefail + report="artifacts/mutation/${{ matrix.name }}/reports/mutation-report.json" + if [ ! -f "$report" ]; then + echo "${{ matrix.name }}: no report produced" | tee -a "$GITHUB_STEP_SUMMARY" + exit 0 + fi + # Stryker writes a report even when it selects nothing, so "no mutant" has to be read from + # the report's contents, not from the file's absence: count the mutants that were actually + # put to the test, i.e. neither filtered out nor rejected by the compiler. + tested=$(jq '[.files[].mutants[] + | select(.status != "Ignored" and .status != "CompileError")] | length' "$report") + rows=$(jq -r --arg ws "$GITHUB_WORKSPACE/" ' + .files | to_entries[] | .key as $f | .value.mutants[] + | select(.status == "Survived" or .status == "Timeout" or .status == "NoCoverage") + | "| \(.status) | \($f | ltrimstr($ws)):\(.location.start.line) | \(.mutatorName) |" + ' "$report") + { + echo "### Mutation sweep — ${{ matrix.name }}" + echo + if [ "$tested" -eq 0 ]; then + echo "No mutant was selected — check the run's Stryker output." + elif [ -z "$rows" ]; then + echo "All $tested mutants were killed." + else + echo "| Status | Location | Mutation |" + echo "| --- | --- | --- |" + echo "$rows" + fi + } | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Upload the mutation report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: mutation-report-full-${{ matrix.name }} + path: artifacts/mutation/${{ matrix.name }}/reports/ + # The sweep always produces a report; nothing to upload means the run never got that far. + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 018e3e99..7fdfff19 100644 --- a/.gitignore +++ b/.gitignore @@ -299,3 +299,7 @@ tools/floor-check/build.log tools/justdummies-check/local-feed/ tools/justdummies-check/packages/ tools/justdummies-check/*.log +# Stryker mutation runs (the `mutation` workflow, or `dotnet stryker` locally): reports and the temporary +# copies of the solution Stryker compiles the mutants in. The workflow writes under artifacts/ (already +# ignored above); a local run defaults to StrykerOutput/ next to the config file. +StrykerOutput/ diff --git a/build/stryker/binder.json b/build/stryker/binder.json new file mode 100644 index 00000000..59e20cec --- /dev/null +++ b/build/stryker/binder.json @@ -0,0 +1,21 @@ +{ + "stryker-config": { + "solution": "FirstClassErrors.sln", + "project": "FirstClassErrors.RequestBinder.csproj", + "test-projects": [ + "FirstClassErrors.RequestBinder.UnitTests/FirstClassErrors.RequestBinder.UnitTests.csproj", + "FirstClassErrors.RequestBinder.PropertyTests/FirstClassErrors.RequestBinder.PropertyTests.csproj" + ], + "test-runner": "mtp", + "coverage-analysis": "off", + "reporters": [ + "html", + "json" + ], + "thresholds": { + "high": 100, + "low": 98, + "break": 95 + } + } +} diff --git a/build/stryker/core.json b/build/stryker/core.json new file mode 100644 index 00000000..3178b7d9 --- /dev/null +++ b/build/stryker/core.json @@ -0,0 +1,21 @@ +{ + "stryker-config": { + "solution": "FirstClassErrors.sln", + "project": "FirstClassErrors.csproj", + "test-projects": [ + "FirstClassErrors.UnitTests/FirstClassErrors.UnitTests.csproj", + "FirstClassErrors.PropertyTests/FirstClassErrors.PropertyTests.csproj" + ], + "test-runner": "mtp", + "coverage-analysis": "off", + "reporters": [ + "html", + "json" + ], + "thresholds": { + "high": 100, + "low": 98, + "break": 95 + } + } +} diff --git a/build/stryker/justdummies-xunit.json b/build/stryker/justdummies-xunit.json new file mode 100644 index 00000000..f6f89a2b --- /dev/null +++ b/build/stryker/justdummies-xunit.json @@ -0,0 +1,20 @@ +{ + "stryker-config": { + "solution": "FirstClassErrors.sln", + "project": "JustDummies.Xunit.csproj", + "test-projects": [ + "JustDummies.Xunit.UnitTests/JustDummies.Xunit.UnitTests.csproj" + ], + "test-runner": "mtp", + "coverage-analysis": "off", + "reporters": [ + "html", + "json" + ], + "thresholds": { + "high": 85, + "low": 70, + "break": 50 + } + } +} diff --git a/build/stryker/justdummies.json b/build/stryker/justdummies.json new file mode 100644 index 00000000..f0b91dc4 --- /dev/null +++ b/build/stryker/justdummies.json @@ -0,0 +1,21 @@ +{ + "stryker-config": { + "solution": "FirstClassErrors.sln", + "project": "JustDummies.csproj", + "test-projects": [ + "JustDummies.UnitTests/JustDummies.UnitTests.csproj", + "JustDummies.PropertyTests/JustDummies.PropertyTests.csproj" + ], + "test-runner": "mtp", + "coverage-analysis": "off", + "reporters": [ + "html", + "json" + ], + "thresholds": { + "high": 95, + "low": 85, + "break": 0 + } + } +} diff --git a/build/stryker/testing.json b/build/stryker/testing.json new file mode 100644 index 00000000..fc74b95d --- /dev/null +++ b/build/stryker/testing.json @@ -0,0 +1,20 @@ +{ + "stryker-config": { + "solution": "FirstClassErrors.sln", + "project": "FirstClassErrors.Testing.csproj", + "test-projects": [ + "FirstClassErrors.Testing.UnitTests/FirstClassErrors.Testing.UnitTests.csproj" + ], + "test-runner": "mtp", + "coverage-analysis": "off", + "reporters": [ + "html", + "json" + ], + "thresholds": { + "high": 80, + "low": 60, + "break": 40 + } + } +} From 6db70e43ffc61e86f25cccbe66bdc5639dfd1239 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 13:07:37 +0000 Subject: [PATCH 3/4] docs: document the mutation workflows Adds the bilingual reference pages for `mutation` and `justdummies-mutation`, indexes both in the workflow reference, and points CLAUDE.md's build & test section at them. The `mutation` page carries the shared mechanics; the JustDummies page carries what is specific to it and defers to the other rather than duplicating prose, plus the checklist for the day JustDummies moves to its own repository and the reason its own score gate is still off. Between them the pages record what measurement established, and what a reader would otherwise re-discover the hard way: why the Microsoft Testing Platform runner is mandatory rather than preferred, why coverage-based selection is off, that `--since` selects per changed file and rejects `HEAD` as a target, that the CI warning ratchet turns out not to reach Stryker's mutant compilation, where the per-library thresholds come from, and how to answer an equivalent mutant with a `// Stryker disable` comment rather than a lowered threshold. --- CLAUDE.md | 9 + .../for-maintainers/workflows/README.fr.md | 2 + .../for-maintainers/workflows/README.md | 2 + .../workflows/justdummies-mutation.en.md | 140 +++++++++ .../workflows/justdummies-mutation.fr.md | 154 ++++++++++ .../for-maintainers/workflows/mutation.en.md | 256 ++++++++++++++++ .../for-maintainers/workflows/mutation.fr.md | 280 ++++++++++++++++++ 7 files changed, 843 insertions(+) create mode 100644 doc/handwritten/for-maintainers/workflows/justdummies-mutation.en.md create mode 100644 doc/handwritten/for-maintainers/workflows/justdummies-mutation.fr.md create mode 100644 doc/handwritten/for-maintainers/workflows/mutation.en.md create mode 100644 doc/handwritten/for-maintainers/workflows/mutation.fr.md diff --git a/CLAUDE.md b/CLAUDE.md index 7ba54a5a..2b119b51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,15 @@ errors should stay structured, documented, and close to the code. to apply it are in [`doc/handwritten/for-maintainers/WritingJustDummiesTests.en.md`](doc/handwritten/for-maintainers/WritingJustDummiesTests.en.md) (decision: ADR-0040). Read it before adding a JustDummies test. +* Mutation testing gates every pull request on the files it changed, for the five + shipped libraries, through two independent checks — one for the FirstClassErrors + libraries, one for the JustDummies packages (decision: ADR-0043). A test that + *executes* new code without *asserting* it will pass `dotnet test` and still + fail that gate. Reproduce it on a branch with + `dotnet tool restore && dotnet stryker --config-file build/stryker/.json --since:$(git merge-base origin/main HEAD)`; + the configurations and the reasons behind them are in + [`mutation.en.md`](doc/handwritten/for-maintainers/workflows/mutation.en.md) and + [`justdummies-mutation.en.md`](doc/handwritten/for-maintainers/workflows/justdummies-mutation.en.md). * Only report tests as passing if you actually ran the corresponding command. * If you did not run a relevant command, say so explicitly. diff --git a/doc/handwritten/for-maintainers/workflows/README.fr.md b/doc/handwritten/for-maintainers/workflows/README.fr.md index 9812fd73..55ce45d9 100644 --- a/doc/handwritten/for-maintainers/workflows/README.fr.md +++ b/doc/handwritten/for-maintainers/workflows/README.fr.md @@ -68,6 +68,8 @@ documentées une seule fois ici plutôt que répétées sur chaque page. | --- | --- | | [`ci`](ci.fr.md) | Construit et teste toute la solution sous Linux et Windows, avec couverture. Le barrage principal. | | [`sonar`](sonar.fr.md) | Analyse SonarQube Cloud — quality gate et remontée de couverture. | +| [`mutation`](mutation.fr.md) | Tests de mutation des bibliothèques FirstClassErrors avec Stryker.NET — check obligatoire sur ce qu'une PR modifie, plus un balayage complet hebdomadaire. | +| [`justdummies-mutation`](justdummies-mutation.fr.md) | Idem pour les packages JustDummies, avec son propre check obligatoire — séparé pour que la future séparation de dépôt soit un déplacement de fichier. | | [`analyzers`](analyzers.fr.md) | Dogfood des analyzers Roslyn embarqués, y compris sur le plus vieux compilateur supporté (le floor Roslyn). | | [`commit-lint`](commit-lint.fr.md) | Impose la convention Conventional Commits sur chaque commit de PR, via le même script que le hook local. | | [`adr-check`](adr-check.fr.md) | Consultatif, dispatch manuel : confronte une branche à la base d'ADR (nouvelle décision / remplacement / conflit). Le repli pour les contributeurs sans Claude Code ; ne bloque jamais. | diff --git a/doc/handwritten/for-maintainers/workflows/README.md b/doc/handwritten/for-maintainers/workflows/README.md index c35927db..e50cf6ee 100644 --- a/doc/handwritten/for-maintainers/workflows/README.md +++ b/doc/handwritten/for-maintainers/workflows/README.md @@ -64,6 +64,8 @@ here instead of being repeated on every page. | --- | --- | | [`ci`](ci.en.md) | Build and test the whole solution on Linux and Windows, with coverage. The primary gate. | | [`sonar`](sonar.en.md) | SonarQube Cloud analysis — quality gate and coverage reporting. | +| [`mutation`](mutation.en.md) | Mutation testing of the FirstClassErrors libraries with Stryker.NET — a required check on what a PR changed, plus a weekly full sweep. | +| [`justdummies-mutation`](justdummies-mutation.en.md) | The same, for the JustDummies packages, with its own required check — kept separate so the future repository split is a file move. | | [`analyzers`](analyzers.en.md) | Dogfood the bundled Roslyn analyzers, including on the oldest supported compiler (the Roslyn floor). | | [`commit-lint`](commit-lint.en.md) | Enforce the Conventional Commits convention on every PR commit, using the same script as the local hook. | | [`adr-check`](adr-check.en.md) | Advisory, manual dispatch: check a branch against the ADR base (new decision / supersede / conflict). The fallback for contributors without Claude Code; never blocks. | diff --git a/doc/handwritten/for-maintainers/workflows/justdummies-mutation.en.md b/doc/handwritten/for-maintainers/workflows/justdummies-mutation.en.md new file mode 100644 index 00000000..85977c66 --- /dev/null +++ b/doc/handwritten/for-maintainers/workflows/justdummies-mutation.en.md @@ -0,0 +1,140 @@ +# `justdummies-mutation` workflow + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](justdummies-mutation.fr.md) + +> Maintainer documentation — part of the [workflow reference](README.md). +> Not part of the user documentation under `doc/`. + +**Workflow file:** [`.github/workflows/justdummies-mutation.yml`](../../../../.github/workflows/justdummies-mutation.yml) + +## What it is for + +Mutation testing for the **two JustDummies packages** — `JustDummies` and its +xUnit v3 adapter `JustDummies.Xunit` ([ADR-0039](../adr/0039-adapt-dummies-to-xunit-v3-through-a-companion-package.md)). +On a pull request it mutates only the files the pull request changed and fails +when the score falls under the library's threshold; a weekly sweep measures +everything else. What mutation testing *is*, and why this repository gates on +it, is explained once on the [`mutation`](mutation.en.md) page — this workflow is +the same machine with a different matrix. + +## Why it is a separate workflow + +`JustDummies` is a standalone, error-agnostic package that deliberately holds no +reference to `FirstClassErrors` ([ADR-0011](../adr/0011-host-dummies-as-a-standalone-package.md)), +and it is headed for a repository of its own. Splitting the mutation gate along +that future boundary now means the move is a **file move rather than an edit**: +nothing in this workflow names a FirstClassErrors project, and nothing in +[`mutation`](mutation.en.md) names a JustDummies one. + +It also gives JustDummies its **own required check**, +**`JustDummies mutation gate`**, independent of the FirstClassErrors one. Two +gates, two branch-protection entries, two bars that move independently — which is +what two libraries at different levels of test maturity need anyway. + +## When it runs + +- On every **pull request targeting `main`** — diff-scoped. **This is the gate.** +- **Weekly** on a schedule (Monday, 03:47 UTC) — the full sweep, advisory. The + slot is offset from `mutation`'s so the two sweeps do not contend for runners. +- On demand via **`workflow_dispatch`** — the full sweep. + +## How it runs + +Identically to [`mutation`](mutation.en.md), whose page documents the mechanism +in full: `changed` mutates the diff from the fork point, `gate` collapses the +matrix into one stable check name, `full` sweeps everything with the threshold +disabled. The per-library Stryker configurations are +[`build/stryker/justdummies.json`](../../../../build/stryker/justdummies.json) +and [`build/stryker/justdummies-xunit.json`](../../../../build/stryker/justdummies-xunit.json). + +Two points from that page matter more here than anywhere else: + +- **`JustDummies` is the largest library in the repository** — a few thousand + mutants — so its full sweep is the longest job the repository runs. That is the + whole reason the gate is diff-scoped rather than a full sweep per pull request. +- **`"test-runner": "mtp"` and `"coverage-analysis": "off"` are not tuning knobs.** + With Stryker's default VSTest runner these suites score 0 % — every mutant + reported as survived, because the runner cannot activate a mutant in an xUnit v3 + test project. Read + [that section](mutation.en.md#two-settings-that-are-not-tuning-knobs) before + changing either. + +## `JustDummies` has no score threshold yet + +Every other library's bar was set from a measured full sweep of that library +([how and why](mutation.en.md#where-the-thresholds-come-from)). `JustDummies` was +not: it carries a few thousand mutants over a heavy suite, its full sweep runs +well past an hour, and **no score for it has been measured**. Rather than invent +a number, [`justdummies.json`](../../../../build/stryker/justdummies.json) sets +`break` to **0** — the score gate for this one library is off. + +That is deliberate and it is temporary. The leg still runs, still fails on a +broken build or a failing suite, and still lists its surviving mutants in the run +summary; what it does not yet do is refuse a pull request over a score. **The +first weekly sweep publishes the library-wide figure** — that is the run this +threshold is waiting on. Read it, and set `break` from it exactly as the other +libraries' bars were set. + +`JustDummies.Xunit` needs no such caveat: it is small enough that its bar came +from a full sweep like the rest, and it gates normally. + +## Permissions & security + +`contents: read` only. The workflow checks out, builds and runs tests; it stores +no secret and needs no write scope. + +## When JustDummies moves to its own repository + +Take, unchanged: + +- this workflow file, renamed to `mutation.yml` there (and its `name:` with it); +- [`build/stryker/justdummies.json`](../../../../build/stryker/justdummies.json) + and [`build/stryker/justdummies-xunit.json`](../../../../build/stryker/justdummies-xunit.json); +- [`.config/dotnet-tools.json`](../../../../.config/dotnet-tools.json) — the + Stryker pin; +- this page, plus the shared sections of [`mutation`](mutation.en.md) folded into + it, since the page it defers to will not exist over there. + +Then change exactly one thing: the **`solution`** field in the two +configurations, which still names `FirstClassErrors.sln`. The `project` and +`test-projects` paths are already repository-relative and unchanged by the move. + +On this side, delete this workflow, its configurations and this page, and drop +the `JustDummies mutation gate` entry from the branch protection. + +## Handle with care + +- **Keep this workflow and [`mutation`](mutation.en.md) in step.** They are + duplicated on purpose — that is what makes the split a file move — so a fix to + one is a fix to both until the split happens. +- Everything under + [*Handle with care* on the `mutation` page](mutation.en.md#handle-with-care) + applies here word for word: `fetch-depth: 0`, `--since` rejecting `HEAD`, + `if: always()` on `gate`, the pinned engine, where the thresholds live. + +## Running it locally + +```bash +dotnet tool restore +dotnet stryker --config-file build/stryker/justdummies.json +``` + +That is the full sweep of the largest library and it takes a while. To reproduce +what the gate does on a branch: + +```bash +dotnet stryker --config-file build/stryker/justdummies.json --since:$(git merge-base origin/main HEAD) +``` + +Reports land in `StrykerOutput/` (git-ignored); open `reports/mutation-report.html`. + +## Related + +- [`mutation`](mutation.en.md) — the same machine for the FirstClassErrors + libraries, and where the mechanism is documented in full. +- [`justdummies`](../../../../.github/workflows/justdummies.yml) *(no reference + page yet)* — the other JustDummies-scoped workflow: it proves the packaged + `netstandard2.0` and `net8.0` assets behave on their own runtimes. +- [ADR 0043 — Gate pull requests on the mutation score of what they + changed](../adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md) + — the decision both workflows implement. diff --git a/doc/handwritten/for-maintainers/workflows/justdummies-mutation.fr.md b/doc/handwritten/for-maintainers/workflows/justdummies-mutation.fr.md new file mode 100644 index 00000000..ec73d09a --- /dev/null +++ b/doc/handwritten/for-maintainers/workflows/justdummies-mutation.fr.md @@ -0,0 +1,154 @@ +# Workflow `justdummies-mutation` + +🌍 🇬🇧 [English](justdummies-mutation.en.md) · 🇫🇷 Français (ce fichier) + +> Documentation mainteneur — fait partie de la [référence des workflows](README.fr.md). +> Ne fait pas partie de la documentation utilisateur sous `doc/`. + +**Fichier du workflow :** [`.github/workflows/justdummies-mutation.yml`](../../../../.github/workflows/justdummies-mutation.yml) + +## À quoi il sert + +Les tests de mutation des **deux packages JustDummies** : `JustDummies` et son +adaptateur xUnit v3 `JustDummies.Xunit` +([ADR-0039](../adr/0039-adapt-dummies-to-xunit-v3-through-a-companion-package.fr.md)). +Sur une pull request, il ne mute que les fichiers modifiés par celle-ci et échoue +si le score passe sous le seuil de la bibliothèque ; un balayage hebdomadaire +mesure tout le reste. Ce que *sont* les tests de mutation, et pourquoi ce dépôt +en fait un barrage, est expliqué une seule fois sur la page +[`mutation`](mutation.fr.md) — ce workflow est la même machine avec une matrice +différente. + +## Pourquoi un workflow séparé + +`JustDummies` est un package autonome et agnostique des erreurs, qui ne référence +volontairement pas `FirstClassErrors` +([ADR-0011](../adr/0011-host-dummies-as-a-standalone-package.fr.md)), et il est +destiné à un dépôt à lui. Découper le barrage de mutation le long de cette +frontière future dès maintenant fait de la migration **un déplacement de fichier +plutôt qu'une réécriture** : rien dans ce workflow ne nomme un projet +FirstClassErrors, et rien dans [`mutation`](mutation.fr.md) ne nomme un projet +JustDummies. + +Cela donne aussi à JustDummies **son propre check obligatoire**, +**`JustDummies mutation gate`**, indépendant de celui de FirstClassErrors. Deux +barrages, deux entrées de protection de branche, deux barres qui évoluent +séparément — ce dont deux bibliothèques de maturité de test différente ont de +toute façon besoin. + +## Quand il s'exécute + +- Sur chaque **pull request ciblant `main`** — cantonné au diff. **C'est le + barrage.** +- **Chaque semaine** sur planification (lundi, 03h47 UTC) — le balayage complet, + consultatif. Le créneau est décalé de celui de `mutation` pour que les deux + balayages ne se disputent pas les runners. +- À la demande via **`workflow_dispatch`** — le balayage complet. + +## Comment il s'exécute + +À l'identique de [`mutation`](mutation.fr.md), dont la page documente le +mécanisme en entier : `changed` mute le diff depuis le point de fourche, `gate` +regroupe la matrice sous un nom de check stable, `full` balaie tout avec le seuil +désactivé. Les configurations Stryker sont +[`build/stryker/justdummies.json`](../../../../build/stryker/justdummies.json) et +[`build/stryker/justdummies-xunit.json`](../../../../build/stryker/justdummies-xunit.json). + +Deux points de cette page comptent ici plus qu'ailleurs : + +- **`JustDummies` est la plus grosse bibliothèque du dépôt** — quelques milliers + de mutants — et son balayage complet est donc le job le plus long que le dépôt + exécute. C'est toute la raison pour laquelle le barrage est cantonné au diff + plutôt qu'un balayage complet par pull request. +- **`"test-runner": "mtp"` et `"coverage-analysis": "off"` ne sont pas des + réglages de confort.** Avec le runner VSTest par défaut de Stryker, ces suites + scorent 0 % — tous les mutants rapportés survivants, parce que le runner ne sait + pas activer un mutant dans un projet de tests xUnit v3. Lisez + [cette section](mutation.fr.md#deux-réglages-qui-nen-sont-pas) avant de toucher + à l'un ou à l'autre. + +## `JustDummies` n'a pas encore de seuil de score + +La barre de chaque autre bibliothèque a été fixée à partir d'un balayage complet +mesuré sur cette bibliothèque +([comment et pourquoi](mutation.fr.md#doù-viennent-les-seuils)). Pas celle de +`JustDummies` : elle porte quelques milliers de mutants sur une suite lourde, son +balayage complet dépasse largement l'heure, et **aucun score n'a été mesuré pour +elle**. Plutôt que d'inventer un chiffre, +[`justdummies.json`](../../../../build/stryker/justdummies.json) met `break` à +**0** — le barrage sur le score est coupé pour cette seule bibliothèque. + +C'est délibéré et c'est temporaire. La branche s'exécute toujours, échoue toujours +sur un build cassé ou une suite en échec, et liste toujours ses mutants +survivants dans le résumé du run ; ce qu'elle ne fait pas encore, c'est refuser +une pull request sur un score. **Le premier balayage hebdomadaire publie le +chiffre sur toute la bibliothèque** — c'est ce run que ce seuil attend. +Lisez-le, et fixez `break` à partir de là, exactement comme les barres des autres +bibliothèques l'ont été. + +`JustDummies.Xunit` n'appelle pas cette réserve : elle est assez petite pour que +sa barre vienne d'un balayage complet comme les autres, et elle barre normalement. + +## Permissions & sécurité + +`contents: read` seulement. Le workflow fait un checkout, un build et lance des +tests ; il ne stocke aucun secret et n'a besoin d'aucun périmètre en écriture. + +## Quand JustDummies partira dans son propre dépôt + +À emporter tel quel : + +- ce fichier de workflow, renommé `mutation.yml` là-bas (et son `name:` avec) ; +- [`build/stryker/justdummies.json`](../../../../build/stryker/justdummies.json) + et [`build/stryker/justdummies-xunit.json`](../../../../build/stryker/justdummies-xunit.json) ; +- [`.config/dotnet-tools.json`](../../../../.config/dotnet-tools.json) — + l'épinglage de Stryker ; +- cette page, augmentée des sections partagées de [`mutation`](mutation.fr.md) + repliées dedans, puisque la page à laquelle elle renvoie n'existera pas là-bas. + +Puis changer exactement une chose : le champ **`solution`** des deux +configurations, qui nomme encore `FirstClassErrors.sln`. Les chemins `project` et +`test-projects` sont déjà relatifs au dépôt et inchangés par la migration. + +De ce côté-ci, supprimer ce workflow, ses configurations et cette page, et +retirer l'entrée `JustDummies mutation gate` de la protection de branche. + +## À manipuler avec précaution + +- **Gardez ce workflow et [`mutation`](mutation.fr.md) synchronisés.** Ils sont + dupliqués à dessein — c'est ce qui fait de la migration un déplacement de + fichier —, donc un correctif sur l'un est un correctif sur l'autre tant que la + séparation n'a pas eu lieu. +- Tout ce qui figure sous + [*À manipuler avec précaution* sur la page `mutation`](mutation.fr.md#à-manipuler-avec-précaution) + vaut ici mot pour mot : `fetch-depth: 0`, `--since` qui refuse `HEAD`, + `if: always()` sur `gate`, le moteur épinglé, l'endroit où vivent les seuils. + +## L'exécuter en local + +```bash +dotnet tool restore +dotnet stryker --config-file build/stryker/justdummies.json +``` + +C'est le balayage complet de la plus grosse bibliothèque, et cela prend un +moment. Pour reproduire ce que fait le barrage sur une branche : + +```bash +dotnet stryker --config-file build/stryker/justdummies.json --since:$(git merge-base origin/main HEAD) +``` + +Les rapports atterrissent dans `StrykerOutput/` (ignoré par git) ; ouvrez +`reports/mutation-report.html`. + +## Voir aussi + +- [`mutation`](mutation.fr.md) — la même machine pour les bibliothèques + FirstClassErrors, et l'endroit où le mécanisme est documenté en entier. +- [`justdummies`](../../../../.github/workflows/justdummies.yml) *(pas encore de + page de référence)* — l'autre workflow cantonné à JustDummies : il prouve que + les assets `netstandard2.0` et `net8.0` publiés se comportent bien sur leurs + runtimes respectifs. +- [ADR 0043 — Gate pull requests on the mutation score of what they + changed](../adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.fr.md) + — la décision que les deux workflows mettent en œuvre. diff --git a/doc/handwritten/for-maintainers/workflows/mutation.en.md b/doc/handwritten/for-maintainers/workflows/mutation.en.md new file mode 100644 index 00000000..8459d0bc --- /dev/null +++ b/doc/handwritten/for-maintainers/workflows/mutation.en.md @@ -0,0 +1,256 @@ +# `mutation` workflow + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](mutation.fr.md) + +> Maintainer documentation — part of the [workflow reference](README.md). +> Not part of the user documentation under `doc/`. + +**Workflow file:** [`.github/workflows/mutation.yml`](../../../../.github/workflows/mutation.yml) + +## What it is for + +Coverage answers *"was this line executed by a test?"*. Mutation testing answers +the question that actually matters: *"would any test have noticed if this line +had been wrong?"*. + +[Stryker.NET](https://stryker-mutator.io/docs/stryker-net/introduction/) rewrites +the library one small change at a time — flip a comparison, drop a statement, +return the other constant, remove a block — rebuilds it, and re-runs the test +suite against each rewrite. A **mutant** the suite still passes on is a +**survivor**: a behaviour the code has and nothing asserts. A killed mutant is a +test doing its job. + +This workflow makes that check automatic. On a pull request it mutates **only the +files the pull request changed**, which is what keeps it cheap enough to be a +required check; a weekly sweep measures everything else. + +Its scope is the **three FirstClassErrors libraries the repository ships** — +`FirstClassErrors`, `FirstClassErrors.Testing` and +`FirstClassErrors.RequestBinder`. The `fce` tooling and the Roslyn analyzers are +deliberately out of scope; see *Handle with care* below. + +**JustDummies is not measured here.** It has its own workflow with its own gate, +[`justdummies-mutation`](justdummies-mutation.en.md), because it is headed for a +repository of its own ([ADR-0011](../adr/0011-host-dummies-as-a-standalone-package.md)). +The two are the same machine with different matrices; everything in this page +except the scope applies to both, and the JustDummies page links back here rather +than repeating it. + +## When it runs + +- On every **pull request targeting `main`** — diff-scoped. **This is the gate.** +- **Weekly** on a schedule (Monday, 03:23 UTC) — the full sweep, advisory. +- On demand via **`workflow_dispatch`** — the full sweep. + +## How it runs + +Each mutated library has its own Stryker configuration under +[`build/stryker/`](../../../../build/stryker/): the project to mutate, the test +projects that must kill its mutants, and the thresholds. Nothing about the run +policy lives only in the YAML, so `dotnet stryker --config-file +build/stryker/core.json` on a maintainer's machine gates exactly like CI does. + +The engine itself is pinned in +[`.config/dotnet-tools.json`](../../../../.config/dotnet-tools.json) and restored +with `dotnet tool restore`. That pin is load-bearing: a newer Stryker invents new +mutants, which moves every score on its own. + +### `changed` — the diff, on every pull request + +One matrix leg per shipped library. Each leg: + +1. Checks out with **`fetch-depth: 0`** — Stryker's `--since` diffs against a + commit, so the history has to be there. +2. Resolves the **fork point** (`git merge-base` of the pull request's base and + `HEAD`), not the base branch tip: the tip may have moved on since the branch + was cut, and every file changed on `main` in the meantime would otherwise be + counted as "changed by this pull request". +3. Runs Stryker with `--since:`, so only mutants **in files this pull + request touched** are tested. +4. Renders the surviving mutants — status, file, line, kind of rewrite — into the + run summary, so a failing gate can be diagnosed without leaving the run page. +5. Uploads the HTML and JSON reports as an artifact — `if: always()`, because the + HTML view shows each survivor *in its source*, which the summary table cannot. + +A leg whose library the pull request did not touch selects no mutant, reports +*"unable to calculate a mutation score"*, and exits 0. That is a pass. + +### `gate` — the single required check + +A matrix produces one check per leg, so marking the gate as required on `main` +would mean re-declaring the leg names in the branch protection every time the +matrix changes. `gate` collapses them into one stable check name — **`Mutation +gate`** — and that is the one to make required. + +It runs with `if: always()`, which is load-bearing: without it, a failed leg +would leave `gate` *skipped*, and GitHub reports a skipped required check as a +success. + +### `full` — the weekly sweep + +The same three legs with the `--since` filter removed: every mutant of every +library in scope. It is **advisory by construction** — `--break-at 0` disables the +threshold — because its job is to publish a trend, not to turn `main` red on a +Monday morning over code nobody changed. Read it from the uploaded HTML report. + +## Two settings that are not tuning knobs + +`build/stryker/*.json` carries two settings that look like performance tuning and +are not. Both were established by measurement; changing either silently breaks +the gate rather than making it slower. + +### `"test-runner": "mtp"` — mandatory, not a preference + +Stryker's **default VSTest runner does not work on this test bed at all.** Every +test project here is xUnit v3, and an xUnit v3 test project *is* an executable +that the VSTest adapter launches as a child process — out of reach of the +in-process hooks Stryker uses both to capture coverage and, crucially, to +**activate** the mutant. The run completes, reports a plausible test count, and +scores **0 %**: every mutant comes back "survived", including mutants that +demonstrably break the suite when the same edit is applied by hand. Upstream: +[stryker-net#3117](https://github.com/stryker-mutator/stryker-net/issues/3117). + +The Microsoft Testing Platform runner launches the test executable itself, so the +mutant is activated and the score is real. Stryker flags it **preview** and says +so on every run; that warning is expected here, not a misconfiguration. + +If a future Stryker upgrade makes every score collapse to zero, this is the first +thing to check. + +### `"coverage-analysis": "off"` — accuracy, not speed + +Stryker normally runs a coverage pass first so each mutant only re-runs the tests +that reach it. Under the MTP runner that selection is still incomplete +([stryker-net#3629](https://github.com/stryker-mutator/stryker-net/issues/3629)): +mutants the suite *does* kill get classified as uncovered and counted against the +score. Measured on `Error.cs`, the same population scores 75 % with selection on +and 100 % with it off — and the 100 % is the true figure. + +Turning it off costs almost nothing because these suites are fast: every mutant +runs the library's whole suite, and that is still a fraction of a second to a +couple of seconds per mutant. + +## The cost model, and why the gate is diff-scoped + +**One full run of the library's test suite per mutant**, plus roughly two minutes +of fixed cost per leg (solution analysis, build, initial test run, mutant +generation). A full sweep of one library is therefore minutes; a full sweep of +every library, on every push, is not something to sit in front of. + +That is what makes the diff scope right for a required check. It also explains +two things that surprise people: + +- **Selection is per changed *file*, not per changed *line*.** Stryker's `--since` + has no line granularity. Adding one line to a large file selects **every** + mutant in that file, so the gate reports the whole file's mutation score — not + just the score of what was added. On the biggest files that is a longer job and + a score that reflects pre-existing debt. +- **A pull request that only adds tests still selects mutants**, through the test + files it changed. + +## Where the thresholds come from + +Each library carries its own `break` in `build/stryker/*.json`, and the values +differ from one to the next on purpose. They are **not** an opinion about how good a library +ought to be: each one was set from that library's measured full-sweep score at +the time the gate was introduced, rounded down, with a little room left for the +odd equivalent mutant. + +That makes the gate a **ratchet**, not an aspiration. It says *do not go below +where this library already is* — a bar every library clears on day one, so the +gate never starts red, and one that only ever moves up. Raising a value after the +weekly sweep shows real headroom is the intended way to use it; lowering one +should feel like a decision. + +The consequence to keep in mind: a library sitting well below 100 % has a low bar +today, and a pull request touching one of its weaker files can still fall under +it. That is the gate working, not misfiring — the report says which assertion is +missing. + +One library escapes this rule for now: `JustDummies`, whose sweep is too long to +have been calibrated against, ships with its score gate off. See +[`justdummies-mutation`](justdummies-mutation.en.md#justdummies-has-no-score-threshold-yet). + +## When the survivor is an equivalent mutant + +Sometimes the honest answer is that the mutant cannot be killed: the rewrite does +not change observable behaviour, so no test could tell the difference. Writing a +test to chase it would be writing a test that asserts an implementation detail — +worse than the gap. + +Stryker takes that answer in the source, next to the code, as a comment: + +```csharp +// Stryker disable once Statement : the trace call has no observable effect +``` + +The form is `// Stryker disable [once] [: reason]`, with +`// Stryker restore all` to end a non-`once` block. Prefer `once`, prefer naming +the mutator rather than `all`, and always give the reason — an undocumented +exclusion is indistinguishable from a missing test six months later. Reach for it +only after deciding the mutant really is equivalent; lowering a threshold to +silence one survivor hides every future survivor with it. + +## Permissions & security + +`contents: read` only. The workflow checks out, builds and runs tests; it stores +no secret and needs no write scope. + +## Handle with care + +- **`fetch-depth: 0` is required**, not a habit. A shallow clone leaves the fork + point unreachable and `--since` cannot resolve it. +- **`--since` wants a branch, a tag or a real commit SHA — `HEAD` is rejected.** + `--since:HEAD` fails the whole run with *"No branch or tag or commit found with + given target"*, which is why the workflow resolves `git merge-base` to a SHA + first rather than passing a rev expression through. +- **The CI warning ratchet does not need disabling here.** It is a fair worry — + Stryker compiles *mutated* source, and a mutant routinely raises a warning the + original never had — but measured, `GITHUB_ACTIONS=true` changes nothing: + Stryker compiles the mutants through Roslyn with its own options and does not + inherit `TreatWarningsAsErrors` from + [`Directory.Build.props`](../../../../Directory.Build.props). The + compile-error count is identical with the ratchet on and off. If a future + Stryker started honouring it, mutants would silently turn into compile errors + instead of being tested — the count in the run log is where that would show. +- **`if: always()` on `gate` is required.** Remove it and a red matrix leg turns + the required check green. +- **The Stryker version is pinned in the tool manifest.** Bumping it is a + deliberate act: expect the scores to move, and re-read the thresholds. +- **The thresholds live in `build/stryker/*.json`, not in the YAML.** That is what + keeps a local run and CI in agreement. `break` is the value that fails the + build; `high`/`low` only colour the report. +- **The tooling and the analyzers are out of scope on purpose.** Their tests + drive Roslyn compilations and spawn processes, so the per-mutant cost is an + order of magnitude above the libraries', and their behaviour is already pinned + end to end by [`analyzers`](analyzers.en.md), `gendoc-docs` and the `floor` job + of [`ci`](ci.en.md). Adding them is a cost decision, not an oversight. +- **A survivor is not automatically a bug**, and the answer to an equivalent one + is a `// Stryker disable once` comment with a reason, never a lowered threshold + — see *When the survivor is an equivalent mutant* above. + +## Running it locally + +```bash +dotnet tool restore +dotnet stryker --config-file build/stryker/core.json +``` + +That is the full sweep of one library and it takes a while. To reproduce what the +gate does on a branch: + +```bash +dotnet stryker --config-file build/stryker/core.json --since:$(git merge-base origin/main HEAD) +``` + +Reports land in `StrykerOutput/` (git-ignored); open `reports/mutation-report.html`. + +## Related + +- [`ci`](ci.en.md) — the primary gate, and where the warning ratchet is enforced. +- [`sonar`](sonar.en.md) — line and branch coverage. Mutation testing is the + complement, not the replacement: Sonar tells you what was *executed*, this + workflow tells you what was *asserted*. +- [ADR 0043 — Gate pull requests on the mutation score of what they + changed](../adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md) + — the decision this workflow implements. diff --git a/doc/handwritten/for-maintainers/workflows/mutation.fr.md b/doc/handwritten/for-maintainers/workflows/mutation.fr.md new file mode 100644 index 00000000..c0747e13 --- /dev/null +++ b/doc/handwritten/for-maintainers/workflows/mutation.fr.md @@ -0,0 +1,280 @@ +# Workflow `mutation` + +🌍 🇬🇧 [English](mutation.en.md) · 🇫🇷 Français (ce fichier) + +> Documentation mainteneur — fait partie de la [référence des workflows](README.fr.md). +> Ne fait pas partie de la documentation utilisateur sous `doc/`. + +**Fichier du workflow :** [`.github/workflows/mutation.yml`](../../../../.github/workflows/mutation.yml) + +## À quoi il sert + +La couverture répond à *« cette ligne a-t-elle été exécutée par un test ? »*. Les +tests de mutation répondent à la question qui compte vraiment : *« un test +aurait-il remarqué quoi que ce soit si cette ligne avait été fausse ? »*. + +[Stryker.NET](https://stryker-mutator.io/docs/stryker-net/introduction/) réécrit +la bibliothèque un petit changement à la fois — inverser une comparaison, +supprimer une instruction, retourner l'autre constante, retirer un bloc —, la +recompile, et rejoue la suite de tests contre chaque réécriture. Un **mutant** que +la suite laisse passer est un **survivant** : un comportement que le code a et que +rien n'affirme. Un mutant tué, c'est un test qui fait son travail. + +Ce workflow rend ce contrôle automatique. Sur une pull request, il ne mute **que +les fichiers modifiés par la pull request** — c'est ce qui le rend assez peu +coûteux pour être un check obligatoire ; un balayage hebdomadaire mesure tout le +reste. + +Son périmètre, ce sont les **trois bibliothèques FirstClassErrors que le dépôt +livre** : `FirstClassErrors`, `FirstClassErrors.Testing` et +`FirstClassErrors.RequestBinder`. L'outillage `fce` et les analyseurs Roslyn en +sont volontairement exclus ; voir *À manipuler avec précaution* plus bas. + +**JustDummies n'est pas mesuré ici.** Il a son propre workflow et son propre +barrage, [`justdummies-mutation`](justdummies-mutation.fr.md), parce qu'il est +destiné à un dépôt à lui +([ADR-0011](../adr/0011-host-dummies-as-a-standalone-package.fr.md)). Les deux +sont la même machine avec une matrice différente ; tout ce qui suit, hormis le +périmètre, vaut pour les deux, et la page JustDummies renvoie ici plutôt que de +le répéter. + +## Quand il s'exécute + +- Sur chaque **pull request ciblant `main`** — cantonné au diff. **C'est le + barrage.** +- **Chaque semaine** sur planification (lundi, 03h23 UTC) — le balayage complet, + consultatif. +- À la demande via **`workflow_dispatch`** — le balayage complet. + +## Comment il s'exécute + +Chaque bibliothèque mutée a sa propre configuration Stryker sous +[`build/stryker/`](../../../../build/stryker/) : le projet à muter, les projets de +tests censés tuer ses mutants, et les seuils. Rien de la politique d'exécution ne +vit uniquement dans le YAML : `dotnet stryker --config-file +build/stryker/core.json` sur la machine d'un mainteneur applique donc exactement +le même barrage que la CI. + +Le moteur lui-même est épinglé dans +[`.config/dotnet-tools.json`](../../../../.config/dotnet-tools.json) et restauré +par `dotnet tool restore`. Cet épinglage est porteur : un Stryker plus récent +invente de nouveaux mutants, ce qui déplace tous les scores à lui seul. + +### `changed` — le diff, sur chaque pull request + +Une branche de matrice par bibliothèque livrée. Chaque branche : + +1. Fait un checkout avec **`fetch-depth: 0`** — le `--since` de Stryker compare à + un commit, l'historique doit donc être présent. +2. Résout le **point de fourche** (`git merge-base` entre la base de la pull + request et `HEAD`), et non la tête de la branche de base : celle-ci a pu + avancer depuis la création de la branche, et tout fichier modifié sur `main` + entre-temps serait alors compté comme « modifié par cette pull request ». +3. Lance Stryker avec `--since:` : seuls les mutants **des + fichiers touchés par la pull request** sont testés. +4. Rend les mutants survivants — statut, fichier, ligne, nature de la réécriture — + dans le résumé du run, pour qu'un barrage en échec se diagnostique sans quitter + la page. +5. Uploade les rapports HTML et JSON en artefact — `if: always()`, car la vue HTML + montre chaque survivant *dans sa source*, ce que le tableau du résumé ne peut + pas faire. + +Une branche dont la bibliothèque n'a pas été touchée par la pull request ne +sélectionne aucun mutant, signale *« unable to calculate a mutation score »*, et +sort en 0. C'est un succès. + +### `gate` — l'unique check obligatoire + +Une matrice produit un check par branche : rendre le barrage obligatoire sur +`main` obligerait donc à redéclarer les noms des branches dans la protection de +branche à chaque évolution de la matrice. `gate` les regroupe sous un nom de check +stable — **`Mutation gate`** — et c'est celui-là qu'il faut rendre obligatoire. + +Il s'exécute avec `if: always()`, ce qui est porteur : sans cela, une branche en +échec laisserait `gate` *skipped*, et GitHub compte un check obligatoire skippé +comme un succès. + +### `full` — le balayage hebdomadaire + +Les mêmes trois branches, filtre `--since` retiré : tous les mutants de toutes les +bibliothèques du périmètre. Il est **consultatif par construction** — `--break-at 0` +désactive le seuil — car son rôle est de publier une tendance, pas de faire virer +`main` au rouge un lundi matin pour du code que personne n'a touché. Il se lit +dans le rapport HTML uploadé. + +## Deux réglages qui n'en sont pas + +`build/stryker/*.json` porte deux réglages qui ressemblent à de l'optimisation et +n'en sont pas. Les deux ont été établis par la mesure ; changer l'un ou l'autre +casse le barrage en silence plutôt que de le ralentir. + +### `"test-runner": "mtp"` — obligatoire, pas une préférence + +**Le runner VSTest par défaut de Stryker ne fonctionne pas du tout sur ce banc de +tests.** Tous les projets de tests sont ici en xUnit v3, et un projet de tests +xUnit v3 *est* un exécutable que l'adaptateur VSTest lance dans un processus fils +— hors de portée des crochets in-process dont Stryker se sert pour capturer la +couverture et, surtout, pour **activer** le mutant. Le run va au bout, annonce un +nombre de tests plausible, et score **0 %** : tous les mutants reviennent +« survivants », y compris des mutants qui cassent la suite de façon démontrable +quand on applique la même modification à la main. Ticket amont : +[stryker-net#3117](https://github.com/stryker-mutator/stryker-net/issues/3117). + +Le runner Microsoft Testing Platform lance lui-même l'exécutable de tests : le +mutant est donc activé et le score est réel. Stryker le marque **preview** et le +signale à chaque run ; cet avertissement est attendu ici, ce n'est pas une erreur +de configuration. + +Si une future montée de version de Stryker fait s'effondrer tous les scores à +zéro, c'est la première chose à vérifier. + +### `"coverage-analysis": "off"` — pour l'exactitude, pas pour la vitesse + +Stryker fait normalement une passe de couverture au préalable, pour que chaque +mutant ne rejoue que les tests qui l'atteignent. Sous le runner MTP, cette +sélection est encore incomplète — voir +[stryker-net#3629](https://github.com/stryker-mutator/stryker-net/issues/3629) — +et des mutants que la suite tue *effectivement* sont classés non couverts et +comptés contre le score. Mesuré sur `Error.cs`, la même population score 75 % +sélection activée et 100 % sélection coupée — et c'est le 100 % qui est le vrai +chiffre. + +La couper ne coûte presque rien parce que ces suites sont rapides : chaque mutant +rejoue toute la suite de la bibliothèque, et cela reste de l'ordre de la fraction +de seconde à quelques secondes par mutant. + +## Le modèle de coût, et pourquoi le barrage est cantonné au diff + +**Une exécution complète de la suite de tests de la bibliothèque par mutant**, +plus environ deux minutes de coût fixe par branche (analyse de la solution, +build, run de tests initial, génération des mutants). Le balayage complet d'une +bibliothèque se compte donc en minutes ; celui de toutes, à chaque push, n'est pas +quelque chose que l'on attend devant son écran. + +C'est ce qui rend le cantonnement au diff pertinent pour un check obligatoire. +Cela explique aussi deux choses qui surprennent : + +- **La sélection se fait par *fichier* modifié, pas par *ligne* modifiée.** Le + `--since` de Stryker n'a pas de granularité à la ligne. Ajouter une ligne dans + un gros fichier sélectionne **tous** les mutants de ce fichier : le barrage + rapporte alors le score de mutation du fichier entier — pas seulement celui de + ce qui a été ajouté. Sur les plus gros fichiers, cela fait un job long et un + score qui reflète une dette préexistante. +- **Une pull request qui n'ajoute que des tests sélectionne quand même des + mutants**, via les fichiers de tests qu'elle modifie. + +## D'où viennent les seuils + +Chaque bibliothèque porte son propre `break` dans `build/stryker/*.json`, et les +valeurs diffèrent de l'une à l'autre à dessein. Elles ne traduisent **pas** un avis sur le +niveau que telle bibliothèque devrait atteindre : chacune a été fixée à partir +du score de balayage complet mesuré sur cette bibliothèque au moment de +l'introduction du barrage, arrondi vers le bas, avec un peu de marge pour +l'éventuel mutant équivalent. + +Cela fait du barrage un **cliquet**, pas une aspiration. Il dit *ne descendez pas +sous le niveau où cette bibliothèque est déjà* — une barre que toutes franchissent +dès le premier jour, si bien que le barrage ne démarre jamais rouge, et qui ne +peut que monter. Relever une valeur quand le balayage hebdomadaire montre de la +marge est l'usage prévu ; en baisser une devrait ressembler à une décision. + +La conséquence à garder en tête : une bibliothèque nettement sous les 100 % a une +barre basse aujourd'hui, et une pull request qui touche l'un de ses fichiers les +plus faibles peut quand même passer dessous. C'est le barrage qui fonctionne, pas +qui se trompe — le rapport dit quelle assertion manque. + +Une bibliothèque échappe pour l'instant à cette règle : `JustDummies`, dont le +balayage est trop long pour avoir servi de calibration, part avec son barrage sur +le score coupé. Voir +[`justdummies-mutation`](justdummies-mutation.fr.md#justdummies-na-pas-encore-de-seuil-de-score). + +## Quand le survivant est un mutant équivalent + +Il arrive que la réponse honnête soit que le mutant ne peut pas être tué : la +réécriture ne change pas le comportement observable, aucun test ne saurait donc +faire la différence. Écrire un test pour le poursuivre reviendrait à écrire un +test qui affirme un détail d'implémentation — pire que le manque. + +Stryker accepte cette réponse dans la source, au plus près du code, sous forme de +commentaire : + +```csharp +// Stryker disable once Statement : the trace call has no observable effect +``` + +La forme est `// Stryker disable [once] [: raison]`, avec +`// Stryker restore all` pour refermer un bloc sans `once`. Préférez `once`, +préférez nommer le mutateur plutôt que `all`, et donnez toujours la raison — une +exclusion non documentée est indiscernable d'un test manquant six mois plus tard. +N'y recourez qu'après avoir établi que le mutant est bien équivalent ; baisser un +seuil pour faire taire un survivant masque avec lui tous les survivants à venir. + +## Permissions & sécurité + +`contents: read` seulement. Le workflow fait un checkout, un build et lance des +tests ; il ne stocke aucun secret et n'a besoin d'aucun périmètre en écriture. + +## À manipuler avec précaution + +- **`fetch-depth: 0` est nécessaire**, ce n'est pas une habitude. Un clone + superficiel rend le point de fourche inatteignable et `--since` ne peut plus le + résoudre. +- **`--since` veut une branche, un tag ou un vrai SHA de commit — `HEAD` est + refusé.** `--since:HEAD` fait échouer tout le run avec *« No branch or tag or + commit found with given target »* ; c'est pourquoi le workflow résout d'abord + `git merge-base` en SHA au lieu de passer une expression de révision. +- **Le cliquet de warnings de la CI n'a pas besoin d'être coupé ici.** + L'inquiétude est légitime — Stryker compile du code *muté*, et un mutant lève + couramment un warning que l'original n'avait pas — mais, mesure faite, + `GITHUB_ACTIONS=true` ne change rien : Stryker compile les mutants via Roslyn + avec ses propres options et n'hérite pas du `TreatWarningsAsErrors` de + [`Directory.Build.props`](../../../../Directory.Build.props). Le nombre + d'erreurs de compilation est identique cliquet actif ou coupé. Si un futur + Stryker se mettait à en tenir compte, des mutants deviendraient silencieusement + des erreurs de compilation au lieu d'être testés — c'est ce compteur, dans le + log du run, qui le révélerait. +- **`if: always()` sur `gate` est nécessaire.** Retirez-le et une branche de + matrice rouge rend le check obligatoire vert. +- **La version de Stryker est épinglée dans le manifeste d'outils.** La monter est + un acte délibéré : attendez-vous à voir les scores bouger, et relisez les + seuils. +- **Les seuils vivent dans `build/stryker/*.json`, pas dans le YAML.** C'est ce + qui garde un run local et la CI d'accord. `break` est la valeur qui fait échouer + le build ; `high`/`low` ne colorent que le rapport. +- **L'outillage et les analyseurs sont hors périmètre à dessein.** Leurs tests + pilotent des compilations Roslyn et lancent des processus : le coût par mutant y + est d'un ordre de grandeur au-dessus de celui des bibliothèques, et leur + comportement est déjà tenu de bout en bout par [`analyzers`](analyzers.fr.md), + `gendoc-docs` et le job `floor` de [`ci`](ci.fr.md). Les ajouter est une décision + de coût, pas un oubli. +- **Un survivant n'est pas automatiquement un bug**, et la réponse à un mutant + équivalent est un commentaire `// Stryker disable once` avec sa raison, jamais un + seuil abaissé — voir *Quand le survivant est un mutant équivalent* plus haut. + +## L'exécuter en local + +```bash +dotnet tool restore +dotnet stryker --config-file build/stryker/core.json +``` + +C'est le balayage complet d'une bibliothèque, et cela prend un moment. Pour +reproduire ce que fait le barrage sur une branche : + +```bash +dotnet stryker --config-file build/stryker/core.json --since:$(git merge-base origin/main HEAD) +``` + +Les rapports atterrissent dans `StrykerOutput/` (ignoré par git) ; ouvrez +`reports/mutation-report.html`. + +## Voir aussi + +- [`ci`](ci.fr.md) — le barrage principal, et l'endroit où le cliquet de warnings + est imposé. +- [`sonar`](sonar.fr.md) — couverture de lignes et de branches. Les tests de + mutation en sont le complément, pas le remplacement : Sonar dit ce qui a été + *exécuté*, ce workflow dit ce qui a été *vérifié*. +- [ADR 0043 — Gate pull requests on the mutation score of what they + changed](../adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md) + — la décision que ce workflow met en œuvre *(rédigée en anglais)*. From d23248ed2c3fbafbaa8f4944e4fe979ff2ddf664 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 14:50:42 +0000 Subject: [PATCH 4/4] ci: mutate the tooling and the analyzers too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scope excluded the `fce` command line, the documentation generator and the Roslyn analyzers on a cost argument: their suites drive Roslyn compilations and snapshot comparisons, so their mutants are the expensive ones. That argument does not survive contact with the weekly sweep, which is the run allowed to take as long as it takes — and the exclusion left a hole rather than a boundary, since a pull request touching only the analyzers crossed no mutation gate at all. Measured before making the change: Stryker runs on all three under the MTP runner, snapshot suites and process-spawning tests included, and they carry 3744 mutants between them — about as many again as the FirstClassErrors libraries already in scope. They ship with their score gate disabled, like JustDummies: their sweeps are too long to have been run interactively, so no score was measured and none was guessed. Their legs still run, still fail on a broken build or a failing suite, and still list survivors; the first weekly sweep supplies the four missing figures. What stays out now stays out for a reason other than cost: the `Usage` samples and the binder benchmarks are not shipped behaviour, and `GenDoc.Worker` is a process entry point no test exercises in process — the `floor` job of `ci` proves it end to end, and mutating it would only manufacture unkillable survivors. Both sweep caps go from 180 to 350 minutes, just under the six-hour ceiling GitHub imposes on a job. The weekly run is meant to take as long as it takes rather than be cut short: JustDummies alone runs well past an hour on four cores. See ADR-0043 for the decision. --- .github/workflows/justdummies-mutation.yml | 6 +- .github/workflows/mutation.yml | 31 ++++++-- CLAUDE.md | 12 +-- build/stryker/analyzers.json | 20 +++++ build/stryker/cli.json | 20 +++++ build/stryker/gendoc.json | 20 +++++ ...ts-on-the-mutation-score-of-the-diff.fr.md | 74 +++++++++++-------- ...uests-on-the-mutation-score-of-the-diff.md | 66 ++++++++++------- .../for-maintainers/workflows/README.fr.md | 2 +- .../for-maintainers/workflows/README.md | 2 +- .../for-maintainers/workflows/mutation.en.md | 64 ++++++++++------ .../for-maintainers/workflows/mutation.fr.md | 71 +++++++++++------- 12 files changed, 265 insertions(+), 123 deletions(-) create mode 100644 build/stryker/analyzers.json create mode 100644 build/stryker/cli.json create mode 100644 build/stryker/gendoc.json diff --git a/.github/workflows/justdummies-mutation.yml b/.github/workflows/justdummies-mutation.yml index b73a184f..e36f7dcb 100644 --- a/.github/workflows/justdummies-mutation.yml +++ b/.github/workflows/justdummies-mutation.yml @@ -184,8 +184,10 @@ jobs: runs-on: ubuntu-latest # The sweep runs the library's whole test suite once per mutant, and `JustDummies` carries a few # thousand of them — this is the longest job in the repository, and the reason the sweep is - # weekly and the gate is diff-scoped. - timeout-minutes: 180 + # weekly and the gate is diff-scoped. Measured locally on four cores, it runs well past an hour; + # the cap therefore sits just under the six-hour ceiling GitHub imposes on a job, because this + # run is meant to take as long as it takes rather than be cut short. + timeout-minutes: 350 strategy: fail-fast: false matrix: diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 9dc6309b..891da8c5 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -47,10 +47,12 @@ jobs: # Run every leg to completion: a survivor in the binder is worth seeing even when core is red. fail-fast: false matrix: - # The three FirstClassErrors libraries this repository SHIPS. The `fce` tooling and the Roslyn - # analyzers are deliberately out of scope: their tests drive Roslyn compilations and spawn - # processes, so the per-mutant cost is an order of magnitude higher, and their behaviour is - # already pinned end to end by the `analyzers`, `gendoc-docs` and `ci` (floor) workflows. + # Every FirstClassErrors project whose code is compiled and shipped — the three libraries AND + # the tooling: the `fce` command line, the documentation generator, the Roslyn analyzers. The + # `Usage` samples and the binder benchmarks stay out (samples and a measurement harness, not + # shipped behaviour), and so does `GenDoc.Worker`: nothing unit-tests it in process — it is a + # process entry point, exercised end to end by the `floor` job of `ci` — so mutating it would + # report survivors that no test could ever kill. # JustDummies and JustDummies.Xunit live in `justdummies-mutation.yml`. See ADR-0043. include: - name: core @@ -59,6 +61,12 @@ jobs: config: build/stryker/testing.json - name: binder config: build/stryker/binder.json + - name: analyzers + config: build/stryker/analyzers.json + - name: gendoc + config: build/stryker/gendoc.json + - name: cli + config: build/stryker/cli.json steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -182,9 +190,12 @@ jobs: name: Full sweep (${{ matrix.name }}) if: github.event_name != 'pull_request' runs-on: ubuntu-latest - # The sweep runs the library's whole test suite once per mutant. `core` carries a few hundred of - # them, which is the reason the sweep is weekly and the gate is diff-scoped. - timeout-minutes: 180 + # The sweep runs the project's whole test suite once per mutant, and the six projects together + # carry several thousand — the tooling's suites being the slow ones, since they drive Roslyn + # compilations and snapshot comparisons. That is the reason the sweep is weekly and the gate is + # diff-scoped. The cap sits just under the six-hour ceiling GitHub imposes on a job: this run is + # meant to take as long as it takes. + timeout-minutes: 350 strategy: fail-fast: false matrix: @@ -195,6 +206,12 @@ jobs: config: build/stryker/testing.json - name: binder config: build/stryker/binder.json + - name: analyzers + config: build/stryker/analyzers.json + - name: gendoc + config: build/stryker/gendoc.json + - name: cli + config: build/stryker/cli.json steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/CLAUDE.md b/CLAUDE.md index 2b119b51..32790fea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,12 +29,12 @@ errors should stay structured, documented, and close to the code. to apply it are in [`doc/handwritten/for-maintainers/WritingJustDummiesTests.en.md`](doc/handwritten/for-maintainers/WritingJustDummiesTests.en.md) (decision: ADR-0040). Read it before adding a JustDummies test. -* Mutation testing gates every pull request on the files it changed, for the five - shipped libraries, through two independent checks — one for the FirstClassErrors - libraries, one for the JustDummies packages (decision: ADR-0043). A test that - *executes* new code without *asserting* it will pass `dotnet test` and still - fail that gate. Reproduce it on a branch with - `dotnet tool restore && dotnet stryker --config-file build/stryker/.json --since:$(git merge-base origin/main HEAD)`; +* Mutation testing gates every pull request on the files it changed, for every + project whose code ships or runs, through two independent checks — one for the + FirstClassErrors libraries and tooling, one for the JustDummies packages + (decision: ADR-0043). A test that *executes* new code without *asserting* it + will pass `dotnet test` and still fail that gate. Reproduce it on a branch with + `dotnet tool restore && dotnet stryker --config-file build/stryker/.json --since:$(git merge-base origin/main HEAD)`; the configurations and the reasons behind them are in [`mutation.en.md`](doc/handwritten/for-maintainers/workflows/mutation.en.md) and [`justdummies-mutation.en.md`](doc/handwritten/for-maintainers/workflows/justdummies-mutation.en.md). diff --git a/build/stryker/analyzers.json b/build/stryker/analyzers.json new file mode 100644 index 00000000..2fcb7cb8 --- /dev/null +++ b/build/stryker/analyzers.json @@ -0,0 +1,20 @@ +{ + "stryker-config": { + "solution": "FirstClassErrors.sln", + "project": "FirstClassErrors.Analyzers.csproj", + "test-projects": [ + "FirstClassErrors.Analyzers.UnitTests/FirstClassErrors.Analyzers.UnitTests.csproj" + ], + "test-runner": "mtp", + "coverage-analysis": "off", + "reporters": [ + "html", + "json" + ], + "thresholds": { + "high": 100, + "low": 60, + "break": 0 + } + } +} diff --git a/build/stryker/cli.json b/build/stryker/cli.json new file mode 100644 index 00000000..d24dfe70 --- /dev/null +++ b/build/stryker/cli.json @@ -0,0 +1,20 @@ +{ + "stryker-config": { + "solution": "FirstClassErrors.sln", + "project": "FirstClassErrors.Cli.csproj", + "test-projects": [ + "FirstClassErrors.Cli.UnitTests/FirstClassErrors.Cli.UnitTests.csproj" + ], + "test-runner": "mtp", + "coverage-analysis": "off", + "reporters": [ + "html", + "json" + ], + "thresholds": { + "high": 100, + "low": 60, + "break": 0 + } + } +} diff --git a/build/stryker/gendoc.json b/build/stryker/gendoc.json new file mode 100644 index 00000000..5290eb1f --- /dev/null +++ b/build/stryker/gendoc.json @@ -0,0 +1,20 @@ +{ + "stryker-config": { + "solution": "FirstClassErrors.sln", + "project": "FirstClassErrors.GenDoc.csproj", + "test-projects": [ + "FirstClassErrors.GenDoc.UnitTests/FirstClassErrors.GenDoc.UnitTests.csproj" + ], + "test-runner": "mtp", + "coverage-analysis": "off", + "reporters": [ + "html", + "json" + ], + "thresholds": { + "high": 100, + "low": 60, + "break": 0 + } + } +} diff --git a/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.fr.md b/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.fr.md index 5c9b11ce..f07fb7ea 100644 --- a/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.fr.md +++ b/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.fr.md @@ -76,8 +76,8 @@ nom de check par branche. ## Décision Toute pull request ciblant `main` doit franchir un seuil de score de mutation, -mesuré par Stryker.NET sur les mutants des fichiers qu'elle modifie, pour chacune -des cinq bibliothèques que le dépôt livre — imposé par deux barrages +mesuré par Stryker.NET sur les mutants des fichiers qu'elle modifie, pour chaque +projet du dépôt dont le code est livré ou exécuté — imposé par deux barrages indépendants, découpés le long de la frontière de dépôt à venir. ## Justification @@ -110,13 +110,18 @@ et c'est la bonne direction pour l'erreur : elle pousse à la hausse la couvertu des fichiers les plus faibles au contact, et le mainteneur peut toujours écarter une branche. -Restreindre le périmètre aux **cinq bibliothèques livrées** applique le même -argument de coût dans l'autre sens. L'outillage `fce` et les analyseurs Roslyn ne -sont liés dans l'application de personne ; leurs tests pilotent des compilations -Roslyn et lancent des processus, leur coût par mutant est donc d'un ordre de -grandeur supérieur, et leur comportement est déjà tenu de bout en bout par des -workflows dédiés. Les inclure multiplierait le coût du barrage là où un défaut -est le moins cher à remarquer. +Le périmètre couvre **l'outillage et les analyseurs autant que les +bibliothèques**. Leurs suites sont les lentes — elles pilotent des compilations +Roslyn et des comparaisons de snapshots, donc leurs mutants sont les chers —, mais +la dépense est une raison de les mettre dans le balayage hebdomadaire, pas une +raison de les laisser sans mesure : ce balayage est précisément le run autorisé à +durer le temps qu'il faut. Sur le barrage, le cantonnement au diff borne déjà ce +qu'ils coûtent, puisqu'une pull request qui ne touche pas le générateur ne paie +rien pour lui. Ce qui reste exclu l'est pour une autre raison que le coût : les +échantillons `Usage` et les benchmarks du binder ne sont pas du comportement +livré, et le worker de documentation est un point d'entrée de processus qu'aucun +test n'exerce en processus — le muter ne fabriquerait que des survivants qu'aucun +test ne pourrait tuer. L'imposer par **deux barrages plutôt qu'un** ne coûte rien aujourd'hui et achète la migration. Les packages JustDummies sont déjà isolés du reste par construction, @@ -179,14 +184,13 @@ est comparable d'un run à l'autre, et il ne peut pas être contourné en n'effleurant que les bords d'un fichier. Rejetée pour deux raisons. La première est le coût : le coût par mutant est une -exécution complète de la suite de la bibliothèque, si bien qu'un balayage complet -des cinq se compte en dizaines de minutes au mieux — payées à chaque push, -essentiellement pour re-mesurer du code que personne n'a changé. La seconde suffit -à elle seule : **un score sur toute la bibliothèque est bien trop insensible pour -servir de barrage**. La plus grosse bibliothèque porte plusieurs centaines de -mutants : un comportement nouvellement ajouté et non affirmé déplace donc son -score d'une fraction de pour-cent — bien en dessous de tout seuil qui ne serait -pas lui-même du bruit. Le score cantonné au diff est sensible précisément parce +exécution complète de la suite du projet, si bien qu'un balayage complet de tout +le périmètre se compte en heures — payées à chaque push, essentiellement pour +re-mesurer du code que personne n'a changé. La seconde suffit à elle seule : **un +score sur tout un projet est bien trop insensible pour servir de barrage**. La +plus grosse bibliothèque porte quelques milliers de mutants : un comportement +nouvellement ajouté et non affirmé déplace donc son score d'une fraction de +pour-cent — bien en dessous de tout seuil qui ne serait pas lui-même du bruit. Le score cantonné au diff est sensible précisément parce que son dénominateur est petit : une poignée de mutants neufs, dont un survit, c'est une chute visible. Le balayage hebdomadaire récupère le chiffre sur toute la bibliothèque là où il a sa place — comme tendance, pas comme barrage. @@ -214,18 +218,21 @@ mutants qui cassent la suite de façon démontrable quand on les applique à la main. La choisir reviendrait soit à un barrage rouge en permanence, soit à un seuil assez bas pour ne rien vouloir dire. -### Étendre le barrage à l'outillage et aux analyseurs +### Restreindre le périmètre aux bibliothèques livrées, en laissant l'outillage dehors -Envisagée par souci d'uniformité — une règle unique couvrant tout le dépôt se -défend mieux qu'une liste de cinq projets. +Envisagée, et retenue dans un premier temps, pour son coût : les tests +d'analyseurs et de générateur compilent du code et lancent des processus, leurs +mutants sont donc d'un ordre de grandeur plus chers que ceux d'une bibliothèque, +et leur comportement visible de l'extérieur est déjà tenu par les jobs +`analyzers`, `gendoc-docs` et le `floor` de `ci`. -Rejetée parce que le coût est concentré exactement là où la valeur est la plus -faible. Les tests d'analyseurs et de générateur compilent du code et lancent des -processus ; leurs suites sont les lentes, donc leurs mutants sont les chers. Leur -comportement visible de l'extérieur est déjà tenu par les jobs `analyzers`, -`gendoc-docs` et le `floor` de `ci`, et un défaut s'y manifeste par un build -cassé ou un document faux, pas par une réponse silencieusement fausse dans -l'application d'un consommateur. +Rejetée parce que le coût qu'elle évite est précisément celui que le balayage +hebdomadaire est là pour absorber, et parce que l'exclusion laissait un trou +plutôt qu'une frontière : une pull request ne touchant que les analyseurs n'aurait +franchi aucun barrage de mutation. Mesuré avant de revenir sur la décision, les +projets exclus portent à peu près autant de mutants que les bibliothèques +FirstClassErrors déjà dans le périmètre, et Stryker tourne sur tous — suites à +snapshots et tests lanceurs de processus compris. ## Conséquences @@ -254,6 +261,10 @@ l'application d'un consommateur. l'impose. * Les mutants équivalents rendent une partie de la distance restante jusqu'à 100 % inatteignable : le seuil relève donc du jugement, pas d'un calcul. +* Le balayage hebdomadaire est long — des heures, dominées par la plus grosse + bibliothèque et par les suites lentes de l'outillage. C'est assumé : c'est la + raison pour laquelle ce balayage est hebdomadaire et consultatif, pas un + barrage. ### Risques @@ -290,10 +301,11 @@ l'application d'un consommateur. elle classera correctement les mutants couverts. * Relire les seuils après chaque montée de version du moteur, et après tout ajout d'une bibliothèque au périmètre. -* Fixer le seuil de `JustDummies` à partir du premier balayage hebdomadaire. Son - balayage complet est trop long pour être exécuté interactivement : aucun score - n'a donc été mesuré pour elle et sa barre est pour l'instant désactivée — la - seule bibliothèque que le barrage ne tient pas encore à un score. +* Fixer un seuil pour les projets qui n'en ont pas encore — `JustDummies`, les + analyseurs, le générateur de documentation et la ligne de commande — à partir du + premier balayage hebdomadaire. Leurs balayages sont trop longs pour être + exécutés interactivement : aucun score n'a donc été mesuré pour eux, et leurs + barrages de score sont livrés désactivés plutôt que devinés. ## Références diff --git a/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md b/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md index ebe865d0..7a81584f 100644 --- a/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md +++ b/doc/handwritten/for-maintainers/adr/0043-gate-pull-requests-on-the-mutation-score-of-the-diff.md @@ -72,9 +72,9 @@ checks individually — a matrix contributes one check name per leg. ## Decision Every pull request targeting `main` must clear a mutation-score threshold, -measured by Stryker.NET over the mutants of the files it changed, for each of the -five libraries the repository ships — enforced by two independent gates, split -along the prospective repository boundary. +measured by Stryker.NET over the mutants of the files it changed, for every +project in the repository whose code is shipped or executed — enforced by two +independent gates, split along the prospective repository boundary. ## Rationale @@ -103,13 +103,17 @@ it merely touched. That is a real cost, and it is the acceptable direction of error: it pushes coverage of the weakest files up on contact, and the maintainer can always waive a leg. -Restricting the scope to the **five shipped libraries** follows the same cost -argument in the other direction. The `fce` tooling and the Roslyn analyzers are -not linked into anyone's application; their tests drive Roslyn compilations and -spawn processes, so their per-mutant cost is an order of magnitude higher, and -their behaviour is already pinned end to end by dedicated workflows. Including -them would multiply the gate's cost for the part of the codebase where a defect -is cheapest to notice. +The scope covers the **tooling and the analyzers as well as the libraries**. Their +suites are the slow ones — they drive Roslyn compilations and snapshot +comparisons, so their mutants are the expensive ones — but expense is a reason to +put them in the weekly sweep, not a reason to leave them unmeasured: the sweep is +precisely the run allowed to take as long as it takes. On the gate, the diff scope +already bounds what they cost, since a pull request that does not touch the +generator pays nothing for it. What is excluded is excluded for a reason other +than cost: the `Usage` samples and the binder benchmarks are not shipped +behaviour, and the documentation worker is a process entry point that no test +exercises in process — mutating it would only manufacture survivors no test could +ever kill. Enforcing it through **two gates rather than one** costs nothing today and buys the move. The JustDummies packages are already isolated from the rest by design, @@ -168,12 +172,12 @@ Considered because it is the honest measurement: a score over the whole library is comparable between runs, and it cannot be gamed by touching a file's edges. Rejected on two counts. The first is cost: the per-mutant cost is a full run of -the library's suite, so a complete sweep of the five libraries is a matter of -tens of minutes at best — paid on every push, mostly to re-measure code nobody -changed. The second is decisive on its own: **a whole-library score is far too -insensitive to gate on**. The largest library carries several hundred mutants, so -one newly added, unasserted behaviour moves its score by a fraction of a -percent — well below any threshold that is not itself noise. The diff-scoped +the project's suite, so a complete sweep of everything in scope runs into hours — +paid on every push, mostly to re-measure code nobody changed. The second is +decisive on its own: **a whole-project score is far too insensitive to gate on**. +The largest library carries a few thousand mutants, so one newly added, +unasserted behaviour moves its score by a fraction of a percent — well below any +threshold that is not itself noise. The diff-scoped score is sensitive precisely because its denominator is small: a handful of new mutants, one of which survives, is a visible drop. The weekly sweep recovers the whole-library number where it belongs — as a trend, not as a gate. @@ -198,17 +202,19 @@ measurement: it reports every mutant as survived, including mutants that demonstrably break the suite when applied by hand. Choosing it would mean either a permanently red gate or a threshold low enough to be meaningless. -### Extend the gate to the tooling and the analyzers +### Restrict the scope to the shipped libraries, leaving the tooling out -Considered for uniformity — a single rule covering the whole repository is easier -to defend than a list of five projects. +Considered, and initially chosen, on cost: analyzer and generator tests compile +code and spawn processes, so their mutants are an order of magnitude more +expensive than a library's, and their externally visible behaviour is already +pinned by the `analyzers`, `gendoc-docs` and `ci` floor jobs. -Rejected because the cost is concentrated exactly where the value is lowest. -Analyzer and generator tests compile code and spawn processes; their suites are -the slow ones, so their mutants are the expensive ones. Their externally visible -behaviour is already pinned by the `analyzers`, `gendoc-docs` and `ci` floor -jobs, and a defect there surfaces as a broken build or a wrong document, not as a -silently wrong answer inside a consumer's application. +Rejected because the cost it avoids is cost the weekly sweep exists to absorb, +and because the exclusion left a hole rather than a boundary: a pull request +touching only the analyzers would have crossed no mutation gate at all. Measured +before reversing the decision, the excluded projects carry about as many mutants +again as the FirstClassErrors libraries already in scope, and Stryker runs on all +of them — snapshot suites and process-spawning tests included. ## Consequences @@ -235,6 +241,9 @@ silently wrong answer inside a consumer's application. one is a fix to both, and nothing enforces that. * Equivalent mutants make part of the remaining distance to 100 % unreachable, so the threshold is a judgement call rather than a derived value. +* The weekly sweep is long — hours, dominated by the largest library and by the + tooling's slow suites. That is accepted deliberately; it is the reason the sweep + is weekly and advisory rather than a gate. ### Risks @@ -268,9 +277,10 @@ silently wrong answer inside a consumer's application. covered mutants correctly. * Re-read the thresholds after each engine upgrade, and after any library is added to the scope. -* Set the `JustDummies` threshold from the first weekly sweep. Its full sweep is - too long to run interactively, so no score was measured for it and its bar is - currently disabled — the one library the gate does not yet hold to a score. +* Set a threshold for the projects that have none yet — `JustDummies`, the + analyzers, the documentation generator and the command line — from the first + weekly sweep. Their sweeps are too long to run interactively, so no score was + measured for them and their score gates ship disabled rather than guessed. ## References diff --git a/doc/handwritten/for-maintainers/workflows/README.fr.md b/doc/handwritten/for-maintainers/workflows/README.fr.md index 55ce45d9..17f5c495 100644 --- a/doc/handwritten/for-maintainers/workflows/README.fr.md +++ b/doc/handwritten/for-maintainers/workflows/README.fr.md @@ -68,7 +68,7 @@ documentées une seule fois ici plutôt que répétées sur chaque page. | --- | --- | | [`ci`](ci.fr.md) | Construit et teste toute la solution sous Linux et Windows, avec couverture. Le barrage principal. | | [`sonar`](sonar.fr.md) | Analyse SonarQube Cloud — quality gate et remontée de couverture. | -| [`mutation`](mutation.fr.md) | Tests de mutation des bibliothèques FirstClassErrors avec Stryker.NET — check obligatoire sur ce qu'une PR modifie, plus un balayage complet hebdomadaire. | +| [`mutation`](mutation.fr.md) | Tests de mutation des bibliothèques et de l'outillage FirstClassErrors avec Stryker.NET — check obligatoire sur ce qu'une PR modifie, plus un balayage complet hebdomadaire. | | [`justdummies-mutation`](justdummies-mutation.fr.md) | Idem pour les packages JustDummies, avec son propre check obligatoire — séparé pour que la future séparation de dépôt soit un déplacement de fichier. | | [`analyzers`](analyzers.fr.md) | Dogfood des analyzers Roslyn embarqués, y compris sur le plus vieux compilateur supporté (le floor Roslyn). | | [`commit-lint`](commit-lint.fr.md) | Impose la convention Conventional Commits sur chaque commit de PR, via le même script que le hook local. | diff --git a/doc/handwritten/for-maintainers/workflows/README.md b/doc/handwritten/for-maintainers/workflows/README.md index e50cf6ee..ae25cee9 100644 --- a/doc/handwritten/for-maintainers/workflows/README.md +++ b/doc/handwritten/for-maintainers/workflows/README.md @@ -64,7 +64,7 @@ here instead of being repeated on every page. | --- | --- | | [`ci`](ci.en.md) | Build and test the whole solution on Linux and Windows, with coverage. The primary gate. | | [`sonar`](sonar.en.md) | SonarQube Cloud analysis — quality gate and coverage reporting. | -| [`mutation`](mutation.en.md) | Mutation testing of the FirstClassErrors libraries with Stryker.NET — a required check on what a PR changed, plus a weekly full sweep. | +| [`mutation`](mutation.en.md) | Mutation testing of the FirstClassErrors libraries and tooling with Stryker.NET — a required check on what a PR changed, plus a weekly full sweep. | | [`justdummies-mutation`](justdummies-mutation.en.md) | The same, for the JustDummies packages, with its own required check — kept separate so the future repository split is a file move. | | [`analyzers`](analyzers.en.md) | Dogfood the bundled Roslyn analyzers, including on the oldest supported compiler (the Roslyn floor). | | [`commit-lint`](commit-lint.en.md) | Enforce the Conventional Commits convention on every PR commit, using the same script as the local hook. | diff --git a/doc/handwritten/for-maintainers/workflows/mutation.en.md b/doc/handwritten/for-maintainers/workflows/mutation.en.md index 8459d0bc..7dbc63b7 100644 --- a/doc/handwritten/for-maintainers/workflows/mutation.en.md +++ b/doc/handwritten/for-maintainers/workflows/mutation.en.md @@ -24,10 +24,11 @@ This workflow makes that check automatic. On a pull request it mutates **only th files the pull request changed**, which is what keeps it cheap enough to be a required check; a weekly sweep measures everything else. -Its scope is the **three FirstClassErrors libraries the repository ships** — -`FirstClassErrors`, `FirstClassErrors.Testing` and -`FirstClassErrors.RequestBinder`. The `fce` tooling and the Roslyn analyzers are -deliberately out of scope; see *Handle with care* below. +Its scope is **every FirstClassErrors project whose code is shipped or executed**: +the three libraries (`FirstClassErrors`, `FirstClassErrors.Testing`, +`FirstClassErrors.RequestBinder`) *and* the tooling — the `fce` command line, the +documentation generator, the Roslyn analyzers. What stays out, and why, is under +*Handle with care* below. **JustDummies is not measured here.** It has its own workflow with its own gate, [`justdummies-mutation`](justdummies-mutation.en.md), because it is headed for a @@ -57,7 +58,7 @@ mutants, which moves every score on its own. ### `changed` — the diff, on every pull request -One matrix leg per shipped library. Each leg: +One matrix leg per project in scope. Each leg: 1. Checks out with **`fetch-depth: 0`** — Stryker's `--since` diffs against a commit, so the history has to be there. @@ -72,8 +73,9 @@ One matrix leg per shipped library. Each leg: 5. Uploads the HTML and JSON reports as an artifact — `if: always()`, because the HTML view shows each survivor *in its source*, which the summary table cannot. -A leg whose library the pull request did not touch selects no mutant, reports -*"unable to calculate a mutation score"*, and exits 0. That is a pass. +A leg whose project the pull request did not touch selects no mutant, reports +*"unable to calculate a mutation score"*, and exits 0. That is a pass — and it is +the common case, since most pull requests touch one project. ### `gate` — the single required check @@ -88,8 +90,8 @@ success. ### `full` — the weekly sweep -The same three legs with the `--since` filter removed: every mutant of every -library in scope. It is **advisory by construction** — `--break-at 0` disables the +The same six legs with the `--since` filter removed: every mutant of every +project in scope. It is **advisory by construction** — `--break-at 0` disables the threshold — because its job is to publish a trend, not to turn `main` red on a Monday morning over code nobody changed. Read it from the uploaded HTML report. @@ -126,9 +128,11 @@ mutants the suite *does* kill get classified as uncovered and counted against th score. Measured on `Error.cs`, the same population scores 75 % with selection on and 100 % with it off — and the 100 % is the true figure. -Turning it off costs almost nothing because these suites are fast: every mutant -runs the library's whole suite, and that is still a fraction of a second to a -couple of seconds per mutant. +Turning it off costs little on the libraries, whose suites are fast — a fraction +of a second to a couple of seconds per mutant. It costs more on the tooling, whose +suites compile Roslyn and compare snapshots. That is a reason to keep those legs +out of the critical path, not a reason to re-enable a selection that reports the +wrong number. ## The cost model, and why the gate is diff-scoped @@ -150,11 +154,19 @@ two things that surprise people: ## Where the thresholds come from -Each library carries its own `break` in `build/stryker/*.json`, and the values -differ from one to the next on purpose. They are **not** an opinion about how good a library -ought to be: each one was set from that library's measured full-sweep score at -the time the gate was introduced, rounded down, with a little room left for the -odd equivalent mutant. +Each project carries its own `break` in `build/stryker/*.json`, and the values +differ from one to the next on purpose. They are **not** an opinion about how good +a project ought to be: each one was set from that project's measured full-sweep +score at the time the gate was introduced, rounded down, with a little room left +for the odd equivalent mutant. + +**Four projects have no bar yet** — the analyzers, the documentation generator, +the command line and `JustDummies`. Their sweeps are too long to have been run +interactively, so no score was ever measured for them, and a bar was **not** +guessed: their `break` is `0`. Their legs still run, still fail on a broken build +or a failing suite, and still list their survivors — they simply do not yet refuse +a pull request over a score. The first weekly sweep is what supplies those four +figures. That makes the gate a **ratchet**, not an aspiration. It says *do not go below where this library already is* — a bar every library clears on day one, so the @@ -220,11 +232,19 @@ no secret and needs no write scope. - **The thresholds live in `build/stryker/*.json`, not in the YAML.** That is what keeps a local run and CI in agreement. `break` is the value that fails the build; `high`/`low` only colour the report. -- **The tooling and the analyzers are out of scope on purpose.** Their tests - drive Roslyn compilations and spawn processes, so the per-mutant cost is an - order of magnitude above the libraries', and their behaviour is already pinned - end to end by [`analyzers`](analyzers.en.md), `gendoc-docs` and the `floor` job - of [`ci`](ci.en.md). Adding them is a cost decision, not an oversight. +- **Three things are out of scope, and none of them for cost.** The `Usage` + samples and the binder benchmarks are not shipped behaviour — a sample and a + measurement harness. `FirstClassErrors.GenDoc.Worker` is a process entry point + that no test exercises *in process*: it is proven end to end by the `floor` job + of [`ci`](ci.en.md), and mutating it would only manufacture survivors that no + test could ever kill. Everything else that compiles into a shipped artefact is + measured. +- **The tooling legs are the slow ones.** Analyzer and generator suites drive + Roslyn compilations and snapshot comparisons, so each of their mutants costs + more than a library's. That is why they belong to the weekly sweep — the run + allowed to take as long as it takes — and why the gate's diff scope matters + more for them than anywhere else: a pull request that does not touch the + generator pays nothing for it. - **A survivor is not automatically a bug**, and the answer to an equivalent one is a `// Stryker disable once` comment with a reason, never a lowered threshold — see *When the survivor is an equivalent mutant* above. diff --git a/doc/handwritten/for-maintainers/workflows/mutation.fr.md b/doc/handwritten/for-maintainers/workflows/mutation.fr.md index c0747e13..f6a2c430 100644 --- a/doc/handwritten/for-maintainers/workflows/mutation.fr.md +++ b/doc/handwritten/for-maintainers/workflows/mutation.fr.md @@ -25,10 +25,12 @@ les fichiers modifiés par la pull request** — c'est ce qui le rend assez peu coûteux pour être un check obligatoire ; un balayage hebdomadaire mesure tout le reste. -Son périmètre, ce sont les **trois bibliothèques FirstClassErrors que le dépôt -livre** : `FirstClassErrors`, `FirstClassErrors.Testing` et -`FirstClassErrors.RequestBinder`. L'outillage `fce` et les analyseurs Roslyn en -sont volontairement exclus ; voir *À manipuler avec précaution* plus bas. +Son périmètre, c'est **tout projet FirstClassErrors dont le code est livré ou +exécuté** : les trois bibliothèques (`FirstClassErrors`, +`FirstClassErrors.Testing`, `FirstClassErrors.RequestBinder`) *et* l'outillage — +la ligne de commande `fce`, le générateur de documentation, les analyseurs +Roslyn. Ce qui en reste dehors, et pourquoi, est sous *À manipuler avec +précaution* plus bas. **JustDummies n'est pas mesuré ici.** Il a son propre workflow et son propre barrage, [`justdummies-mutation`](justdummies-mutation.fr.md), parce qu'il est @@ -62,7 +64,7 @@ invente de nouveaux mutants, ce qui déplace tous les scores à lui seul. ### `changed` — le diff, sur chaque pull request -Une branche de matrice par bibliothèque livrée. Chaque branche : +Une branche de matrice par projet du périmètre. Chaque branche : 1. Fait un checkout avec **`fetch-depth: 0`** — le `--since` de Stryker compare à un commit, l'historique doit donc être présent. @@ -79,9 +81,10 @@ Une branche de matrice par bibliothèque livrée. Chaque branche : montre chaque survivant *dans sa source*, ce que le tableau du résumé ne peut pas faire. -Une branche dont la bibliothèque n'a pas été touchée par la pull request ne -sélectionne aucun mutant, signale *« unable to calculate a mutation score »*, et -sort en 0. C'est un succès. +Une branche dont le projet n'a pas été touché par la pull request ne sélectionne +aucun mutant, signale *« unable to calculate a mutation score »*, et sort en 0. +C'est un succès — et c'est le cas courant, la plupart des pull requests ne +touchant qu'un projet. ### `gate` — l'unique check obligatoire @@ -96,8 +99,8 @@ comme un succès. ### `full` — le balayage hebdomadaire -Les mêmes trois branches, filtre `--since` retiré : tous les mutants de toutes les -bibliothèques du périmètre. Il est **consultatif par construction** — `--break-at 0` +Les mêmes six branches, filtre `--since` retiré : tous les mutants de tous les +projets du périmètre. Il est **consultatif par construction** — `--break-at 0` désactive le seuil — car son rôle est de publier une tendance, pas de faire virer `main` au rouge un lundi matin pour du code que personne n'a touché. Il se lit dans le rapport HTML uploadé. @@ -139,9 +142,11 @@ comptés contre le score. Mesuré sur `Error.cs`, la même population score 75 % sélection activée et 100 % sélection coupée — et c'est le 100 % qui est le vrai chiffre. -La couper ne coûte presque rien parce que ces suites sont rapides : chaque mutant -rejoue toute la suite de la bibliothèque, et cela reste de l'ordre de la fraction -de seconde à quelques secondes par mutant. +La couper coûte peu sur les bibliothèques, dont les suites sont rapides — de la +fraction de seconde à quelques secondes par mutant. Cela coûte davantage sur +l'outillage, dont les suites compilent du Roslyn et comparent des snapshots. C'est +une raison de garder ces branches hors du chemin critique, pas une raison de +réactiver une sélection qui rapporte le mauvais chiffre. ## Le modèle de coût, et pourquoi le barrage est cantonné au diff @@ -165,12 +170,20 @@ Cela explique aussi deux choses qui surprennent : ## D'où viennent les seuils -Chaque bibliothèque porte son propre `break` dans `build/stryker/*.json`, et les -valeurs diffèrent de l'une à l'autre à dessein. Elles ne traduisent **pas** un avis sur le -niveau que telle bibliothèque devrait atteindre : chacune a été fixée à partir -du score de balayage complet mesuré sur cette bibliothèque au moment de -l'introduction du barrage, arrondi vers le bas, avec un peu de marge pour -l'éventuel mutant équivalent. +Chaque projet porte son propre `break` dans `build/stryker/*.json`, et les valeurs +diffèrent de l'une à l'autre à dessein. Elles ne traduisent **pas** un avis sur le +niveau que tel projet devrait atteindre : chacune a été fixée à partir du score de +balayage complet mesuré sur ce projet au moment de l'introduction du barrage, +arrondi vers le bas, avec un peu de marge pour l'éventuel mutant équivalent. + +**Quatre projets n'ont pas encore de barre** : les analyseurs, le générateur de +documentation, la ligne de commande et `JustDummies`. Leurs balayages sont trop +longs pour avoir été exécutés interactivement : aucun score n'a donc été mesuré +pour eux, et une barre n'a **pas** été devinée — leur `break` vaut `0`. Leurs +branches tournent quand même, échouent toujours sur un build cassé ou une suite en +échec, et listent toujours leurs survivants ; elles ne refusent simplement pas +encore une pull request sur un score. C'est le premier balayage hebdomadaire qui +fournira ces quatre chiffres. Cela fait du barrage un **cliquet**, pas une aspiration. Il dit *ne descendez pas sous le niveau où cette bibliothèque est déjà* — une barre que toutes franchissent @@ -241,12 +254,20 @@ tests ; il ne stocke aucun secret et n'a besoin d'aucun périmètre en écriture - **Les seuils vivent dans `build/stryker/*.json`, pas dans le YAML.** C'est ce qui garde un run local et la CI d'accord. `break` est la valeur qui fait échouer le build ; `high`/`low` ne colorent que le rapport. -- **L'outillage et les analyseurs sont hors périmètre à dessein.** Leurs tests - pilotent des compilations Roslyn et lancent des processus : le coût par mutant y - est d'un ordre de grandeur au-dessus de celui des bibliothèques, et leur - comportement est déjà tenu de bout en bout par [`analyzers`](analyzers.fr.md), - `gendoc-docs` et le job `floor` de [`ci`](ci.fr.md). Les ajouter est une décision - de coût, pas un oubli. +- **Trois choses restent hors périmètre, et aucune pour une raison de coût.** Les + échantillons `Usage` et les benchmarks du binder ne sont pas du comportement + livré — un échantillon et un harnais de mesure. `FirstClassErrors.GenDoc.Worker` + est un point d'entrée de processus qu'aucun test n'exerce *en processus* : il est + prouvé de bout en bout par le job `floor` de [`ci`](ci.fr.md), et le muter ne + fabriquerait que des survivants qu'aucun test ne pourrait tuer. Tout le reste de + ce qui se compile dans un artefact livré est mesuré. +- **Les branches d'outillage sont les lentes.** Les suites d'analyseurs et de + générateur pilotent des compilations Roslyn et des comparaisons de snapshots : + chacun de leurs mutants coûte plus cher que celui d'une bibliothèque. C'est + pourquoi elles relèvent du balayage hebdomadaire — le run autorisé à durer le + temps qu'il faut — et pourquoi le cantonnement au diff compte plus pour elles + que partout ailleurs : une pull request qui ne touche pas le générateur ne paie + rien pour lui. - **Un survivant n'est pas automatiquement un bug**, et la réponse à un mutant équivalent est un commentaire `// Stryker disable once` avec sa raison, jamais un seuil abaissé — voir *Quand le survivant est un mutant équivalent* plus haut.