[ADD] hygiène : signaler un nom de machine pleinement qualifié

L'outil voyait les adresses, les courriels et les chemins de compte, mais pas
les NOMS — la classe que la règle interdit au même titre, et celle qu'un
assistant qui parcourt le réseau multiplie. Le rapport annonçait « 0
identifiant » sur un fichier qui en portait un.

Un signal à relire, jamais une trouvaille : un nom d'hôte NU ne se distingue
mécaniquement ni d'un mot ordinaire ni du nom d'un logiciel, donc seule la
forme qualifiée se reconnaît, contre une liste FERMÉE de suffixes. En sortent
l'en-tête de copyright, les domaines de la RFC 2606, et toute adresse portée
par une URL. Vérifié : 54 tests, et 21 signaux sur le dépôt, tous des miroirs.

--- EN ---

The tool saw addresses, e-mails and account paths, but not NAMES — the class
the rule forbids just as much, and the one an assistant sweeping the network
multiplies. The report announced "0 identifying" on a file that carried one.

A signal to re-read, never a finding: a BARE host name is mechanically
indistinguishable from an ordinary word or a piece of software, so only the
qualified form is recognised, against a CLOSED list of suffixes. Out of it go
the copyright header, the RFC 2606 domains, and any address carried by a URL.
Checked: 54 tests, and 21 signals across the repository, all mirrors.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-09 05:39:59 -04:00
parent 19410e5b73
commit c0b24925bf
3 changed files with 192 additions and 2 deletions

View file

@ -60,7 +60,12 @@ python3 script/analyse/check_comment_hygiene.py --staged
🔴 `identifiant` est une trouvaille, à retirer. 🟡 `récit` est un signal à
relire : l'outil ne sait pas si la phrase énonce un fait durable ou raconte
une journée, et ne tranche pas à votre place.
une journée, et ne tranche pas à votre place. 🟡 `nom` en est un autre, et sa
limite est plus dure : un nom d'hôte NU ne se distingue mécaniquement ni d'un
mot ordinaire ni du nom d'un logiciel, donc l'outil ne voit que la forme
pleinement qualifiée. Un miroir de paquets nommé dans un commentaire y
répond, et se confirme d'un coup d'œil ; l'absence de signal ne prouve rien
sur les noms.
Trois exemples pris dans ce dépôt, leurs noms propres masqués — une règle qui
interdit de nommer ne se cite pas elle-même en clair.

View file

@ -8,7 +8,7 @@ La convention est dans `.claude/rules/04-code-conventions.md` : un commentaire
dit COMMENT le code marche, il ne porte aucune donnée identifiante et il ne
raconte pas l'enquête. Cet outil en vérifie la part mécanique.
Deux familles, de sûreté très différente :
Trois familles, de sûreté très différente :
- `identifiant` — adresse IP, courriel, chemin de compte. Une
correspondance est une trouvaille : ces formes n'ont aucune raison d'être
@ -17,6 +17,12 @@ Deux familles, de sûreté très différente :
absolue, première personne. Une correspondance est un SIGNAL À RELIRE : la
même phrase peut énoncer un fait durable. L'outil ne trie pas à la place
du lecteur.
- `nom` — un nom de machine pleinement qualifié. Aussi un signal à relire,
et pour une raison plus dure : un nom d'hôte NU ne se distingue
mécaniquement ni d'un mot ordinaire ni du nom d'un logiciel, donc cette
famille ne voit que la forme qualifiée. Les miroirs de paquets nommés dans
un commentaire y répondent, et c'est le prix d'un signal mécanique — le
lecteur confirme en deux secondes, là où la classe entière était invisible.
Il lit les commentaires `#` et, en Python, les docstrings de module, de classe
et de fonction. Le reste du code ne l'intéresse pas.
@ -116,6 +122,64 @@ MOTIFS_RECIT = (
("personne", PERSONNE),
)
# Le nom de machine est la SEULE classe interdite qu'aucun motif ne tranche :
# un nom d'hôte ne se distingue mécaniquement ni d'un mot pointé ordinaire, ni
# du nom d'un logiciel. Seule sa forme pleinement qualifiée se reconnaît, et
# encore : « chemin.home » et « logging.info » ont la même forme. D'où un
# signal à relire et non une trouvaille.
#
# Le suffixe est comparé à une liste FERMÉE. Sans elle, tout attribut pointé
# du code correspondrait. Les suffixes de service — local, lan, internal —
# y sont exprès : c'est sous eux qu'une machine du parc se nomme.
SUFFIXES_DHOTE = (
"com",
"ca",
"net",
"org",
"io",
"dev",
"fr",
"be",
"ch",
"eu",
"us",
"uk",
"biz",
"cloud",
"app",
"tech",
"quebec",
"local",
"lan",
"internal",
"intra",
"corp",
)
# Le point qui SUIT décide de deux choses opposées : suivi d'un caractère de
# nom, il prolonge l'adresse et la correspondance n'est qu'un préfixe ; seul,
# c'est le point d'une phrase, et l'adresse s'arrête là. Les confondre rendait
# invisible toute adresse en fin de phrase.
NOM_DHOTE = re.compile(
r"(?<![\w.@-])((?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+"
r"(?:%s))(?![\w-])(?!\.[\w-])" % "|".join(SUFFIXES_DHOTE),
re.IGNORECASE,
)
# Ce que la convention autorise nommément. Le dépôt nomme son PROPRIÉTAIRE et
# sa licence dans l'en-tête de chaque fichier : les signaler reviendrait à
# signaler l'en-tête obligatoire partout.
DOMAINES_PERMIS = re.compile(
r"^(?:www\.)?(?:technolibre\.ca|gnu\.org)$"
r"|^(?:[a-z0-9-]+\.)*(?:example|exemple)\.(?:com|net|org)$",
re.IGNORECASE,
)
# Une adresse portée par une URL désigne une ressource publique — un miroir de
# paquets, une page de documentation — et non une machine du parc. La machine
# qui fuite dans un commentaire s'y écrit nue.
DANS_UNE_URL = re.compile(r"[a-z][a-z0-9+.-]*://\S*$", re.IGNORECASE)
def _bloc(sous_lignes):
"""Un bloc : son texte recollé, et les lignes physiques qui le portent.
@ -264,6 +328,28 @@ def recits(texte):
return sorted(trouves, key=lambda t: t[2])
def noms_dhote(texte):
"""Les noms de machine pleinement qualifiés d'un texte.
Rend (motif, extrait, position), comme `recits` et `identifiants`, pour
que les trois familles se traitent de la même façon.
Trois formes sortent : le domaine du propriétaire et celui de la licence,
que l'en-tête de chaque fichier porte ; les domaines réservés à la
documentation par la RFC 2606 ; et toute adresse portée par une URL, qui
désigne une ressource publique.
"""
trouves = []
for trouve in NOM_DHOTE.finditer(texte):
nom = trouve.group(1)
if DOMAINES_PERMIS.match(nom):
continue
if DANS_UNE_URL.search(texte[: trouve.start()]):
continue
trouves.append(("nom d'hôte", nom, trouve.start()))
return trouves
def ligne_a(bloc, position):
"""La ligne physique qui porte cette position du texte recollé."""
numero = bloc["line"]
@ -287,6 +373,7 @@ def inspect(chemin, source=None, termes=None):
familles = (
("identifiant", identifiants(bloc["text"], termes)),
("récit", recits(bloc["text"])),
("nom", noms_dhote(bloc["text"])),
)
for genre, trouves in familles:
for motif, extrait, position in trouves:

View file

@ -382,5 +382,103 @@ class TestLeHook(unittest.TestCase):
self.assertEqual(0, r.returncode, r.stderr)
class TestLesNomsDHote(unittest.TestCase):
"""La classe interdite qu'aucun motif ne tranche vraiment.
Un nom d'hôte NU — « rig », « la-machine-de-tests » — ne se distingue
mécaniquement ni d'un mot ordinaire ni du nom d'un logiciel : ces tests
fixent donc ce que l'outil VOIT, la forme pleinement qualifiée, et ce
qu'il continue de ne pas voir. Confondre les deux ferait croire la classe
couverte.
Les noms sont inventés et ne paraissent nulle part ailleurs dans le
dépôt, comme l'exige la règle pour l'exemple qui illustre un interdit.
"""
def _noms(self, texte):
return [extrait for _, extrait, _ in hygiene.noms_dhote(texte)]
def test_un_nom_pleinement_qualifie(self):
self.assertEqual(
self._noms("# le service tourne sur garance-01.interne.lan"),
["garance-01.interne.lan"],
)
def test_un_domaine_de_client(self):
self.assertEqual(
self._noms("# la base de airelle-conseil.ca"),
["airelle-conseil.ca"],
)
def test_le_domaine_du_proprietaire_passe(self):
"""L'en-tête de copyright est l'exception nommée par la convention."""
self.assertEqual(self._noms("# © TechnoLibre www.technolibre.ca"), [])
def test_le_domaine_de_la_licence_passe(self):
self.assertEqual(
self._noms("# License AGPL, www.gnu.org/licenses"), []
)
def test_un_domaine_de_documentation_passe(self):
"""La RFC 2606 les réserve : ils ne désignent aucune machine."""
self.assertEqual(self._noms("# par exemple vpn.example.com"), [])
self.assertEqual(self._noms("# ou bien hote.exemple.com"), [])
def test_une_url_passe(self):
"""Une URL désigne une ressource publique, non une machine du parc."""
self.assertEqual(
self._noms("# voir https://docs.airelle-conseil.ca/guide"), []
)
def test_un_attribut_pointe_ne_pointe_rien(self):
"""Le suffixe se compare à une liste fermée, sinon tout correspond."""
self.assertEqual(self._noms("# logging.info dit la version"), [])
self.assertEqual(self._noms("# asyncio.wait attend un futur"), [])
self.assertEqual(self._noms("# chemin.home est le répertoire"), [])
def test_un_fichier_ne_pointe_rien(self):
self.assertEqual(self._noms("# voir todo.py et les autres"), [])
def test_un_nom_nu_reste_invisible(self):
"""La limite, énoncée plutôt que cachée.
« rig » est un nom de machine de ce parc, et rien ne le distingue
d'un mot. L'absence de trouvaille ne prouve donc rien sur les noms —
c'est pourquoi la famille est un signal 🟡 et non une trouvaille."""
self.assertEqual(self._noms("# le calcul tourne sur rig"), [])
def test_la_famille_est_un_signal_a_relire(self):
"""Le genre décide de l'icône et de --identifying-only."""
trouvailles = hygiene.inspect(
"essai.py", source='"""Sur garance-01.interne.lan."""\n'
)
self.assertEqual(genres(trouvailles), {"nom"})
durs = [f for f in trouvailles if f["kind"] == "identifiant"]
self.assertEqual(durs, [])
def test_le_genre_sort_de_identifying_only(self):
with tempfile.NamedTemporaryFile(
"w", suffix=".py", delete=False, encoding="utf-8"
) as fh:
fh.write('"""Sur garance-01.interne.lan."""\n')
chemin = fh.name
try:
sortie = subprocess.run(
[
sys.executable,
OUTIL,
chemin,
"--identifying-only",
"--no-color",
],
capture_output=True,
text=True,
)
self.assertEqual(sortie.returncode, 0)
self.assertNotIn("garance-01", sortie.stdout)
finally:
os.unlink(chemin)
if __name__ == "__main__":
unittest.main(verbosity=2)