From 7844387fa1c72b354ed4f195e876375a42aad336 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 30 Aug 2026 02:02:38 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20commit-msg=20:=20contr=C3=B4ler=20le=20?= =?UTF-8?q?corps,=20sa=20longueur=20et=20l'identifiant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le garde-fou ne lisait que le sujet : le corps portait des noms de bases réelles, des adresses et des chemins de compte, et sa longueur médiane faisait une fois et demie le plafond de la convention. Il lit désormais le corps, trailers et diff de --cleanup=scissors exclus : longueur par langue, adresse IP, courriel, chemin de compte, et les termes d'une liste qui vit hors du dépôt puisque c'est elle qu'elle protège — absente, le contrôle se tait. Le prédicat qui sépare une adresse d'une version de manifeste est partagé ; 40 tests couvrent les deux lectures. --- EN --- The guard rail read the subject only, while the body carried real database names, addresses and account paths, and its median length was one and a half times the convention's ceiling. It now reads the body, trailers and the --cleanup=scissors diff excluded: length per language, IP address, e-mail, account path, and the terms of a list living outside the repository, since the list is what it protects — absent, that check stays silent. The predicate telling an address from a manifest version is shared; 40 tests cover both readings. Assisted-by: Claude Opus 5 --- script/git/commit_msg_lib.py | 162 +++++++++++++++++++++++++++++-- script/lib_identifiant.py | 109 +++++++++++++++++++++ test/test_git_commit_msg.py | 180 +++++++++++++++++++++++++++++++++-- 3 files changed, 437 insertions(+), 14 deletions(-) create mode 100644 script/lib_identifiant.py diff --git a/script/git/commit_msg_lib.py b/script/git/commit_msg_lib.py index 2c9366d..3d23eeb 100644 --- a/script/git/commit_msg_lib.py +++ b/script/git/commit_msg_lib.py @@ -1,20 +1,40 @@ #!/usr/bin/env python3 # © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -"""Le sujet d'un commit tient-il la convention ? +"""Le message d'un commit tient-il la convention ? La convention est dans `.claude/rules/04-code-conventions.md`, son mode d'emploi dans `conf/template_claude_commands_commit.md`. Ce module en vérifie -la part MÉCANIQUE : le tag, la longueur, et le sujet qui s'ouvre sur une -citation. Le reste — « ce sujet dit-il sur quoi porte le code » — est un -jugement, et aucun hook ne le rendra. +la part MÉCANIQUE, sur deux plans : + +- le sujet : le tag, la longueur, l'ouverture sur une citation ; +- le corps : sa longueur par langue, et la donnée identifiante. + +Le reste — « ce sujet dit-il sur quoi porte le code », « ce corps raconte-t-il +l'enquête plutôt que le fonctionnement » — est un jugement, et aucun hook ne +le rendra. Compté en CARACTÈRES et non en octets : « préchauffer » pèse 12 caractères et 14 octets, et une limite en octets refuserait des sujets français conformes. """ +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from lib_identifiant import ( # noqa: E402 + NOMS_INTERDITS, + identifiants, + termes_interdits, +) MAX = 72 +# Lignes non vides par langue. Le corps est bilingue : ce budget est celui +# d'UNE des deux moitiés, pas du message entier. +MAX_BODY = 10 + TAGS = ("ADD", "FIX", "UPD", "IMP", "REF", "REM", "MOV", "I18N") # Ce que git écrit lui-même, ou ce qu'un rebase consomme : hors convention. @@ -24,6 +44,22 @@ GENERATED = ("Merge ", "Revert ", "fixup!", "squash!", "amend!") # preuve va dans le corps — le sujet doit nommer la cause. QUOTES = ("«", '"', "'", "`", "“", "‘") +# Le marqueur nomme la langue de ce qui SUIT : il sépare les deux moitiés. +MARKER = re.compile(r"^---\s*(FR|EN)\s*---\s*$", re.MULTILINE) + +# `git commit --cleanup=scissors` laisse le diff en clair sous cette ligne : +# tout ce qui suit appartient à git, pas à l'auteur. +CISEAUX = re.compile(r"^#?\s*-{2,}\s*>8\s*-{2,}") + +# Un trailer porte légitimement une adresse, et `-x` ajoute sa propre ligne. +# La liste est fermée : « Checked: … » reste du corps et se fait vérifier. +TRAILER = re.compile( + r"^(?:Assisted-by|Co-authored-by|Signed-off-by|Reviewed-by|Acked-by" + r"|Tested-by|Reported-by|Suggested-by|Cc|Fixes|Closes|Refs|Link):\s" + r"|^\(cherry picked from commit [0-9a-f]+\)$", + re.IGNORECASE, +) + def subject_of(message: str) -> str: """La première ligne utile : ni commentaire, ni ligne vide.""" @@ -35,12 +71,50 @@ def subject_of(message: str) -> str: return "" -def check(message: str) -> list: - """Rend la liste des problèmes. Vide si le sujet passe.""" - subject = subject_of(message) - if not subject or subject.startswith(GENERATED): +def _apres_le_sujet(message: str) -> list: + """Les lignes du corps : ni commentaire, ni diff de `--verbose`.""" + lines = message.split("\n") + for index, line in enumerate(lines): + stripped = line.strip() + if stripped and not stripped.startswith("#"): + lines = lines[index + 1 :] + break + else: return [] + gardees = [] + for line in lines: + stripped = line.strip() + if CISEAUX.match(stripped): + break + if stripped.startswith("#"): + continue + gardees.append(line) + return gardees + + +def body_of(message: str, trailers: bool = False) -> str: + """Ce qui suit le sujet, sans les lignes que git ajoute ou qu'il ignore. + + Sans `trailers`, les lignes `Assisted-by:` et consorts sont retirées : le + budget de lignes ne les compte pas, et l'adresse d'un `Co-authored-by:` est + légitime. Avec, elles restent — un nom de client logé dans un `Refs:` est + tout aussi publié que dans une phrase. + """ + gardees = _apres_le_sujet(message) + if not trailers: + gardees = [ + ligne for ligne in gardees if not TRAILER.match(ligne.strip()) + ] + return "\n".join(gardees) + + +def _moities(body: str) -> list: + """Le corps découpé par le marqueur de langue. Une seule moitié sans lui.""" + return [part for part in MARKER.split(body) if part not in ("FR", "EN")] + + +def _check_subject(subject: str) -> list: problems = [] tag = None @@ -69,3 +143,75 @@ def check(message: str) -> list: ) return problems + + +def _check_body(sans_trailers: str, avec_trailers: str) -> list: + """Longueur par langue et donnée identifiante. Rien sur le style. + + Le budget de lignes et le courriel se jugent SANS les trailers, qui sont + de git et portent légitimement une adresse. L'adresse IP, le chemin de + compte et le nom privé se jugent AVEC : un `Refs:` publie autant. + """ + problems = [] + + for moitie in _moities(sans_trailers): + pleines = [ligne for ligne in moitie.split("\n") if ligne.strip()] + if len(pleines) > MAX_BODY: + problems.append( + f"le corps fait {len(pleines)} lignes pour une langue," + f" {MAX_BODY} au plus.\n" + " Le corps dit pourquoi c'était nécessaire, puis s'arrête.\n" + " L'enquête, les mesures datées et les impasses vont dans\n" + " tasks/, qui n'est pas versionné." + ) + break + + termes = termes_interdits(NOMS_INTERDITS) + par_motif = {} + for motif, extrait, _ in identifiants(avec_trailers, termes): + if motif != "courriel": + par_motif.setdefault(motif, []).append(extrait) + for motif, extrait, _ in identifiants(sans_trailers, ()): + if motif == "courriel": + par_motif.setdefault(motif, []).append(extrait) + + adresses = sorted(set(par_motif.get("adresse", []))) + if adresses: + problems.append( + f"le corps porte une adresse IP : {', '.join(adresses)}.\n" + " Une adresse désigne une machine. Nommez la CLASSE de\n" + " situation — « sur un hôte derrière un NAT » — pas la machine." + ) + + courriels = sorted(set(par_motif.get("courriel", []))) + if courriels: + problems.append( + f"le corps porte une adresse de courriel : {', '.join(courriels)}." + ) + + comptes = sorted(set(par_motif.get("compte", []))) + if comptes: + problems.append( + f"le corps porte un chemin de compte : {comptes[0]}….\n" + " Écrivez ~/ ou /home//." + ) + + noms = sorted(set(par_motif.get("nom privé", []))) + if noms: + problems.append( + f"le corps porte un nom de la liste privée : {', '.join(noms)}.\n" + " Généralisez — « sur une base de production » — ou retirez la\n" + " phrase. La liste est dans private/noms_interdits.txt." + ) + + return problems + + +def check(message: str) -> list: + """Rend la liste des problèmes. Vide si le message passe.""" + subject = subject_of(message) + if not subject or subject.startswith(GENERATED): + return [] + return _check_subject(subject) + _check_body( + body_of(message), body_of(message, trailers=True) + ) diff --git a/script/lib_identifiant.py b/script/lib_identifiant.py new file mode 100644 index 0000000..de95b77 --- /dev/null +++ b/script/lib_identifiant.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ce texte porte-t-il une donnée qui désigne quelqu'un ou quelque chose ? + +La règle est dans `.claude/rules/04-code-conventions.md` : ce qui est versionné +ne nomme ni client, ni base réelle, ni machine, ni adresse. Deux garde-fous s'en +servent — `script/git/hooks/commit-msg` sur le message, et +`script/analyse/check_comment_hygiene.py` sur les commentaires — et ils +partagent CE module. Deux prédicats séparés pour la même question dérivent. + +Chaque trouvaille porte sa position dans le texte : l'appelant en déduit la +ligne exacte, plutôt que de rechercher l'extrait à l'aveugle. +""" + +from __future__ import annotations + +import io +import os +import re + +RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) + +# La liste des clients et des machines ne peut pas vivre dans le dépôt public : +# c'est ce qu'elle protège. Absente, le contrôle qui s'en sert est muet. +NOMS_INTERDITS = os.path.join(RACINE, "private", "noms_interdits.txt") + +IPV4 = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b") + +# Les blocs de documentation de la RFC 5737, qui existent pour l'exemple. +IPV4_DOCUMENTAIRES = ("192.0.2.", "198.51.100.", "203.0.113.") + +EMAIL = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]*[\w-]\b") + +# L'adresse du propriétaire du dépôt n'est pas une donnée de client. +EMAIL_PERMIS = re.compile(r"@(?:technolibre|erplibre)\.", re.IGNORECASE) + +# Un segment entre chevrons ou une variable est un gabarit, pas un compte. +HOME = re.compile(r"/(?:home|Users)/(?![<$\"'{])([\w.-]+)/") +HOME_PERMIS = frozenset({"runner", "user", "utilisateur", "USER"}) + + +def adresse_de_machine(valeur): + """Cette suite de quatre nombres désigne-t-elle une machine du parc ? + + Non pour un octet hors bornes, une version de module Odoo (`18.0.1.3`), la + boucle locale, un masque, un bloc documentaire, et une adresse de RÉSEAU — + un dernier octet nul nomme une plage, pas un hôte. + """ + try: + nombres = [int(o) for o in valeur.split(".")] + except ValueError: + return False + if len(nombres) != 4 or any(n > 255 for n in nombres): + return False + if 12 <= nombres[0] <= 18 and nombres[1] == 0: + return False + if nombres[0] in (0, 127, 255) or nombres[3] == 0: + return False + if valeur.startswith(IPV4_DOCUMENTAIRES): + return False + return True + + +def termes_interdits(chemin=NOMS_INTERDITS): + """Les termes du fichier privé, en minuscules. Vide s'il n'existe pas.""" + try: + with io.open(chemin, encoding="utf-8") as fh: + contenu = fh.read() + except OSError: + return [] + termes = [] + for ligne in contenu.split("\n"): + terme = ligne.strip() + if terme and not terme.startswith("#"): + termes.append(terme.lower()) + return termes + + +def identifiants(texte, termes=()): + """Les données identifiantes d'un texte : (motif, extrait, position). + + Chaque OCCURRENCE est rendue, et non chaque valeur : la même adresse citée + à deux endroits est à corriger aux deux. + """ + trouves = [] + + for trouve in IPV4.finditer(texte): + if adresse_de_machine(trouve.group(0)): + trouves.append(("adresse", trouve.group(0), trouve.start())) + + for trouve in EMAIL.finditer(texte): + if not EMAIL_PERMIS.search(trouve.group(0)): + trouves.append(("courriel", trouve.group(0), trouve.start())) + + for trouve in HOME.finditer(texte): + if trouve.group(1) not in HOME_PERMIS: + trouves.append( + ("compte", f"/home/{trouve.group(1)}/", trouve.start()) + ) + + minuscules = texte.lower() + for terme in termes: + depart = minuscules.find(terme) + while depart != -1: + trouves.append(("nom privé", terme, depart)) + depart = minuscules.find(terme, depart + 1) + + return sorted(trouves, key=lambda t: t[2]) diff --git a/test/test_git_commit_msg.py b/test/test_git_commit_msg.py index 0cd7d62..5a63a44 100644 --- a/test/test_git_commit_msg.py +++ b/test/test_git_commit_msg.py @@ -17,9 +17,18 @@ import sys import tempfile import unittest -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "script", "git")) +sys.path.insert( + 0, os.path.join(os.path.dirname(__file__), "..", "script", "git") +) -from commit_msg_lib import MAX, check, subject_of # noqa: E402 +import commit_msg_lib # noqa: E402 +from commit_msg_lib import ( # noqa: E402 + MAX, + MAX_BODY, + body_of, + check, + subject_of, +) HOOK = os.path.join( os.path.dirname(__file__), "..", "script", "git", "hooks", "commit-msg" @@ -41,7 +50,9 @@ class TestCeQuiPasse(unittest.TestCase): def test_les_accents_comptent_pour_un_caractere(self): """« é » pèse 2 octets : une limite en octets refuserait ce sujet.""" - sujet = "[FIX] déploiement : " + "é" * (MAX - len("[FIX] déploiement : ")) + sujet = "[FIX] déploiement : " + "é" * ( + MAX - len("[FIX] déploiement : ") + ) self.assertEqual(MAX, len(sujet)) self.assertGreater(len(sujet.encode("utf-8")), MAX) self.assertEqual([], check(sujet)) @@ -50,7 +61,7 @@ class TestCeQuiPasse(unittest.TestCase): for genere in ( "Merge branch 'develop' into master", "Merge remote-tracking branch 'origin/develop'", - "Revert \"[FIX] portée : quelque chose\"", + 'Revert "[FIX] portée : quelque chose"', "fixup! [FIX] portée : quelque chose", "squash! [FIX] portée : quelque chose", ): @@ -92,7 +103,9 @@ class TestCeQuiEstRefuse(unittest.TestCase): def test_un_sujet_qui_ouvre_sur_une_citation(self): for ouvrant in ("«", '"', "'", "`", "“"): - problemes = check(f"[FIX] proxmox : {ouvrant}il manque le stockage{ouvrant}") + problemes = check( + f"[FIX] proxmox : {ouvrant}il manque le stockage{ouvrant}" + ) self.assertEqual(1, len(problemes), ouvrant) self.assertIn("citation", problemes[0]) @@ -103,10 +116,165 @@ class TestCeQuiEstRefuse(unittest.TestCase): ) def test_deux_problemes_sont_rapportes_ensemble(self): - problemes = check("proxmox : « un sujet sans tag et beaucoup trop long » " + "a" * 40) + problemes = check( + "proxmox : « un sujet sans tag et beaucoup trop long » " + "a" * 40 + ) self.assertEqual(3, len(problemes)) +def _message(corps): + return "[FIX] portée : quelque chose\n\n" + corps + "\n" + + +class TestLeCorps(unittest.TestCase): + """Le corps est vérifié sur deux plans : sa longueur, et l'identifiant.""" + + def test_un_corps_conforme(self): + self.assertEqual([], check(_message("Une raison, en trois mots."))) + + def test_un_sujet_seul_na_pas_de_corps(self): + self.assertEqual("", body_of("[FIX] portée : sujet\n")) + self.assertEqual([], check("[FIX] portée : sujet\n")) + + def test_dix_lignes_par_langue_passent(self): + moitie = "\n".join(f"ligne {n}" for n in range(MAX_BODY)) + corps = f"{moitie}\n\n--- FR ---\n\n{moitie}" + self.assertEqual([], check(_message(corps))) + + def test_onze_lignes_pour_une_langue_sont_refusees(self): + moitie = "\n".join(f"ligne {n}" for n in range(MAX_BODY + 1)) + problemes = check(_message(moitie)) + self.assertEqual(1, len(problemes)) + self.assertIn(f"{MAX_BODY + 1} lignes", problemes[0]) + + def test_la_longueur_est_par_langue_et_non_par_message(self): + """Le bilinguisme achète la concision : il ne double pas le budget.""" + moitie = "\n".join(f"ligne {n}" for n in range(MAX_BODY + 1)) + self.assertTrue(check(_message(f"{moitie}\n\n--- FR ---\n\n{moitie}"))) + + def test_les_lignes_vides_ne_comptent_pas(self): + corps = "\n\n".join(f"ligne {n}" for n in range(MAX_BODY)) + self.assertEqual([], check(_message(corps))) + + def test_les_trailers_et_le_report_ne_comptent_pas(self): + corps = "\n".join(f"ligne {n}" for n in range(MAX_BODY)) + corps += "\n\nAssisted-by: Claude Opus 5" + corps += "\nCo-authored-by: Quelqu'un " + corps += "\n(cherry picked from commit 0123456789abcdef)" + self.assertEqual([], check(_message(corps))) + + def test_une_adresse_ip(self): + problemes = check(_message("La VM répondait en 192.168.123.170.")) + self.assertEqual(1, len(problemes)) + self.assertIn("192.168.123.170", problemes[0]) + + def test_les_adresses_sans_porteur_passent(self): + """0.0.0.0 et 127.0.0.1 ne désignent aucune machine du parc.""" + self.assertEqual( + [], check(_message("Le service écoute sur 127.0.0.1.")) + ) + self.assertEqual( + [], check(_message("Lié à 0.0.0.0, masque 255.255.255.0.")) + ) + + def test_une_version_nest_pas_une_adresse(self): + self.assertEqual([], check(_message("Passage de 17.0 à 18.0."))) + + def test_un_courriel_dans_le_corps(self): + problemes = check(_message("Signalé par personne@exemple.ca.")) + self.assertEqual(1, len(problemes)) + self.assertIn("courriel", problemes[0]) + + def test_un_chemin_de_compte(self): + problemes = check( + _message("Le venv vit dans /home/mathieu/git/erplibre/.") + ) + self.assertEqual(1, len(problemes)) + self.assertIn("chemin de compte", problemes[0]) + + def test_un_chemin_en_gabarit_passe(self): + self.assertEqual( + [], check(_message("Le venv vit dans /home//.")) + ) + + def test_une_ligne_checked_reste_du_corps(self): + """« Checked: » ressemble à un trailer : il ne doit pas s'y soustraire.""" + self.assertTrue(check(_message("Checked: 10.10.10.152 répond."))) + + def test_la_liste_privee_absente_ne_refuse_rien(self): + origine = commit_msg_lib.NOMS_INTERDITS + commit_msg_lib.NOMS_INTERDITS = os.path.join( + os.path.dirname(origine), "absent_de_ce_depot.txt" + ) + try: + self.assertEqual([], check(_message("Migration de acmecorp."))) + finally: + commit_msg_lib.NOMS_INTERDITS = origine + + def test_la_liste_privee_refuse_le_nom_quelle_porte(self): + origine = commit_msg_lib.NOMS_INTERDITS + with tempfile.NamedTemporaryFile( + "w", suffix=".txt", delete=False, encoding="utf-8" + ) as fh: + fh.write("# un commentaire\n\nacmecorp\n") + commit_msg_lib.NOMS_INTERDITS = fh.name + try: + problemes = check(_message("Migration de AcmeCorp, six paliers.")) + self.assertEqual(1, len(problemes)) + self.assertIn("liste privée", problemes[0]) + finally: + os.unlink(commit_msg_lib.NOMS_INTERDITS) + commit_msg_lib.NOMS_INTERDITS = origine + + def test_un_merge_nest_pas_juge(self): + """git écrit le corps d'un merge : le refuser refuserait le merge.""" + self.assertEqual( + [], check("Merge branch 'develop'\n\n" + "ligne\n" * 40) + ) + + def test_une_version_de_manifeste_odoo_nest_pas_une_adresse(self): + """« 18.0.1.0 » a quatre nombres et n'est pas une machine.""" + for version in ("18.0.1.0", "17.0.1.3", "12.0.2.1"): + self.assertEqual( + [], + check(_message(f"Le manifeste passe à {version}.")), + version, + ) + + def test_le_diff_de_cleanup_scissors_nest_pas_le_corps(self): + """Sous la ligne de ciseaux, tout appartient à git.""" + corps = "Une raison.\n\n" + corps += "# ------------------------ >8 ------------------------\n" + corps += "diff --git a/x b/x\n" + corps += "".join("+une ligne avec 10.10.10.5\n" for _ in range(30)) + self.assertEqual([], check(_message(corps))) + + def test_le_courriel_dun_trailer_est_legitime(self): + corps = "Une raison.\n\nCo-authored-by: Quelquun " + self.assertEqual([], check(_message(corps))) + + def test_un_nom_prive_dans_un_trailer_est_refuse(self): + """Un « Refs: » publie autant qu'une phrase du corps.""" + origine = commit_msg_lib.NOMS_INTERDITS + with tempfile.NamedTemporaryFile( + "w", suffix=".txt", delete=False, encoding="utf-8" + ) as fh: + fh.write("acmecorp\n") + commit_msg_lib.NOMS_INTERDITS = fh.name + try: + problemes = check(_message("Une raison.\n\nRefs: acmecorp-42")) + self.assertEqual(1, len(problemes)) + self.assertIn("liste privée", problemes[0]) + finally: + os.unlink(commit_msg_lib.NOMS_INTERDITS) + commit_msg_lib.NOMS_INTERDITS = origine + + def test_body_of_rend_les_trailers_sur_demande(self): + message = _message("Une raison.\n\nAssisted-by: Un modèle") + self.assertNotIn("Assisted-by", body_of(message)) + self.assertIn("Assisted-by", body_of(message, trailers=True)) + + class TestLeHookLuiMeme(unittest.TestCase): """Le module peut être juste et le hook faux : on l'exécute vraiment."""