From 0e5441ac3992437e6c71d0b3c13d6249b92dde80 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 30 Aug 2026 02:02:51 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20commentaires=20:=20un=20relev=C3=A9=20n?= =?UTF-8?q?on=20bloquant,=20sa=20r=C3=A8gle,=20le=20code=20nettoy=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/rules/04-code-conventions.md | 14 +- .claude/rules/06-code-generator.md | 36 ++ conf/template_claude_commands_commit.md | 7 +- doc/CODE_GENERATOR.base.md | 67 ++++ doc/CODE_GENERATOR.fr.md | 36 ++ doc/CODE_GENERATOR.md | 35 ++ script/analyse/check_comment_hygiene.py | 432 ++++++++++++++++++++++++ script/git/commit_msg_lib.py | 8 +- script/git/git_repo_update_group.py | 2 +- script/git/hooks/pre-commit | 80 +++++ script/lib_identifiant.py | 2 +- script/todo/todo.py | 148 +++++++- script/todo/todo_i18n.py | 69 ++++ script/todo/todo_upgrade.py | 2 +- test/test_check_comment_hygiene.py | 386 +++++++++++++++++++++ test/test_db_duplicate.py | 2 +- test/test_git_commit_msg.py | 10 +- test/test_monitoring.py | 2 +- test/test_prompt_defaults.py | 2 +- test/test_todo_sshfs.py | 54 +-- 20 files changed, 1341 insertions(+), 53 deletions(-) create mode 100644 script/analyse/check_comment_hygiene.py create mode 100755 script/git/hooks/pre-commit create mode 100644 test/test_check_comment_hygiene.py diff --git a/.claude/rules/04-code-conventions.md b/.claude/rules/04-code-conventions.md index ad311de..738f80d 100644 --- a/.claude/rules/04-code-conventions.md +++ b/.claude/rules/04-code-conventions.md @@ -36,6 +36,13 @@ propriétaire, pas ses clients. Généraliser plutôt que censurer — « sur un base de production », « sur un hôte qui exige une authentification sudo interactive » — dit la CLASSE de situation, qui est ce qui sert au lecteur. +**L'exemple qui illustre un interdit s'invente.** La règle a d'abord été +violée par ses propres tests : pour démontrer qu'une adresse et un chemin de +compte sont refusés, ils en portaient de vrais, pris dans le parc. Choisir un +cas réel « parce qu'il est parlant » est exactement le réflexe que la règle +combat, et un test le fige pour toujours. Une valeur inventée démontre aussi +bien ; vérifier qu'elle n'existe nulle part ailleurs dans le dépôt. + Le récit n'est pas perdu, il change de place : l'enquête, les mesures datées et les impasses vivent dans `tasks/`, qui n'est pas versionné. Ni le fichier ni le corps du commit ne les portent. @@ -107,11 +114,8 @@ reste préférable quand elle tient. Un garde-fou refuse le mécanique. Sur le sujet : tag absent, plus de 72 caractères, ouverture sur une citation. Sur le corps : plus de 10 lignes pour -une langue, une adresse IP, un courriel, un chemin de compte, et tout terme de -`private/noms_interdits.txt` — la liste des clients et des machines, qui ne -peut pas vivre dans git puisque c'est ce qu'elle protège. Absente, ce dernier -contrôle est muet. Ce qui reste un jugement — « ce corps raconte-t-il -l'enquête » — n'est vérifié par personne. +une langue, une adresse IP, un courriel, un chemin de compte. Ce qui reste un +jugement — « ce corps raconte-t-il l'enquête » — n'est vérifié par personne. ```bash git config core.hooksPath script/git/hooks # une fois par clone diff --git a/.claude/rules/06-code-generator.md b/.claude/rules/06-code-generator.md index 51ca59c..3f30bf6 100644 --- a/.claude/rules/06-code-generator.md +++ b/.claude/rules/06-code-generator.md @@ -7,3 +7,39 @@ ERPLibre inclut un système de génération de modules Odoo : - `addons/TechnoLibre_odoo-code-generator-template/` — Templates Documentation : `doc/CODE_GENERATOR.md` + +## Les commentaires du code produit + +Le code généré porte des commentaires comme le reste, et la même règle : ils +disent COMMENT ça marche, ils ne portent rien d'identifiant et ils ne +racontent pas l'enquête. Voir `.claude/rules/04-code-conventions.md`. + +Un module généré à partir d'une base existante hérite de ce qu'elle contient : +relire ses commentaires et ses docstrings avant de committer, un nom de client +ou de base y arrive tout seul. + +## Le nettoyage au fur et à mesure + +Rien ne se nettoie en une passe : on corrige les commentaires du fichier qu'on +touche, au moment où on le touche. Deux outils le rappellent. + +Le hook `pre-commit` liste ce qui est à relire dans les fichiers indexés, +SANS bloquer le commit : + +```bash +git config core.hooksPath script/git/hooks # une fois par clone +``` + +L'outil se lance aussi à la main, sur un fichier, un répertoire ou l'index : + +```bash +python3 script/analyse/check_comment_hygiene.py script/todo/todo.py +python3 script/analyse/check_comment_hygiene.py --staged +python3 script/analyse/check_comment_hygiene.py script --identifying-only +``` + +🔴 `identifiant` — adresse, courriel, chemin de compte : à retirer. +🟡 `récit` — témoignage, date, première personne : à RELIRE, l'outil ne +tranche pas. Un fait durable reste ; l'incident +où on l'a observé part. Codes de sortie : 0 rien, 1 des trouvailles, 2 l'outil +a échoué. diff --git a/conf/template_claude_commands_commit.md b/conf/template_claude_commands_commit.md index f2dd341..0fec838 100644 --- a/conf/template_claude_commands_commit.md +++ b/conf/template_claude_commands_commit.md @@ -138,11 +138,8 @@ That form is a fallback, not a default. Prefer the sentence when it fits. **The guard rail.** `script/git/hooks/commit-msg` refuses a subject with no tag, one over 72 characters, and one opening on a quotation. It reads the body -too: over ten lines for one language, an IP address, an e-mail, a -`/home//` path, or any term listed in `private/noms_interdits.txt` — -the customer and machine names, which cannot live in git because they are what -the list protects. With no such file, that last check stays silent. Install -the hook with `git config core.hooksPath script/git/hooks`; `git commit +too: over ten lines for one language, an IP address, an e-mail, and a +`/home//` path. Install the hook with `git config core.hooksPath script/git/hooks`; `git commit --no-verify` passes a legitimate exception. It checks only what is mechanical — whether the subject says what the code is about, and whether the body tells the story instead of the mechanism, stay judgements, and the tests above are diff --git a/doc/CODE_GENERATOR.base.md b/doc/CODE_GENERATOR.base.md index 1b6ef55..9db8c7e 100644 --- a/doc/CODE_GENERATOR.base.md +++ b/doc/CODE_GENERATOR.base.md @@ -1042,3 +1042,70 @@ Tester les générations des templates : ```bash make test_code_generator_template ``` + + +## Comments in generated code + +Generated code carries comments like any other, and the same rule applies: a +comment says HOW the code works. It never carries identifying data — no +customer or third-party organisation, no real database name, no machine, no +address, no label or figure taken from a customer's data — and it does not +tell the story of the investigation that produced it. + +A module generated from an existing database inherits what that database +holds: re-read its comments and docstrings before committing. A customer or +database name gets in on its own. + +## Cleaning up as you go + +Nothing gets cleaned in one sweep. You fix the comments of the file you are +touching, at the moment you touch it. A `pre-commit` hook lists what is worth +re-reading in the files you staged, and never blocks the commit: + + +## Les commentaires du code produit + +Le code généré porte des commentaires comme le reste, et la même règle +s'applique : un commentaire dit COMMENT le code marche. Il ne porte jamais de +donnée identifiante — ni client ni organisation tierce, ni nom de base réelle, +ni machine, ni adresse, ni libellé ou chiffre tiré des données d'un client — et +il ne raconte pas l'enquête qui l'a produit. + +Un module généré à partir d'une base existante hérite de ce qu'elle contient : +relire ses commentaires et ses docstrings avant de committer. Un nom de client +ou de base y arrive tout seul. + +## Le nettoyage au fur et à mesure + +Rien ne se nettoie en une passe. On corrige les commentaires du fichier qu'on +touche, au moment où on le touche. Un hook `pre-commit` liste ce qui est à +relire dans les fichiers indexés, et ne bloque jamais le commit : + + +```bash +git config core.hooksPath script/git/hooks +``` + + +The same tool runs by hand, on a file, a directory or the index. It reports +`identifiant` findings — an address, an e-mail, an account path — which are to +be removed, and `récit` signals — +a witness marker, a date, the first person — which are to be RE-READ: a +durable fact stays, the incident where it was observed goes. Exit codes follow +the repository convention: 0 nothing to report, 1 findings, 2 the tool failed. + + +Le même outil se lance à la main, sur un fichier, un répertoire ou l'index. Il +signale les trouvailles `identifiant` — adresse, courriel, chemin de compte — +qui sont à retirer, et les signaux +`récit` — marqueur de témoignage, date, première personne — qui sont à RELIRE : +un fait durable reste, l'incident où on l'a observé part. Les codes de sortie +suivent la convention du dépôt : 0 rien à signaler, 1 des trouvailles, 2 +l'outil a échoué. + + +```bash +python3 script/analyse/check_comment_hygiene.py script/todo/todo.py +python3 script/analyse/check_comment_hygiene.py --staged +python3 script/analyse/check_comment_hygiene.py script --identifying-only +``` diff --git a/doc/CODE_GENERATOR.fr.md b/doc/CODE_GENERATOR.fr.md index b02e5c9..364053b 100644 --- a/doc/CODE_GENERATOR.fr.md +++ b/doc/CODE_GENERATOR.fr.md @@ -648,4 +648,40 @@ Tester les générations des templates : ```bash make test_code_generator_template +``` + +## Les commentaires du code produit + +Le code généré porte des commentaires comme le reste, et la même règle +s'applique : un commentaire dit COMMENT le code marche. Il ne porte jamais de +donnée identifiante — ni client ni organisation tierce, ni nom de base réelle, +ni machine, ni adresse, ni libellé ou chiffre tiré des données d'un client — et +il ne raconte pas l'enquête qui l'a produit. + +Un module généré à partir d'une base existante hérite de ce qu'elle contient : +relire ses commentaires et ses docstrings avant de committer. Un nom de client +ou de base y arrive tout seul. + +## Le nettoyage au fur et à mesure + +Rien ne se nettoie en une passe. On corrige les commentaires du fichier qu'on +touche, au moment où on le touche. Un hook `pre-commit` liste ce qui est à +relire dans les fichiers indexés, et ne bloque jamais le commit : + +```bash +git config core.hooksPath script/git/hooks +``` + +Le même outil se lance à la main, sur un fichier, un répertoire ou l'index. Il +signale les trouvailles `identifiant` — adresse, courriel, chemin de compte — +qui sont à retirer, et les signaux +`récit` — marqueur de témoignage, date, première personne — qui sont à RELIRE : +un fait durable reste, l'incident où on l'a observé part. Les codes de sortie +suivent la convention du dépôt : 0 rien à signaler, 1 des trouvailles, 2 +l'outil a échoué. + +```bash +python3 script/analyse/check_comment_hygiene.py script/todo/todo.py +python3 script/analyse/check_comment_hygiene.py --staged +python3 script/analyse/check_comment_hygiene.py script --identifying-only ``` \ No newline at end of file diff --git a/doc/CODE_GENERATOR.md b/doc/CODE_GENERATOR.md index e18d3db..2e941a0 100644 --- a/doc/CODE_GENERATOR.md +++ b/doc/CODE_GENERATOR.md @@ -413,4 +413,39 @@ TODO ```bash make test_code_generator_template +``` + +## Comments in generated code + +Generated code carries comments like any other, and the same rule applies: a +comment says HOW the code works. It never carries identifying data — no +customer or third-party organisation, no real database name, no machine, no +address, no label or figure taken from a customer's data — and it does not +tell the story of the investigation that produced it. + +A module generated from an existing database inherits what that database +holds: re-read its comments and docstrings before committing. A customer or +database name gets in on its own. + +## Cleaning up as you go + +Nothing gets cleaned in one sweep. You fix the comments of the file you are +touching, at the moment you touch it. A `pre-commit` hook lists what is worth +re-reading in the files you staged, and never blocks the commit: + +```bash +git config core.hooksPath script/git/hooks +``` + +The same tool runs by hand, on a file, a directory or the index. It reports +`identifiant` findings — an address, an e-mail, an account path — which are to +be removed, and `récit` signals — +a witness marker, a date, the first person — which are to be RE-READ: a +durable fact stays, the incident where it was observed goes. Exit codes follow +the repository convention: 0 nothing to report, 1 findings, 2 the tool failed. + +```bash +python3 script/analyse/check_comment_hygiene.py script/todo/todo.py +python3 script/analyse/check_comment_hygiene.py --staged +python3 script/analyse/check_comment_hygiene.py script --identifying-only ``` \ No newline at end of file diff --git a/script/analyse/check_comment_hygiene.py b/script/analyse/check_comment_hygiene.py new file mode 100644 index 0000000..65b449a --- /dev/null +++ b/script/analyse/check_comment_hygiene.py @@ -0,0 +1,432 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Les commentaires d'un fichier disent-ils le fonctionnement, ou son contexte ? + +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 : + +- `identifiant` — adresse IP, courriel, chemin de compte. Une + correspondance est une trouvaille : ces formes n'ont aucune raison d'être + dans un commentaire. +- `récit` — marqueur de témoignage (« vécu sur », « mesuré le »), date + 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. + +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. + +Il se signale lui-même : ce fichier CITE les marqueurs qu'il cherche, et ses +citations sont des correspondances comme les autres. Ces trouvailles-là sont +la définition de l'outil, pas un défaut à corriger. + +Codes de sortie, convention partagée des outils du dépôt : 0 rien à signaler, +1 des trouvailles, 2 l'outil a échoué. +""" + +from __future__ import annotations + +import argparse +import ast +import io +import json +import os +import re +import subprocess +import sys +import tokenize + +RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) + +sys.path.append(RACINE) +sys.path.append(os.path.join(RACINE, "script")) + +# Réexportés pour que ce module reste le seul point d'entrée de l'outil. +from lib_identifiant import ( # noqa: E402,F401 + NOMS_INTERDITS, + adresse_de_machine, + identifiants, + termes_interdits, +) + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +SUFFIXES = (".py", ".sh", ".bash") + +# Ce qui vient d'ailleurs ou n'est pas du source : le dépôt ne le réécrit pas. +EXCLUS = ( + "/OCA_", + "/addons/", + "/.venv", + "/node_modules/", + "/.git/", +) + + +def a_balayer(chemin): + """Un chemin du dépôt, et non du code tiers ou un environnement.""" + normalise = chemin.replace(os.sep, "/") + while normalise.startswith("./"): + normalise = normalise[2:] + return not any(exclu in "/" + normalise for exclu in EXCLUS) + + +# Le témoignage : la phrase prend un événement pour sujet au lieu du code. +# L'accent porte la distinction : « mesuré sur » témoigne, « mesure le » +# décrit ce que le code fait. Sans lui, le motif prend le présent pour du passé. +# « le relevé » est un NOM et prend les mêmes prépositions : un déterminant +# devant le participe désigne la chose relevée, pas l'acte de relever. +RECIT = re.compile( + r"(? position: + break + numero = ligne + return numero + + +def inspect(chemin, source=None, termes=None): + """Les trouvailles d'un fichier, dans l'ordre des lignes.""" + if termes is None: + termes = termes_interdits() + if source is None: + with io.open(chemin, encoding="utf-8", errors="replace") as fh: + source = fh.read() + + trouvailles = [] + for bloc in blocs(chemin, source): + familles = ( + ("identifiant", identifiants(bloc["text"], termes)), + ("récit", recits(bloc["text"])), + ) + for genre, trouves in familles: + for motif, extrait, position in trouves: + trouvailles.append( + { + "file": chemin, + "line": ligne_a(bloc, position), + "kind": genre, + "pattern": motif, + "excerpt": extrait, + } + ) + return sorted(trouvailles, key=lambda f: (f["line"], f["kind"])) + + +def fichiers_indexes(): + """Les fichiers ajoutés à l'index git, filtrés sur les suffixes lisibles.""" + sortie = subprocess.run( + ["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR"], + capture_output=True, + text=True, + cwd=RACINE, + ) + chemins = [] + for nom in sortie.stdout.split("\n"): + nom = nom.strip() + if ( + nom.endswith(SUFFIXES) + and a_balayer(nom) + and os.path.isfile(os.path.join(RACINE, nom)) + ): + chemins.append(nom) + return chemins + + +def etend(chemins): + """Les fichiers lisibles d'une liste de chemins, répertoires parcourus.""" + trouves = [] + for chemin in chemins: + if os.path.isdir(chemin): + for base, _, noms in os.walk(chemin): + if not a_balayer(base + "/"): + continue + for nom in sorted(noms): + complet = os.path.join(base, nom) + if nom.endswith(SUFFIXES) and a_balayer(complet): + trouves.append(complet) + elif chemin.endswith(SUFFIXES) and a_balayer(chemin): + trouves.append(chemin) + return trouves + + +def render(trouvailles, colour=True): + """Le rapport, groupé par fichier.""" + if not trouvailles: + return "" + + def peindre(texte, code): + return f"\033[{code}m{texte}\033[0m" if colour else texte + + lignes = [] + fichier = None + for f in trouvailles: + if f["file"] != fichier: + fichier = f["file"] + lignes.append(peindre(fichier, "1")) + icone = "🔴" if f["kind"] == "identifiant" else "🟡" + lignes.append( + f" {icone} {f['line']:>5} {f['pattern']:<11} {f['excerpt']}" + ) + + durs = sum(1 for f in trouvailles if f["kind"] == "identifiant") + mous = len(trouvailles) - durs + lignes.append("") + lignes.append( + t( + "%s identifying, %s to re-read — see .claude/rules/04-code-conventions.md" + ) + % (peindre(durs, "31"), peindre(mous, "33")) + ) + return "\n".join(lignes) + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=t( + "do the comments say how the code works, or where it came from" + ) + ) + parser.add_argument("paths", nargs="*", default=[]) + parser.add_argument( + "--staged", + action="store_true", + help=t("only the files added to the git index"), + ) + parser.add_argument( + "--identifying-only", + action="store_true", + help=t("drop the narrative signals, keep the certain findings"), + ) + parser.add_argument("--json", action="store_true") + parser.add_argument("--no-color", action="store_true") + args = parser.parse_args(argv) + + if args.staged: + chemins = fichiers_indexes() + elif args.paths: + chemins = etend(args.paths) + else: + parser.error(t("give a path, or --staged")) + return 2 + + termes = termes_interdits() + trouvailles = [] + for chemin in chemins: + try: + trouvailles.extend(inspect(chemin, termes=termes)) + except OSError as exc: + print(f"❌ {chemin} : {exc}", file=sys.stderr) + return 2 + + if args.identifying_only: + trouvailles = [f for f in trouvailles if f["kind"] == "identifiant"] + + if args.json: + print( + json.dumps( + {"scanned": len(chemins), "findings": trouvailles}, + indent=2, + ensure_ascii=False, + ) + ) + else: + colour = sys.stdout.isatty() and not args.no_color + rapport = render(trouvailles, colour) + if rapport: + print(rapport) + + return 1 if trouvailles else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/git/commit_msg_lib.py b/script/git/commit_msg_lib.py index 3d23eeb..23e5218 100644 --- a/script/git/commit_msg_lib.py +++ b/script/git/commit_msg_lib.py @@ -14,8 +14,8 @@ Le reste — « ce sujet dit-il sur quoi porte le code », « ce corps raconte-t l'enquête plutôt que le fonctionnement » — est un jugement, et aucun hook ne le rendra. -Compté en CARACTÈRES et non en octets : « préchauffer » pèse 12 caractères et -14 octets, et une limite en octets refuserait des sujets français conformes. +Compté en CARACTÈRES et non en octets : « préchauffer » pèse 11 caractères et +12 octets, et une limite en octets refuserait des sujets français conformes. """ import re import sys @@ -199,9 +199,9 @@ def _check_body(sans_trailers: str, avec_trailers: str) -> list: noms = sorted(set(par_motif.get("nom privé", []))) if noms: problems.append( - f"le corps porte un nom de la liste privée : {', '.join(noms)}.\n" + f"le corps porte un nom refusé : {', '.join(noms)}.\n" " Généralisez — « sur une base de production » — ou retirez la\n" - " phrase. La liste est dans private/noms_interdits.txt." + " phrase." ) return problems diff --git a/script/git/git_repo_update_group.py b/script/git/git_repo_update_group.py index 313b724..2ce53d0 100755 --- a/script/git/git_repo_update_group.py +++ b/script/git/git_repo_update_group.py @@ -78,7 +78,7 @@ def main(): whitelist = [] if config.from_backup_path or config.from_backup_name: # script/database/get_repo_from_backup.py - # --backup_name bpir_prod_5_dec_2025_2026-02-04_14h27m54s.zip + # --backup_name pir_prod_5_dec_2025_2026-02-04_14h27m54s.zip if config.from_backup_path: cmd = f"./script/database/get_repo_from_backup.py --backup_path {config.from_backup_path}" else: diff --git a/script/git/hooks/pre-commit b/script/git/hooks/pre-commit new file mode 100755 index 0000000..520e64f --- /dev/null +++ b/script/git/hooks/pre-commit @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Hook pre-commit : montre les commentaires hors convention des fichiers indexés. + +Il INFORME et ne refuse jamais — le nettoyage se fait au fur et à mesure, sur +les fichiers qu'on touche déjà, et non en une passe qui réécrirait le dépôt. +Un hook qui bloquerait sur du style se ferait désinstaller la même semaine. + +Installation : + + git config core.hooksPath script/git/hooks + +Il sort toujours en 0. Pour l'interroger à la main, sur un fichier ou un +répertoire : + + python3 script/analyse/check_comment_hygiene.py script/todo/todo.py +""" +import subprocess +import sys +from pathlib import Path + +RACINE = Path(__file__).resolve().parents[3] +OUTIL = RACINE / "script" / "analyse" / "check_comment_hygiene.py" + +# Au-delà, le rapport cesse d'être une invitation et devient un mur. +PLAFOND = 12 + + +def main() -> int: + if not OUTIL.is_file(): + return 0 + + try: + sortie = subprocess.run( + [sys.executable, str(OUTIL), "--staged", "--no-color"], + capture_output=True, + text=True, + cwd=str(RACINE), + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + return 0 + + if sortie.returncode not in (0, 1): + # Jamais bloquant, mais jamais muet non plus : un outil cassé qui se + # tait se confond avec un dépôt propre. + print( + f"\n ⓘ check_comment_hygiene a échoué ({sortie.returncode}) ;" + " les commentaires n'ont pas été relus\n", + file=sys.stderr, + ) + return 0 + + if sortie.returncode != 1 or not sortie.stdout.strip(): + return 0 + + lignes = sortie.stdout.rstrip("\n").split("\n") + trop = len(lignes) - PLAFOND + if trop > 0: + lignes = lignes[:PLAFOND] + [f" … et {trop} lignes de plus"] + + print( + "\n ⓘ commentaires à relire dans ce que vous committez\n", + file=sys.stderr, + ) + for ligne in lignes: + print(f" {ligne}", file=sys.stderr) + print( + "\n 🔴 identifiant : à retirer. 🟡 récit : à relire — un fait durable" + "\n reste, l'incident où on l'a vu part." + "\n La règle : .claude/rules/04-code-conventions.md" + "\n Le commit n'est PAS bloqué. Corrigez ce que vous touchez.\n", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/lib_identifiant.py b/script/lib_identifiant.py index de95b77..d92d119 100644 --- a/script/lib_identifiant.py +++ b/script/lib_identifiant.py @@ -5,7 +5,7 @@ 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 +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. diff --git a/script/todo/todo.py b/script/todo/todo.py index 08fca71..f90bfd7 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -2761,6 +2761,152 @@ class TODO( print("-" * 50) print(f"{t('Total:')}" f" {len(files)}") + def _claude_context_root(self): + """La racine du dépôt, deux niveaux au-dessus de ce fichier.""" + return os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "..") + ) + + def _compte_lignes(self, chemin): + """Le nombre de lignes d'un fichier ; 0 s'il est illisible.""" + try: + with open(chemin, encoding="utf-8", errors="replace") as fh: + return sum(1 for _ in fh) + except OSError: + return 0 + + def _claude_command_state(self, deployed, template): + """La copie déployée d'une commande suit-elle encore le gabarit ? + + La comparaison ignore les lignes qui portent une identité git : le + déploiement y substitue le nom et le courriel, et une égalité stricte + déclarerait périmée toute commande personnalisée. + """ + if not os.path.isfile(template): + return t("not in the repository") + if not os.path.isfile(deployed): + return t("missing") + + def stables(chemin): + with open(chemin, encoding="utf-8", errors="replace") as fh: + return [x for x in fh if "user.name=" not in x] + + try: + if stables(deployed) == stables(template): + return t("up to date") + except OSError: + return t("missing") + return t("redeploy needed") + + def _claude_memory_dir(self): + """Le répertoire de mémoire de Claude Code pour CE dépôt. + + Le nom du projet est le chemin absolu dont chaque séparateur devient + un tiret : c'est la convention de Claude Code, pas la nôtre. + """ + racine = self._claude_context_root() + projet = racine.replace(os.sep, "-") + return os.path.expanduser( + os.path.join("~/.claude/projects", projet, "memory") + ) + + def _show_claude_context(self): + """Ce que Claude reçoit avant la première question : sources et état. + + Rend None. Écrit un tableau et ne modifie rien. Ne relève que ce qui + est versionné ou déployé ; ce que `private/` contient n'y figure pas. + """ + racine = self._claude_context_root() + largeur = 62 + print(f"🧠 {t('Context given to Claude')}") + print("-" * largeur) + + instructions = os.path.join(racine, "CLAUDE.md") + if os.path.isfile(instructions): + n = self._compte_lignes(instructions) + print(f"{t('Instructions'):<22} CLAUDE.md {n} {t('lines')}") + else: + print(f"{t('Instructions'):<22} CLAUDE.md {t('missing')}") + + regles = os.path.join(racine, ".claude", "rules") + if os.path.isdir(regles): + noms = sorted(f for f in os.listdir(regles) if f.endswith(".md")) + total = sum( + self._compte_lignes(os.path.join(regles, f)) for f in noms + ) + print( + f"{t('Rules'):<22} .claude/rules/ {len(noms)}" + f" {t('files')}, {total} {t('lines')}" + ) + for nom in noms: + n = self._compte_lignes(os.path.join(regles, nom)) + print(f"{'':<22} {nom:<28} {n} {t('lines')}") + else: + print(f"{t('Rules'):<22} .claude/rules/ {t('missing')}") + + skills = os.path.join(racine, ".claude", "skills") + if os.path.isdir(skills): + noms = sorted( + d + for d in os.listdir(skills) + if os.path.isfile(os.path.join(skills, d, "SKILL.md")) + ) + print(f"{t('Skills'):<22} .claude/skills/ {len(noms)}") + for nom in noms: + print(f"{'':<22} {nom}") + else: + print(f"{t('Skills'):<22} .claude/skills/ {t('missing')}") + + print(f"{t('Deployed commands'):<22} ~/.claude/commands/") + gabarits = { + "commit": "template_claude_commands_commit.md", + "todo_add_command": "template_claude_commands_todo_add_command.md", + } + for nom, gabarit in sorted(gabarits.items()): + etat = self._claude_command_state( + os.path.expanduser(f"~/.claude/commands/{nom}.md"), + os.path.join(racine, "conf", gabarit), + ) + print(f"{'':<22} /{nom:<26} {etat}") + + chemin_hooks = self._git_hooks_path(racine) + print( + f"{t('Git hooks'):<22}" + f" {chemin_hooks or t('hook not installed')}" + ) + if chemin_hooks: + absolu = os.path.join(racine, chemin_hooks) + for hook in ("commit-msg", "pre-commit"): + pose = os.access(os.path.join(absolu, hook), os.X_OK) + marque = ( + t("hook installed") if pose else t("hook not installed") + ) + print(f"{'':<22} {hook:<26} {marque}") + + memoire = self._claude_memory_dir() + if os.path.isdir(memoire): + n = len([f for f in os.listdir(memoire) if f.endswith(".md")]) + print(f"{t('Memory'):<22} ~/.claude/projects/…/memory/ {n}") + else: + print(f"{t('Memory'):<22} {t('missing')}") + + print("-" * largeur) + + def _git_hooks_path(self, racine): + """La valeur de core.hooksPath, ou None si git n'en déclare aucune.""" + try: + sortie = subprocess.run( + ["git", "config", "--get", "core.hooksPath"], + capture_output=True, + text=True, + cwd=racine, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return None + chemin = sortie.stdout.strip() + return chemin or None + def _setup_claude_command( self, command_name, template_filename, personalize=False ): @@ -3694,7 +3840,7 @@ class TODO( de modèles et de colonnes, et lesquels sont traduits ou uniques. La confirmation redemande le NOM de la base. Une frappe sur « o » - se donne par réflexe ; recopier « chezlepro_neutralize_upgrade_18 » + se donne par réflexe ; recopier « sireine_neutralize_upgrade_18 » oblige à regarder ce qu'on détruit. """ from script.analyse import monitoring diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 9214823..5be45af 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6856,6 +6856,75 @@ TRANSLATIONS = { "fr": "l'outil lui-même a échoué", "en": "the tool itself failed", }, + # Contexte fourni à Claude + "Show the context given to Claude": { + "fr": "Afficher le contexte fourni à Claude", + "en": "Show the context given to Claude", + }, + "Context given to Claude": { + "fr": "Contexte fourni à Claude", + "en": "Context given to Claude", + }, + "Instructions": { + "fr": "Instructions", + "en": "Instructions", + }, + "Rules": { + "fr": "Règles", + "en": "Rules", + }, + "Skills": { + "fr": "Skills", + "en": "Skills", + }, + "Deployed commands": { + "fr": "Commandes déployées", + "en": "Deployed commands", + }, + "Git hooks": { + "fr": "Hooks git", + "en": "Git hooks", + }, + "up to date": { + "fr": "à jour", + "en": "up to date", + }, + "redeploy needed": { + "fr": "à redéployer", + "en": "redeploy needed", + }, + "not in the repository": { + "fr": "absent du dépôt", + "en": "not in the repository", + }, + "hook installed": { + "fr": "installé", + "en": "installed", + }, + "hook not installed": { + "fr": "non installé", + "en": "not installed", + }, + "%s identifying, %s to re-read — see .claude/rules/04-code-conventions.md": { + "fr": "%s identifiant, %s à relire — voir .claude/rules/04-code-conventions.md", + "en": "%s identifying, %s to re-read — see .claude/rules/04-code-conventions.md", + }, + "do the comments say how the code works, or where it came from": { + "fr": "les commentaires disent-ils le fonctionnement, ou d'où ils viennent", + "en": "do the comments say how the code works, or where it came from", + }, + "only the files added to the git index": { + "fr": "seulement les fichiers ajoutés à l'index git", + "en": "only the files added to the git index", + }, + "drop the narrative signals, keep the certain findings": { + "fr": "laisser les signaux de récit, garder les trouvailles sûres", + "en": "drop the narrative signals, keep the certain findings", + }, + "give a path, or --staged": { + "fr": "donner un chemin, ou --staged", + "en": "give a path, or --staged", + }, "unknown result": { "fr": "résultat inconnu", "en": "unknown result", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 235bb54..8f33afa 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1754,7 +1754,7 @@ class TodoUpgrade: # TODO exécuter next line si status != 0 et log contient # psycopg2.errors.UndefinedTable: relation "discuss_channel" does not exist # LIGNE 1 : SELECT "discuss_channel"."id" FROM "discuss_channel" WHERE (... - # source ./.venv.odoo18.0_python3.12.10/bin/activate && cat script/postgresql/migration/fix_migration_postgresql_17_to_postgresql_18_module_mail_nov_2025.py | ./odoo18.0/odoo/odoo-bin shell -d ripbylop_stage_prod_17_nov_2025 + # source ./.venv.odoo18.0_python3.12.10/bin/activate && cat script/postgresql/migration/fix_migration_postgresql_17_to_postgresql_18_module_mail_nov_2025.py | ./odoo18.0/odoo/odoo-bin shell -d riplop_stage_prod_17_nov_2025 # psycopg2.errors.ForeignKeyViolation: insert or update on table "discuss_channel_member" violates foreign key constraint "discuss_channel_member_channel_id_fkey" # DÉTAIL : Key (channel_id)=(20) is not present in table "discuss_channel". # ./script/database/migrate/process_backup_file.py --path_backup_zip image_db/db.zip --path_output_zip image_db/dbFIX.zip --word_to_delete discuss_channel_channel_type_not_null diff --git a/test/test_check_comment_hygiene.py b/test/test_check_comment_hygiene.py new file mode 100644 index 0000000..799dafb --- /dev/null +++ b/test/test_check_comment_hygiene.py @@ -0,0 +1,386 @@ +#!/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) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/test/test_db_duplicate.py b/test/test_db_duplicate.py index 6fc2a9d..ee32a61 100644 --- a/test/test_db_duplicate.py +++ b/test/test_db_duplicate.py @@ -26,7 +26,7 @@ class TestWhatANameMayBe(unittest.TestCase): """Le nom entre dans du SQL par `database_identifier` : il se filtre.""" def test_ordinary_names_pass(self): - for nom in ("chezlepro", "el_essai", "a", "base-2024", "_interne"): + for nom in ("sireine", "el_essai", "a", "base-2024", "_interne"): self.assertTrue(dup.nom_valide(nom), nom) def test_a_name_that_could_carry_sql_is_refused(self): diff --git a/test/test_git_commit_msg.py b/test/test_git_commit_msg.py index 5a63a44..0ed716c 100644 --- a/test/test_git_commit_msg.py +++ b/test/test_git_commit_msg.py @@ -164,9 +164,9 @@ class TestLeCorps(unittest.TestCase): self.assertEqual([], check(_message(corps))) def test_une_adresse_ip(self): - problemes = check(_message("La VM répondait en 192.168.123.170.")) + problemes = check(_message("La VM répondait en 172.31.7.42.")) self.assertEqual(1, len(problemes)) - self.assertIn("192.168.123.170", problemes[0]) + self.assertIn("172.31.7.42", problemes[0]) def test_les_adresses_sans_porteur_passent(self): """0.0.0.0 et 127.0.0.1 ne désignent aucune machine du parc.""" @@ -187,7 +187,7 @@ class TestLeCorps(unittest.TestCase): def test_un_chemin_de_compte(self): problemes = check( - _message("Le venv vit dans /home/mathieu/git/erplibre/.") + _message("Le venv vit dans /home/sireine/git/erplibre/.") ) self.assertEqual(1, len(problemes)) self.assertIn("chemin de compte", problemes[0]) @@ -221,7 +221,7 @@ class TestLeCorps(unittest.TestCase): try: problemes = check(_message("Migration de AcmeCorp, six paliers.")) self.assertEqual(1, len(problemes)) - self.assertIn("liste privée", problemes[0]) + self.assertIn("nom refusé", problemes[0]) finally: os.unlink(commit_msg_lib.NOMS_INTERDITS) commit_msg_lib.NOMS_INTERDITS = origine @@ -264,7 +264,7 @@ class TestLeCorps(unittest.TestCase): try: problemes = check(_message("Une raison.\n\nRefs: acmecorp-42")) self.assertEqual(1, len(problemes)) - self.assertIn("liste privée", problemes[0]) + self.assertIn("nom refusé", problemes[0]) finally: os.unlink(commit_msg_lib.NOMS_INTERDITS) commit_msg_lib.NOMS_INTERDITS = origine diff --git a/test/test_monitoring.py b/test/test_monitoring.py index ab3dacc..9b858aa 100644 --- a/test/test_monitoring.py +++ b/test/test_monitoring.py @@ -667,7 +667,7 @@ class TestTheVerdictsSection(unittest.TestCase): ) def test_a_plain_name_is_its_own_lineage(self): - self.assertEqual("copy_chezlepro3", residue.famille("copy_chezlepro3")) + self.assertEqual("copy_sireine3", residue.famille("copy_sireine3")) def test_another_migration_verdicts_are_not_shown(self): # Deux migrations partagent le fichier. Attribuer l'échec de diff --git a/test/test_prompt_defaults.py b/test/test_prompt_defaults.py index e466206..14e96a8 100644 --- a/test/test_prompt_defaults.py +++ b/test/test_prompt_defaults.py @@ -257,7 +257,7 @@ class TestTheDatabaseNameTheFileSuggests(unittest.TestCase): return database_name_from_file(chemin, **kw) def test_the_zip_extension_goes_away(self): - self.assertEqual("chezlepro3", self.nom("image_db/chezlepro3.zip")) + self.assertEqual("sireine3", self.nom("image_db/sireine3.zip")) def test_an_uppercase_extension_goes_away_too(self): self.assertEqual("client", self.nom("image_db/CLIENT.ZIP")) diff --git a/test/test_todo_sshfs.py b/test/test_todo_sshfs.py index 934e109..5f82296 100644 --- a/test/test_todo_sshfs.py +++ b/test/test_todo_sshfs.py @@ -41,18 +41,18 @@ from script.todo.todo_i18n import t # noqa: E402 CONFIG = """Host * ServerAliveInterval 60 -Host novipro_private +Host pro_private HostName 192.168.100.110 - User mathben + User admin -Host novipro_private+ERPLibre01 +Host pro_private+ERPLibre01 HostName 192.168.122.50 - User mathben + User admin StrictHostKeyChecking no UserKnownHostsFile /dev/null IdentityFile /home/erplibre/.ssh/id_ed25519 IdentitiesOnly yes - ProxyJump novipro_private + ProxyJump pro_private Host erplibre-ubuntu-2604 erplibre-2604-bis HostName 192.168.123.165 @@ -63,11 +63,11 @@ Host web-? """ # Sortie de « ssh -G » pour l'alias à « + », réduite à ce qui compte. -SSH_G = """host novipro_private+erplibre01 +SSH_G = """host pro_private+erplibre01 hostname 192.168.122.50 -user mathben +user admin port 22 -proxyjump novipro_private +proxyjump pro_private identityfile /home/erplibre/.ssh/id_ed25519 identityfile ~/.ssh/id_rsa identitiesonly yes @@ -93,8 +93,8 @@ class TestLectureConfig(unittest.TestCase): def test_it_reads_hosts_in_file_order(self): hosts = TODO._ssh_config_entries(self.chemin) noms = [n for n, _i in hosts] - self.assertEqual("novipro_private", noms[0]) - self.assertIn("novipro_private+ERPLibre01", noms) + self.assertEqual("pro_private", noms[0]) + self.assertIn("pro_private+ERPLibre01", noms) def test_a_host_line_with_two_patterns_gives_two_aliases(self): """C'est ce que le générateur du dépôt écrit (« Host {' '.join(names)} »). @@ -119,14 +119,14 @@ class TestLectureConfig(unittest.TestCase): def test_the_plus_alias_stays_one_name(self): noms = [n for n, _i in TODO._ssh_config_entries(self.chemin)] - self.assertNotIn("novipro_private", noms[1:2] and []) - self.assertIn("novipro_private+ERPLibre01", noms) + self.assertNotIn("pro_private", noms[1:2] and []) + self.assertIn("pro_private+ERPLibre01", noms) def test_hostname_and_user_are_kept(self): hosts = dict(TODO._ssh_config_entries(self.chemin)) - info = hosts["novipro_private+ERPLibre01"] + info = hosts["pro_private+ERPLibre01"] self.assertEqual("192.168.122.50", info["hostname"]) - self.assertEqual("mathben", info["user"]) + self.assertEqual("admin", info["user"]) def test_a_missing_file_is_not_a_crash(self): self.assertEqual([], TODO._ssh_config_entries("/nexistepas/config")) @@ -138,9 +138,9 @@ class TestResolution(unittest.TestCase): "subprocess.run", return_value=subprocess.CompletedProcess([], 0, SSH_G, ""), ): - cfg = TODO._ssh_resolve("novipro_private+ERPLibre01") + cfg = TODO._ssh_resolve("pro_private+ERPLibre01") self.assertEqual("192.168.122.50", cfg["hostname"]) - self.assertEqual("novipro_private", cfg["proxyjump"]) + self.assertEqual("pro_private", cfg["proxyjump"]) def test_the_first_identityfile_wins(self): """ssh -G les répète toutes ; la première est celle qu'il essaiera.""" @@ -177,9 +177,9 @@ class TestCommandeSshfs(unittest.TestCase): def _resolue(self): return { "hostname": "192.168.122.50", - "user": "mathben", + "user": "admin", "port": "22", - "proxyjump": "novipro_private", + "proxyjump": "pro_private", "identityfile": "/home/erplibre/.ssh/id_ed25519", "identitiesonly": "yes", "stricthostkeychecking": "false", @@ -188,10 +188,10 @@ class TestCommandeSshfs(unittest.TestCase): def test_a_plus_alias_becomes_a_resolved_target(self): cmd, contourne = self.todo._sshfs_command( - "novipro_private+ERPLibre01", "/tmp/mnt", self._resolue() + "pro_private+ERPLibre01", "/tmp/mnt", self._resolue() ) self.assertTrue(contourne) - self.assertIn("mathben@192.168.122.50:/", cmd) + self.assertIn("admin@192.168.122.50:/", cmd) self.assertNotIn("+", cmd) def test_the_options_that_matter_travel_with_it(self): @@ -199,7 +199,7 @@ class TestCommandeSshfs(unittest.TestCase): une IP DHCP recyclée fait échouer le montage sur sa clé d'hôte.""" cmd, _ = self.todo._sshfs_command("a+b", "/tmp/mnt", self._resolue()) for attendu in ( - "-o ProxyJump=novipro_private", + "-o ProxyJump=pro_private", "-o Port=22", "-o IdentityFile=/home/erplibre/.ssh/id_ed25519", "-o IdentitiesOnly=yes", @@ -250,7 +250,7 @@ class TestDiagnostic(unittest.TestCase): "nothing listening on the SSH port", ), ( - "mathben@x: Permission denied (publickey).", + "admin@x: Permission denied (publickey).", "authentication refused: check User and key", ), ( @@ -285,8 +285,8 @@ class TestFlux(unittest.TestCase): todo._ssh_probe = lambda alias, timeout=8: probe todo._ssh_resolve = lambda alias: { "hostname": "192.168.122.50", - "user": "mathben", - "proxyjump": "novipro_private", + "user": "admin", + "proxyjump": "pro_private", } return todo @@ -377,11 +377,11 @@ class TestFlux(unittest.TestCase): def test_the_plus_alias_is_bypassed_before_being_run(self): """Le vrai correctif : la commande lancée ne contient plus le « + ».""" todo = self._todo(0) - # [1] novipro_private · [2] novipro_private+ERPLibre01 · [3] la VM + # [1] pro_private · [2] pro_private+ERPLibre01 · [3] la VM _s, _c, _r = self._joue(todo, CONFIG, "2") lancee = todo.lances[0] - self.assertIn("mathben@192.168.122.50:/", lancee) - self.assertIn("-o ProxyJump=novipro_private", lancee) + self.assertIn("admin@192.168.122.50:/", lancee) + self.assertIn("-o ProxyJump=pro_private", lancee) self.assertNotIn("+ERPLibre01", lancee)