Mon code C a 100% de couverture. Je n'ai pas écrit un seul test.

Le paradoxe

22 422 lignes de production. 97 000 lignes de tests. Ratio 4.3:1. 100% de couverture — lignes et branches. Zéro fuite Valgrind. Zéro race condition TSAN. Zéro erreur ASAN.

Je n’ai écrit ni le code ni les tests.

Ces chiffres ne tombent pas du ciel. Derrière chaque module livré, il y a un cycle TDD de 6 étapes exécuté par 6 agents spécialisés, orchestrés dans Claude Code. Cet article est le déroulement concret de ce cycle — ce qui se passe à l’intérieur, étape par étape, sur un vrai exemple.

Pour comprendre la vue macro — l’architecture globale du pipeline, les décisions de design, le rôle de chaque agent — lis 22 000 lignes de C écrites par une IA — comment j’ai architecturé le pipeline. Cet article est le complément : la vue micro, le cycle lui-même.


Le TDD que personne ne fait

TDD : tout le monde le cite dans les rétros, presque personne ne le pratique vraiment. Les tests arrivent après le code, la couverture est ajoutée en fin de sprint pour satisfaire une métrique, et les specs vivent dans la tête du développeur jusqu’à la prochaine PR.

Pourquoi ? Parce que RED-GREEN-REFACTOR coûte cher en discipline. Écrire les tests avant l’implémentation, ne pas sauter une étape, documenter les pré/post-conditions — c’est de la rigueur mécanique. Le genre de contrainte que les humains contournent quand ils sont sous pression.

C’est exactement là qu’une IA excelle : exécuter mécaniquement une discipline, sans raccourcis, à chaque cycle. Le TDD n’a jamais été un problème technique. C’était un problème de coût humain.


Les 6 étapes — déroulement sur bc_vector_contains

L’exemple qui suit est réel. bc_vector_contains est une fonction d’une bibliothèque C interne : chercher un élément dans un vecteur générique par comparaison mémoire. Fonction courte, mais représentative du cycle complet.

Étape 1 — Research (c-researcher)

Le c-researcher a un seul droit : lire. Pas de modification de fichier, pas de décision d’architecture. Son travail : explorer le code source, comprendre le contexte, produire un rapport.

Pour bc_vector_contains, il a identifié que bc_hashmap_contains et bc_set_contains utilisent déjà memcmp pour la comparaison d’éléments opaques — un motif cohérent dans la bibliothèque. Il a noté le risque de débordement sur size_t si element_size est nul. Il a identifié les règles CERT C applicables (EXP34-C, ARR30-C).

Résultat : 00-research.md avec le contexte, les motifs existants, et les risques identifiés.

Étape 2 — Spec (c-architect)

Le c-architect lit le rapport de recherche. Son travail : prendre des décisions et les documenter.

Il a évalué deux alternatives pour la comparaison :

Option A : memcmp(element, item, vector->element_size)
  + Cohérent avec bc_hashmap_contains et bc_set_contains
  + API uniforme, zéro allocation
  - Ne supporte pas les types avec padding non déterministe

Option B : callback comparateur (fn(a, b) -> bool)
  + Flexible, supporte tous les types
  - Rupture d'API avec les autres modules contains
  - Complexité supplémentaire pour le cas d'usage standard

Décision : Option A. La cohérence de l’API interne prime. La signature retenue :

bool bc_vector_contains(const bc_vector_t *vector,
                        const void *element,
                        bool *out_found);

Avec ses pré-conditions (vector != NULL, element != NULL, out_found != NULL, vector->element_size > 0) et post-conditions (*out_found == true si et seulement si un élément correspond octet par octet).

Résultat : 01-spec.md avec la signature exacte, les alternatives écartées, et les conditions documentées.

Étape 3 — RED (c-tester)

Le c-tester lit la spec. Son travail : écrire les tests avant l’implémentation, créer un stub qui fait échouer ces tests, et vérifier que l’échec est propre.

Il a écrit 10 tests cmocka :

Test 1  : vecteur vide → out_found = false, retourne true
Test 2  : élément présent en position 0 → out_found = true
Test 3  : élément présent en dernière position
Test 4  : élément présent au milieu
Test 5  : élément absent, vecteur non-vide
Test 6  : doublon — élément en position 0 et 3, retourne true au premier match
Test 7  : correspondance partielle (premiers N octets identiques, dernier byte différent)
Test 8  : vector == NULL → retourne false (précondition violée)
Test 9  : element == NULL → retourne false
Test 10 : out_found == NULL → retourne false

Ensuite il a inséré un stub TDD dans le source :

bool bc_vector_contains(const bc_vector_t *vector,
                        const void *element,
                        bool *out_found)
{
    /* ===== TDD RED stub — do not implement ===== */
    (void)vector;
    (void)element;
    (void)out_found;
    return false;
}

Lancement de build-quick.sh : les 10 tests compilent, 7 échouent. C’est le but. Le RED est propre — pas d’erreur de compilation, pas de segfault, juste des assertions qui ne passent pas.

Résultat : 02-test-red.md avec la liste des tests, le log d’échec, et le statut RED confirmé.

Étape 4 — GREEN (c-developer)

Le c-developer lit le rapport RED. Il voit les 10 tests, il voit le stub, il connaît la spec. Son travail : implémenter le minimum pour faire passer ces tests. Rien de plus.

bool bc_vector_contains(const bc_vector_t *vector,
                        const void *element,
                        bool *out_found)
{
    if (!vector || !element || !out_found) {
        return false;
    }

    *out_found = false;

    for (size_t i = 0; i < vector->size; i++) {
        const void *item = bc_vector_at_unsafe(vector, i);
        if (memcmp(element, item, vector->element_size) == 0) {
            *out_found = true;
            return true;
        }
    }

    return true;
}

Il supprime le stub TDD. Il lance build-quick.sh : 121 tests passent — les 10 nouveaux et les 111 existants. Aucune régression.

Résultat : 03-impl-green.md avec l’implémentation, le log de build, et le statut GREEN — 121/121.

Étapes 5 et 6 — Review + Security (en parallèle)

Ces deux étapes sont lancées simultanément sur le même état du code.

c-reviewer : lecture seule du code livré. Il vérifie les conventions du projet — en-tête SPDX présent, nommage des fonctions (bc_ prefix, snake_case), ordre des includes (projet avant système), style clang-format conforme. Il produit 04-review.md.

c-security-auditor : il lance validate.sh complet (8 étapes décrites ci-dessous) et conduit en parallèle un audit sémantique manuel — overflow possible sur i * element_size dans bc_vector_at_unsafe si element_size est géré sans contrôle, accès hors-bornes si size est corrompu, comportement sur vecteur désalloué. Il produit 04-security.md.

Les deux rapports terminent avec status: ok. Le cycle est complet.


Les 8 contrôles de validate.sh

Chaque cycle TDD se termine par validate.sh, le script de validation complet. 8 étapes, organisées en 3 groupes, avec arrêt au premier échec. Chaque étape s’exécute dans son propre répertoire de build isolé pour éviter les contaminations d’état.

Groupe A — Analyse statique
  1. clang-format --dry-run (style)
  2. cppcheck (analyse statique C)
  3. audit-rules (conventions projet : SPDX, nommage, structure)

Groupe B — Fonctionnel
  4. build-quick (compilation debug + tests cmocka)
  5. Valgrind --leak-check=full (fuites mémoire)

Groupe C — Sécurité et couverture
  6. ASAN + UBSAN (comportement indéfini, dépassements)
  7. TSAN (accès concurrents, atomiques C11)
  8. gcovr (100% lignes + branches requis)

Si le contrôle 1 échoue, les contrôles 2 et 3 ne s’exécutent pas. Si le contrôle 4 échoue, pas la peine de lancer Valgrind. Chaque groupe doit être vert avant que le suivant commence.

Trois bugs concrets ont été attrapés par ce pipeline pendant le développement :

  • Accès concurrent dans le pool allocator — TSAN l’a détecté. Deux threads libéraient un bloc simultanément sans verrou. L’erreur n’apparaissait jamais en exécution séquentielle. Le contrôle 7 l’a isolée avec la trace d’appels exacte.

  • Débordement dans le calcul de découpage — UBSAN l’a capturé. chunk_count * chunk_size dépassait SIZE_MAX pour des tailles d’entrée spécifiques. Le débordement était silencieux en release. Le contrôle 6 l’a rendu fatal.

  • Fuite mémoire dans la liste libre — Valgrind l’a trouvée. Un chemin d’erreur dans l’initialisation allouait un tampon mais ne le libérait pas si une assertion interne échouait ensuite. Contrôle 5, bloc still reachable, 1 allocation de 64 octets.

Aucun de ces bugs n’aurait été trouvé par un simple ninja test en build debug.


Pourquoi ça marche

Trois propriétés expliquent la cohérence du résultat.

Séparation des responsabilités. Le c-tester écrit les tests sans voir l’implémentation finale. Le c-developer implémente sans modifier les tests. Ce n’est pas une convention arbitraire : c’est ce qui garantit que les tests testent vraiment quelque chose. Un développeur qui écrit le code et les tests en même temps a toutes les chances de faire correspondre les deux plutôt que de vérifier le comportement attendu.

Communication par rapports écrits. Chaque agent lit un Markdown structuré produit par l’étape précédente. Il n’y a pas de contexte partagé implicite, pas de mémoire de session commune, pas d’hypothèse sur “ce que l’autre a compris”. Chaque transition est explicite. Le rapport de research informe la spec. La spec informe les tests. Les tests informent l’implémentation. La chaîne est traçable.

991 lignes de règles. C’est le vrai produit de six mois de travail. 32 principes issus d’échecs réels : un agent qui oubliait l’en-tête SPDX, un autre qui réinitialisait errno sans raison, un troisième qui utilisait les options courtes dans les scripts alors que les scripts doivent utiliser les options longues pour rester lisibles. Chaque règle est une erreur qui s’est produite une fois et ne doit plus se reproduire. Sans ces contraintes, l’IA répète les mêmes erreurs à chaque nouveau module — comme un développeur junior sans accompagnement.

Le code se régénère. Les règles, non.


Conclusion

100% de couverture n’est pas un objectif. C’est une contrainte de départ. La question n’est pas “comment atteindre 100%” mais “comment rendre 100% inévitable” — c’est-à-dire structurer le cycle pour qu’un module ne soit jamais livré sans que cette contrainte soit vérifiée mécaniquement.

Le TDD n’a jamais été un problème technique. C’est un problème de discipline, et la discipline coûte cher en temps humain. Quand le coût tombe à zéro — parce qu’un agent l’exécute mécaniquement à chaque cycle — le TDD devient ce qu’il a toujours dû être : la norme, pas l’exception.

19 minutes par cycle. 6 agents. 8 contrôles. 32 principes. Les 991 lignes de règles valent plus que les 22 000 lignes de code livré. Le code s’écrit. Les contraintes se construisent.

Lis. Comprends. Maîtrise. Recommence.