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
109 lines
3.9 KiB
Python
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])
|