erplibre/script/lib_identifiant.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

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/commit_msg_lib.py` 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])