[I18N] commit-msg : le garde-fou parle la langue du dépôt
Les messages du garde-fou étaient des littéraux français : l'anglais n'était pas supporté, quelle que soit la valeur de EL_LANG. Onze clés passent désormais par t(), pour 6 ms de surcoût mesuré par commit. L'en-tête annonçait « sujet » alors que le contrôle lit aussi le corps ; il dit « message ». Les tests ne passaient que parce que le dépôt est en français : un poste en anglais en cassait quarante. La langue devient une précondition — épinglée en mémoire, car set_lang() écrirait un fichier suivi — et les assertions qui citent du texte français se sautent en nommant leur raison. Vérifié en basculant EL_LANG : 48 tests OK en français, 2 sautés en anglais. --- EN --- The guard rail's messages were French literals: English was not supported at all, whatever EL_LANG said. Eleven keys now go through t(), for a measured 6 ms of overhead per commit. The header announced "subject" while the check reads the body too; it says "message". The tests passed only because the repository is in French: a workstation in English broke forty of them. The language becomes a precondition — pinned in memory, since set_lang() would write a tracked file — and the assertions quoting French text skip while naming their reason. Checked by switching EL_LANG: 48 tests OK in French, 2 skipped in English. Assisted-by: Claude Opus 5
This commit is contained in:
parent
fcad37c24f
commit
54f44755cd
4 changed files with 239 additions and 26 deletions
|
|
@ -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/<utilisateur>/."
|
||||
t(
|
||||
"the body carries an account path: %s….\n"
|
||||
" Write ~/ or /home/<user>/."
|
||||
)
|
||||
% 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/<user>/.": {
|
||||
"fr": "le corps porte un chemin de compte : %s….\n"
|
||||
" Écrivez ~/ ou /home/<utilisateur>/.",
|
||||
"en": "the body carries an account path: %s….\n"
|
||||
" Write ~/ or /home/<user>/.",
|
||||
},
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue