erplibre/script/lib_identifiant.py
Mathieu Benoit 7844387fa1 [ADD] commit-msg : contrôler le corps, sa longueur et l'identifiant
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
2026-08-30 05:11:36 -04:00

109 lines
3.9 KiB
Python

#!/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])