ADR-143 — Couverture CI par couche : ne mesurer/tester que les couches touchées ; couverture complète rare
Statut : Accepté (2026-07-30, décision BDFL). RFC de mécanisme (ADR-015).
Complète ADR-031 (couverture en root sur runners self-hosted),
ADR-036 (filtrage FULL),
ADR-035 (exceptions). S’appuie sur
[package.metadata.air] layer = N (couche de chaque crate) et ADR-052/check-layers.
Catégorie : outillage CI (ci.yml + xtask couvrable-vide).
Contexte
Le job test-coverage prend 15‑20 min par PR. Décomposé :
- Il exécute toujours
cargo test --workspace(tous les tests : lib + intégration + doc, dont les gros interop réels — SFTP ≥ 100 Mio,ssh, conformité ABI C). C’est le vrai coût par PR. - La couverture lourde (
couvrable-vide --strict, llvm-cov instrumenté en root, sccache désactivé ⇒ recompile complète) ne tourne déjà que surFULL= couche 0 touchée OU push main OU nightly (ADR-036). Elle n’est donc pas le coût du cas courant (PR couche 1/2).
Directive BDFL (2026-07-30). « On ne peut pas passer 15‑20 min pour un test de couverture. Couverture PAR COUCHE : ne relancer que les couches touchées, vérifier la non-dégradation. Réduire drastiquement ce job. Garder la couverture complète pour des occasions rares. »
Décision
1. Le job changes sort les couches touchées : couche0 / couche1 / couche2
Étendre la détection existante (qui produit déjà couche0 et docs_only) : mapper chaque fichier
modifié vers la couche de son crate ([package.metadata.air] layer) → trois sorties booléennes.
Un fichier hors crate structurant la CI/le build (ci.yml, xtask/, Cargo.toml racine,
rt/targets/, toolchain) force le mode complet (prudence : impact potentiellement transverse).
2. TESTS cargo test scopés par couche (couche touchée + couches supérieures)
Le pass/fail des tests ne s’exécute que sur les crates nécessaires :
- couche 0 touchée → tout le workspace (tout en dépend) = mode complet ;
- couche 1 touchée → crates couche 1 + couche 2 (la 2 dépend de la 1, peut casser) ;
- couche 2 touchée → crates couche 2 seuls.
Sûreté : une couche basse non touchée a un code identique ⇒ ses tests passent inchangés,
inutile de les rejouer. On sur-teste au plus la couche touchée entière (pas de calcul de
dépendants-inverses fin — [Alternatives]). La liste des crates par couche est dérivée du metadata
layer (une commande xtask la fournit).
3. COUVERTURE couvrable-vide --layer N — mesurer la couche touchée seule
xtask couvrable-vide gagne un mode --layer N : il lance llvm-cov uniquement sur les crates
de la couche N, puis :
- couche 0 / 1 → réconciliation ligne-à-ligne contre
docs/COVERAGE-EXCEPTIONS.md(100 % modulo exceptions,--strict) — la vraie garantie, inchangée mais scopée ; - couche 2 → plancher lignes (> 90 %) + branches, scopé couche 2.
Sur une PR couche N, la CI lance couvrable-vide --layer N (pour chaque couche touchée). La
non-dégradation = la couche touchée tient son seuil. Les couches non touchées ne sont pas
re-mesurées (code + tests identiques ⇒ couverture stable).
4. Couverture COMPLÈTE (toutes couches réconciliées) — occasions rares seulement
Le mode complet (couvrable-vide --strict sur tout) tourne uniquement sur :
- tag de release (garantie forte avant livraison),
workflow_dispatchmanuel (avant audit, à la demande),- nightly/hebdomadaire (
schedule, attrape la dérive lente).
Retiré : le déclencheur FULL sur push main (décision BDFL) — la PR a déjà vérifié sa couche
en pré-merge ; inutile de refaire une couverture complète à chaque merge. (Le filet reste : nightly +
release.)
Conséquences
Positives. Une PR couche 2 (cas courant — air-agent en aura beaucoup) ne compile/teste/mesure que la couche 2 ⇒ de ~15‑20 min à quelques minutes. La garantie de couverture reste exacte sur la couche touchée (réconciliation 100 % couches 0/1). La couverture complète, coûteuse, ne tourne qu’aux jalons rares.
Coûts / risques. (a) Une régression inter-couches non couverte par le scope (théorique : un
changement couche 2 qui ferait chuter la couverture d’une couche basse — impossible puisque le code
bas est inchangé). (b) Le mode complet post-merge disparaît ⇒ une dérive ne serait attrapée qu’au
nightly/release, pas au merge — assumé (la PR par-couche est le gate pré-merge). (c) Un faux
classement de couche (metadata layer erroné) mal-scoperait — mitigé par check-layers (déjà en CI)
et le repli complet sur tout fichier CI/build.
Alternatives rejetées
- Tout recompiler/tester à chaque PR (statu quo) : 15‑20 min systématiques. Rejeté (la directive).
- Scope par crate + dépendants-inverses (
cargo tree --invert) : plus fin (une PR d’un crate feuille couche 2 ne teste que lui), mais plus de tooling et plus fragile. Rejeté au profit du par-couche (BDFL) — simple, sûr ; affinage par-crate possible plus tard si besoin. - Couverture complète sur push main : redondant avec le gate par-couche pré-merge. Rejeté (BDFL).
Mise en œuvre (référence)
ci.yml:changes→couche1/couche2(+couche0existant) ; step tests scopé ; step couverturecouvrable-vide --layer Nconditionné aux couches touchées ; mode complet surschedule/workflow_dispatch/ tag de release.xtask/src/couvrable_vide.rs: mode--layer N(llvm-cov scopé aux crates de la couche + réconciliation couche 0/1 ou plancher couche 2). + une commande listant les crates d’une couche (pour le scope des tests).- La PR qui introduit ceci touche
ci.yml/xtask⇒ mode complet (elle se valide en couverture complète).
Suite
- Enregistrement :
SUMMARY.md,registre-adrs-fr.md. - Mesure : comparer le temps du job avant/après sur une PR couche 2 typique.
Amendement — critère sans_code : un diff sans code n’exécute aucun job de code (2026-08-01, RFC ADR-015)
Statut : Accepté (2026-08-01, décision BDFL). RFC de mécanisme (ADR-015, « Licence MPL 2.0,
contribution DCO, gouvernance évolutive », qui institue le mécanisme d’amendement par RFC).
Amende la Décision 1 ci-dessus (la sortie
docs_only y est renommée et son critère élargi) et rend caduque la troisième puce de
« Mise en œuvre (référence) » (« la PR qui introduit ceci touche ci.yml/xtask ⇒ mode complet »).
N’amende ni les Décisions 2-4 (scope par couche), ni ADR-031 (« Mesure de couverture en root
sur les runners self-hosted »), ni ADR-036 (« Filtrage par chemin de la re-vérification d’une
couche scellée ») — qui décident respectivement comment la couverture est mesurée et quand
elle est relancée, deux questions que le présent amendement ne touche pas.
Contexte
Directive BDFL (2026-08-01). « Quand une PR passe, on doit être en mesure de détecter si on a des ajouts/modifs/suppressions de code Rust, C, Swift, C++. Si c’est le cas, on lance tout. Si ce n’est pas le cas, on s’oriente vers un commit “docs-only” et il n’y a pas de risque de régression du code. »
Le critère docs_only d’origine était par chemin et par répertoire : seuls docs/** et les
points d’entrée racine échappaient aux jobs de code. Deux conséquences pratiques :
- toucher
.github/workflows/ci.yml— un fichier qui décide de ce que la CI exécute, jamais de ce quecargoproduit — forçait ~25 min de recompilation sur deux machines ; - le répertoire
scripts/(outillage de documentation : build mdBook, générateurs de registre et d’index, contrôle du journal) tombait dans la clause fourre-tout « hors crate et hors doc » et forçait, lui aussi, le mode complet.
Le nom docs_only décrivait par ailleurs mal la chose : le critère utile n’est pas « ce diff est
100 % de la documentation » mais « aucun fichier de ce diff ne peut changer le résultat d’un
cargo build / cargo test ».
Décision
1. docs_only devient sans_code. Le nom dit ce que la sortie signifie. Les cinq jobs de code
(test-coverage, cross-check-aarch64, loom, supply-chain, aarch64-native) restent gouvernés
par elle.
2. Le critère a DEUX étages, le premier primant.
- Étage 1 — SOURCES. Tout ajout, modification ou suppression d’un fichier
.rs .c .h .cc .cpp .cxx .hpp .hh .swift .s .asm .capnp, où qu’il soit dans l’arbre : on lance tout. Ce contrôle porte sur le diff brut et court-circuite délibérément l’allowlist de l’étage 2, qui raisonne par répertoire — un.rsd’exemple posé sousdocs/, un helper.csous.github/, ne doivent pas passer pour de la documentation.git diff --name-onlylistant les fichiers ajoutés, modifiés et supprimés, les trois cas de la directive sont couverts par construction. - Étage 2 — ALLOWLIST STRICTE de chemins :
docs/**,.github/**, points d’entrée racine (README*,CLAUDE.md,AGENTS.md,LICENSE), plus les fichiers inertes écartés en amont (.gitignore,book.toml,.claude/,scripts/,.githooks/README.md,docs.yml). Tout le reste compte comme du code, dontCargo.lock— une bascule de dépendance régresse le code sans qu’aucun.rsne bouge —,rust-toolchain.toml,deny.toml,.gitattributes(peut changer le contenu au checkout) et.githooks/commit-msg.docs/COVERAGE-EXCEPTIONS.mdreste l’exception dansdocs/: il pilote le gate de couverture.
3. scripts/ rejoint les fichiers inertes. Vérifié avant de le déclarer : aucun Cargo.toml,
build.rs, .cargo/config.toml, hook git ni sous-commande xtask ne lit ce répertoire. Le workflow
docs exécute ces scripts sur chaque PR — c’est lui qui les valide.
4. CONTREPARTIE, indissociable de la décision 2. scripts/test-ci-perimetre.sh devient une
étape OBLIGATOIRE du job changes. Ce test existait mais aucun workflow ne le lançait : la
garde n’était gardée par rien. Il extrait la logique de décision de ci.yml et la rejoue sur
une trentaine de diffs simulés — donc c’est bien cette logique qu’il valide, pas une copie à tenir
en phase. Quelques secondes, sans compilation. Étant une étape de changes, dont tous les autres
jobs dépendent, son échec bloque la PR sans exiger de réglage de protection de branche.
5. La boucle de mappage fichier → couche examine l’extension AVANT le répertoire. Un fichier
source hors crates/ n’est rattachable à aucune couche, donc non scopable : il force le mode
complet. Sans cette précédence, un .rs sous docs/ était excusé comme documentation.
Conséquences
Positives. Une PR de documentation ou d’outillage documentaire ne mobilise plus ni speedy ni le
Pi : changes en ~10 s, docs en ~2 min, contre ~25 min auparavant. Le critère est en outre plus
sûr que l’ancien sur un point précis : un fichier source égaré hors des répertoires de code est
désormais détecté, ce que le critère par chemin ne faisait pas.
Coûts / risques. Limite assumée : le gate de périmètre valide la décision de périmètre,
pas le contenu des jobs de code. Un seuil de couverture abaissé à l’intérieur de test-coverage
passerait sous son radar ; cela reste couvert par la relecture humaine et par la première PR de code
qui suit. C’est le prix de ne plus recompiler aveuglément à chaque changement de workflow.
Alternatives rejetées
- Garder la règle «
ci.ymltouché ⇒ CI complète ». Défendable : changer un garde-fou sans le faire tourner est imprudent. Rejeté (BDFL) au profit d’une vérification ciblée — le gate de périmètre — qui couvre le risque réel en quelques secondes au lieu de 25 min de recompilation aveugle. - Sauter les jobs de code sans contrepartie. Le plus rapide, mais une PR affaiblissant la CI mergerait au vert sans que le pipeline modifié n’ait jamais été vérifié. Rejeté.
- Détecter les sources par extension SEULEMENT (denylist, sans allowlist de chemins). Rejeté :
Cargo.lock,rust-toolchain.tomletdeny.tomlne portent aucune extension de source et régressent pourtant le code. Les deux étages sont complémentaires, pas redondants.
Mise en œuvre
ci.yml (job changes : étape test-ci-perimetre.sh + étape detect) et
scripts/test-ci-perimetre.sh (32 cas). Mergé en #587. Documentation courante :
CI.md, section « Périmètre : ce qui déclenche quoi ».