Nous avons muté notre propre code. Six tests n'ont rien vu..
Une suite de tests au vert ne prouve pas qu'elle teste quelque chose. Nous avons donc cassé notre code source à dessein pour voir si elle s'en apercevait. Quarante-deux mutations, trente-six attrapées, six survivantes, dont trois vrais trous. Voici les six, y compris les trois que nous n'avons pas bouchés.
Une suite de tests au vert prouve une chose : elle n'a pas échoué. Elle ne prouve pas qu'elle aurait pu. Un test dont les assertions sont vraies par construction passe pour toujours, et il ne garde rien.
Le seul moyen de le savoir est de casser le code exprès. On mute une ligne du code source, on relance les tests, et on regarde s'ils rougissent. Un test qui ne rougit pas devant un défaut injecté ne verra pas non plus un défaut réel.
Nous l'avons fait sur sept modules du cœur de runward. 42 mutations valides, 36 arrêtées, 6 survivantes. Les 36 méritent leur détail : 27 ont été tuées par un test qui rougit, 9 par le compilateur qui a refusé de construire. Neuf n'ont donc jamais rencontré un test.
Trois vrais trous
Le garde anti-traversée avait un test qui ne le gardait pas. Chaque ligne de preuve d'une mission passe par une fonction qui refuse un pointeur sortant du projet. Un test portait le nom de la chose, sur ce chemin exact. Il n'exerçait qu'un chemin absolu, que deux protections redondantes attrapent : on pouvait retirer l'une ou l'autre séparément, ou effondrer la vérification de confinement, sans faire rougir la suite complète. Un test qui porte le nom d'une garantie et ne la pinne pas est plus dangereux qu'un test absent, parce qu'il se lit comme une preuve.
Rien ne vérifiait que runward fonctionne depuis un sous-dossier. La commande remonte l'arborescence jusqu'à trouver la racine de mission : c'est ce qui permet de lancer runward check depuis n'importe où dans un dépôt, et c'est ainsi que l'outil est réellement utilisé. Arrêter cette remontée dès le premier tour de boucle passait inaperçu.
Un commentaire de bloc qui mangeait sa ligne. Dans le lecteur de manifestes de déploiement, faire démarrer un commentaire sur n'importe quelle barre oblique passait inaperçu, parce que le test existant n'affirmait que sur des clés situées sur d'autres lignes. Tout ce qui suit un /* sur la même ligne disparaissait en silence.
Un quatrième défaut a été trouvé hors de ce banc, par une mutation jouée à la main : * qui traverse les barres obliques dans le langage de motifs. Un territoire déclaré serait devenu silencieusement plus large que ce que la règle déclare. Il est corrigé, mais il ne fait pas partie des six survivantes : le dire évite de gonfler le résultat du banc.
Trois qui n'en étaient pas
C'est la section que nous avions écrite en premier, et c'est celle que la vérification a démolie. Nous avions déclaré trois survivantes inoffensives. Deux étaient de vrais défauts. Nous laissons le raisonnement fautif visible, parce qu'il est instructif.
Nous avions écrit qu'une mutation du lecteur de manifestes TOML était quasi équivalente, que le cas déclencheur « n'existe pas », et que fabriquer une fixture pour la faire rougir serait du théâtre. Les trois affirmations sont fausses. L'erreur : nous raisonnions ligne par ligne sur un automate qui porte son état à travers les lignes. Sous la mutation, un dièse situé deux caractères après un guillemet ouvrant devient un commentaire, la ligne est tronquée, un tableau perd son crochet fermant, et la boucle d'équilibrage avale tout le reste du fichier. Le dièse n'a même pas besoin d'être sur une ligne que runward lit :
[vars]
DOC_LINKS = ["https://acme.dev/docs#install"]
[triggers]
crons = ["0 3 * * *"]
Ce tableau, dans une table que runward ne consulte jamais, détruisait le [triggers] en dessous. Une règle HIGH cessait de remonter, en silence. Une URL de documentation avec une ancre n'est pas une fixture tordue, c'est un fichier de configuration ordinaire.
La deuxième mutation portait sur une note de dérivation. Nous l'avions classée « message d'information, jamais un verdict ». C'est exact sur le code de sortie, et c'est un mauvais critère. Sous la mutation, la note peut affirmer qu'un manifeste ne déclare aucun travail planifié dans le même run qui vient d'en dériver un cron, taire une absence réelle, ou désigner le mauvais fichier. Classer un défaut par son canal de sortie plutôt que par sa nature, dans un produit dont la thèse est « aucune étape sans preuve », c'était se payer de mots.
La troisième est bien équivalente, sur 3 267 comparaisons. Mais par accident : elle ne tient que tant que chaque template livré se termine par un saut de ligne. Retirez-en un, et un livrable bascule de « en cours » à « rempli », ce qui ouvre une phase. Rien ne gardait cet invariant. Le test ajouté ne tue pas la mutation : il garde la raison pour laquelle elle ne peut pas nuire.
107 mutations candidates n'ont pas été essayées. Le banc est borné par fichier. Ceci est un échantillon, pas une preuve de couverture, et le dire fait partie du résultat.
Ce que la même passe a trouvé sur notre propre site
Le catalogue publié annonçait « les 64 règles de craft que la porte peut exiger, chacune reliée à une ou plusieurs phases ». Les deux moitiés étaient fausses : la porte n'exige qu'une règle d'impact CRITICAL ou HIGH rattachée à une phase gardée, et 33 règles ne portent aucune phase.
Notre première correction était fausse elle aussi. Nous avions publié 27. C'est 31. Le calcul comparait les règles à une liste de phases écrite à la main, où manquait handover, qui est une phase gardée. Quatre règles disparaissaient du compte, dont une CRITICAL. Nos propres chiffres se contredisaient sans que nous le voyions : 27 exigibles plus 33 sans phase font 60, pas 64.
Et nous avions écrit que le chiffre était « calculé depuis le contrat machine, donc il ne peut plus diverger ». L'impact et les phases venaient bien du contrat. L'ensemble des phases gardées, lui, était redit à la main, et avait déjà divergé au moment où nous publiions la phrase. La correction d'un excès de langage reproduisait exactement le mode de défaillance qu'elle prétendait supprimer.
Le contrat machine publie désormais la liste des phases gardées, et la documentation la lit au lieu de la deviner. Si le champ manque, le build échoue plutôt que de retomber sur un défaut silencieux, puisque c'est un défaut silencieux qui a laissé passer la première dérive.
Le fil qui relie tout ça
Une précision au passage, puisque nous invoquions plus haut un test qui refuse le mot « audit-grade » hors négation : ce test ne lit qu'un seul fichier de doctrine. Il ne couvre ni le reste du dépôt, ni le site où la faute a eu lieu. Invoquer une discipline qui ne surveillait pas l'endroit de la faute, c'était de la rhétorique.
Quatre choses trouvées en deux jours, et une seule forme derrière : un mécanisme correct et inatteignable n'est pas un mécanisme.
Une carte de territoire écrite sous un titre de niveau 1 au lieu de 2 était silencieusement ignorée. Le diagnostic existait, disait la bonne chose, et se trouvait à la ligne 16 d'une sortie que personne ne déroule. L'instrument qui mesure le vieillissement de cette carte vivait dans la seule commande qu'une mission gouvernée n'a aucune raison de lancer. La charte remise à l'agent lui ordonnait de confronter les règles « au point d'action » sans jamais dire comment savoir lesquelles. Et six tests ne pouvaient pas échouer.
Aucun de ces quatre défauts n'est une erreur de raisonnement. Chacun est un mécanisme juste, posé là où il n'atteint personne. C'est une catégorie de bug qui ne se voit pas en relisant du code, parce que le code est correct. Elle se voit en mesurant si quelqu'un peut réellement s'en servir.
Ce que 0.30.0 change pour vous
Une seule chose de visible : la charte d'agent écrite à l'initialisation porte désormais le geste que son obligation réclamait. Elle disait déjà de confronter les règles au point d'action, puis ne nommait qu'une commande qui lit une règle dont on connaît déjà le nom. runward rules --for <chemins> répond enfin à « lesquelles », avec le motif qui retient chacune.
Rien d'autre ne bougeait en 0.30.0 : aucun changement de comportement de la porte, aucun code de sortie modifié. Une mission existante ne reçoit pas cette phrase : runward update ne réécrit jamais votre charte d'agent, parce que c'est un livrable dont vous êtes propriétaire, et c'est exactement la frontière que la mise à jour protège. La ligne se reporte à la main. Nous avions écrit l'inverse dans la première version de cet article.
La version 0.31.0, elle, corrige les deux défauts trouvés par la vérification et publie la liste des phases gardées dans le contrat machine.
Ce que cet article a coûté, et pourquoi il reste en ligne
Sa première version contenait sept erreurs. Un chiffre faux dans le paragraphe même qui corrigeait un excès de langage. Une livraison promise qui n'avait pas lieu. Huit modules pour sept. Une absence de test affirmée alors qu'un test existait. Trente-six mutations « attrapées » dont neuf ne l'étaient que par le compilateur. Une survivante empruntée à un autre banc. Et, surtout, deux défauts réels déclarés inoffensifs.
Nous ne les avons pas trouvées en relisant. Nous les avons trouvées en lançant contre notre propre texte des vérificateurs à charge inversée, dont la consigne était de nous donner tort et qui avaient le droit d'exécuter le code pour le prouver. Ce sont eux qui ont fabriqué le fichier TOML qui casse, et qui ont mesuré que notre garde anti-traversée avait un test incapable de le voir tomber.
Nous ne prétendons pas que la suite est complète. Nous ne prétendons plus non plus savoir ce qui, parmi ce qui survit, est inoffensif : nous l'avons cru trois fois et nous nous sommes trompés deux fois.
← Toutes les nouveautés