#!/usr/bin/env python3 # © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """L'outil voit-il ce qu'il doit, et se tait-il sur le reste ? Un outil de style qui crie pour rien se fait ignorer en entier : ces tests pèsent donc les silences autant que les trouvailles. Le présent de l'indicatif (« mesure le temps »), une version Odoo à quatre nombres, la boucle locale et un chemin en gabarit doivent passer sans un mot. La part qu'aucun motif ne juge — « cette phrase énonce-t-elle un fait durable ou raconte-t-elle une journée » — n'est pas testée : elle n'est pas décidable. """ import os import subprocess import sys import tempfile import unittest sys.path.insert( 0, os.path.join(os.path.dirname(__file__), "..", "script", "analyse") ) import check_comment_hygiene as hygiene # noqa: E402 OUTIL = os.path.join( os.path.dirname(__file__), "..", "script", "analyse", "check_comment_hygiene.py", ) def genres(trouvailles): return {f["kind"] for f in trouvailles} def motifs(trouvailles): return {f["pattern"] for f in trouvailles} class TestLesAdresses(unittest.TestCase): """Quatre nombres séparés par des points ne font pas une machine.""" def test_une_adresse_de_machine(self): self.assertTrue(hygiene.adresse_de_machine("172.31.7.42")) self.assertTrue(hygiene.adresse_de_machine("172.20.4.9")) def test_une_version_odoo(self): for version in ("18.0.1.3", "17.0.1.0", "12.0.2.1"): self.assertFalse(hygiene.adresse_de_machine(version), version) def test_la_boucle_locale_et_les_masques(self): for valeur in ("127.0.0.1", "127.0.1.1", "0.0.0.0", "255.255.255.255"): self.assertFalse(hygiene.adresse_de_machine(valeur), valeur) def test_une_adresse_de_reseau(self): """Un dernier octet nul nomme une plage, pas un hôte.""" self.assertFalse(hygiene.adresse_de_machine("192.168.122.0")) def test_les_blocs_documentaires(self): self.assertFalse(hygiene.adresse_de_machine("192.0.2.5")) self.assertFalse(hygiene.adresse_de_machine("203.0.113.9")) def test_un_octet_hors_bornes(self): self.assertFalse(hygiene.adresse_de_machine("999.1.1.1")) class TestLeTemoignage(unittest.TestCase): """L'accent sépare le passé qui témoigne du présent qui décrit.""" def _recits(self, texte): return hygiene.recits(texte) def test_le_participe_passe(self): for phrase in ( "Vécu sur une machine du parc.", "Mesuré : trois secondes.", "Vécu, sur la base intermédiaire.", "Rapporté au premier essai.", "Mesuré — deux fois de suite.", ): self.assertTrue(self._recits(phrase), phrase) def test_le_present_de_lindicatif(self): """« mesure le temps » dit ce que le code fait : rien à signaler.""" for phrase in ( "La sonde mesure le temps de réponse.", "Le pilote signale au menu que l'étape est finie.", "L'écran constate au démarrage que le service répond.", ): self.assertEqual([], self._recits(phrase), phrase) def test_une_date_absolue(self): self.assertIn( "date", {t[0] for t in self._recits("Relevé le 2026-08-12.")} ) self.assertIn( "date", {t[0] for t in self._recits("Le 24 août 2026, la VM.")} ) def test_la_premiere_personne(self): for phrase in ( "Ma conclusion était fausse.", "j'avais écrit le contraire.", ): self.assertIn( "personne", {t[0] for t in self._recits(phrase)}, phrase ) def test_le_nom_releve_nest_pas_le_verbe(self): """« dans le relevé du serveur » nomme une chose, ne témoigne pas.""" for phrase in ( "Dans le relevé du serveur, la valeur est vide.", "Le constaté au démarrage sert de référence.", ): self.assertEqual([], self._recits(phrase), phrase) def test_hier_ne_se_trouve_pas_dans_hierarchie(self): for phrase in ( "La hiérarchie des modèles.", "The hierarchy of models.", ): self.assertEqual([], self._recits(phrase), phrase) def test_reproduit_est_aussi_du_present(self): """Présent et participe s'écrivent pareil : le motif ne tranche pas.""" self.assertEqual([], self._recits("Le pilote reproduit la config.")) def test_toutes_les_occurrences_dun_bloc(self): """Un bloc qui répète le marqueur ne cache pas le reste du travail.""" trouves = self._recits("Mesuré sur la base. Puis vécu sur la copie.") self.assertEqual(2, len({t[1].lower() for t in trouves})) class TestLesIdentifiants(unittest.TestCase): def test_un_courriel(self): self.assertIn( "courriel", {t[0] for t in hygiene.identifiants("a@exemple.ca")} ) def test_le_courriel_du_proprietaire_passe(self): self.assertEqual([], hygiene.identifiants("contact@technolibre.ca")) def test_un_chemin_de_compte(self): self.assertIn( "compte", {t[0] for t in hygiene.identifiants("/home/quelquun/git/")}, ) def test_un_chemin_en_gabarit_passe(self): for chemin in ("/home//git/", "/home/$USER/git/"): self.assertEqual([], hygiene.identifiants(chemin), chemin) def test_la_liste_privee(self): trouves = hygiene.identifiants( "migration de AcmeCorp", termes=["acmecorp"] ) self.assertEqual([("nom privé", "acmecorp", 13)], trouves) def test_sans_liste_privee_rien_nest_refuse(self): self.assertEqual([], hygiene.identifiants("migration de AcmeCorp")) class TestCeQuiEstLu(unittest.TestCase): """Les commentaires et les docstrings, et rien d'autre du code.""" def test_une_docstring_de_module(self): source = '"""Vécu sur une machine du parc."""\n\n\nX = 1\n' trouvailles = hygiene.inspect("x.py", source=source, termes=[]) self.assertEqual({"récit"}, genres(trouvailles)) def test_une_docstring_de_fonction(self): source = 'def f():\n """Mesuré sur la copie."""\n return 1\n' trouvailles = hygiene.inspect("x.py", source=source, termes=[]) self.assertEqual(2, trouvailles[0]["line"]) def test_une_chaine_de_code_nest_pas_un_commentaire(self): """Seule la PREMIÈRE expression d'une portée est une docstring.""" source = 'def f():\n return "Vécu sur la copie"\n' self.assertEqual([], hygiene.inspect("x.py", source=source, termes=[])) def test_les_lignes_consecutives_forment_un_bloc(self): """Une phrase coupée en deux lignes reste une phrase.""" source = ( "# une raison, puis mesuré\n# sur la copie de la base\nX = 1\n" ) trouvailles = hygiene.inspect("x.py", source=source, termes=[]) self.assertEqual(1, len(trouvailles)) def test_un_trou_separe_deux_blocs(self): source = "# mesuré sur la copie\nX = 1\n# vécu sur la copie\nY = 2\n" trouvailles = hygiene.inspect("x.py", source=source, termes=[]) self.assertEqual([1, 3], [f["line"] for f in trouvailles]) def test_la_ligne_pointee_est_celle_du_marqueur(self): """Pointer le début d'un bloc de vingt lignes ne guide personne.""" source = "# une raison\n# une autre\n# vécu sur la copie\nX = 1\n" trouvailles = hygiene.inspect("x.py", source=source, termes=[]) self.assertEqual([3], [f["line"] for f in trouvailles]) def test_un_source_illisible_se_replie_sur_les_lignes(self): """Rendre un rapport vide dirait « propre » d'un fichier non lu.""" source = "def f(:\n # vécu sur la copie\n" trouvailles = hygiene.inspect("x.py", source=source, termes=[]) self.assertEqual([2], [f["line"] for f in trouvailles]) def test_un_mot_qui_en_contient_un_autre_ne_pointe_rien(self): """« je » vit dans « sujet » et « projeté » : pas une trouvaille.""" source = ( "# Le sujet est la decision, pas la connexion. Avec une ABI\n" "# injectee, rien ne bouge. Le trajet reste projete.\n" "# Ici seulement je regarde le resultat.\n" "X = 1\n" ) trouvailles = hygiene.inspect("x.py", source=source, termes=[]) self.assertEqual([3], [f["line"] for f in trouvailles]) def test_un_commentaire_shell_de_fin_de_ligne(self): source = "#!/bin/sh\nrsync -a src dst # copie vers 10.0.0.42\n" trouvailles = hygiene.inspect("x.sh", source=source, termes=[]) self.assertEqual( [(2, "adresse")], [(f["line"], f["pattern"]) for f in trouvailles] ) def test_un_diese_entre_guillemets_nouvre_rien(self): source = '#!/bin/sh\necho "# vécu sur la copie"\n' self.assertEqual([], hygiene.inspect("x.sh", source=source, termes=[])) def test_un_diese_colle_a_un_mot_nouvre_rien(self): """`${VAR#prefixe}` et une URL à ancre ne sont pas des commentaires.""" source = "#!/bin/sh\necho ${CHEMIN#vécu sur la copie}\n" self.assertEqual([], hygiene.inspect("x.sh", source=source, termes=[])) def test_un_script_shell(self): source = "#!/bin/bash\n# vécu sur la copie\necho ok\n" trouvailles = hygiene.inspect("x.sh", source=source, termes=[]) self.assertEqual([2], [f["line"] for f in trouvailles]) def test_le_shebang_nest_pas_un_commentaire(self): self.assertEqual( [], hygiene.inspect( "x.sh", source="#!/bin/bash\necho ok\n", termes=[] ), ) class TestLePerimetre(unittest.TestCase): def test_le_code_tiers_est_hors_perimetre(self): for chemin in ( "script/OCA_maintainer-tools/tools/x.py", "addons/quelque_chose/models/x.py", ".venv.erplibre/lib/x.py", ): self.assertFalse(hygiene.a_balayer(chemin), chemin) def test_le_code_du_depot_est_dans_le_perimetre(self): for chemin in ( "script/todo/todo.py", "test/test_x.py", "long_test/x.py", ): self.assertTrue(hygiene.a_balayer(chemin), chemin) class TestLaLigneDeCommande(unittest.TestCase): """Le module peut être juste et le programme faux : on l'exécute.""" def _lancer(self, source, suffixe=".py", args=()): with tempfile.NamedTemporaryFile( "w", suffix=suffixe, delete=False, encoding="utf-8" ) as fh: fh.write(source) chemin = fh.name try: return subprocess.run( [sys.executable, OUTIL, chemin, "--no-color", *args], capture_output=True, text=True, ) finally: os.unlink(chemin) def test_zero_quand_il_ny_a_rien_a_signaler(self): r = self._lancer("# la sonde mesure le temps\nX = 1\n") self.assertEqual(0, r.returncode, r.stdout + r.stderr) self.assertEqual("", r.stdout.strip()) def test_un_quand_il_y_a_des_trouvailles(self): """0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué.""" r = self._lancer("# vécu sur la copie\nX = 1\n") self.assertEqual(1, r.returncode) self.assertIn("témoignage", r.stdout) def test_le_json_porte_le_compte_et_les_trouvailles(self): import json r = self._lancer("# vécu sur la copie\nX = 1\n", args=("--json",)) rendu = json.loads(r.stdout) self.assertEqual(1, rendu["scanned"]) self.assertEqual(1, len(rendu["findings"])) def test_identifying_only_laisse_le_recit_dehors(self): r = self._lancer( "# vécu sur la copie\nX = 1\n", args=("--identifying-only",) ) self.assertEqual(0, r.returncode) def test_sans_chemin_il_le_dit(self): r = subprocess.run( [sys.executable, OUTIL], capture_output=True, text=True ) self.assertEqual(2, r.returncode) class TestLeHook(unittest.TestCase): """Le hook informe : il ne bloque jamais un commit sur du style.""" HOOK = os.path.join( os.path.dirname(__file__), "..", "script", "git", "hooks", "pre-commit" ) def _depot_jetable(self, contenu): """Un dépôt git neuf, l'outil et le hook posés où le hook les cherche.""" import shutil racine = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, racine, True) for relatif in ("script/analyse", "script/git/hooks"): os.makedirs(os.path.join(racine, relatif)) shutil.copy(OUTIL, os.path.join(racine, "script", "analyse")) shutil.copy( os.path.join(os.path.dirname(OUTIL), "..", "lib_identifiant.py"), os.path.join(racine, "script"), ) shutil.copy(self.HOOK, os.path.join(racine, "script", "git", "hooks")) chemin = os.path.join(racine, "exemple.py") with open(chemin, "w", encoding="utf-8") as fh: fh.write(contenu) for commande in ( ["git", "init", "-q"], ["git", "add", "exemple.py"], ): subprocess.run( commande, cwd=racine, check=True, capture_output=True ) return racine def _lancer_dans(self, racine): return subprocess.run( [ sys.executable, os.path.join(racine, "script/git/hooks/pre-commit"), ], capture_output=True, text=True, cwd=racine, ) def test_il_rapporte_ce_qui_est_indexe(self): racine = self._depot_jetable( "# vécu sur la copie, en 10.0.0.42\nX = 1\n" ) r = self._lancer_dans(racine) self.assertEqual(0, r.returncode, r.stderr) self.assertIn("exemple.py", r.stderr) self.assertIn("10.0.0.42", r.stderr) self.assertIn("PAS bloqué", r.stderr) def test_il_se_tait_sur_un_fichier_propre(self): racine = self._depot_jetable('"""Rend le code de sortie."""\nX = 1\n') r = self._lancer_dans(racine) self.assertEqual(0, r.returncode) self.assertEqual("", r.stderr.strip()) def test_il_sort_toujours_en_zero(self): r = subprocess.run( [sys.executable, self.HOOK], capture_output=True, text=True ) 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)