Outillage cargo xtask — gates qualité gravés
Ce document décrit la crate d’outillage xtask : elle grave en outillage
déterministe et réutilisable les gates qualité qu’on vérifiait à la main —
en particulier ceux qui nous ont mordus (numéros de syscalls par arche,
invariant « couvrable vide », dérive du décompte d’exceptions).
Statut.
xtaskest de l’outillage de développement, pas du code Air livré. Il est membre du workspace (pour quecargo xtask <gate>se résolve via l’alias.cargo/config.toml), mais exclu de la mesure de couverture (--exclude xtask) et non soumis au 100 % des couches 0/1 (carve-out esprit ADR-030). Il reste linté (clippy --workspace -D warnings), testé (cargo test --workspaceexécute ses tests de parseurs) et audité (deny/audit/machete). Il n’introduit aucune dépendance externe (std seulement) : leCargo.lockdu produit et la reproductibilité (ADR-025) restent intacts.
Invocation
cargo xtask barrier [--no-coverage] # toute la barrière, un seul point d'entrée
cargo xtask couvrable-vide [--strict] [--no-root] [--layer N]
cargo xtask crates-of-layer N [--and-above] [--names]
cargo xtask check-syscalls
cargo xtask check-layers
cargo xtask audit-exceptions [--strict]
cargo xtask repro [--keep]
Chaque sous-commande accepte --json (sortie machine) et rend un code de
sortie : 0 = vert (ou signalements non bloquants), 1 = violation, 2 =
erreur d’usage / interne. Conçu pour la CI comme pour Claude Code.
Les gates
1. barrier — un seul point d’entrée pour la barrière
Enchaîne et agrège : fmt (stable et nightly), clippy --workspace --all-targets -D warnings, cargo test --workspace, couverture lignes +
branches en root (planchers ADR-031 :
lignes ≥ 96, branches ≥ 78), cargo audit, cargo deny check, cargo machete,
présence d’un // SAFETY: sur chaque bloc unsafe { … } du socle, cohérence
Cargo.toml ↔ DEPENDENCIES.md. Récapitulatif structuré pass/fail + chiffres.
--no-coverage saute les étapes root (boucle de dev rapide sans sudo).
2. couvrable-vide — anti-gaming (le plus important)
Lance llvm-cov (root), parse les lignes non couvertes du code production et
les croise avec COVERAGE-EXCEPTIONS.md.
- Gate DUR (mécaniquement décidable) : le plancher agrégat des lignes des
crates non réconciliés (≥ 90) et, pour les branches, la non-régression
contre la référence versionnée
xtask/couverture-reference.toml(échec sousréférence − 0.10; proposition de relèvement au-dessus deréférence + 0.25). C’est le filet anti-gaming central — la couverture brute ne peut pas chuter en douce, et l’ancien plancher absolu (78) ne peut plus dériver au-dessus de la mesure réelle. Cf. CI.md. - Assist (advisory /
--strict) : l’invariant « couvrable VIDE » (toute ligne rouge atteignable ⊆ registre) n’est pas mécaniquement vérifiable — la reachability est une décision humaine (cf. CI.md) et lesfichier:lignedu registre dérivent. La réconciliation se fait donc par symbole (fonction englobante mentionnée au registre) ou par proximité de ligne (±40, pour absorber la dérive). Les lignes non rapprochées sont signalées ;--strictles durcit en violations (et fait échouer aussi sur un fichier production non couvert sans aucune section au registre). Les références du registre au-delà du fichier sont signalées comme orphelines.
--no-root mesure sans sudo (couverture partielle, ebpf plafonné — diagnostic
seulement).
--layer N — mesure PAR COUCHE (ADR-143).
Ne change aucune règle : il change le périmètre. llvm-cov reçoit
-p <crate> pour les seules crates dont [package.metadata.air] layer = N
(moins xtask et air-libc-c, hors mesure comme en mode complet) ; les deux
volets ci-dessus s’appliquent ensuite à l’identique sur ce qui a été mesuré. Le
mode sans --layer reste le mode complet, inchangé. Utilisé par la CI
pour ne re-mesurer que les couches touchées par une PR.
Limite. Une couche mesurée seule ne bénéficie plus des tests des couches supérieures : la mesure peut être plus sévère que dans le mode complet (faux échec possible, jamais faux succès). D’où l’absence de
--layer 0en CI : une PR couche 0 escalade en mode complet.
2 bis. crates-of-layer — le scope de test par couche
$ cargo xtask crates-of-layer 2
-p air-account-cli -p air-admin-gate -p air-async …
$ cargo xtask crates-of-layer 1 --and-above --names
air-account air-account-cli air-account-schema …
Helper (pas un gate) : sortie brute sur stdout, directement injectable dans
cargo test $(cargo xtask crates-of-layer 2). --and-above ajoute les couches
supérieures (ADR-143 §2 : couche 1 touchée → tester 1 et 2, la 2 en
dépendant). --names omet les -p. Même source de vérité que check-layers :
un scope vide est une erreur (code 2), jamais un silence.
3. check-syscalls — numéros déclarés vs headers uapi (ce qui nous a piégés)
Extrait les numéros de syscalls déclarés dans air-sys-syscall (commentaires
// SAFETY: SYS_<nom> (<arche> = <N>) et constantes d’arche const SYS_… /
const NR_IO_URING_…) et les compare, pour chaque arche, aux headers
uapi :
| Arche | Header (overridable) |
|---|---|
| x86_64 | /usr/include/x86_64-linux-gnu/asm/unistd_64.h (XTASK_UNISTD_X86_64) |
| aarch64 | /usr/include/asm-generic/unistd.h (XTASK_UNISTD_AARCH64) — aarch64 utilise la table générique |
Le header générique est présent sur les deux exécuteurs, donc les deux
arches sont vérifiables depuis n’importe lequel. Tout écart ⇒ VIOLATION
(exit ≠ 0), avec le détail syscall : code_déclaré vs header. Si un header
manque pour une arche, c’est signalé clairement (et ignoré), jamais un échec
silencieux. La résolution des indirections (__NR_mmap → __NR3264_mmap → 222)
est gérée.
4. audit-exceptions — format/taxonomie ADR-035 du registre
Valide chaque entrée de COVERAGE-EXCEPTIONS.md :
- catégorie ∈ {
STRUCTURAL,PRIVILEGE,FEATURE-KERNEL,CHILD-EXIT,DEFERRED-TOOLING} ; aucunTEST-HARNESSau registre production (ADR-035 §2) ; - justification non vide ;
fichier:ligneexistant : tout.rscité doit exister, toute ligne citée ≤ la taille du fichier, et tout symbole entre backticks doit être présent dans le fichier cité (anti-dérive — résout la dette de re-sync notée au sceau) ;- récapitulatif recomputé à partir des rangées et confronté au
récapitulatif affiché — toute divergence est une VIOLATION (anti-dérive du
décompte, le travers dénoncé par l’audit
053).
Les entrées DEFERRED-TOOLING sont signalées (dette à couvrir). --strict
durcit les orphelines fichier:ligne / symbole en violations.
5. repro — double-build bit-pour-bit (ADR-025)
Construit le même arbre deux fois en environnement contrôlé (toolchain
pinné, SOURCE_DATE_EPOCH fixe, --remap-path-prefix sur la source / le
target de chaque build / CARGO_HOME), dans deux target distincts remappés
vers le même préfixe logique, puis diffe bit-pour-bit l’ensemble des
*.rlib du socle. Toute divergence ⇒ exit ≠ 0, avec le détail des fichiers
non déterministes (premier octet divergent). C’est l’attestation de
reproductibilité visée par l’ADR-025 (le sceau couche 0 n’avait qu’un constat
same-host non outillé). --keep conserve target/repro pour inspection.
6. deb — paquets Debian des livrables Air (ADR-100/ADR-103 déploiement)
Produit les paquets Debian des exécutables livrés — air-account (crate
air-account-cli), air-keystore (crate air-keystore-cli) et air-sshd (binaires
air-sshd + air-ssh) — pour
l’architecture de l’hôte : build linux-air (-Z build-std contre la std
linux-air, ADR-103), assemblage d’un arbre DEBIAN/control + usr/sbin/<bin> (0755,
outils root), scellé via dpkg-deb (outil natif Ubuntu — pas de dépendance cargo,
Principe 6). Garde-fou : readelf -d doit rendre zéro NEEDED (static-pie,
zéro glibc) — sinon échec. La table des livrables vit dans xtask/src/packaging.rs,
partagée avec le gate check-target (voir plus bas) qui prouve qu’aucun exécutable
livrable ne l’a manquée. Sortie : target/debian/<package>_<version>_<arch>.deb.
Déterministe :
SOURCE_DATE_EPOCH figé clampe les mtime ⇒ .deb reproductible pour un
binaire donné (ADR-025). Les deux architectures (amd64 sur speedy/carbon,
arm64 sur raspi-srv-2) se produisent nativement sur leur runner — aucun
cross-toolchain. Prérequis : dpkg-deb (Debian/Ubuntu).
Dépendances inter-paquets. Les livrables se lisent entre eux, et le control
le déclare (champ depends de la table, rendu en Depends:) :
| paquet | Depends: | pourquoi |
|---|---|---|
air-account | (aucun) | racine du graphe — la base comptes ne présuppose rien. |
air-keystore | air-account | le gate admin ADR-101 qui autorise toute mutation du keystore lit shadow.aircfg. |
air-sshd | air-keystore, air-account | authentifie des utilisateurs de la base comptes ; administre ses certificats via la CLI keystore. |
D’où l’ordre d’installation air-account → air-keystore → air-sshd, désormais
tenu par dpkg/apt plutôt que par une consigne. Les dépendances sont non
versionnées : ce qui est requis est le fichier que l’autre paquet installe, pas une
révision de son format — et les trois livrables suivent des cycles de version distincts.
Une liste vide n’écrit aucune ligne Depends: (un champ vide serait mal formé).
check-layers — doctrine de couche mécanisée (ADR-052 / ADR-077)
La doctrine de couche — « seule la couche 1 consomme la couche 0 » ; « les toits
C-ABI (air-libc-*, *-capi) bindent la couche 1, jamais la 0 en direct »
(ADR-077) ; air-sys-types transverse
(ADR-052 D6) — était tenue à la main
/ en revue. Ce gate la mécanise : chaque crate air-* déclare sa couche
dans [package.metadata.air] (layer = 0|1|2), zone ignorée par Cargo (même
précédent que [package.metadata.cargo-machete]) donc inerte ; le gate lit ces
déclarations et prouve que le graphe de dépendances les respecte :
- (a) toute crate
air-*déclarelayer(sinon « crate sans couche ») ; - (b) pas de dépendance montante (
M ≤ N) ; - (c) pas de saut (
M ≥ N-1) : un toitlayer=2ne touche pas la couche 0 en direct — il passe par la couche 1.
air-sys-types est exclu de (b)/(c) (transverse). Les arêtes air-*
cible-only ([target.'cfg(…)'.dependencies]) qui violeraient les règles — p.ex.
l’escape hatch syscall de la couche 0 cadré par
ADR-087, absent du graphe hôte livré —
sont signalées en advisory (non bloquant), pas rouge. Branché dans barrier.
check-abi — la rupture d’ABI C, refusée (BLOQUANT)
cargo xtask check-abi [--regenerer]
xtask/abi-reference.toml fige la surface exportée des 4 headers C committés (392
déclarations au 2026-08-14). Une déclaration qui disparaît ou dont la signature
change est une violation : dans le premier cas le consommateur C cesse de lier, dans le
second — le pire des deux — il compile encore et se comporte autrement. Un ajout n’est
que signalé : il ne rompt personne.
Pourquoi il fallait un gate de plus. gen-capi-header --check prouve que le header
coïncide avec les sources, et les tests abi_conformance compilent un vrai consommateur C.
Ni l’un ni l’autre ne voit une rupture : retirer un symbole et régénérer le header donne un
header parfaitement cohérent avec un code parfaitement cohérent. C’est précisément ce qu’une
promesse de dix ans (ADR-012) interdit.
Le zonage, et pourquoi il n’est pas une échappatoire. Chaque crate portant un header
committé déclare sa zone de stabilité (ADR-012) dans
[package.metadata.air], au même endroit que layer et role :
[package.metadata.air]
layer = 2
abi_zone = "stable" # stable | internal | experimental
Une rupture est refusée en zone stable et seulement signalée en internal /
experimental — sans quoi ces deux zones, qu’ADR-012 crée précisément pour permettre
l’évolution libre, ne serviraient à rien. Une zone absente est traitée au plus strict : on
ne relâche pas la garde au motif qu’on ne sait pas ce qu’on garde. Et une valeur hors
vocabulaire est distinguée de l’absence, pour qu’une faute de frappe ne devienne pas
silencieuse.
Une zone ne se déclasse pas. La référence consigne la zone de chaque surface ; l’abaisser
(stable → experimental) est refusé comme une rupture. Sans cette règle, il suffirait de
reclasser une surface la veille de la rompre : le zonage serait exactement l’échappatoire
qu’il est censé encadrer. La monter est libre — c’est s’engager davantage.
Une rupture délibérée se fait avec --regenerer, dans le commit qui la porte. Ce n’est
pas une échappatoire : la rupture devient un diff committé, visible en revue, au lieu d’un
silence.
Le niveau des symboles. Depuis le 2026-08-14, le gate confronte aussi la surface
exportée par chaque cdylib à la surface déclarée. C’est un niveau que ni
gen-capi-header --check ni la comparaison des déclarations ne peuvent atteindre : tous deux
lisent des textes, alors qu’un consommateur C lie un objet, et les deux peuvent diverger
sans qu’aucun ne mente.
| Écart | Verdict |
|---|---|
| déclaré, non exporté | violation — le consommateur compile, puis échoue au lien |
| exporté, non déclaré | violation — surface livrée que rien ne garde |
| non déclaré mais consigné | toléré : cliquet, dette nommée |
| bibliothèque non construite | signalé — « non vérifié », jamais « conforme » |
Deux listes nommées, jamais devinées : les _Unwind_* du dérouleur, qu’une cdylib porte
par construction ; et les symboles que le consommateur fournit (air_main, dont le header
documente un contrat en sens inverse — c’est le programme lié qui l’apporte au runtime).
Premier passage : 27 écarts, tous réels — 20 air_value_* ré-exportés par
libair_object_c.so (à retirer, décision BDFL du 2026-08-14) et 7 découverts à cette
occasion dans libair_c.so (air_fmt_*, air_libc_*). Ils sont consignés dans la référence
comme cliquet : ceux-là passent, un nouveau échoue.
Ce qu’il ne prouve toujours pas. La disposition des structures (un champ réordonné
garde sa signature) et le versionnement de symboles ELF — ce dernier relevant de
check-symver et
d’ADR-158.
check-symver — aucune .so livrée sans versions de symboles (BLOQUANT)
cargo xtask check-symver
ADR-158 retient le versionnement de symboles et en diffère la mise en œuvre : Air ne livre aujourd’hui aucune bibliothèque partagée, et le versionnement achète une seule chose — la coexistence de deux versions d’un même symbole —, qui ne protège personne tant que rien n’est livré.
Ce gate est ce qui rend ce report sûr. Attacher des versions à des symboles qui n’en
portaient pas est soi-même une rupture de liaison : une première bibliothèque publiée sans
versions rendrait la voie A inatteignable sans casser ce qu’elle protège. Une .so doit donc
porter ses versions dès sa première publication, et c’est ce que ce gate exige.
Aujourd’hui il ne trouve aucune bibliothèque livrée — et il l’écrit, plutôt que de rendre un vert muet. Le compteur est publié pour qu’on voie le jour où il cesse d’être zéro.
Comment il conclut. Une bibliothèque se reconnaît à son nom (lib*.so*, le nommage
d’ADR-029) — supposition assumée, un exécutable static-PIE étant
lui aussi un ELF ET_DYN. La présence des versions se lit dans l’objet : la section
.gnu.version_d ne naît que d’un script de version. La table des sections est décodée à la
main, sans dépendance ni readelf à installer, et un objet illisible est refusé, jamais
déclaré conforme.
check-dco — l’attestation d’origine, mécanisée (BLOQUANT)
cargo xtask check-dco [--base <ref>]
Sur la plage base..HEAD (défaut : origin/main..HEAD), chaque commit apporté par la branche
doit porter son Signed-off-by — le Developer Certificate of Origin, la déclaration
nominative « j’ai le droit de soumettre ce code sous la licence du projet ». Le gate refuse
aussi tout trailer Co-Authored-By: Claude : un outil n’est pas co-auteur.
Les commits de fusion sont hors champ (--no-merges), et c’est de fond : une fusion
n’introduit aucun contenu écrit par quiconque, elle joint deux histoires déjà attestées par
les commits qu’elle réunit. Sans cette exemption, le gate échouait sur un commit que personne
n’a écrit — pour une pull request, actions/checkout place HEAD sur refs/pull/N/merge,
une fusion que GitHub fabrique à la volée.
Trois précisions sur ce qu’il prouve. La concordance entre le sign-off et l’auteur n’est
qu’un signalement — relayer le patch d’un tiers est légitime ; et elle est omise quand
l’auteur est un …@users.noreply.github.com, identité que la forge substitue au squash-merge.
La signature cryptographique n’est constatée que présente : git distingue « non signée »
de « signée mais invérifiable ici », et un runner de CI n’a pas les clés publiques des
contributeurs. Enfin, quand la plage est vide, le gate écrit « 0 commit vérifié » plutôt que
de rendre un vert muet.
check-dependencies — la règle des 80 %, ce qu’on peut en mécaniser (consultatif)
cargo xtask check-dependencies [--bloquant]
Toute dépendance livrée ([dependencies] et [target.*.dependencies] ; les
dev-dependencies n’embarquent rien chez personne) doit porter un audit dans le
DEPENDENCIES.md de sa crate, nom entre accents graves — une mention en prose ne vaut pas
audit. Deux exclusions nommées : les façades rustc-std-workspace-* (tirées par
rustc-dep-of-std pour construire std) et loom, hors du graphe de build normal.
Il ne juge pas le ratio de 80 % : estimer la part d’API consommée demande de lire le code appelant et la surface publique de la crate. C’est un jugement, pas une mesure — et un gate qui prétendrait le mesurer serait exactement le travers que ce catalogue combat.
check-safety-comments — chaque bloc unsafe porte-t-il sa justification ? (consultatif)
cargo xtask check-safety-comments [--bloquant]
Balaye crates/ et dénombre séparément production et code de test — y compris les modules
hors-ligne (src/fd/tests.rs), dont le #[cfg(test)] vit chez le parent et qui, sans cela,
passeraient pour du code livré. Traverse les formes légitimes : commentaire multi-ligne,
attribut intercalé, continuation d’expression, // SAFETY: partagé par deux blocs voisins,
et bloc en tête d’une unsafe fn dont la doc porte déjà # Safety.
Consultatif par nature, et c’est écrit dans ses tests : un contrôle ligne-à-ligne ne peut pas établir qu’un commentaire distant couvre bien ce bloc.
check-target — discipline de build linux-air mécanisée (ADR-103)
Doctrine ADR-103 : tout exécutable
Air est livré pour *-unknown-linux-air (std linux-air, PAL couche 1, sans libc C),
jamais pour *-unknown-linux-gnu — la std gnu reste un banc de test hôte, jamais
un livrable. Comme check-layers, le gate lit la zone inerte
[package.metadata.air] des Cargo.toml : aucune dépendance, aucun build.
Marquage et cible :
- (a) toute crate déclare
[package.metadata.air](crate identifié) ; - (b) tout exécutable déclare
target = "linux-air"ou"host-tool"— un choix explicite, jamais un défaut ; - (c) la valeur de
targetest l’une des deux ; - (d) un exécutable
host-tool(outillage dev/build hôte gnu, jamais livré) doit être allowlisté —xtaskseul aujourd’hui.
Couverture de livraison (couplage avec deb) : déclarer la bonne cible ne suffit
pas — un exécutable linux-air que cargo xtask deb ne packagerait pas serait
compilé mais jamais livré, silencieusement. Le gate vérifie donc dans les deux
sens contre la table xtask/src/packaging.rs :
- (e) tout crate produisant un exécutable
linux-airest packagé (ou justifié dansUNPACKAGED_LINUX_AIR, vide aujourd’hui), et chacun de ses binaires est installé par le paquet ; - (f) réciproquement, tout paquet désigne un crate qui existe, produit un
exécutable, est
linux-air, et produit bien chacun des binaires déclarés.
La table de packaging vit dans un module partagé (xtask/src/packaging.rs) consommé
par deb (production) et par ce gate : le couplage est tenu par le compilateur,
pas par un parseur de source — le gate ne peut pas mentir sur ce que deb fabrique.
(f) attrape la dérive inverse (crate renommé, binaire supprimé) : deb la signalerait
aussi, mais seulement au build, et seulement sur un runner Linux-air ; le gate, lui,
tourne partout et sans build. Branché dans barrier et en CI.
Intégration CI
Le job supply-chain du workflow ci appelle cargo xtask check-syscalls,
check-c-surface, check-target et audit-exceptions (rapides, sans root) en
source unique des gates correspondants.
Le job test-coverage porte la couverture, mesurée en root
(ADR-031) par couvrable-vide lui-même :
- mode complet —
cargo xtask couvrable-vide --strict(toutes couches réconciliées) sur les occasions rares d’ADR-143 §4 (tag,workflow_dispatch, nightly, couche 0 touchée, forçage CI/build) ; - mode par couche —
cargo xtask couvrable-vide --strict --layer Npour chaque couche touchée par la PR, etcargo test $(cargo xtask crates-of-layer N …)pour le périmètre de test (cf. CI.md).
repro et barrier restent disponibles localement / à la demande.
Pourquoi un gate buggé est pire que pas de gate
Un gate qui rougit au hasard (faux positif) ou qui passe à tort (faux négatif)
donne une fausse confiance. C’est pourquoi les parseurs de xtask
(couvrable-vide, check-syscalls, audit-exceptions, et les helpers de
barrier/repro) sont testés sur entrées synthétiques (cargo test --workspace), et pourquoi la frontière entre gate dur (mécaniquement
décidable : planchers, numéros de syscalls, taxonomie, cohérence du décompte) et
assist advisory (reachability, dérive fichier:ligne) est explicite.
Licence : MPL 2.0