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