[ADD] commentaires : un relevé non bloquant, sa règle, le code nettoyé

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
This commit is contained in:
Mathieu Benoit 2026-08-30 02:02:51 -04:00
parent 7844387fa1
commit 0e5441ac39
20 changed files with 1341 additions and 53 deletions

View file

@ -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 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. 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 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 et les impasses vivent dans `tasks/`, qui n'est pas versionné. Ni le fichier
ni le corps du commit ne les portent. 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 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 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 une langue, une adresse IP, un courriel, un chemin de compte. Ce qui reste un
`private/noms_interdits.txt` — la liste des clients et des machines, qui ne jugement — « ce corps raconte-t-il l'enquête » — n'est vérifié par personne.
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.
```bash ```bash
git config core.hooksPath script/git/hooks # une fois par clone git config core.hooksPath script/git/hooks # une fois par clone

View file

@ -7,3 +7,39 @@ ERPLibre inclut un système de génération de modules Odoo :
- `addons/TechnoLibre_odoo-code-generator-template/` — Templates - `addons/TechnoLibre_odoo-code-generator-template/` — Templates
Documentation : `doc/CODE_GENERATOR.md` 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é.

View file

@ -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 **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 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 too: over ten lines for one language, an IP address, an e-mail, and a
`/home/<account>/` path, or any term listed in `private/noms_interdits.txt` — `/home/<account>/` path. Install the hook with `git config core.hooksPath script/git/hooks`; `git commit
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
--no-verify` passes a legitimate exception. It checks only what is mechanical --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 — 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 the story instead of the mechanism, stay judgements, and the tests above are

View file

@ -1042,3 +1042,70 @@ Tester les générations des templates :
```bash ```bash
make test_code_generator_template make test_code_generator_template
``` ```
<!-- [en] -->
## 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:
<!-- [fr] -->
## 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 :
<!-- [common] -->
```bash
git config core.hooksPath script/git/hooks
```
<!-- [en] -->
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.
<!-- [fr] -->
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é.
<!-- [common] -->
```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
```

View file

@ -648,4 +648,40 @@ Tester les générations des templates :
```bash ```bash
make test_code_generator_template 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
``` ```

View file

@ -413,4 +413,39 @@ TODO
```bash ```bash
make test_code_generator_template 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
``` ```

View file

@ -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"(?<!\w)(?<!(?:le|du|au|un|ce)\s)(?<!(?:son|des|les|aux|mon|ton)\s)"
r"(?:vécu|mesuré|relevé|rapporté|constaté|observé|signalé)e?s?"
r"(?:[,]?\s+(?:sur|le|la|les|dans|chez|par|au|aux|en|à|ce|deux|trois)\b"
r"|\s*[:—])",
re.IGNORECASE,
)
# Une date absolue date le commentaire : le fonctionnement, lui, n'a pas de date.
DATE = re.compile(
r"\b\d{4}-\d{2}-\d{2}\b"
r"|\b\d{1,2}\s+(?:janvier|février|mars|avril|mai|juin|juillet|août"
r"|septembre|octobre|novembre|décembre)\s+\d{4}\b",
re.IGNORECASE,
)
# Le rédacteur pris pour sujet : « ma conclusion était fausse », « mes essais ».
PERSONNE = re.compile(
r"(?<![\w'])(?:j'|je|ma|mon|mes|nous\s+avons"
r"|ce\s+matin|la\s+veille|ce\s+jour-là|hier)\b",
re.IGNORECASE,
)
MOTIFS_RECIT = (
("témoignage", RECIT),
("date", DATE),
("personne", PERSONNE),
)
def _bloc(sous_lignes):
"""Un bloc : son texte recollé, et les lignes physiques qui le portent.
Le texte recollé sert à CHERCHER — une phrase coupée en trois lignes reste
une phrase. Chaque sous-ligne garde en plus son OFFSET dans ce texte : une
occurrence trouvée à la position n se convertit alors en numéro de ligne
sans jamais rechercher l'extrait, qui se retrouverait dans un autre mot.
"""
debuts, position = [], 0
for numero, texte in sous_lignes:
debuts.append((position, numero))
position += len(texte) + 1
return {
"line": sous_lignes[0][0],
"text": " ".join(texte for _, texte in sous_lignes),
"lines": sous_lignes,
"offsets": debuts,
}
def _regroupe(lignes_commentees):
"""Les lignes consécutives forment un bloc ; un trou en ouvre un autre."""
blocs, courant = [], []
for numero, texte in lignes_commentees:
if courant and numero == courant[-1][0] + 1:
courant.append((numero, texte))
else:
if courant:
blocs.append(_bloc(courant))
courant = [(numero, texte)]
if courant:
blocs.append(_bloc(courant))
return blocs
def blocs_python(source):
"""Les commentaires et docstrings d'un source Python.
Une docstring garde ses lignes PHYSIQUES, relues dans le source : le
littéral de l'AST a perdu son indentation et ses numéros.
"""
lignes_source = source.split("\n")
commentaires = []
lisible = True
try:
for jeton in tokenize.generate_tokens(io.StringIO(source).readline):
if jeton.type == tokenize.COMMENT:
commentaires.append(
(jeton.start[0], jeton.string.lstrip("#").strip())
)
except (tokenize.TokenError, IndentationError, SyntaxError):
# Un source que Python refuse reste du texte : le balayage ligne à
# ligne voit encore les « # », là où rendre un rapport vide dirait
# « propre » d'un fichier qu'on n'a pas lu.
lisible = False
trouves = _regroupe(commentaires) if lisible else blocs_shell(source)
try:
arbre = ast.parse(source)
except SyntaxError:
return sorted(trouves, key=lambda b: b["line"])
portees = [arbre] + [
noeud
for noeud in ast.walk(arbre)
if isinstance(
noeud, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
)
]
for noeud in portees:
corps = getattr(noeud, "body", None)
if not corps or not isinstance(corps[0], ast.Expr):
continue
valeur = corps[0].value
if not (
isinstance(valeur, ast.Constant) and isinstance(valeur.value, str)
):
continue
debut = corps[0].lineno
fin = getattr(corps[0], "end_lineno", debut) or debut
sous_lignes = [
(numero, lignes_source[numero - 1].strip())
for numero in range(debut, min(fin, len(lignes_source)) + 1)
]
if sous_lignes:
trouves.append(_bloc(sous_lignes))
return sorted(trouves, key=lambda b: b["line"])
def commentaire_shell(ligne):
"""Ce que dit le commentaire de cette ligne shell, ou None.
Le « # » qui ouvre un commentaire est celui qui n'est ni protégé par des
guillemets, ni échappé, ni collé à un mot — `${VAR#préfixe}` et une URL
`…/#ancre` n'ouvrent rien. Le shebang n'est pas un commentaire.
"""
if ligne.lstrip().startswith("#!"):
return None
quote = None
precedent = ""
for index, caractere in enumerate(ligne):
if precedent == "\\":
precedent = ""
continue
if quote:
if caractere == quote:
quote = None
elif caractere in "\"'":
quote = caractere
elif caractere == "#" and (index == 0 or ligne[index - 1].isspace()):
return ligne[index:].lstrip("#").strip()
precedent = caractere
return None
def blocs_shell(source):
"""Les commentaires d'un script shell, les consécutifs regroupés."""
commentaires = []
for numero, ligne in enumerate(source.split("\n"), start=1):
texte = commentaire_shell(ligne)
if texte is not None:
commentaires.append((numero, texte))
return _regroupe(commentaires)
def blocs(chemin, source):
"""Les commentaires d'un fichier, selon son suffixe."""
if chemin.endswith(".py"):
return blocs_python(source)
return blocs_shell(source)
def recits(texte):
"""Les marqueurs de récit d'un texte : (motif, extrait, position).
Toutes les occurrences, et non la première : un bloc de vingt lignes en
porte souvent plusieurs, et n'en montrer qu'une cache le reste du travail.
"""
trouves = []
for nom, motif in MOTIFS_RECIT:
for trouve in motif.finditer(texte):
trouves.append((nom, trouve.group(0).strip(), trouve.start()))
return sorted(trouves, key=lambda t: t[2])
def ligne_a(bloc, position):
"""La ligne physique qui porte cette position du texte recollé."""
numero = bloc["line"]
for debut, ligne in bloc["offsets"]:
if debut > 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())

View file

@ -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 l'enquête plutôt que le fonctionnement » — est un jugement, et aucun hook ne
le rendra. le rendra.
Compté en CARACTÈRES et non en octets : « préchauffer » pèse 12 caractères et Compté en CARACTÈRES et non en octets : « préchauffer » pèse 11 caractères et
14 octets, et une limite en octets refuserait des sujets français conformes. 12 octets, et une limite en octets refuserait des sujets français conformes.
""" """
import re import re
import sys import sys
@ -199,9 +199,9 @@ def _check_body(sans_trailers: str, avec_trailers: str) -> list:
noms = sorted(set(par_motif.get("nom privé", []))) noms = sorted(set(par_motif.get("nom privé", [])))
if noms: if noms:
problems.append( 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" " Généralisez — « sur une base de production » — ou retirez la\n"
" phrase. La liste est dans private/noms_interdits.txt." " phrase."
) )
return problems return problems

View file

@ -78,7 +78,7 @@ def main():
whitelist = [] whitelist = []
if config.from_backup_path or config.from_backup_name: if config.from_backup_path or config.from_backup_name:
# script/database/get_repo_from_backup.py # 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: if config.from_backup_path:
cmd = f"./script/database/get_repo_from_backup.py --backup_path {config.from_backup_path}" cmd = f"./script/database/get_repo_from_backup.py --backup_path {config.from_backup_path}"
else: else:

80
script/git/hooks/pre-commit Executable file
View file

@ -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())

View file

@ -5,7 +5,7 @@
La règle est dans `.claude/rules/04-code-conventions.md` : ce qui est versionné 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 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 `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. partagent CE module. Deux prédicats séparés pour la même question dérivent.

View file

@ -2761,6 +2761,152 @@ class TODO(
print("-" * 50) print("-" * 50)
print(f"{t('Total:')}" f" {len(files)}") 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( def _setup_claude_command(
self, command_name, template_filename, personalize=False 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. de modèles et de colonnes, et lesquels sont traduits ou uniques.
La confirmation redemande le NOM de la base. Une frappe sur « o » 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. oblige à regarder ce qu'on détruit.
""" """
from script.analyse import monitoring from script.analyse import monitoring

View file

@ -6856,6 +6856,75 @@ TRANSLATIONS = {
"fr": "l'outil lui-même a échoué", "fr": "l'outil lui-même a échoué",
"en": "the tool itself failed", "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": { "unknown result": {
"fr": "résultat inconnu", "fr": "résultat inconnu",
"en": "unknown result", "en": "unknown result",

View file

@ -1754,7 +1754,7 @@ class TodoUpgrade:
# TODO exécuter next line si status != 0 et log contient # TODO exécuter next line si status != 0 et log contient
# psycopg2.errors.UndefinedTable: relation "discuss_channel" does not exist # psycopg2.errors.UndefinedTable: relation "discuss_channel" does not exist
# LIGNE 1 : SELECT "discuss_channel"."id" FROM "discuss_channel" WHERE (... # 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" # 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". # 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 # ./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

View file

@ -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/<utilisateur>/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)

View file

@ -26,7 +26,7 @@ class TestWhatANameMayBe(unittest.TestCase):
"""Le nom entre dans du SQL par `database_identifier` : il se filtre.""" """Le nom entre dans du SQL par `database_identifier` : il se filtre."""
def test_ordinary_names_pass(self): 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) self.assertTrue(dup.nom_valide(nom), nom)
def test_a_name_that_could_carry_sql_is_refused(self): def test_a_name_that_could_carry_sql_is_refused(self):

View file

@ -164,9 +164,9 @@ class TestLeCorps(unittest.TestCase):
self.assertEqual([], check(_message(corps))) self.assertEqual([], check(_message(corps)))
def test_une_adresse_ip(self): 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.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): def test_les_adresses_sans_porteur_passent(self):
"""0.0.0.0 et 127.0.0.1 ne désignent aucune machine du parc.""" """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): def test_un_chemin_de_compte(self):
problemes = check( 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.assertEqual(1, len(problemes))
self.assertIn("chemin de compte", problemes[0]) self.assertIn("chemin de compte", problemes[0])
@ -221,7 +221,7 @@ class TestLeCorps(unittest.TestCase):
try: try:
problemes = check(_message("Migration de AcmeCorp, six paliers.")) problemes = check(_message("Migration de AcmeCorp, six paliers."))
self.assertEqual(1, len(problemes)) self.assertEqual(1, len(problemes))
self.assertIn("liste privée", problemes[0]) self.assertIn("nom refusé", problemes[0])
finally: finally:
os.unlink(commit_msg_lib.NOMS_INTERDITS) os.unlink(commit_msg_lib.NOMS_INTERDITS)
commit_msg_lib.NOMS_INTERDITS = origine commit_msg_lib.NOMS_INTERDITS = origine
@ -264,7 +264,7 @@ class TestLeCorps(unittest.TestCase):
try: try:
problemes = check(_message("Une raison.\n\nRefs: acmecorp-42")) problemes = check(_message("Une raison.\n\nRefs: acmecorp-42"))
self.assertEqual(1, len(problemes)) self.assertEqual(1, len(problemes))
self.assertIn("liste privée", problemes[0]) self.assertIn("nom refusé", problemes[0])
finally: finally:
os.unlink(commit_msg_lib.NOMS_INTERDITS) os.unlink(commit_msg_lib.NOMS_INTERDITS)
commit_msg_lib.NOMS_INTERDITS = origine commit_msg_lib.NOMS_INTERDITS = origine

View file

@ -667,7 +667,7 @@ class TestTheVerdictsSection(unittest.TestCase):
) )
def test_a_plain_name_is_its_own_lineage(self): 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): def test_another_migration_verdicts_are_not_shown(self):
# Deux migrations partagent le fichier. Attribuer l'échec de # Deux migrations partagent le fichier. Attribuer l'échec de

View file

@ -257,7 +257,7 @@ class TestTheDatabaseNameTheFileSuggests(unittest.TestCase):
return database_name_from_file(chemin, **kw) return database_name_from_file(chemin, **kw)
def test_the_zip_extension_goes_away(self): 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): def test_an_uppercase_extension_goes_away_too(self):
self.assertEqual("client", self.nom("image_db/CLIENT.ZIP")) self.assertEqual("client", self.nom("image_db/CLIENT.ZIP"))

View file

@ -41,18 +41,18 @@ from script.todo.todo_i18n import t # noqa: E402
CONFIG = """Host * CONFIG = """Host *
ServerAliveInterval 60 ServerAliveInterval 60
Host novipro_private Host pro_private
HostName 192.168.100.110 HostName 192.168.100.110
User mathben User admin
Host novipro_private+ERPLibre01 Host pro_private+ERPLibre01
HostName 192.168.122.50 HostName 192.168.122.50
User mathben User admin
StrictHostKeyChecking no StrictHostKeyChecking no
UserKnownHostsFile /dev/null UserKnownHostsFile /dev/null
IdentityFile /home/erplibre/.ssh/id_ed25519 IdentityFile /home/erplibre/.ssh/id_ed25519
IdentitiesOnly yes IdentitiesOnly yes
ProxyJump novipro_private ProxyJump pro_private
Host erplibre-ubuntu-2604 erplibre-2604-bis Host erplibre-ubuntu-2604 erplibre-2604-bis
HostName 192.168.123.165 HostName 192.168.123.165
@ -63,11 +63,11 @@ Host web-?
""" """
# Sortie de « ssh -G » pour l'alias à « + », réduite à ce qui compte. # 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 hostname 192.168.122.50
user mathben user admin
port 22 port 22
proxyjump novipro_private proxyjump pro_private
identityfile /home/erplibre/.ssh/id_ed25519 identityfile /home/erplibre/.ssh/id_ed25519
identityfile ~/.ssh/id_rsa identityfile ~/.ssh/id_rsa
identitiesonly yes identitiesonly yes
@ -93,8 +93,8 @@ class TestLectureConfig(unittest.TestCase):
def test_it_reads_hosts_in_file_order(self): def test_it_reads_hosts_in_file_order(self):
hosts = TODO._ssh_config_entries(self.chemin) hosts = TODO._ssh_config_entries(self.chemin)
noms = [n for n, _i in hosts] noms = [n for n, _i in hosts]
self.assertEqual("novipro_private", noms[0]) self.assertEqual("pro_private", noms[0])
self.assertIn("novipro_private+ERPLibre01", noms) self.assertIn("pro_private+ERPLibre01", noms)
def test_a_host_line_with_two_patterns_gives_two_aliases(self): 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)} »). """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): def test_the_plus_alias_stays_one_name(self):
noms = [n for n, _i in TODO._ssh_config_entries(self.chemin)] noms = [n for n, _i in TODO._ssh_config_entries(self.chemin)]
self.assertNotIn("novipro_private", noms[1:2] and []) self.assertNotIn("pro_private", noms[1:2] and [])
self.assertIn("novipro_private+ERPLibre01", noms) self.assertIn("pro_private+ERPLibre01", noms)
def test_hostname_and_user_are_kept(self): def test_hostname_and_user_are_kept(self):
hosts = dict(TODO._ssh_config_entries(self.chemin)) 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("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): def test_a_missing_file_is_not_a_crash(self):
self.assertEqual([], TODO._ssh_config_entries("/nexistepas/config")) self.assertEqual([], TODO._ssh_config_entries("/nexistepas/config"))
@ -138,9 +138,9 @@ class TestResolution(unittest.TestCase):
"subprocess.run", "subprocess.run",
return_value=subprocess.CompletedProcess([], 0, SSH_G, ""), 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("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): def test_the_first_identityfile_wins(self):
"""ssh -G les répète toutes ; la première est celle qu'il essaiera.""" """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): def _resolue(self):
return { return {
"hostname": "192.168.122.50", "hostname": "192.168.122.50",
"user": "mathben", "user": "admin",
"port": "22", "port": "22",
"proxyjump": "novipro_private", "proxyjump": "pro_private",
"identityfile": "/home/erplibre/.ssh/id_ed25519", "identityfile": "/home/erplibre/.ssh/id_ed25519",
"identitiesonly": "yes", "identitiesonly": "yes",
"stricthostkeychecking": "false", "stricthostkeychecking": "false",
@ -188,10 +188,10 @@ class TestCommandeSshfs(unittest.TestCase):
def test_a_plus_alias_becomes_a_resolved_target(self): def test_a_plus_alias_becomes_a_resolved_target(self):
cmd, contourne = self.todo._sshfs_command( cmd, contourne = self.todo._sshfs_command(
"novipro_private+ERPLibre01", "/tmp/mnt", self._resolue() "pro_private+ERPLibre01", "/tmp/mnt", self._resolue()
) )
self.assertTrue(contourne) self.assertTrue(contourne)
self.assertIn("mathben@192.168.122.50:/", cmd) self.assertIn("admin@192.168.122.50:/", cmd)
self.assertNotIn("+", cmd) self.assertNotIn("+", cmd)
def test_the_options_that_matter_travel_with_it(self): 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.""" 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()) cmd, _ = self.todo._sshfs_command("a+b", "/tmp/mnt", self._resolue())
for attendu in ( for attendu in (
"-o ProxyJump=novipro_private", "-o ProxyJump=pro_private",
"-o Port=22", "-o Port=22",
"-o IdentityFile=/home/erplibre/.ssh/id_ed25519", "-o IdentityFile=/home/erplibre/.ssh/id_ed25519",
"-o IdentitiesOnly=yes", "-o IdentitiesOnly=yes",
@ -250,7 +250,7 @@ class TestDiagnostic(unittest.TestCase):
"nothing listening on the SSH port", "nothing listening on the SSH port",
), ),
( (
"mathben@x: Permission denied (publickey).", "admin@x: Permission denied (publickey).",
"authentication refused: check User and key", "authentication refused: check User and key",
), ),
( (
@ -285,8 +285,8 @@ class TestFlux(unittest.TestCase):
todo._ssh_probe = lambda alias, timeout=8: probe todo._ssh_probe = lambda alias, timeout=8: probe
todo._ssh_resolve = lambda alias: { todo._ssh_resolve = lambda alias: {
"hostname": "192.168.122.50", "hostname": "192.168.122.50",
"user": "mathben", "user": "admin",
"proxyjump": "novipro_private", "proxyjump": "pro_private",
} }
return todo return todo
@ -377,11 +377,11 @@ class TestFlux(unittest.TestCase):
def test_the_plus_alias_is_bypassed_before_being_run(self): def test_the_plus_alias_is_bypassed_before_being_run(self):
"""Le vrai correctif : la commande lancée ne contient plus le « + ».""" """Le vrai correctif : la commande lancée ne contient plus le « + »."""
todo = self._todo(0) 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") _s, _c, _r = self._joue(todo, CONFIG, "2")
lancee = todo.lances[0] lancee = todo.lances[0]
self.assertIn("mathben@192.168.122.50:/", lancee) self.assertIn("admin@192.168.122.50:/", lancee)
self.assertIn("-o ProxyJump=novipro_private", lancee) self.assertIn("-o ProxyJump=pro_private", lancee)
self.assertNotIn("+ERPLibre01", lancee) self.assertNotIn("+ERPLibre01", lancee)