Complexité cyclomatique : le vrai risque du rollout
Résumé
La complexité cyclomatique compte les chemins indépendants dans une fonction. Ce que les dashboards cachent : c'est le delta de complexité du diff, croisé au delta de test, qui prédit vraiment les incidents de rollout. Gater un canary sur ce ratio, pas juste sur le SLO, économise les 15 premières minutes d'incident.
Complexité cyclomatique counts the number of independent paths through a function. NIST puts the safe ceiling at 10 per function, and most teams enforce something close to that in CI. What that number does not tell you is which of those paths will actually get exercised by the canary you're about to ship to 5% of production traffic, and that gap is where most rollout incidents actually start. Treat cyclomatic complexity as a rollout risk input, not a lint rule, and the number starts earning its keep.
Qu'est-ce que la complexité cyclomatique mesure vraiment
La métrique de McCabe est un décompte de graphe : nœuds, arêtes, composantes connexes. Chaque if, for, while, case, et opérateur booléen ajoute un chemin. Une fonction avec une complexité de 25 possède au minimum 25 chemins linéairement indépendants. Ce n'est pas une opinion, c'est la mathématique des tests de chemin d'exécution, et elle fixe un plancher sur le nombre de cas de test nécessaires pour couvrir la fonction une fois, de bout en bout. La synthèse de Sourcegraph donne les seuils complets : 1-10 risque faible, 11-20 modéré, 21-50 élevé, 50+ c'est "il faut qu'on parle".
La plupart des équipes ne atteignent pas ce plancher. Les outils de couverture rapportent la couverture de ligne, pas la couverture de chemin, donc une fonction peut afficher 90% de couverture alors que la moitié de ses branches ne sont jamais exécutées en CI. C'est la partie que personne ne met sur le template de PR, et c'est la raison pour laquelle "les tests passent" et "c'est sûr de shipper" sont deux affirmations différentes qui se font passer pour une seule.
On a lu assez de post-mortems pour repérer le pattern : la revue d'incident a toujours une ligne pour la consommation du budget d'erreur et une ligne pour le délai de détection. Elle n'a presque jamais une ligne pour la complexité de la fonction modifiée avant que le diff ne soit mergé. C'est une lacune dans la documentation, pas seulement dans l'outillage.

Pourquoi un chiffre de complexité plat cache le vrai rayon d'explosion
Voilà la partie que chaque dashboard de complexité se plante : deux fonctions peuvent afficher le même score de complexité cyclomatique et avoir des risques complètement différents. Un switch de 20 branches qui dispatche vers des handlers bien testés n'est pas le même animal qu'une fonction avec quatre poches distinctes de conditionnelles imbriquées dispersées sur 80 lignes, chacune trois niveaux de profondeur. CodeScene appelle cette deuxième forme une "bumpy road", et le nom est juste. C'est la profondeur d'imbrication, pas le nombre brut de branches, qui surcharge la charge cognitive et cache le cas limite que personne n'a pensé à tester. L'article de CodeScene approfondit la comparaison, et ça correspond clairement à ce qu'on observe dans les données de rollout.
C'est pertinent pour nous spécifiquement parce qu'un canary ne s'écrase pas sur la complexité moyenne. Il s'écrase sur la fonction bumpy qui a été touchée dans ce diff, à 2h du matin, sous une charge que personne n'a load-testé. Dans les post-mortems qu'on a examinés avec les équipes platform, le pattern se répète : la fonction racine de l'incident n'a presque jamais le score de complexité le plus élevé du repo. Elle a le delta de complexité le plus élevé du diff qui a été shippé. C'est un signal différent, et presque personne ne l'instrumente.
Un dashboard qui dit que la complexité de la codebase baisse, ce n'est pas le même dashboard qui dit que ce rollout-ci, maintenant, vient de toucher une fonction qui vient de gagner trois branches imbriquées supplémentaires. L'un est un rapport de santé qu'on lit une fois par trimestre. L'autre est une gate qu'on voudrait vraiment avoir dans le pipeline de déploiement, à côté du check SLO, pas enterré dans un outil d'analyse statique qu'on n'ouvre jamais en incident.
Les rollouts en rings et en pourcentages supposent déjà que certains diffs sont plus risqués que d'autres ; c'est la prémisse entière d'un canary. Ce que la plupart des pipelines ne font pas, c'est laisser la forme de complexité du diff lui-même éclairer la vitesse à laquelle ce canary devrait s'élargir. C'est une préoccupation de code review, puis oubliée dès que le PR est mergé.
La gate qu'on fixe à 10 pour tout le monde, et pourquoi ça ne change rien
Le conseil standard : plafonner la complexité cyclomatique à 10 et échouer le build au-delà, c'est l'étape "skip" que presque chaque blog d'ingénierie recommande et que presque personne ne valide par rapport aux vraies données d'incident. Ce n'est pas faux, exactement. C'est incomplet de manière à laisser les équipes croire qu'elles ont géré le risque alors qu'elles l'ont surtout déplacé.
Deux modes de défaillance, tous deux courants chez les équipes qu'on a interrogées :
Jouer avec le chiffre. Extract Method est le correctif classique, et ça marche : la complexité cyclomatique par fonction baisse. Mais si l'extraction ne réduit pas le nombre réel de décisions, juste la relocalisé sur trois fonctions au lieu d'une, la complexité du système est inchangée. La gate redevient verte. Le rayon d'explosion ne rétrécit pas, il devient juste plus difficile à voir en une vue diff.
Ignorer la forme. Un seuil plat traite un dispatcher de 12 branches et un chemin bumpy de 12 comme également risqués. Ils ne le sont pas. Le dispatcher va probablement bien ; c'est du routage mécanique. Le bumpy road, c'est là où vit votre prochain rollback, parce que la personne qui l'a examiné a arrêté de tracker l'état trois niveaux d'imbrication plus bas.
Les agents de codage IA rendent ça pire avant de le rendre meilleur. Devin et les agents autonomes similaires shippent les PRs vite, et "vite" signifie souvent ajouter une branche au lieu de refactoriser celle qui existe déjà. C'est le chemin de moindre résistance pour un modèle qui optimise sur une suite de tests réussie, pas sur la charge cognitive d'un reviewer. Si votre équipe merge des diffs écrits par IA à volume, le delta de complexité par PR est une métrique qu'on veut sur le dashboard avant qu'elle devienne une conclusion de post-mortem, pas après.

Ce que le delta de complexité te dit vraiment sur la charge de test
Oublie le score absolu une seconde. Le chiffre qui prédit le risque d'incident, c'est le changement de complexité introduit par un seul diff, croisé avec la question de savoir si les tests touchant ce diff ont vraiment grandi pour le suivre.
Une fonction qui passe de complexité 8 à 19 en un seul PR a, selon la mathématique des tests de chemin d'exécution ci-dessus, grossièrement doublé son nombre de cas de test minimum requis. Si le PR a ajouté deux tests, tu as shippé une lacune de couverture déguisée en exécution CI réussie. Cette lacune ne se voit que lorsque le canary frappe la tranche de trafic 5% qui exerce la branche non testée, et à ce moment-là c'est un incident, pas un commentaire de code review non résolu dans un fil PR.
C'est la lacune d'instrumentation que le AI Pilot d'upstreamapi est construit pour combler côté rollout : gater le pourcentage de canary et le hold time sur le delta de complexité du diff shippé, croisé contre son delta de test, pas juste sur le SLO du taux d'erreur en aval. Un SLO de taux d'erreur te dit que quelque chose s'est déjà cassé. Un gate de delta de complexité te dit que le diff était plus susceptible de casser quelque chose avant qu'il serve du trafic en direct, ce qui est le seul moment où cette information est encore actionnable.
// Gate de canary simplifié : élargir lentement sur les diffs faible-risque, tenir sur les hauts-risque
function canaryStep(diff: DiffMetrics): CanaryDecision {
const complexityRisk = diff.complexityDelta / Math.max(diff.testDelta, 1);
if (complexityRisk > 3 && diff.slo.errorBudgetBurn > 0.1) {
return { action: "hold", trafficPct: diff.currentTrafficPct };
}
if (complexityRisk > 3) {
return { action: "extend_bake_time", bakeMinutes: 45 };
}
return { action: "advance", trafficPct: diff.currentTrafficPct + 10 };
}La recherche est-elle d'accord sur ça ?
Non, et c'est bon de le dire clairement. Certaines recherches de praticiens poussent fortement contre la complexité cyclomatique comme prédicteur de défauts du tout, arguant que les équipes qui optimisent sur un score plus bas relocalisent souvent la complexité quelque part de moins visible. La critique de GetDX fait ce cas et pointe les équipes vers les métriques d'expérience développeur à la place. On ne pense pas que cet argument tue la métrique ; on pense qu'il tue la métrique utilisée seule, comme un score au niveau du repo, déconnectée du diff et du rollout auquel elle est attachée.
Comment on gaterait vraiment un canary sur la complexité, pas juste le taux d'erreur
Trois choses, dans l'ordre de friction qu'elles ajoutent à un PR :
Calculer le delta de complexité par diff, pas par repo. Les moyennes au niveau du repo cachent la fonction qui compte cette semaine. Le delta par PR est bon marché à calculer dans la plupart des outils d'analyse statique, et c'est le chiffre qui corrèle avec ce qui se casse vraiment dans les 48 heures suivantes.
Croiser contre le delta de test, pas le nombre de tests. Une fonction avec 40 tests et un saut de complexité de 8 à 19 avec zéro nouveaux tests est un risque plus grand qu'une fonction toute neuve avec complexité 15 et couverture assortie dès le départ.
Alimenter le ratio dans le rythme de rollout, pas juste l'approbation de merge. Un diff à delta-complexité-élevé n'a pas besoin d'être bloqué en review ; beaucoup de logique métier légitime et complexe (machines d'état, parseurs de protocole) aura toujours un score élevé. Il a besoin d'un canary plus lent et d'un leash d'erreur-budget plus court, ce qui est une décision de rollout, pas une décision de code review.
Écris l'entrée du runbook avant le rollout, pas après la page. Si l'ingénieur on-call qui ouvre le canal incident à 3h du matin doit reverse-engineer pourquoi un PR "clean" vient de brûler le budget d'erreur, la documentation a échoué avant le code. Une simple ligne dans le log de déploiement ("complexité delta 11, delta test 1, held at 20%") ne coûte rien à écrire et économise les 15 premières minutes de chaque revue d'incident qui suit.

Ça vaut la peine de tracker, ou juste un autre chiffre du dashboard ?
Ça dépend entièrement de ce à quoi tu l'attaches. La complexité cyclomatique comme ligne de tendance santé du repo, c'est surtout de la décoration : jolie pour un slide trimestriel, inutile à 2h du matin. La complexité cyclomatique comme delta par diff, alimentée dans le rythme de canary et croisée contre la croissance du test, c'est l'un des indicateurs avancés les moins chers qu'on ait trouvés pour "ce rollout va appeler quelqu'un".
Le post-mortem va demander à quoi ressemblait le budget SLO avant le rollback. De plus en plus, le nôtre demande aussi à quoi ressemblait le delta de complexité sur le diff qui a été shippé. Ça vaut la peine d'ajouter cette question à ton propre runbook avant que l'incident force la conversation, pas pendant la retro quand la réponse c'est un haussement d'épaules et une promesse "d'ajouter de meilleurs tests la prochaine fois".