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."""