diff --git a/script/git/commit_msg_lib.py b/script/git/commit_msg_lib.py index 23e5218..f4f1b6c 100644 --- a/script/git/commit_msg_lib.py +++ b/script/git/commit_msg_lib.py @@ -29,6 +29,16 @@ from lib_identifiant import ( # noqa: E402 termes_interdits, ) +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + MAX = 72 # Lignes non vides par langue. Le corps est bilingue : ce budget est celui @@ -124,22 +134,28 @@ def _check_subject(subject: str) -> list: break if tag is None: problems.append( - f"le sujet doit commencer par un tag : {', '.join('[%s]' % t for t in TAGS)}" + t("the subject must start with a tag: %s") + % ", ".join("[%s]" % tag for tag in TAGS) ) if len(subject) > MAX: problems.append( - f"le sujet fait {len(subject)} caractères, {MAX} au plus.\n" - " Ne le tronquez pas : à cette longueur, écrivez des MOTS-CLÉS\n" - " qui résument plutôt qu'une phrase amputée. « proxmox : pmxcfs,\n" - " stockage, diagnostic » vaut mieux qu'une phrase coupée net." + t( + "the subject is %s characters, %s at most.\n" + " Do not truncate it: at that length, write KEYWORDS that\n" + " summarise rather than an amputated sentence. « proxmox: pmxcfs,\n" + " storage, diagnosis » beats a sentence cut short." + ) + % (len(subject), MAX) ) rest = subject.split(":", 1)[1].strip() if ":" in subject else "" if rest.startswith(QUOTES): problems.append( - "le sujet s'ouvre sur une citation. Un message d'écran est une\n" - " preuve : elle va dans le corps. Le sujet nomme la cause." + t( + "the subject opens on a quotation. A screen message is\n" + " evidence: it belongs in the body. The subject names the cause." + ) ) return problems @@ -158,11 +174,13 @@ def _check_body(sans_trailers: str, avec_trailers: str) -> list: pleines = [ligne for ligne in moitie.split("\n") if ligne.strip()] if len(pleines) > MAX_BODY: problems.append( - f"le corps fait {len(pleines)} lignes pour une langue," - f" {MAX_BODY} au plus.\n" - " Le corps dit pourquoi c'était nécessaire, puis s'arrête.\n" - " L'enquête, les mesures datées et les impasses vont dans\n" - " tasks/, qui n'est pas versionné." + t( + "the body is %s lines for one language, %s at most.\n" + " The body says why it was necessary, then stops.\n" + " The investigation, the dated measurements and the dead ends go\n" + " to tasks/, which is not versioned." + ) + % (len(pleines), MAX_BODY) ) break @@ -178,30 +196,39 @@ def _check_body(sans_trailers: str, avec_trailers: str) -> list: adresses = sorted(set(par_motif.get("adresse", []))) if adresses: problems.append( - f"le corps porte une adresse IP : {', '.join(adresses)}.\n" - " Une adresse désigne une machine. Nommez la CLASSE de\n" - " situation — « sur un hôte derrière un NAT » — pas la machine." + t( + "the body carries an IP address: %s.\n" + " An address designates a machine. Name the CLASS of\n" + " situation — « on a host behind a NAT » — not the machine." + ) + % ", ".join(adresses) ) courriels = sorted(set(par_motif.get("courriel", []))) if courriels: problems.append( - f"le corps porte une adresse de courriel : {', '.join(courriels)}." + t("the body carries an e-mail address: %s.") % ", ".join(courriels) ) comptes = sorted(set(par_motif.get("compte", []))) if comptes: problems.append( - f"le corps porte un chemin de compte : {comptes[0]}….\n" - " Écrivez ~/ ou /home//." + t( + "the body carries an account path: %s….\n" + " Write ~/ or /home//." + ) + % comptes[0] ) noms = sorted(set(par_motif.get("nom privé", []))) if noms: problems.append( - f"le corps porte un nom refusé : {', '.join(noms)}.\n" - " Généralisez — « sur une base de production » — ou retirez la\n" - " phrase." + t( + "the body carries a refused name: %s.\n" + " Generalise — « on a production database » — or drop the\n" + " sentence." + ) + % ", ".join(noms) ) return problems diff --git a/script/git/hooks/commit-msg b/script/git/hooks/commit-msg index 7674ef7..65acf98 100755 --- a/script/git/hooks/commit-msg +++ b/script/git/hooks/commit-msg @@ -16,10 +16,19 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from commit_msg_lib import check # noqa: E402 +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + def main() -> int: if len(sys.argv) < 2: - print("commit-msg : aucun fichier de message reçu", file=sys.stderr) + print(t("commit-msg: no message file received"), file=sys.stderr) return 1 path = Path(sys.argv[1]) try: @@ -32,13 +41,15 @@ def main() -> int: if not problems: return 0 - print("\n ✗ sujet de commit hors convention\n", file=sys.stderr) + print("\n ✗ %s\n" % t("commit message off convention"), file=sys.stderr) for problem in problems: print(f" - {problem}", file=sys.stderr) print( - "\n La règle : .claude/rules/04-code-conventions.md" - "\n Le mode d'emploi : conf/template_claude_commands_commit.md" - "\n Passer outre pour un cas légitime : git commit --no-verify\n", + t( + "\n The rule: .claude/rules/04-code-conventions.md" + "\n The manual: conf/template_claude_commands_commit.md" + "\n Override for a legitimate case: git commit --no-verify\n" + ), file=sys.stderr, ) return 1 diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index c182046..21fdc67 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6889,6 +6889,93 @@ TRANSLATIONS = { "fr": "absent", "en": "missing", }, + # Garde-fou du message de commit + "the subject must start with a tag: %s": { + "fr": "le sujet doit commencer par un tag : %s", + "en": "the subject must start with a tag: %s", + }, + "the subject is %s characters, %s at most.\n" + " Do not truncate it: at that length, write KEYWORDS that\n" + " summarise rather than an amputated sentence. « proxmox: pmxcfs,\n" + " storage, diagnosis » beats a sentence cut short.": { + "fr": "le sujet fait %s caractères, %s au plus.\n" + " Ne le tronquez pas : à cette longueur, écrivez des MOTS-CLÉS\n" + " qui résument plutôt qu'une phrase amputée. « proxmox : pmxcfs,\n" + " stockage, diagnostic » vaut mieux qu'une phrase coupée net.", + "en": "the subject is %s characters, %s at most.\n" + " Do not truncate it: at that length, write KEYWORDS that\n" + " summarise rather than an amputated sentence. « proxmox: pmxcfs,\n" + " storage, diagnosis » beats a sentence cut short.", + }, + "the subject opens on a quotation. A screen message is\n" + " evidence: it belongs in the body. The subject names the cause.": { + "fr": "le sujet s'ouvre sur une citation. Un message d'écran est une\n" + " preuve : elle va dans le corps. Le sujet nomme la cause.", + "en": "the subject opens on a quotation. A screen message is\n" + " evidence: it belongs in the body. The subject names the cause.", + }, + "the body is %s lines for one language, %s at most.\n" + " The body says why it was necessary, then stops.\n" + " The investigation, the dated measurements and the dead ends go\n" + " to tasks/, which is not versioned.": { + "fr": "le corps fait %s lignes pour une langue, %s au plus.\n" + " Le corps dit pourquoi c'était nécessaire, puis s'arrête.\n" + " L'enquête, les mesures datées et les impasses vont dans\n" + " tasks/, qui n'est pas versionné.", + "en": "the body is %s lines for one language, %s at most.\n" + " The body says why it was necessary, then stops.\n" + " The investigation, the dated measurements and the dead ends go\n" + " to tasks/, which is not versioned.", + }, + "the body carries an IP address: %s.\n" + " An address designates a machine. Name the CLASS of\n" + " situation — « on a host behind a NAT » — not the machine.": { + "fr": "le corps porte une adresse IP : %s.\n" + " Une adresse désigne une machine. Nommez la CLASSE de\n" + " situation — « sur un hôte derrière un NAT » — pas la machine.", + "en": "the body carries an IP address: %s.\n" + " An address designates a machine. Name the CLASS of\n" + " situation — « on a host behind a NAT » — not the machine.", + }, + "the body carries an e-mail address: %s.": { + "fr": "le corps porte une adresse de courriel : %s.", + "en": "the body carries an e-mail address: %s.", + }, + "the body carries an account path: %s….\n" + " Write ~/ or /home//.": { + "fr": "le corps porte un chemin de compte : %s….\n" + " Écrivez ~/ ou /home//.", + "en": "the body carries an account path: %s….\n" + " Write ~/ or /home//.", + }, + "the body carries a refused name: %s.\n" + " Generalise — « on a production database » — or drop the\n" + " sentence.": { + "fr": "le corps porte un nom refusé : %s.\n" + " Généralisez — « sur une base de production » — ou retirez la\n" + " phrase.", + "en": "the body carries a refused name: %s.\n" + " Generalise — « on a production database » — or drop the\n" + " sentence.", + }, + "commit-msg: no message file received": { + "fr": "commit-msg : aucun fichier de message reçu", + "en": "commit-msg: no message file received", + }, + "commit message off convention": { + "fr": "message de commit hors convention", + "en": "commit message off convention", + }, + "\n The rule: .claude/rules/04-code-conventions.md" + "\n The manual: conf/template_claude_commands_commit.md" + "\n Override for a legitimate case: git commit --no-verify\n": { + "fr": "\n La règle : .claude/rules/04-code-conventions.md" + "\n Le mode d'emploi : conf/template_claude_commands_commit.md" + "\n Passer outre pour un cas légitime : git commit --no-verify\n", + "en": "\n The rule: .claude/rules/04-code-conventions.md" + "\n The manual: conf/template_claude_commands_commit.md" + "\n Override for a legitimate case: git commit --no-verify\n", + }, "up to date": { "fr": "à jour", "en": "up to date", diff --git a/test/test_git_commit_msg.py b/test/test_git_commit_msg.py index 0ed716c..3bc9036 100644 --- a/test/test_git_commit_msg.py +++ b/test/test_git_commit_msg.py @@ -10,6 +10,10 @@ un fixup de rebase — tout cela doit passer. La part de la convention qu'aucun hook ne juge — « ce sujet dit-il sur quoi porte le code » — n'est pas testée ici parce qu'elle n'est pas vérifiable. + +Les messages sont traduits. Les assertions qui citent du texte fixent donc la +langue à « fr » pour la durée du module : sinon elles dépendraient de EL_LANG, +et un poste en anglais les ferait toutes échouer. """ import os import subprocess @@ -17,10 +21,33 @@ import sys import tempfile import unittest +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) sys.path.insert( 0, os.path.join(os.path.dirname(__file__), "..", "script", "git") ) +from script.todo import todo_i18n # noqa: E402 + +# Relevée AVANT d'épingler : c'est la langue que lira un hook lancé en +# sous-processus, qui relit env_var.sh et ignore ce que ce module épingle. +LANGUE_DEPOT = todo_i18n.get_lang() +_LANGUE_ORIGINE = todo_i18n._current_lang + +EN_FRANCAIS = unittest.skipUnless( + LANGUE_DEPOT == "fr", + "le dépôt est en « %s » : ce test lit du texte français" % LANGUE_DEPOT, +) + + +def setUpModule(): + """Fixe « fr » en mémoire. set_lang() écrirait env_var.sh, qui est suivi.""" + todo_i18n._current_lang = "fr" + + +def tearDownModule(): + todo_i18n._current_lang = _LANGUE_ORIGINE + + import commit_msg_lib # noqa: E402 from commit_msg_lib import ( # noqa: E402 MAX, @@ -275,6 +302,44 @@ class TestLeCorps(unittest.TestCase): self.assertIn("Assisted-by", body_of(message, trailers=True)) +class TestLesDeuxLangues(unittest.TestCase): + """Un refus se lit dans la langue du dépôt, pas seulement en français.""" + + def setUp(self): + self.addCleanup(setattr, todo_i18n, "_current_lang", "fr") + + def _en(self, message): + todo_i18n._current_lang = "en" + return check(message) + + def test_le_tag_manquant_se_dit_en_anglais(self): + probleme = self._en("pas de tag")[0] + self.assertIn("must start with a tag", probleme) + self.assertNotIn("doit commencer", probleme) + + def test_la_longueur_du_sujet_se_dit_en_anglais(self): + probleme = self._en("[FIX] portée : " + "a" * 80)[0] + self.assertIn("characters", probleme) + self.assertIn("KEYWORDS", probleme) + + def test_les_identifiants_se_disent_en_anglais(self): + corps = "[FIX] portée : sujet\n\nUne raison, 10.10.10.5 et a@b.ca.\n" + problemes = " ".join(self._en(corps)) + self.assertIn("IP address", problemes) + self.assertIn("e-mail address", problemes) + + def test_les_deux_langues_signalent_AUTANT_de_problemes(self): + """Traduire ne doit ni ajouter ni perdre un refus.""" + corps = "[FIX] portée : sujet\n\nUne raison, 10.10.10.5 et a@b.ca.\n" + todo_i18n._current_lang = "fr" + fr = len(check(corps)) + self.assertEqual(fr, len(self._en(corps))) + + def test_une_cle_sans_traduction_rend_la_cle(self): + """Le repli de t() ne doit jamais faire tomber le hook.""" + self.assertEqual([], check("[FIX] portée : quelque chose")) + + class TestLeHookLuiMeme(unittest.TestCase): """Le module peut être juste et le hook faux : on l'exécute vraiment.""" @@ -298,11 +363,27 @@ class TestLeHookLuiMeme(unittest.TestCase): self.assertEqual(0, r.returncode, r.stderr) def test_il_sort_en_un_et_dit_pourquoi(self): + """Le code de sortie et le chemin de la règle ne sont pas traduits.""" r = self._lancer("[FIX] portée : " + "a" * 80 + "\n") self.assertEqual(1, r.returncode) + self.assertIn(".claude/rules/04-code-conventions.md", r.stderr) + + @EN_FRANCAIS + def test_il_dit_pourquoi_en_francais(self): + r = self._lancer("[FIX] portée : " + "a" * 80 + "\n") self.assertIn("hors convention", r.stderr) self.assertIn("MOTS-CLÉS", r.stderr) + def test_il_dit_pourquoi_dans_la_langue_du_depot(self): + """Le sous-processus rend la langue d'env_var.sh, quelle qu'elle soit.""" + todo_i18n._current_lang = LANGUE_DEPOT + try: + attendu = todo_i18n.t("commit message off convention") + finally: + todo_i18n._current_lang = "fr" + r = self._lancer("[FIX] portée : " + "a" * 80 + "\n") + self.assertIn(attendu, r.stderr) + def test_il_nomme_le_contournement(self): """Sans issue annoncée, un hook se contourne en le supprimant.""" r = self._lancer("pas de tag\n") @@ -313,6 +394,13 @@ class TestLeHookLuiMeme(unittest.TestCase): [sys.executable, HOOK], capture_output=True, text=True ) self.assertEqual(1, r.returncode) + self.assertIn("commit-msg", r.stderr) + + @EN_FRANCAIS + def test_sans_argument_il_le_dit_en_francais(self): + r = subprocess.run( + [sys.executable, HOOK], capture_output=True, text=True + ) self.assertIn("aucun fichier", r.stderr)