erplibre/script/git/commit_msg_lib.py
Mathieu Benoit 0e5441ac39 [ADD] commentaires : un relevé non bloquant, sa règle, le code nettoyé
Rien ne relevait les commentaires hors convention. L'outil lit commentaires et
docstrings, jamais le code autour, et rend deux familles inégales :
l'identifiant — adresse, courriel, chemin de compte, nom de la liste privée —
est une trouvaille ; le récit — témoignage, date, personne — un signal à relire.

Le hook pre-commit le lance sur l'index, sort toujours en 0 — un contrôle
bloquant à cette échelle se fait désinstaller — et parle quand l'outil échoue.
La règle du générateur et sa doc portent le nettoyage au fur et à mesure, et
l'exemple d'un interdit s'invente : base, adresse, compte et hôte en prennent un.

L'écran du contexte est posé, sans entrée de menu. Vérifié : 252 tests des six
fichiers d'essai touchés passent.

--- EN ---

Nothing reported the comments that break the convention. The tool reads
comments and docstrings, never the code around them, and returns two unequal
families: identifying data — address, e-mail, account path, private-list name
— is a finding; narrative — witness marker, date, person — a signal to re-read.

The pre-commit hook runs it on the index, always exits 0 — a blocking check at
that scale gets uninstalled — and speaks when the tool fails. The generator
rule and its doc carry the clean-up as you go, and a forbidden thing's example
is invented: a real database, address, account and host each take one.

The context screen is in place, with no menu entry. Checked: 252 tests of the
six touched fixture files pass.

Assisted-by: Claude Opus 5
2026-08-30 06:08:30 -04:00

217 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""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, 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 11 caractères et
12 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.
GENERATED = ("Merge ", "Revert ", "fixup!", "squash!", "amend!")
# Un sujet qui s'ouvre sur une citation cite un écran. C'est une PREUVE, et une
# 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."""
for line in message.split("\n"):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
return stripped
return ""
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
for candidate in TAGS:
if subject.startswith(f"[{candidate}]"):
tag = candidate
break
if tag is None:
problems.append(
f"le sujet doit commencer par un tag : {', '.join('[%s]' % t for t in TAGS)}"
)
if len(subject) > MAX:
problems.append(
f"le sujet fait {len(subject)} caractères, {MAX} au plus.\n"
" Ne le tronquez pas : à cette longueur, écrivez des MOTS-CLÉS\n"
" qui résument plutôt qu'une phrase amputée. « proxmox : pmxcfs,\n"
" stockage, diagnostic » vaut mieux qu'une phrase coupée net."
)
rest = subject.split(":", 1)[1].strip() if ":" in subject else ""
if rest.startswith(QUOTES):
problems.append(
"le sujet s'ouvre sur une citation. Un message d'écran est une\n"
" preuve : elle va dans le corps. Le sujet nomme la cause."
)
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/<utilisateur>/."
)
noms = sorted(set(par_motif.get("nom privé", [])))
if noms:
problems.append(
f"le corps porte un nom refusé : {', '.join(noms)}.\n"
" Généralisez — « sur une base de production » — ou retirez la\n"
" phrase."
)
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)
)