Une refactorisation peut échouer avant même que le code soit réellement faux. Renommer un identifiant chez un producteur et chez son consommateur, par exemple, crée deux modifications qui vont ensemble. Si vous les séparez en commits distincts, le premier peut laisser le dépôt dans un état rouge, ou pire : compiler, tout en cachant l'incompatibilité jusqu'à l'exécution. Le problème, ce n'est pas la taille des changements. C'est la façon de les découper : par fichiers plutôt que par dépendances.
Du coup, on est tenté de réduire le diff. C'est pratique pour la revue, mais ça ne garantit pas la sécurité. Un petit changement peut dépendre d'un autre ; à l'inverse, un ensemble plus large peut rester cohérent et testable. L'objectif, c'est donc simple à formuler : trouver le plus petit groupe de changements dont les risques sont identifiables, et qu'on peut valider ensemble.
Une façon de procéder consiste à appliquer chaque changement candidat seul, dans un environnement temporaire, puis à relever les tests qui échouent. Quand deux changements produisent des échecs similaires, ils sont souvent liés par un même état ou un même comportement. Voici comment construire cette matrice, la lire sans surinterpréter, et s'en servir pour produire des commits plus fiables.
Pourquoi réduire le diff ne suffit pas
Un diff court est plus facile à lire. Mais ce n'est pas forcément plus sûr à livrer. La sécurité d'une modification dépend aussi de ce qui doit changer en même temps, des comportements couverts par les tests, et de la possibilité de valider chaque étape indépendamment.
Prenons un champ customer_id renommé dans une fonction qui le produit et dans celle qui le consomme. Ces deux changements correspondent à une intention unique. Si vous les séparez, le premier commit peut casser les tests (et vous ralentir), ou pire, ne pas les casser immédiatement tout en restant incompatible. Le découpage par fichiers masque alors la dépendance réelle.
Les tests de caractérisation ne révèlent pas tout
Les tests de caractérisation décrivent le comportement observable actuel. Ils aident à vérifier qu'une refactorisation ne modifie pas ce comportement « au sens des tests ». En revanche, ils ne disent pas toujours comment deux changements se répondent entre eux. Autrement dit : ils ne prouvent pas que deux parties peuvent être appliquées séparément.
C'est important : une suite verte sur l'état de départ confirme un point de référence, pas l'indépendance de toutes les étapes. Pour estimer le couplage, il faut tester les modifications elles-mêmes, une par une.
La signature d'échec comme signal de couplage
Pour chaque modification candidate, appliquez-la seule et relevez les tests qui échouent. Cet ensemble constitue la signature d'échec. Si deux signatures se recouvrent fortement, c'est un bon indice que ces changements touchent probablement au même comportement, ou au même état.
Attention : ce n'est pas une preuve « mathématique » de dépendance. C'est un signal à exploiter avec bon sens. Par exemple :
- deux modifications font échouer exactement les mêmes tests : elles sont probablement liées ;
- elles provoquent des échecs différents : elles peuvent correspondre à des problèmes distincts ;
- aucun test ne tombe : la suite ne montre pas de couplage visible, mais ça ne garantit pas qu'il n'en existe pas.
En pratique, cette mesure aide surtout quand vous avez du mal à découper les refactorisations sans produire des commits intermédiaires rouges, et quand la suite de tests est assez stable pour donner un signal exploitable.
Construire une matrice de signatures de tests
La méthode se déroule en cinq étapes : figer un état de départ, découper le travail en modifications unitaires, exécuter chaque modification isolément, comparer les signatures, puis intégrer les groupes validés progressivement. Avant tout, il faut une référence fiable. Sinon, la matrice mélange des résultats dont l'état de départ n'est pas vraiment le même.
1. Figer une base verte
Commencez depuis un dépôt propre, avec une suite verte. Notez le commit exact et la commande exacte utilisée pour exécuter les tests :
mkdir -p .matrix
git rev-parse HEAD > .matrix/baseline.sha
python -m pytest -q --tb=no 2>&1 | tail -n 3
Gardez cette commande dans un fichier ou dans la documentation du dépôt. Chaque exécution de la matrice doit utiliser le même périmètre de tests et les mêmes options (sauf, éventuellement, la sortie JUnit pour l'analyse).
Avant de lancer quoi que ce soit, vérifiez aussi que l'arbre de travail est propre. Des changements non committés peuvent contaminer le point de départ, ou au minimum compliquer la reproduction.
Si votre module ne possède aucun test qui décrit ses comportements publics, arrêtez-vous ici. Écrivez d'abord quelques tests de caractérisation ciblés. Dix tests simples sur les points d'entrée importants valent mieux qu'une grande suite spéculative qui ne couvre pas vraiment ce qui compte.
2. Découper selon l'intention, pas selon le fichier
Préparez une modification par intention : extraire une fonction, renommer un champ, déplacer une validation. La bonne unité de découpage, c'est la phrase que vous pourriez mettre dans le message de commit, pas le nombre de fichiers touchés.
patches/01-extraire-normalisation.patch
patches/02-renommer-identifiant-client.patch
patches/03-deplacer-validation.patch
Chaque patch doit pouvoir s'appliquer seul à la base de départ. S'il ne s'applique pas sans un autre patch, notez clairement la dépendance textuelle : vous devrez alors empiler les patches dans l'ordre requis. Et gardez les patches assez petits pour que le reviewer comprenne rapidement l'intention et les effets attendus.
Une distinction mérite d'être dite clairement : un patch qui ne s'applique pas seul, ce n'est pas la même chose qu'un patch qui s'applique mais fait échouer des tests. Le premier signale une dépendance de contexte ou de texte ; le second donne justement une signature comportementale à comparer.
3. Exécuter chaque patch dans un worktree temporaire
Ne testez pas les patches les uns après les autres dans votre espace de travail principal. Les modifications précédentes faussent les essais suivants. git worktree permet de créer un répertoire temporaire par patch, puis de le supprimer après l'exécution.
L'idée du script est simple : créer un worktree à partir de la base, appliquer un seul patch, lancer pytest avec un rapport JUnit, récupérer les tests en échec, puis supprimer le worktree. Les détails dépendent de votre environnement, mais la documentation de Git sur les worktrees et celle de pytest sur les rapports JUnit donnent la mécanique.
Dans une implémentation concrète, utilisez bien le commit enregistré dans .matrix/baseline.sha, et pas simplement HEAD au moment où vous lancez le script. Sinon, vous ne comparez plus des patches « sur la même base ». Et au passage, contrôlez le code de retour de pytest, en distinguant : échec de tests, commande interrompue, rapport manquant, patch impossible à appliquer.
Côté représentation, une signature peut être l'ensemble des identifiants des tests en échec, par exemple nom_du_module::test_validation. L'identifiant doit rester stable et suffisamment précis pour que les comparaisons aient du sens. Si un test échoue déjà sur la base de référence, corrigez-le avant de conclure sur les patches.
4. Comparer les signatures
Pour comparer deux ensembles de tests, une mesure courante est la similarité de Jaccard : le nombre de tests communs divisé par le nombre de tests présents dans au moins l'une des deux signatures.
similarité = tests communs / tests présents dans au moins une signature
Le résultat varie de 0 à 1. Une valeur à 1 signifie des signatures identiques ; 0 signifie qu'il n'y a aucun test en commun. Un seuil comme 0,5 peut aider à repérer des candidats à regrouper, mais ce n'est pas une règle universelle. Le bon seuil dépend de la taille de la suite, du coût des « faux rapprochements », et de la structure de vos tests.
Lire les résultats et former des groupes cohérents
La matrice n'est utile que si elle sert à prendre une décision de découpage. Elle ne remplace ni la lecture du code ni l'examen des tests. Son but est d'exposer des dépendances que l'intuition (ou le diff) ne rend pas immédiatement visibles.
| Forme de la signature | Interprétation possible | Action à envisager |
|---|---|---|
| Aucun test en échec | Aucun couplage visible dans la suite | Valider le patch, sans en faire une garantie de sécurité |
| Même signature non vide | Comportement probablement partagé | Examiner les patches comme un groupe |
| Recouvrement partiel | Lien possible, à clarifier | Lire les tests communs et vérifier l'ordre des changements |
| Signatures disjointes et non vides | Échecs apparemment distincts | Enquêter séparément avant de regrouper |
| Patch impossible à appliquer | Dépendance textuelle entre patches | Empiler les modifications dans l'ordre requis |
Les cas les plus parlants
Imaginons un patch d'extraction qui ne fait échouer aucun test. Ça peut être un bon candidat à intégrer tôt, à condition de vérifier le diff et de relancer la suite complète. À l'inverse, un renommage d'identifiant et le déplacement d'une validation peuvent faire tomber exactement les mêmes sept tests. Là, le recouvrement justifie une inspection : ces deux modifications touchent peut-être au même contrat, et la livraison ensemble devient plus logique.
Si un troisième patch fait échouer deux tests différents, ne le rattachez pas automatiquement au groupe. Ça peut être un problème indépendant, une couverture insuffisante, ou une interaction inattendue. La matrice vous dit « où regarder », pas « pourquoi ça casse ».
Attention aux groupes formés par transitivité
Une approche simple consiste à relier les patches dont la similarité dépasse un seuil. Ça peut produire une chaîne : A ressemble un peu à B, B ressemble un peu à C, et au final A et C se retrouvent dans le même groupe... alors qu'ils n'ont presque rien en commun.
Ce n'est pas forcément faux, mais il faut le savoir avant d'interpréter le groupe comme une dépendance forte. Pour des refactorisations sensibles, regardez aussi les similarités deux à deux à l'intérieur du groupe. Vous pouvez aussi adopter une stratégie de regroupement plus stricte : exiger une cohérence globale, pas juste un chemin de recouvrements.
Le seuil de 0,5 donné ici sert de point de départ, pas de valeur à recopier aveuglément. Dans une suite courte, un seul test commun pèse lourd. Dans une suite énorme, des échecs identiques sur plusieurs cas ont plus de sens, mais peuvent aussi venir d'un test d'intégration central qui échoue pour plein de raisons.
Une signature vide n'est pas un certificat de sécurité
Un patch peut ne faire échouer aucun test parce qu'il est réellement indépendant. Mais il peut aussi ne rien toucher dans la couverture : la suite ne « voit » tout simplement pas la partie modifiée. Donc signature vide = absence de couplage visible, pas assurance de sécurité.
Et l'inverse existe : des tests trop sensibles à des détails de présentation, à des dates, ou à l'ordre des clés d'un dictionnaire peuvent échouer pour presque tous les patches. Dans ce cas, les signatures deviennent énormes et la matrice perd sa capacité à discriminer. Avant de conclure, il vaut parfois mieux rendre les assertions plus robustes.
Intégrer la méthode sans ralentir l'équipe
Le process complet peut coûter cher : une exécution de suite par patch, puis une vérification complète après chaque groupe intégré. Si trente patches prennent chacun deux minutes, on est déjà autour d'une heure de calcul, sans compter l'analyse. La méthode n'a de sens que si le coût reste proportionné au risque que vous essayez de réduire.
Intégrer un groupe, puis recalculer la référence
Une fois qu'un groupe est choisi, appliquez-le, lancez la suite complète, et ne commitez que si ça passe. Ensuite, enregistrez le commit et mettez à jour la base (la « référence »). Puis recalculer les signatures des patches restants.
git apply patches/01-extraire-normalisation.patch
python -m pytest -q --tb=no
git commit -am "refactorisation : extraire la normalisation"
git rev-parse HEAD > .matrix/baseline.sha
python couple.py
La base change à chaque étape. Un patch qui semblait indépendant sur l'ancien état peut devenir couplé après un déplacement de fonction ou d'une responsabilité. C'est pour ça qu'on ne peut pas faire une matrice « une fois pour toutes » sur toute la refactorisation.
Maîtriser le coût des exécutions
Si la suite complète est trop lente, vous pouvez d'abord travailler avec une sélection de tests ciblée, ou une commande limitée au paquet concerné. Mais raccourcir comme ça réduit aussi la portée du signal. Une exécution locale utile pour trier n'est pas une validation complète avant intégration.
Une approche pragmatique : utiliser une suite courte pour explorer les signatures, puis relancer l'ensemble des tests avant chaque commit. Dans un dépôt avec CI, ça peut s'automatiser, à condition que l'environnement reste reproductible et que les résultats soient lisibles pour les développeurs.
Quand ne pas utiliser cette méthode
Évitez cette mécanique si la suite complète dépasse franchement le budget, si vos tests ne couvrent pas le module concerné, ou si le risque est déjà évident (au point que le découpage est presque « auto-évident »). Mettre en place une matrice complexe pour un changement trivial ajoute de la friction sans améliorer vraiment la décision.
Elle devient plus intéressante quand le module est large, quand les tests fournissent un signal crédible, quand la refactorisation combine plusieurs intentions, et quand les commits intermédiaires rouges compliquent régulièrement les revues. En clair : utilisez-la quand le coût de la confusion dépasse le coût de quelques exécutions en plus.
Les outils d'IA peuvent suggérer des patches candidats, mais la génération n'est pas là où ça se joue. Ce qui compte, c'est de valider l'intention, de comprendre les signatures, et de décider du découpage. Un patch « plausible » produit vite n'est pas un changement validé. La base, les tests et la revue restent indispensables.
Conclusion : viser le plus petit changement sûr
La taille du diff ne suffit pas pour dire qu'un changement est « sûr ». Ce qui compte, c'est de savoir quelles intentions peuvent être validées séparément, et lesquelles partagent un comportement, un état, ou un contrat.
Les signatures de tests donnent un moyen concret de vérifier ça avant de pousser une refactorisation difficile à découper. La méthode repose sur une base verte, des patches à intention unique, des worktrees isolés, une lecture prudente des tests en échec, et une mise à jour de la référence après chaque intégration. Il y a un coût : plusieurs exécutions, une suite suffisamment fiable, du temps pour analyser. Et elle ne détecte pas ce que vos tests ne couvrent pas.
Commencez par une refactorisation où les commits intermédiaires posent déjà problème. Mettez en place une base propre, testez quelques patches isolément, puis observez si les signatures améliorent vraiment vos choix de découpage. Le bon objectif n'est pas « le diff le plus petit ». C'est le plus petit groupe que vous comprenez, dont vous pouvez expliquer les risques, et que vous arrivez à démontrer comme passant.
