Les agents écrivent du code plus vite que quiconque ne peut le relire. Voici le cliquet qui empêche la base de code de pourrir


Vous demandez à un agent de refactoriser un module. Il vous rend un diff de 800 lignes, vous le survolez, vous fusionnez. Trois mois plus tard vous ouvrez ce fichier : 4 000 lignes, une douzaine de branches par fonction, et une heure de lecture avant de pouvoir modifier une ligne en sécurité. Le problème n’est pas que l’agent a écrit du mauvais code — c’est que la vitesse de génération a dépassé la vitesse de relecture. Quelque chose peut ajouter 5 000 lignes par jour ; personne ne peut lire 5 000 lignes par jour. Les bases de code sont rarement ruinées par un seul commit catastrophique ; elles sont écrasées par mille commits qui ont chacun ajouté « juste un petit peu ».

Le 5 octobre 2026, le dépôt Hermes a fusionné la PR #132646, qui installe un mécanisme intéressant : la santé du code ne peut que s’améliorer. Il ne poursuit pas un idéal. Il fonctionne comme un cliquet — chaque fonction et chaque fichier garde son propre niveau actuel, et ne peut évoluer que dans une seule direction. Voici comment cela fonctionne et ce qui vaut la peine d’être repris.

La règle centrale : des plafonds séparés, aucun emprunt

La règle est courte et stricte :

  • Tout ce qui dépasse déjà la cible ne peut pas grossir. Une fonction à complexité cyclomatique (CC) 40 — un compte approximatif des chemins indépendants dans le code — peut rester à 40 ou descendre à 20, mais jamais atteindre 41.
  • Le nouveau code doit atteindre la cible. Les nouvelles fonctions et les nouveaux fichiers doivent satisfaire : CC 20, 300 lignes propres par fonction, imbrication 6, 2 000 lignes par fichier.

La moitié la plus subtile est que vous ne pouvez pas emprunter de budget. Les violations de règles de motif (par exemple, une exception avalée) sont suivies par empreinte — la règle, la fonction englobante et le texte de la ligne. Corriger une violation ne paie jamais pour en ajouter une autre, et rien n’est mutualisé entre les fichiers. Sans cela, un cliquet se transforme en tableau de scores à truquer.

Aucun fichier de référence — la partie astucieuse

La plupart des outils de santé du code veulent une base de référence : enregistrer chaque violation existante, interdire de la dépasser. Ce fichier devient un aimant à conflits, car deux PR parallèles veulent toutes deux le modifier et celle qui fusionne la première gagne.

Hermes ne stocke aucune base de référence. Il mesure à la volée : il prend les fichiers qu’un changement touche, à la fois sur la base (le premier parent du commit de fusion) et sur la tête, et compare. Trois conséquences :

  • une amélioration qui atterrit sur main devient immédiatement le nouveau plafond, sans que personne n’ait à se rappeler de « resserrer le cliquet » ;
  • les PR parallèles n’entrent jamais en conflit sur l’état du cliquet ;
  • les deux côtés sont toujours mesurés avec la même version de l’outil, donc la comparaison a du sens.

Le code déplacé garde son plafond ; le code copié non

C’est ce qui maintient la refactorisation viable :

  • Les fonctions sont appariées par un hachage du corps qui ignore le nom. Une fonction déplacée vers un autre fichier et légèrement modifiée (similarité du corps ≥ 0,6, une partie et une arrivée) garde son plafond, et les instructions au niveau du module déplacées telles quelles gardent le leur.
  • Une copie qui laisse l’original en place compte comme du nouveau code, et doit atteindre la cible.
  • Les fichiers renommés suivent la détection de renommage de git au lieu d’être traités comme nouveaux.
  • Les fonctions imbriquées sont leurs propres unités, donc modifier une fermeture n’est pas imputé à la fonction qui la contient. La complexité Python utilise le C901 de ruff moins les def imbriquées directes.

Ce qu’il bloque d’autre

La taille est une catégorie. L’ensemble complet des règles (avec la portée et le texte de correction que voit l’agent) se trouve dans scripts/code_health/config.py :

Catégorie Règles
Exceptions avalées S110, BLE001, E722 (except: nu) ; un logger.warning("...: %s", e) avec message seul compte toujours comme avalé
Correction F821/F823/F811/F841, RUF006
Peut se bloquer ASYNC230/ASYNC240, S113, HX006 (subprocess/urlopen sans timeout), HX007 (E/S de configuration synchrone dans un async def), HX008 (get_event_loop), HX009 (résultats de gather comparés à Exception)
Portée du profil HX001 (~/.hermes codé en dur), HX002 (une nouvelle variable d’environnement HERMES_*), HX004 (UnscopedSecretError qui retombe sur os.getenv), HX005 (capture à l’import), HX012 (threading.Thread brut), plus PS-P05/PS-P06
Autres HX003 (identité du processus via argv), HX010 (bool() d’une chaîne d’environnement), HX011 (longues échelles if/elif)

Un sous-ensemble est avertisseur seulement : HX001, HX003, HX010, HX012, PS-P05, PS-P06. Les échantillons des relecteurs situent leur précision autour de 12–60 %, donc ils s’affichent à chaque exécution mais ne bloquent jamais un commit. Passer l’un d’eux en bloquant est un changement d’une ligne dès qu’on dispose de preuves par rejeu.

La porte de sortie : dérogez, mais dites pourquoi

Toute règle a besoin d’exceptions. La syntaxe est :

# health: allow BLE001 -- <why>

La raison est obligatoire, et chaque allow utilisé dans une exécution est listé dans la sortie, donc un relecteur voit combien de dérogations un changement ajoute et ce qu’elles disent. C’est une vraie amélioration par rapport à # noqa, qui est généralement silencieux.

Une seule commande, exactement ce que la CI exécute

La partie la plus pratique est l’expérience locale. python scripts/check exécute ruff, les vérifications de garde (footgun / shebang / no-tmp / config-writer / OS-marker / case-collision / lazy-deps / profile-archive) et le cliquet lui-même en parallèle, en utilisant le ruff épinglé de la CI — pris dans le groupe dev du pyproject, avec repli sur uvx ruff@<pin> plutôt que d’utiliser silencieusement le ruff que vous avez. Environ dix secondes sur l’arbre.

python scripts/check                # tout, par rapport au merge-base avec origin/main
python scripts/check --only health  # juste le cliquet de santé du code
python scripts/check --staged       # juge l'index (ce que lance le hook pre-commit)
python scripts/check --commit REV   # juge un commit (ce que lance le hook pre-push)
python scripts/check --install-hook pre-push
python scripts/check --uninstall-hook

Les hooks sont facultatifs et contournables avec --no-verify ; la CI ne l’est pas. scripts/check nécessite Python 3.11+ (pour tomllib) et le dit clairement, en pointant vers le virtualenv du dépôt, au lieu de mourir dans une trace à chaque commit avec hook.

Il existe aussi un interrupteur principal : ENFORCEMENT = "blocking" | "advisory" | "off" dans scripts/code_health/config.py. Point crucial, il est toujours lu depuis main — en CI, depuis le premier parent du commit de fusion ; localement, depuis origin/main — jamais depuis la branche testée. Un commit d’une ligne sur main désactive le cliquet pour toutes les PR ouvertes, et aucune PR ne peut assouplir sa propre vérification.

Il a été validé sur un rejeu figé avant de bloquer quoi que ce soit

Le pire mode de défaillance pour une nouvelle barrière est de bloquer des choses qui vont en fait très bien, donc il a d’abord été rejoué contre l’historique : chaque PR fusionnée entre le 2026-10-02 et le 2026-10-03.

Vérification Résultat
Rejeu figé (318 PR) 59 seraient bloquées. 38 par la seule croissance des fichiers ; 21 par d’autres règles : croissance de CC ou nouvelle fonction au-dessus de CC 20 dans 10 PR, 13 except aveugles dans 9 PR, cinq F841 dans un seul fichier de test, un F811 de test (un test masqué qui ne s’exécutait jamais), un S110, une nouvelle fonction de 300+ lignes
Cliquet de taille de fichier Le seul bloqueur pour 38 des 318 PR (12 %) dans cette fenêtre, surtout +1 à 50 lignes ajoutées à des fichiers déjà au-delà de 2 000 (main.ts, auxiliary_client.py, cron/scheduler.py, le test_tui_gateway_server.py de 23 000 lignes)
Rapport sur l’arbre complet sur main Unités au-dessus de la cible aujourd’hui : 451 CC, 55 longueur de fonction, 28 imbrication ; 119 fichiers ; BLE001 6 516, S110 828, HX012 227, HX006 209, HX005 53 — une dette existante qui ne bloque qu’à partir du moment où elle grossit

Cette dernière ligne mérite qu’on s’y arrête. Le mécanisme ne demande à personne de rembourser la dette. Les 6 516 exceptions avalées existantes sont gelées, pas planifiées ; elles ne comptent que si elles grossissent. C’est ce qui rend une barrière comme celle-ci supportable — exiger une table rase dès le premier jour garantit que personne n’active l’interrupteur.

Ce qu’il faut reprendre pour votre propre dépôt

Si votre équipe laisse elle aussi les agents écrire beaucoup de code, plusieurs de ces décisions se transposent directement :

  1. Un cliquet, pas une cible. Non pas « la CC doit rester sous 20 à partir de maintenant », mais « ce qui dépasse déjà ne peut pas empirer, et le nouveau code doit passer la barre ». La première version bloque toute modification du code hérité.
  2. Mesurer à la volée, ne stocker aucun fichier de référence. Aucun conflit entre PR parallèles, et aucun problème de discipline pour se rappeler de le resserrer.
  3. Prendre l’empreinte des violations de motif pour qu’une correction n’achète jamais une nouvelle violation.
  4. Livrer les nouvelles règles d’abord en avertissement, prouver leur précision sur des rejeux historiques, puis les rendre bloquantes.
  5. Exiger une raison pour chaque dérogation, et afficher les dérogations — une exception silencieuse équivaut à aucune règle.
  6. Rendre la commande locale identique à la CI, pour que personne n’ait à deviner ce que dira le pipeline.

Pour le contexte, c’est le même dépôt qu’un agent a refactorisé à grande échelle en septembre : 1 393 sous-agents ont réduit le Python hors tests de 34,4 %. Cela a prouvé que les agents peuvent réécrire une base de code en masse. Le cliquet répond à la question suivante — comment l’empêcher de repousser lentement. Si les contraintes d’ingénierie qui atterrissent dans la CI plutôt que dans un wiki vous intéressent, les vérifications de chaîne d’approvisionnement du catalogue de plugins sont une autre version de « écrire la règle dans le pipeline ».

La leçon de conception

Les personnes qui ont construit cela ont fait un choix délibérément modeste : ne pas poursuivre l’idéal, juste tenir la ligne. Les cibles — CC 20, fonctions de 300 lignes, imbrication 6, fichiers de 2 000 lignes — ne sont pas un standard de vertu ; elles sont la barrière où « empirer » s’arrête. Ce qui le fait survivre en pratique tient à trois détails : la dette existante est gelée plutôt que liquidée, l’interrupteur d’application est toujours lu depuis main plutôt que depuis la branche jugée, et chaque règle doit prouver sur des données historiques qu’elle ne produira pas de fausses alertes. Quand générer du code devient presque gratuit, des mécanismes comme celui-ci gagnent en valeur, pas l’inverse — parce que la seule chose qui devient plus chère est le temps qu’une personne doit consacrer à le lire.