Merge branch 'force_convention_commit'

[ADD] commentaires : la convention, ses deux garde-fous, le code nettoyé

Six commits. Le dépôt n'avait aucune règle sur le commentaire de code : le
seul modèle d'écriture disponible était celui du corps de commit, et le
gabarit y renvoyait explicitement le raisonnement qui n'y tenait pas. Le
récit s'est donc écoulé dans les sources, avec les noms qu'il portait.

L'épreuve tient en une phrase : le code est le sujet, au présent de ce qu'il
fait. Une phrase dont le sujet est un incident, une machine, une date ou une
personne part vers tasks/, qui n'est pas versionné ; le mode de défaillance
que le code empêche, lui, reste. Rien d'identifiant hors de private/, et
l'exemple qui illustre cet interdit s'invente plutôt qu'il ne s'emprunte,
un test figeant pour toujours ce qu'il contient.

Deux garde-fous, de sévérité opposée. Le hook commit-msg REFUSE la part
mécanique d'un message : longueur par langue, adresse, courriel, chemin de
compte. Le hook pre-commit INFORME — il liste les commentaires à relire dans
les fichiers indexés et sort toujours en 0, un contrôle bloquant à cette
échelle se faisant désinstaller. L'outil sépare la trouvaille du signal :
l'identifiant se retire, le récit se relit, la même phrase pouvant énoncer
un fait durable.

Un écran du menu TODO relève le contexte fourni à un assistant sur ses six
sources et repère une commande déployée qui a dérivé de son gabarit. Code et
jeux d'essai perdent au passage leurs valeurs réelles — base, adresse,
compte, hôte — au profit de valeurs inventées.

Vérifié : 65 tests neufs, dont 43 pour l'outil seul. Le prédicat qui
distingue une adresse d'une version de manifeste est partagé par les deux
garde-fous, deux prédicats pour une même question finissant par diverger.

--- EN ---

Six commits. The repository had no rule at all about code comments: the only
writing model available was the commit body, and the template explicitly sent
the reasoning that did not fit there into a code comment. The story therefore
flowed into the sources, carrying the names it named.

The test fits in one sentence: the code is the subject, in the present of
what it does. A sentence whose subject is an incident, a machine, a date or a
person goes to tasks/, which is not versioned; the failure mode the code
prevents stays. Nothing identifying outside private/, and the example
illustrating that ban is invented rather than borrowed, a test freezing
forever what it holds.

Two guard rails, of opposite severity. The commit-msg hook REFUSES what is
mechanical in a message: length per language, address, e-mail, account path.
The pre-commit hook INFORMS — it lists the comments worth re-reading in the
staged files and always exits 0, a blocking check at that scale getting
uninstalled. The tool separates the finding from the signal: identifying data
is removed, narrative is re-read, the same sentence being able to state a
durable fact.

A TODO screen reports the context given to an assistant across its six
sources and spots a deployed command that drifted from its template. Code and
fixtures lose their real values along the way — database, address, account,
host — for invented ones.

Checked: 65 new tests, 43 of them for the tool alone. The predicate telling an
address from a manifest version is shared by both guard rails, two predicates
for one question ending up diverging.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-31 00:54:23 -04:00
commit dfd6cd1fb6
27 changed files with 2212 additions and 83 deletions

View file

@ -8,6 +8,79 @@ configuration du dépôt — les lire plutôt que de supposer : `.flake8`,
Prettier (via npm) formate XML/JSON/YAML ; `.editorconfig` donne les
indentations par type de fichier.
## Commentaires
Un commentaire dit COMMENT le code fonctionne : ce que la fonction prend, ce
qu'elle rend, l'invariant qu'elle tient, ses effets de bord, la contrainte
technique qu'on ne devine pas en lisant la ligne d'à côté. Il doit se lire
dans dix ans sans rien savoir de la semaine où il a été écrit. La règle vaut
pour les docstrings autant que pour les lignes `#`.
**L'épreuve : le sujet et le temps.** Chaque phrase a le CODE pour sujet, au
présent de ce qu'il fait. Une phrase dont le sujet est un incident, une
machine, une date ou une personne est à couper, où qu'elle se trouve dans le
paragraphe. Le MODE DE DÉFAILLANCE que le code empêche est du fonctionnement
et reste — « une VM renommée se voit attribuer la passerelle ». L'INCIDENT où
on l'a observé est du récit et part — « vécu sur telle VM, annoncée à telle
adresse ».
**Les chiffres.** Une mesure qui établit un fait durable reste, dépouillée de
sa date, de son lieu et de son opérateur : une limite, un seuil, une valeur
que documente l'éditeur. Un relevé de ce qui répondait ce jour-là part.
**Rien d'identifiant, jamais** : nom d'un client ou d'une organisation tierce,
nom de base de données réelle, nom de VM ou d'hôte, adresse IP, courriel,
chemin portant un nom d'utilisateur, libellé ou chiffre tiré des données d'un
client. La seule exception est l'en-tête de copyright : le dépôt nomme son
propriétaire, pas ses clients. Généraliser plutôt que censurer — « sur une
base de production », « sur un hôte qui exige une authentification sudo
interactive » — dit la CLASSE de situation, qui est ce qui sert au lecteur.
**L'exemple qui illustre un interdit s'invente.** La règle a d'abord été
violée par ses propres tests : pour démontrer qu'une adresse et un chemin de
compte sont refusés, ils en portaient de vrais, pris dans le parc. Choisir un
cas réel « parce qu'il est parlant » est exactement le réflexe que la règle
combat, et un test le fige pour toujours. Une valeur inventée démontre aussi
bien ; vérifier qu'elle n'existe nulle part ailleurs dans le dépôt.
Le récit n'est pas perdu, il change de place : l'enquête, les mesures datées
et les impasses vivent dans `tasks/`, qui n'est pas versionné. Ni le fichier
ni le corps du commit ne les portent.
Cela vaut aussi pour l'existant, mais **au fur et à mesure** : on corrige les
commentaires du fichier qu'on touche, au moment où on le touche, et non en une
passe qui réécrirait le dépôt. Le hook `pre-commit` liste ce qui est à relire
dans les fichiers indexés, sans jamais bloquer le commit ; le même outil se
lance à la main :
```bash
python3 script/analyse/check_comment_hygiene.py script/todo/todo.py
python3 script/analyse/check_comment_hygiene.py --staged
```
🔴 `identifiant` est une trouvaille, à retirer. 🟡 `récit` est un signal à
relire : l'outil ne sait pas si la phrase énonce un fait durable ou raconte
une journée, et ne tranche pas à votre place.
Trois exemples pris dans ce dépôt, leurs noms propres masqués — une règle qui
interdit de nommer ne se cite pas elle-même en clair.
`qemu_manage.py` — la dernière phrase, « Vécu sur « <VM> », annoncée en
<adresse> au lieu de <adresse> », part en entier. Les deux qui la précèdent
disent déjà tout, une fois l'imparfait du récit passé au présent : « une VM
renommée, dont le bail porte encore l'ancien nom d'hôte, SE VOIT attribuer la
passerelle ».
`todo.py` — « recopier « <base_client>_neutralize_upgrade_18 » oblige à
regarder ce qu'on détruit » devient « recopier un nom long oblige à regarder
ce qu'on détruit, là où « o » se tape par réflexe ». L'exemple ne servait qu'à
illustrer « long ».
`qemu_install.py` — le relevé daté des miroirs, qui répondait et qui non tel
jour, part : c'est l'état d'une journée. « Aucun miroir ne réplique tout,
d'où plusieurs entrées plutôt qu'une » reste : c'est la raison d'être de la
liste, et elle est vraie demain.
## Git
- Branches : `develop` (développement), `master` (production)
- Pas de submodules Git — utilise **Google Repo** pour les addons
@ -15,6 +88,10 @@ indentations par type de fichier.
- Format de commit : `[TYPE] portée : sujet`, sujet à l'impératif, 72
caractères au plus. Tags réellement utilisés : `[UPD]`, `[FIX]`, `[ADD]`,
`[IMP]`, `[REF]`.
- **Nommer les fichiers à l'indexation, jamais `git add -A`** : `private/` et
`tasks/` ne sont pas suivis EXPRÈS, et un ratissage les commit. Il emporte
aussi ce qui est en cours ailleurs dans le checkout, sous un sujet qui ne le
couvre pas. `git add -p` quand un fichier porte deux sujets.
### Le sujet
@ -39,8 +116,10 @@ virgules, en disent plus dans la même place — `[FIX] proxmox : pmxcfs à terr
pvesm muet, diagnostic à la source`. C'est un repli, pas un défaut : la phrase
reste préférable quand elle tient.
Un garde-fou refuse le mécanique — tag absent, plus de 72 caractères, sujet
qui s'ouvre sur une citation :
Un garde-fou refuse le mécanique. Sur le sujet : tag absent, plus de 72
caractères, ouverture sur une citation. Sur le corps : plus de 10 lignes pour
une langue, une adresse IP, un courriel, un chemin de compte. Ce qui reste un
jugement — « ce corps raconte-t-il l'enquête » — n'est vérifié par personne.
```bash
git config core.hooksPath script/git/hooks # une fois par clone
@ -60,9 +139,16 @@ Trois exigences, sans exception — `AI_POLICY.md` en donne la raison :
- Corps **bilingue** : le corps, puis `--- FR ---` (ou `--- EN ---`, le
marqueur nomme la langue de ce qui SUIT), puis la traduction.
Court et direct : **10 lignes par langue**, 15 est déjà long. Le corps dit
Court et direct : **8 lignes par langue**, 10 est un plafond. Le corps dit
pourquoi c'était nécessaire, puis s'arrête. Rien de ce que le diff montre
déjà ; on garde le symptôme, le chiffre mesuré et la vérification.
déjà ; on garde le mode de défaillance, le chiffre qui borne et la
vérification. Le bilinguisme achète la concision, il ne l'excuse pas.
Le corps obéit aux mêmes deux règles que les commentaires : **rien
d'identifiant**, et **le fonctionnement plutôt que l'enquête**. Le corps dit
ce que le code fait ou refuse DÉSORMAIS ; il ne raconte ni la séance, ni les
hypothèses écartées, ni qui s'est trompé. Une mesure se généralise à sa classe
de situation — « sur une base de production », jamais son nom.
Le mode d'emploi complet — résolution dynamique du modèle, gabarit, identité
git, taille des correctifs — est dans

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
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

@ -45,5 +45,8 @@
2. **Verify Plan**: Check in before starting implementation.
3. **Track Progress**: Mark items complete as you go.
4. **Explain Changes**: High-level summary at each step.
5. **Document Results**: Add review section to `tasks/todo.md`
5. **Document Results**: Add review section to `tasks/todo.md`. L'enquête,
les mesures datées, les impasses et les traces d'exécution restent LÀ.
`tasks/` n'est pas versionné : il porte ce que ni le code ni le commit ne
doivent porter. Ne les fais pas remonter.
6. **Capture Lessons**: Update `tasks/lessons.md` after corrections

View file

@ -41,6 +41,14 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Ajouté
<!-- [en] -->
- A `pre-commit` hook lists the comments worth re-reading in the files being staged, and never blocks: over the repository's own sources, 373 files yield 463 signals, and a blocking check at that scale gets uninstalled the following week. The tool behind it, `script/analyse/check_comment_hygiene.py`, reports two families of unequal certainty — identifying data, an address, an e-mail or an account path, which is a finding; and narrative, a witness marker, an absolute date or the first person, which is a signal to RE-READ, since it cannot know whether the sentence states a durable fact. It reads comments and docstrings, `#` lines and shell trailing comments alike, skips vendored code, and falls back on a line scan when a source will not parse, an empty report otherwise declaring clean a file it never read. Exit codes follow the repository convention: 0 nothing to report, 1 findings, 2 the tool failed
- The TODO menu shows the context an assistant is given, scattered as it is over six sources: instructions, rules, skills, deployed commands, git hooks and memory. Each deployed command is compared against its repository template, ignoring the git identity lines the deployment substitutes, so a stale copy shows up where a strict equality would declare them all stale. Nothing from `private/` is reported, not even a count: naming a file whose purpose is to hold what must not go out amounts to pointing at it
- Duplicating a database and neutralising it for good: duplication goes through Odoo's `exp_duplicate_database` rather than `CREATE DATABASE … TEMPLATE`, which copies the tables and nothing else. Only Odoo drops the source's open connections — one Odoo shell left open is enough for PostgreSQL to refuse — regenerates `database.uuid`, copies the filestore, and runs the `neutralize.sql` files of the installed modules. The repository's in-house modules obtained none of the four, and one of them opened a door: deleting every `ir.mail_server` makes Odoo fall back on the configuration file's `smtp_server`, where Odoo's own placeholder server exists precisely to plug that hole. Odoo's neutralisation begins at 16; from 12 to 15 the copy falls back on the repository's long-standing `update_prod_to_dev.sh`, which sets no `is_neutralized`, disables no cron and leaves the payment keys, but does remove the mail servers and lay down a development account. The route taken is ANNOUNCED at run time, a copy whose route is unknown being unjudgeable, and a missing script fails the copy rather than letting it come out raw while announcing itself neutralised
- The migration quality screen says whether a run succeeded, where it only compared the tiers: every verdict shows, 12 through 18 — the only way to see that a failure at one tier was recovered higher up — read from the progression file and tied to the ODOO tier, not to the driver's counter, which is off by one. It names where the traces live, config.conf leaving logfile= empty and Odoo's output dying with the terminal; it shows the step-log passage around each command, keeps by tee what it launches itself and re-reads that without rerunning; and it runs six review steps from « r », asking before it switches the checkout, since replaying a test from another tier opened the database with the wrong version, which writes before it fails. A status is read the way the tools write it — 0 nothing to report, 1 findings, 2 the tool failed — so nothing is painted red where nothing failed. Output is captured through a pseudo-terminal, never a pipe: smoke_public_url requires stdin AND stdout to be terminals and, behind a pipe, silently stops offering the COW view repair; nine runs go through it, two stay out, pty.spawn being born 0×0 where a full-screen tool would lay out on nothing
- Three checks join the migration review, each for what a tier destroys without reporting a single failure. The manifest holes: an addons repository absent from one tier's manifest does not exist on disk during that step, so Odoo declares its modules missing, the driver offers to delete them, and the feature leaves with them — only the hole counts, present before and after, absent in between, which turns 35 candidates into 19 real omissions instead of 46% noise. The tree view type, removed in Odoo 18 with no shim, is read in the SOURCES, where every other review tool reads the database and a module that never installed leaves nothing; the word is still a valid identifier, so of 465 occurrences only 80 break, and lxml and ast decide by the literal's position, never a regex. The chart drift: a bump reloads the localisation template in silence — three of the core l10n scripts call try_loading() without force_create=False, so every account the template cannot match by code is CREATED, and the groups thus added reclassify the existing chart. An absolute count says nothing there, only the GAP between two tiers of one migration can be judged, and the tool prescribes replaying the tier rather than repairing: its natural deletion key also catches accounts re-matched by code
- run.sh warms the registry over HTTP while the server starts. Odoo loads a database's registry only on the FIRST request that concerns it, and on a migrated database whoever opens the page waits tens of seconds; the probe takes that time instead and stops at the first answer, a 303, a 404 or a 500 all proving the registry is loaded. It cannot get in the way: it ALWAYS returns 0, dies with run.sh through a trap, gives up after two minutes, and takes the port from the command line, then config.conf, then the log, exact even when the requested port was taken — an empty or wildcard listen interface is probed on the loopback. `--erplibre-disable-warmup-http` turns it off and is the only flag STRIPPED before odoo_bin.sh, which Odoo rejects; `--no-http` and `--stop-after-init` turn it off too and go through, there being nothing to wake when no one listens
- A writing convention for what stays in git. A commit subject read alone, with no diff and no body, must say which part of the system changes and what is now different there — the symptom, the quoted error and the metaphor are EVIDENCE, and evidence belongs in the body. A comment has the CODE for subject, in the present of what it does, so a sentence whose subject is an incident, a machine, a date or a person goes to the unversioned `tasks/`, while the failure mode the code prevents stays. Nothing identifying outside `private/` — no customer, real database, host, address, e-mail or account path — and an example illustrating that ban is invented rather than borrowed, a test freezing forever what it holds. A measurement that establishes a durable fact stays, stripped of its date and its operator; the reading of what answered that day goes
- A `commit-msg` hook refuses the mechanical part of that writing convention and nothing more: no tag, a subject over 72 characters, a subject opening on a quotation, a body over 10 lines for one language, an IP address, an e-mail, an account path, or a term from a forbidden-names list that lives outside git since the list is what it protects — absent, that last check stays silent. It counts characters, not bytes, or a 72-character French subject would fall on its accents; it excludes the trailers and the `--cleanup=scissors` diff; and its refusal names `git commit --no-verify`, a guard rail that refuses too much getting uninstalled. Whether the subject says what the code is about stays a judgement no hook makes. The predicate telling a fleet address from an Odoo manifest version, a loopback, a mask, a network address or an RFC 5737 documentation block is shared with the comment check, two predicates for one question drifting apart. Its 40 tests weigh the acceptances — a « Merge branch », a rebase fixup — as much as the refusals
- `long_test/` — tests that create real machines and take hours, kept out of `test/` so the unit runner stays runnable in seconds. `deep_proxmox.py` stacks Proxmox in Proxmox, `deep_qemu.py` stacks QEMU in QEMU, and they share one engine. Measured on 28 cores: three levels cost 34 minutes, the fourth 4 h 20 of boot plus 7 h 18 of install — everything there is 15 to 30 times slower, and that is where the vendors stop documenting nesting. The depth is a parameter and defaults to three, because three works
- deep_qemu proves KVM at every level instead of assuming it: `deploy_qemu.py` never passes `--cpu host-passthrough` and, when /dev/kvm is missing, it does not fail — it sets `--virt-type qemu` and creates a fully EMULATED VM, seven and a half minutes to boot, with no exit code to say so. Unguarded, the descent would measure stacked TCG while believing it measured nesting. Each level must show `/dev/kvm`, `nested=Y` and a child domain in `type='kvm'`; what was not read counts as NO
- Both long tests take `--hote` to start from a machine you already own, rather than creating a head VM to host a hypervisor you have on hand — that costs five minutes AND one level of nesting. The plan is then sized on the ROOT, read over ssh; the delays count ABSOLUTE depth; and the root is never a level reached, never destroyed, and its ~/.ssh/config entry is never removed
@ -125,6 +133,14 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
<!-- [fr] -->
- Un hook `pre-commit` liste les commentaires à relire dans les fichiers qu'on indexe, et ne bloque jamais : sur les sources du dépôt, 373 fichiers rendent 463 signaux, et un contrôle bloquant à cette échelle se fait désinstaller la semaine suivante. L'outil qui le sert, `script/analyse/check_comment_hygiene.py`, rapporte deux familles de sûreté inégale — la donnée identifiante, adresse, courriel ou chemin de compte, qui est une trouvaille ; et le récit, marqueur de témoignage, date absolue ou première personne, qui est un signal à RELIRE, l'outil ne pouvant savoir si la phrase énonce un fait durable. Il lit les commentaires et les docstrings, les lignes `#` comme les commentaires shell de fin de ligne, écarte le code tiers, et se replie sur un balayage ligne à ligne quand un source ne se parse pas, un rapport vide déclarant sinon propre un fichier qu'il n'a jamais lu. Les codes de sortie suivent la convention du dépôt : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué
- Le menu TODO montre le contexte fourni à un assistant, éparpillé sur six sources : instructions, règles, skills, commandes déployées, hooks git et mémoire. Chaque commande déployée est comparée à son gabarit du dépôt en ignorant les lignes d'identité git que le déploiement substitue, si bien qu'une copie périmée se voit là où une égalité stricte les déclarerait toutes périmées. Rien de `private/` n'est relevé, pas même un compte : nommer un fichier dont l'objet est de retenir ce qui ne doit pas sortir revient à le désigner
- Dupliquer une base et la neutraliser pour de bon : la duplication passe par `exp_duplicate_database` d'Odoo plutôt que par `CREATE DATABASE … TEMPLATE`, qui copie les tables et rien d'autre. Odoo seul coupe les connexions ouvertes sur la source — un shell Odoo laissé ouvert suffit à faire refuser PostgreSQL —, régénère `database.uuid`, copie le filestore et exécute les fichiers `neutralize.sql` des modules installés. Les modules maison du dépôt n'obtenaient aucun des quatre, et l'un d'eux ouvrait une porte : supprimer tous les `ir.mail_server` fait retomber Odoo sur le `smtp_server` du fichier de configuration, là où le serveur bouchon d'Odoo existe précisément pour boucher ce trou. La neutralisation d'Odoo commence à la 16 ; de 12 à 15 la copie retombe sur la technique de longue date du dépôt, `update_prod_to_dev.sh`, qui ne pose pas `is_neutralized`, ne désactive aucun cron et laisse les clés de paiement, mais supprime les serveurs de courriel et pose un compte de développement. Le chemin suivi est ANNONCÉ à l'exécution, une copie dont on ignore par quel chemin elle est passée ne se jugeant pas, et un script introuvable fait échouer la copie plutôt que de la laisser sortir brute en s'annonçant neutralisée
- L'écran de qualité de migration dit si une migration a réussi, là où il comparait seulement les paliers : tous les verdicts s'affichent, de la 12 à la 18 — seule façon de voir qu'un échec a été rattrapé à un palier plus haut — lus dans le journal de progression et rattachés au palier ODOO, non au compteur du pilote, décalé d'un rang. Il dit où vivent les traces, config.conf laissant logfile= vide et la sortie d'Odoo mourant avec le terminal ; il montre le passage du journal d'étape qui entoure chaque commande, garde par tee ce qu'il lance lui-même et le relit sans relancer ; et il lance six étapes de revue par « r », en demandant avant de basculer le checkout, car rejouer un test d'un autre palier ouvrait la base avec la mauvaise version, qui y écrit avant d'échouer. Un statut se lit comme les outils l'écrivent — 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué — si bien que plus rien n'est peint en rouge là où rien n'a échoué. La sortie est capturée par pseudo-terminal, jamais par un tube : smoke_public_url exige stdin ET stdout sur un terminal et, derrière un tube, cesse en silence d'offrir la réparation des vues COW ; neuf exécutions y passent, deux restent dehors, pty.spawn naissant en 0×0 où un plein écran se perdrait
- Trois contrôles rejoignent la revue de migration, chacun pour ce qu'un palier détruit sans signaler le moindre échec. Les trous de manifeste : un dépôt d'addons absent du manifeste d'un palier n'existe pas sur disque pendant cette étape, Odoo déclare donc ses modules introuvables, le pilote propose de les effacer, et la fonctionnalité part avec eux — seul le trou compte, présent avant et après, absent au milieu, ce qui ramène 35 candidats à 19 vraies omissions au lieu de 46 % de bruit. Le type de vue tree, supprimé en Odoo 18 sans conversion, se lit dans les SOURCES, là où tous les autres outils de la revue lisent la base et où un module jamais installé ne laisse rien ; le mot reste un identifiant valide, si bien que sur 465 occurrences 80 seulement cassent, et lxml et ast décident par la position du littéral, jamais une regex. La dérive du plan comptable : une montée de version recharge le gabarit de localisation en silence — trois des scripts l10n du noyau appellent try_loading() sans force_create=False, donc tout compte que le gabarit n'apparie pas par code est CRÉÉ, et les groupes ainsi ajoutés reclassent le plan existant. Un compte absolu n'y dit rien, seul l'ÉCART entre deux paliers d'une même migration se juge, et l'outil prescrit de rejouer le palier plutôt que de réparer : sa clé naturelle de suppression attrape aussi des comptes réappariés par code
- run.sh réveille le registre par HTTP pendant que le serveur démarre. Odoo ne charge le registre d'une base qu'à la PREMIÈRE requête qui la concerne, et sur une base migrée la personne qui ouvre la page attend des dizaines de secondes ; la sonde prend ce temps à sa place et s'arrête à la première réponse, un 303, un 404 ou un 500 prouvant tous que le registre est chargé. Elle ne peut pas nuire : elle rend TOUJOURS 0, meurt avec run.sh par un trap, abandonne après deux minutes, et prend le port sur la ligne de commande, puis dans config.conf, puis dans le journal, exact même quand le port demandé était pris — l'écoute sur une interface vide ou générale est sondée par le bouclage. `--erplibre-disable-warmup-http` la coupe et est le seul drapeau RETIRÉ avant odoo_bin.sh, qu'Odoo refuse ; `--no-http` et `--stop-after-init` la coupent aussi et passent, puisqu'il n'y a rien à réveiller quand personne n'écoute
- Une convention d'écriture pour ce qui reste dans git. Un sujet de commit lu seul, sans diff ni corps, doit dire quelle partie du système change et ce qui y est désormais différent — le symptôme, l'erreur citée et la métaphore sont des PREUVES, et une preuve va dans le corps. Un commentaire a le CODE pour sujet, au présent de ce qu'il fait : une phrase dont le sujet est un incident, une machine, une date ou une personne part vers `tasks/`, non versionné, tandis que le mode de défaillance que le code empêche reste. Rien d'identifiant hors de `private/` — ni client, ni base réelle, ni hôte, ni adresse, ni courriel, ni chemin de compte — et l'exemple qui illustre cet interdit s'invente au lieu de s'emprunter, un test figeant pour toujours ce qu'il contient. Une mesure qui établit un fait durable reste, dépouillée de sa date et de son opérateur ; le relevé de ce qui répondait ce jour-là part
- Un hook `commit-msg` refuse la part mécanique de cette convention d'écriture, et rien de plus : tag absent, sujet de plus de 72 caractères, sujet ouvrant sur une citation, corps de plus de 10 lignes pour une langue, adresse IP, courriel, chemin de compte, terme d'une liste de noms interdits qui vit hors de git puisque c'est elle qu'elle protège — absente, ce dernier contrôle se tait. Il compte des caractères et non des octets, sans quoi un sujet français de 72 caractères tomberait sur ses accents ; il exclut les trailers et le diff de `--cleanup=scissors` ; et son refus nomme `git commit --no-verify`, un garde-fou qui refuse trop étant désinstallé. Dire sur quoi porte le code reste un jugement qu'aucun hook ne rend. Le prédicat qui distingue une adresse du parc d'une version de manifeste Odoo, d'une boucle locale, d'un masque, d'une adresse de réseau ou d'un bloc de documentation RFC 5737 est partagé avec le contrôle des commentaires, deux prédicats pour une même question finissant par diverger. Ses 40 tests pèsent les acceptations — un « Merge branch », un fixup de rebase — autant que les refus
- `long_test/` — des tests qui créent de vraies machines et durent des heures, tenus hors de `test/` pour que le lanceur unitaire reste lançable en quelques secondes. `deep_proxmox.py` empile des Proxmox dans des Proxmox, `deep_qemu.py` des QEMU dans des QEMU, et les deux partagent un moteur. Mesuré sur 28 cœurs : trois étages coûtent 34 minutes, le quatrième 4 h 20 d'amorçage plus 7 h 18 d'installation — tout y est 15 à 30 fois plus lent, et c'est là que les fabricants cessent de documenter l'imbrication. La profondeur est un paramètre et vaut trois par défaut, parce que trois marche
- deep_qemu PROUVE KVM à chaque étage au lieu de le supposer : `deploy_qemu.py` ne passe jamais `--cpu host-passthrough` et, quand /dev/kvm manque, il n'échoue pas — il pose `--virt-type qemu` et crée une VM entièrement ÉMULÉE, sept minutes et demie de démarrage, sans qu'aucun code de retour ne le dise. Sans garde, la descente mesurerait de la TCG empilée en croyant mesurer de l'imbrication. Chaque étage doit montrer `/dev/kvm`, `nested=Y` et un domaine enfant en `type='kvm'` ; ce qui n'a pas été lu vaut NON
- Les deux tests longs acceptent `--hote` pour partir d'une machine qu'on possède déjà, au lieu de créer une VM de tête pour héberger un hyperviseur qu'on a sous la main — cela coûte cinq minutes ET un étage d'imbrication. Le plan se dimensionne alors sur la RACINE, lue par ssh ; les délais comptent la profondeur ABSOLUE ; et la racine n'est jamais un étage atteint, jamais détruite, et son entrée ~/.ssh/config n'est jamais retirée
@ -215,12 +231,16 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Modifié
<!-- [en] -->
- Staging names the files, never `git add -A`. The sweep stages everything untracked, and this repository keeps two directories untracked ON PURPOSE: `private/`, the only place allowed to hold customer data, and `tasks/`, where the convention sends the investigation precisely because it is not versioned. It also swallows whatever else is in flight in the checkout, under a subject that does not cover it; `git add -p` stages the hunks when one file carries two subjects
- A countdown prompt gives 15 seconds to decide, where five were not enough to READ the question: the countdown exists so a run can be left unattended, not to go fast, and too short it does the opposite — the answer comes by reflex, or a default no one read is taken. A restored database is named after the backup file, which already carries a telling name, rather than « test », under which successive migrations all landed on one name. The name is sanitised, since it ends up in a createdb, and capped at 41 characters: the driver appends « _neutralize_upgrade_18 » and PostgreSQL truncates at 63, which would put two tiers on the same name. A remote download keeps the name the server gave
- todo.py split into nine files, one per subject, with a shared base per form. It carried 9 500 lines more than a file should and every subject went through it; the deployment forms repeated the same field-and-validation machinery, so a fix in one never reached the others. No behaviour changes
- Branch, profile and type are chosen per VM. They were global, which meant switching everything to deploy a single machine differently
- One shared base describes the guest system, where each form used to describe it again
<!-- [fr] -->
- L'indexation nomme les fichiers, jamais `git add -A`. Le ratissage indexe tout ce qui n'est pas suivi, et le dépôt garde deux répertoires non suivis EXPRÈS : `private/`, seul endroit autorisé à porter une donnée de client, et `tasks/`, où la convention envoie l'enquête précisément parce qu'il n'est pas versionné. Il emporte aussi ce qui est en cours ailleurs dans le checkout, sous un sujet qui ne le couvre pas ; `git add -p` indexe les hunks quand un fichier porte deux sujets
- Une invite à compte à rebours laisse 15 secondes pour décider, là où cinq ne suffisaient pas à LIRE la question : le compte à rebours n'existe pas pour aller vite mais pour qu'une exécution puisse être laissée sans surveillance, et trop court il fait l'inverse — la réponse vient par réflexe, ou un défaut que personne n'a lu s'applique. Une base restaurée porte le nom du fichier de sauvegarde, qui en porte déjà un parlant, plutôt que « test », sous lequel des migrations successives finissaient toutes sur un même nom. Le nom est assaini, puisqu'il finit dans un createdb, et borné à 41 caractères : le pilote ajoute « _neutralize_upgrade_18 » et PostgreSQL tronque à 63, ce qui ferait finir deux paliers sur le même nom. Un téléchargement distant garde celui que le serveur a donné
- todo.py éclaté en neuf fichiers, un par sujet, avec un socle commun par formulaire. Il portait 9 500 lignes de plus qu'un fichier ne devrait et tous les sujets y passaient ; les formulaires de déploiement répétaient la même mécanique de champs et de validation, si bien qu'une correction dans l'un ne gagnait jamais les autres. Aucun changement de comportement
- La branche, le profil et le type se choisissent par VM. Ils étaient globaux, ce qui obligeait à tout basculer pour déployer une seule machine autrement
- Un socle commun décrit le système invité, là où chaque formulaire le redécrivait
@ -231,6 +251,12 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Corrigé
<!-- [en] -->
- `odoo_bin.sh db --drop` failed with AccessDenied at every migration tier, and the clone then hit « database already exists »: db_restore.py reads the repository's config.conf, sees admin_passwd = admin and therefore sends no master password, while odoo_bin.sh passed no « -c », so Odoo read ~/.odoorc and its hashed one. ODOO_RC closes that seam in one place instead of twenty call sites, versions 12 to 18 reading it after « -c » and before ~/.odoorc, so an explicit choice still wins
- The account.root SQL view Odoo 17 creates is dropped before the load into 18, where the model carries _auto = False and _table_query = '0', so its name enters no query and the missing-table check skips it. The view is not merely dead but WRONG, built on the code column the 18 ORM no longer writes, and it is the sole pin holding the two legacy columns database_cleanup fails on — no DROP COLUMN, OpenUpgrade still reading them afterwards
- The migration repair created pricelists that should not exist, in two ways. It asked `env.user.has_group()`, which says yes as soon as the caller belongs to the group — and the migration adds it along the way — where the settings checkbox reads something else entirely: what `base.group_user` IMPLIES. Deciding on the caller built a pricelist in a database whose feature is off, and Odoo then warned on every opening of the settings that it would archive it. And a pricelist shared across companies, with an empty `company_id`, did not count as belonging to the company: the repair saw a company without a list and made an empty duplicate beside the existing one, on a database that had lost nothing. Both now read the group implication and the tool's own `pricelist_missing` detector, which already tells the two cases apart; the migration-residue check asked the same wrong question and was corrected with them
- The anonymisation blacklist handed its SQL to psql as a single argument, and Linux caps one argument at 131 072 bytes: the mode that covers the most tables was exactly the one that broke, on « OSError: [Errno 7] Argument list too long ». The SQL now travels through a file with `-f`, which keeps the `--single-transaction` that standard input would have silently dropped. Text columns with a declared length are truncated by `left(…, n)`, the id FIRST so a unique column stays unique, and every identifier is quoted — Odoo allows a field named user or order. Skipping every column under a CHECK constraint had left the partner name untouched: on text, only FORM constraints are out of reach, and an UPDATE that fails writes nothing
- Anonymisation respects what a value MEANS, where the SQL type says only what it IS. Odoo declares `parent_path` as `char` but stores an id path in it — 1/7/12/ — parsed straight back with `int()`, so a name written there breaks the first page load; every `_parent_store` model carries one, and `account_payment_term.days_next_month` goes to `int()` the same way. The two known names are skipped, then the content itself is probed: a column whose every value is a path is left alone, even in an in-house module, and the slashes are required, so a value of pure digits does not escape anonymisation by passing for an id. Numbers were drawn uniformly over 0 to 1000, which is wrong for every number bounded by its usage — hours, percentages, rates: a float holding an hour of day, drawn outside 0..23, makes displaying the record raise « ValueError: hour must be in 0..23 », a bound that lives in Odoo's Python where no PostgreSQL constraint declares it. The draw now respects the only bound the DATA declares, its own range, min and max measured per table; 0 to 1000 remains solely for a column that is empty or cannot be probed
- The unit runner took seven filename prefixes, « the rest needing a database », and ran 1131 tests where all 3703 in the directory pass with PostgreSQL unreachable; it globs test/test_*.py. Two shapes make a file silent without an error — unittest.main() placed mid-file, which exits before the second half is even defined (four files, 87 tests), and no __main__ block at all, which counts zero (eight files, 174 tests) — and a guard now refuses both, along with any return to a list of prefixes
- The mobile bundle check accepted only the pack layout, when a real build ships one tar.gz per repository. It failed on `<slug> : index.json absent` and stopped `compile_and_run.sh` before the APK — since 2026-08-20, for anyone on the current mobile main. It now accepts both layouts, and proves the presence of EVERY promised file rather than a sample of twenty: streaming all 139 archives costs 6 s, and 124 350 files are accounted for
- The bundle test guarded the ZIP entry limit by demanding a `chunk` field on every file, which is the pack layout rather than the limit itself. It now counts the entries the APK will carry — 278 against a ceiling of 65 535 — so either layout passes and a return to file-per-source still fails
@ -245,6 +271,12 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- The NAT bridge was written before knowing whether NAT exists. Six lines of iptables and "return code 1" came after the stanza had already gone into /etc/network/interfaces, and nothing in that noise said a reboot was needed: the host was running Debian's cloud kernel, stripped of netfilter. Our own install_proxmox.sh produces that state, so a freshly installed nested Proxmox is ALWAYS in it — the guard now sits where the consequence is, not at host confirmation
<!-- [fr] -->
- « odoo_bin.sh db --drop » échouait par AccessDenied à chaque palier de migration, et le clone butait ensuite sur « database already exists » : db_restore.py lit le config.conf du dépôt, y voit admin_passwd = admin et n'envoie donc aucun mot de passe maître, quand odoo_bin.sh ne passait pas de « -c », si bien qu'Odoo lisait ~/.odoorc et son mot de passe haché. ODOO_RC ferme cette couture en un point plutôt qu'à vingt sites d'appel, les versions 12 à 18 le lisant après « -c » et avant ~/.odoorc, donc un choix explicite l'emporte toujours
- La vue SQL account.root que crée Odoo 17 est retirée avant le chargement en 18, où le modèle porte _auto = False et _table_query = '0', si bien que son nom n'entre plus dans aucune requête et que le contrôle des tables manquantes l'ignore. La vue n'est pas seulement morte mais FAUSSE, bâtie sur la colonne code que l'ORM 18 n'écrit plus, et elle est l'unique épingle des deux colonnes héritées où database_cleanup échoue — aucun DROP COLUMN, OpenUpgrade les lisant encore ensuite
- La réparation de migration créait des listes de prix qui n'avaient pas lieu d'être, de deux façons. Elle interrogeait `env.user.has_group()`, qui répond oui dès que l'exécutant est membre du groupe — et la migration l'y ajoute en cours de route —, là où la case des réglages lit tout autre chose : ce que `base.group_user` IMPLIQUE. Décider sur l'exécutant créait une liste de prix dans une base dont la fonctionnalité est éteinte, et Odoo prévenait alors à chaque ouverture des réglages qu'il allait l'archiver. Et une liste de prix partagée entre sociétés, au `company_id` vide, ne comptait pas comme appartenant à la société : la réparation voyait une société sans liste et fabriquait un doublon vide à côté de l'existante, sur une base qui n'avait pourtant rien perdu. Les deux lisent désormais l'implication du groupe et le détecteur `pricelist_missing` du même outil, qui distingue déjà les deux cas ; le contrôle « restant de migration » posait la même mauvaise question et a été corrigé avec elles
- La liste noire de l'anonymisation passait son SQL à psql en UN seul argument, et Linux plafonne un argument à 131 072 octets : le mode qui couvre le plus de tables était justement celui qui cassait, sur « OSError: [Errno 7] Argument list too long ». Le SQL passe désormais par un fichier avec `-f`, qui garde le `--single-transaction` que l'entrée standard aurait perdu en silence. Les colonnes texte à longueur déclarée sont tronquées par `left(…, n)`, l'identifiant en TÊTE pour qu'une colonne unique le reste, et tous les identifiants sont cités — Odoo laisse nommer un champ user ou order. Écarter toute colonne sous contrainte CHECK laissait le nom du partenaire intact : sur du texte, seules les contraintes de FORME sont hors de portée, et un UPDATE qui échoue n'écrit rien
- L'anonymisation respecte ce qu'une valeur SIGNIFIE, là où le type SQL dit seulement ce qu'elle EST. Odoo déclare `parent_path` en `char` mais y range un chemin d'identifiants — 1/7/12/ — reparsé aussitôt par `int()` : un nom écrit là casse le premier chargement de page ; tout modèle `_parent_store` en porte un, et `account_payment_term.days_next_month` passe de même à `int()`. Les deux noms connus sont écartés, puis le contenu lui-même est sondé : une colonne dont chaque valeur est un chemin reste intacte, même dans un module maison, et les barres obliques sont exigées, si bien qu'une valeur tout en chiffres n'échappe pas à l'anonymisation en passant pour un identifiant. Les nombres étaient tirés uniformément sur 0 à 1000, ce qui est faux pour tout nombre borné par son usage — heures, pourcentages, taux : un `float` qui porte une heure de la journée, tiré hors de 0..23, fait lever « ValueError: hour must be in 0..23 » à l'affichage de la fiche, borne qui vit dans le Python d'Odoo là où aucune contrainte PostgreSQL ne la déclare. Le tirage respecte désormais la seule borne que les DONNÉES déclarent, leur propre étendue, min et max mesurés par table ; 0 à 1000 ne reste que pour une colonne vide ou impossible à sonder
- Le lanceur unitaire ne prenait que sept préfixes de noms, « le reste demandant une base de données », et exécutait 1131 tests là où les 3703 du répertoire passent avec PostgreSQL injoignable ; il balaie test/test_*.py. Deux formes rendent un fichier muet sans erreur — unittest.main() posé en plein milieu, qui sort avant même que la seconde moitié soit définie (quatre fichiers, 87 tests), et l'absence de bloc __main__, qui compte zéro (huit fichiers, 174 tests) — et une garde refuse désormais les deux, ainsi que tout retour à une liste de préfixes
- Le vérificateur du transfert mobile n'acceptait que la disposition en packs, quand une compilation réelle livre un tar.gz par dépôt. Il échouait sur `<slug> : index.json absent` et arrêtait `compile_and_run.sh` avant l'APK — depuis le 2026-08-20, pour quiconque est sur le main mobile actuel. Il accepte désormais les deux dispositions, et prouve la présence de CHAQUE fichier promis plutôt qu'un échantillon de vingt : traverser les 139 archives coûte 6 s, et 124 350 fichiers sont comptés
- Le test du bundle gardait la limite d'entrées du ZIP en exigeant un champ `chunk` sur chaque fichier, c'est-à-dire la disposition en packs plutôt que la limite elle-même. Il compte maintenant les entrées que portera l'APK — 278 pour un plafond de 65 535 — si bien que les deux dispositions passent et qu'un retour au fichier-par-source échoue toujours
@ -263,10 +295,12 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Retiré
<!-- [en] -->
- The residue check that called a language broken when its res_lang row has active at NULL — listed nowhere, no longer re-enablable. False in the 18 source: an ('active','=',False) domain compiles to (IS NULL OR = FALSE), the Languages menu action carries active_test: False, sorting goes through COALESCE(active, FALSE), and reading returns bool(value). Odoo writes that NULL itself, active being a Boolean with no default and res.lang.csv having no such column, which makes one NULL per language added to the catalogue — so « zero before, nonzero after » is not enough to declare a residue. A test now refuses a verdict key that nothing defines any more
- Ubuntu 20.04 and 22.04 support, on every architecture: pikepdf needs qpdf 12.2, whose build requires C++20, while focal ships GCC 9 and publishes no `g++-10` for s390x
<!-- [fr] -->
- Le contrôle de résidus qui jugeait cassée une langue dont la ligne res_lang porte active à NULL — listée nulle part, plus réactivable. Faux dans la source 18 : un domaine ('active','=',False) compile en (IS NULL OR = FALSE), l'action du menu Langues porte active_test: False, le tri passe par COALESCE(active, FALSE), et la lecture rend bool(value). C'est Odoo lui-même qui écrit ce NULL, active étant un booléen sans défaut et res.lang.csv n'ayant pas cette colonne, soit un NULL par langue ajoutée au catalogue — « zéro avant, non nul après » ne suffit donc pas à déclarer un résidu. Un test refuse désormais une clé de verdict que plus rien ne définit
- Le support d'Ubuntu 20.04 et 22.04, sur toutes les architectures : pikepdf réclame qpdf 12.2, dont la compilation exige C++20, quand focal livre GCC 9 et ne publie pas de `g++-10` pour s390x
<!-- [en] -->

View file

@ -15,6 +15,14 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Ajouté
- Un hook `pre-commit` liste les commentaires à relire dans les fichiers qu'on indexe, et ne bloque jamais : sur les sources du dépôt, 373 fichiers rendent 463 signaux, et un contrôle bloquant à cette échelle se fait désinstaller la semaine suivante. L'outil qui le sert, `script/analyse/check_comment_hygiene.py`, rapporte deux familles de sûreté inégale — la donnée identifiante, adresse, courriel ou chemin de compte, qui est une trouvaille ; et le récit, marqueur de témoignage, date absolue ou première personne, qui est un signal à RELIRE, l'outil ne pouvant savoir si la phrase énonce un fait durable. Il lit les commentaires et les docstrings, les lignes `#` comme les commentaires shell de fin de ligne, écarte le code tiers, et se replie sur un balayage ligne à ligne quand un source ne se parse pas, un rapport vide déclarant sinon propre un fichier qu'il n'a jamais lu. Les codes de sortie suivent la convention du dépôt : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué
- Le menu TODO montre le contexte fourni à un assistant, éparpillé sur six sources : instructions, règles, skills, commandes déployées, hooks git et mémoire. Chaque commande déployée est comparée à son gabarit du dépôt en ignorant les lignes d'identité git que le déploiement substitue, si bien qu'une copie périmée se voit là où une égalité stricte les déclarerait toutes périmées. Rien de `private/` n'est relevé, pas même un compte : nommer un fichier dont l'objet est de retenir ce qui ne doit pas sortir revient à le désigner
- Dupliquer une base et la neutraliser pour de bon : la duplication passe par `exp_duplicate_database` d'Odoo plutôt que par `CREATE DATABASE … TEMPLATE`, qui copie les tables et rien d'autre. Odoo seul coupe les connexions ouvertes sur la source — un shell Odoo laissé ouvert suffit à faire refuser PostgreSQL —, régénère `database.uuid`, copie le filestore et exécute les fichiers `neutralize.sql` des modules installés. Les modules maison du dépôt n'obtenaient aucun des quatre, et l'un d'eux ouvrait une porte : supprimer tous les `ir.mail_server` fait retomber Odoo sur le `smtp_server` du fichier de configuration, là où le serveur bouchon d'Odoo existe précisément pour boucher ce trou. La neutralisation d'Odoo commence à la 16 ; de 12 à 15 la copie retombe sur la technique de longue date du dépôt, `update_prod_to_dev.sh`, qui ne pose pas `is_neutralized`, ne désactive aucun cron et laisse les clés de paiement, mais supprime les serveurs de courriel et pose un compte de développement. Le chemin suivi est ANNONCÉ à l'exécution, une copie dont on ignore par quel chemin elle est passée ne se jugeant pas, et un script introuvable fait échouer la copie plutôt que de la laisser sortir brute en s'annonçant neutralisée
- L'écran de qualité de migration dit si une migration a réussi, là où il comparait seulement les paliers : tous les verdicts s'affichent, de la 12 à la 18 — seule façon de voir qu'un échec a été rattrapé à un palier plus haut — lus dans le journal de progression et rattachés au palier ODOO, non au compteur du pilote, décalé d'un rang. Il dit où vivent les traces, config.conf laissant logfile= vide et la sortie d'Odoo mourant avec le terminal ; il montre le passage du journal d'étape qui entoure chaque commande, garde par tee ce qu'il lance lui-même et le relit sans relancer ; et il lance six étapes de revue par « r », en demandant avant de basculer le checkout, car rejouer un test d'un autre palier ouvrait la base avec la mauvaise version, qui y écrit avant d'échouer. Un statut se lit comme les outils l'écrivent — 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué — si bien que plus rien n'est peint en rouge là où rien n'a échoué. La sortie est capturée par pseudo-terminal, jamais par un tube : smoke_public_url exige stdin ET stdout sur un terminal et, derrière un tube, cesse en silence d'offrir la réparation des vues COW ; neuf exécutions y passent, deux restent dehors, pty.spawn naissant en 0×0 où un plein écran se perdrait
- Trois contrôles rejoignent la revue de migration, chacun pour ce qu'un palier détruit sans signaler le moindre échec. Les trous de manifeste : un dépôt d'addons absent du manifeste d'un palier n'existe pas sur disque pendant cette étape, Odoo déclare donc ses modules introuvables, le pilote propose de les effacer, et la fonctionnalité part avec eux — seul le trou compte, présent avant et après, absent au milieu, ce qui ramène 35 candidats à 19 vraies omissions au lieu de 46 % de bruit. Le type de vue tree, supprimé en Odoo 18 sans conversion, se lit dans les SOURCES, là où tous les autres outils de la revue lisent la base et où un module jamais installé ne laisse rien ; le mot reste un identifiant valide, si bien que sur 465 occurrences 80 seulement cassent, et lxml et ast décident par la position du littéral, jamais une regex. La dérive du plan comptable : une montée de version recharge le gabarit de localisation en silence — trois des scripts l10n du noyau appellent try_loading() sans force_create=False, donc tout compte que le gabarit n'apparie pas par code est CRÉÉ, et les groupes ainsi ajoutés reclassent le plan existant. Un compte absolu n'y dit rien, seul l'ÉCART entre deux paliers d'une même migration se juge, et l'outil prescrit de rejouer le palier plutôt que de réparer : sa clé naturelle de suppression attrape aussi des comptes réappariés par code
- run.sh réveille le registre par HTTP pendant que le serveur démarre. Odoo ne charge le registre d'une base qu'à la PREMIÈRE requête qui la concerne, et sur une base migrée la personne qui ouvre la page attend des dizaines de secondes ; la sonde prend ce temps à sa place et s'arrête à la première réponse, un 303, un 404 ou un 500 prouvant tous que le registre est chargé. Elle ne peut pas nuire : elle rend TOUJOURS 0, meurt avec run.sh par un trap, abandonne après deux minutes, et prend le port sur la ligne de commande, puis dans config.conf, puis dans le journal, exact même quand le port demandé était pris — l'écoute sur une interface vide ou générale est sondée par le bouclage. `--erplibre-disable-warmup-http` la coupe et est le seul drapeau RETIRÉ avant odoo_bin.sh, qu'Odoo refuse ; `--no-http` et `--stop-after-init` la coupent aussi et passent, puisqu'il n'y a rien à réveiller quand personne n'écoute
- Une convention d'écriture pour ce qui reste dans git. Un sujet de commit lu seul, sans diff ni corps, doit dire quelle partie du système change et ce qui y est désormais différent — le symptôme, l'erreur citée et la métaphore sont des PREUVES, et une preuve va dans le corps. Un commentaire a le CODE pour sujet, au présent de ce qu'il fait : une phrase dont le sujet est un incident, une machine, une date ou une personne part vers `tasks/`, non versionné, tandis que le mode de défaillance que le code empêche reste. Rien d'identifiant hors de `private/` — ni client, ni base réelle, ni hôte, ni adresse, ni courriel, ni chemin de compte — et l'exemple qui illustre cet interdit s'invente au lieu de s'emprunter, un test figeant pour toujours ce qu'il contient. Une mesure qui établit un fait durable reste, dépouillée de sa date et de son opérateur ; le relevé de ce qui répondait ce jour-là part
- Un hook `commit-msg` refuse la part mécanique de cette convention d'écriture, et rien de plus : tag absent, sujet de plus de 72 caractères, sujet ouvrant sur une citation, corps de plus de 10 lignes pour une langue, adresse IP, courriel, chemin de compte, terme d'une liste de noms interdits qui vit hors de git puisque c'est elle qu'elle protège — absente, ce dernier contrôle se tait. Il compte des caractères et non des octets, sans quoi un sujet français de 72 caractères tomberait sur ses accents ; il exclut les trailers et le diff de `--cleanup=scissors` ; et son refus nomme `git commit --no-verify`, un garde-fou qui refuse trop étant désinstallé. Dire sur quoi porte le code reste un jugement qu'aucun hook ne rend. Le prédicat qui distingue une adresse du parc d'une version de manifeste Odoo, d'une boucle locale, d'un masque, d'une adresse de réseau ou d'un bloc de documentation RFC 5737 est partagé avec le contrôle des commentaires, deux prédicats pour une même question finissant par diverger. Ses 40 tests pèsent les acceptations — un « Merge branch », un fixup de rebase — autant que les refus
- `long_test/` — des tests qui créent de vraies machines et durent des heures, tenus hors de `test/` pour que le lanceur unitaire reste lançable en quelques secondes. `deep_proxmox.py` empile des Proxmox dans des Proxmox, `deep_qemu.py` des QEMU dans des QEMU, et les deux partagent un moteur. Mesuré sur 28 cœurs : trois étages coûtent 34 minutes, le quatrième 4 h 20 d'amorçage plus 7 h 18 d'installation — tout y est 15 à 30 fois plus lent, et c'est là que les fabricants cessent de documenter l'imbrication. La profondeur est un paramètre et vaut trois par défaut, parce que trois marche
- deep_qemu PROUVE KVM à chaque étage au lieu de le supposer : `deploy_qemu.py` ne passe jamais `--cpu host-passthrough` et, quand /dev/kvm manque, il n'échoue pas — il pose `--virt-type qemu` et crée une VM entièrement ÉMULÉE, sept minutes et demie de démarrage, sans qu'aucun code de retour ne le dise. Sans garde, la descente mesurerait de la TCG empilée en croyant mesurer de l'imbrication. Chaque étage doit montrer `/dev/kvm`, `nested=Y` et un domaine enfant en `type='kvm'` ; ce qui n'a pas été lu vaut NON
- Les deux tests longs acceptent `--hote` pour partir d'une machine qu'on possède déjà, au lieu de créer une VM de tête pour héberger un hyperviseur qu'on a sous la main — cela coûte cinq minutes ET un étage d'imbrication. Le plan se dimensionne alors sur la RACINE, lue par ssh ; les délais comptent la profondeur ABSOLUE ; et la racine n'est jamais un étage atteint, jamais détruite, et son entrée ~/.ssh/config n'est jamais retirée
@ -101,12 +109,20 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Modifié
- L'indexation nomme les fichiers, jamais `git add -A`. Le ratissage indexe tout ce qui n'est pas suivi, et le dépôt garde deux répertoires non suivis EXPRÈS : `private/`, seul endroit autorisé à porter une donnée de client, et `tasks/`, où la convention envoie l'enquête précisément parce qu'il n'est pas versionné. Il emporte aussi ce qui est en cours ailleurs dans le checkout, sous un sujet qui ne le couvre pas ; `git add -p` indexe les hunks quand un fichier porte deux sujets
- Une invite à compte à rebours laisse 15 secondes pour décider, là où cinq ne suffisaient pas à LIRE la question : le compte à rebours n'existe pas pour aller vite mais pour qu'une exécution puisse être laissée sans surveillance, et trop court il fait l'inverse — la réponse vient par réflexe, ou un défaut que personne n'a lu s'applique. Une base restaurée porte le nom du fichier de sauvegarde, qui en porte déjà un parlant, plutôt que « test », sous lequel des migrations successives finissaient toutes sur un même nom. Le nom est assaini, puisqu'il finit dans un createdb, et borné à 41 caractères : le pilote ajoute « _neutralize_upgrade_18 » et PostgreSQL tronque à 63, ce qui ferait finir deux paliers sur le même nom. Un téléchargement distant garde celui que le serveur a donné
- todo.py éclaté en neuf fichiers, un par sujet, avec un socle commun par formulaire. Il portait 9 500 lignes de plus qu'un fichier ne devrait et tous les sujets y passaient ; les formulaires de déploiement répétaient la même mécanique de champs et de validation, si bien qu'une correction dans l'un ne gagnait jamais les autres. Aucun changement de comportement
- La branche, le profil et le type se choisissent par VM. Ils étaient globaux, ce qui obligeait à tout basculer pour déployer une seule machine autrement
- Un socle commun décrit le système invité, là où chaque formulaire le redécrivait
## Corrigé
- « odoo_bin.sh db --drop » échouait par AccessDenied à chaque palier de migration, et le clone butait ensuite sur « database already exists » : db_restore.py lit le config.conf du dépôt, y voit admin_passwd = admin et n'envoie donc aucun mot de passe maître, quand odoo_bin.sh ne passait pas de « -c », si bien qu'Odoo lisait ~/.odoorc et son mot de passe haché. ODOO_RC ferme cette couture en un point plutôt qu'à vingt sites d'appel, les versions 12 à 18 le lisant après « -c » et avant ~/.odoorc, donc un choix explicite l'emporte toujours
- La vue SQL account.root que crée Odoo 17 est retirée avant le chargement en 18, où le modèle porte _auto = False et _table_query = '0', si bien que son nom n'entre plus dans aucune requête et que le contrôle des tables manquantes l'ignore. La vue n'est pas seulement morte mais FAUSSE, bâtie sur la colonne code que l'ORM 18 n'écrit plus, et elle est l'unique épingle des deux colonnes héritées où database_cleanup échoue — aucun DROP COLUMN, OpenUpgrade les lisant encore ensuite
- La réparation de migration créait des listes de prix qui n'avaient pas lieu d'être, de deux façons. Elle interrogeait `env.user.has_group()`, qui répond oui dès que l'exécutant est membre du groupe — et la migration l'y ajoute en cours de route —, là où la case des réglages lit tout autre chose : ce que `base.group_user` IMPLIQUE. Décider sur l'exécutant créait une liste de prix dans une base dont la fonctionnalité est éteinte, et Odoo prévenait alors à chaque ouverture des réglages qu'il allait l'archiver. Et une liste de prix partagée entre sociétés, au `company_id` vide, ne comptait pas comme appartenant à la société : la réparation voyait une société sans liste et fabriquait un doublon vide à côté de l'existante, sur une base qui n'avait pourtant rien perdu. Les deux lisent désormais l'implication du groupe et le détecteur `pricelist_missing` du même outil, qui distingue déjà les deux cas ; le contrôle « restant de migration » posait la même mauvaise question et a été corrigé avec elles
- La liste noire de l'anonymisation passait son SQL à psql en UN seul argument, et Linux plafonne un argument à 131 072 octets : le mode qui couvre le plus de tables était justement celui qui cassait, sur « OSError: [Errno 7] Argument list too long ». Le SQL passe désormais par un fichier avec `-f`, qui garde le `--single-transaction` que l'entrée standard aurait perdu en silence. Les colonnes texte à longueur déclarée sont tronquées par `left(…, n)`, l'identifiant en TÊTE pour qu'une colonne unique le reste, et tous les identifiants sont cités — Odoo laisse nommer un champ user ou order. Écarter toute colonne sous contrainte CHECK laissait le nom du partenaire intact : sur du texte, seules les contraintes de FORME sont hors de portée, et un UPDATE qui échoue n'écrit rien
- L'anonymisation respecte ce qu'une valeur SIGNIFIE, là où le type SQL dit seulement ce qu'elle EST. Odoo déclare `parent_path` en `char` mais y range un chemin d'identifiants — 1/7/12/ — reparsé aussitôt par `int()` : un nom écrit là casse le premier chargement de page ; tout modèle `_parent_store` en porte un, et `account_payment_term.days_next_month` passe de même à `int()`. Les deux noms connus sont écartés, puis le contenu lui-même est sondé : une colonne dont chaque valeur est un chemin reste intacte, même dans un module maison, et les barres obliques sont exigées, si bien qu'une valeur tout en chiffres n'échappe pas à l'anonymisation en passant pour un identifiant. Les nombres étaient tirés uniformément sur 0 à 1000, ce qui est faux pour tout nombre borné par son usage — heures, pourcentages, taux : un `float` qui porte une heure de la journée, tiré hors de 0..23, fait lever « ValueError: hour must be in 0..23 » à l'affichage de la fiche, borne qui vit dans le Python d'Odoo là où aucune contrainte PostgreSQL ne la déclare. Le tirage respecte désormais la seule borne que les DONNÉES déclarent, leur propre étendue, min et max mesurés par table ; 0 à 1000 ne reste que pour une colonne vide ou impossible à sonder
- Le lanceur unitaire ne prenait que sept préfixes de noms, « le reste demandant une base de données », et exécutait 1131 tests là où les 3703 du répertoire passent avec PostgreSQL injoignable ; il balaie test/test_*.py. Deux formes rendent un fichier muet sans erreur — unittest.main() posé en plein milieu, qui sort avant même que la seconde moitié soit définie (quatre fichiers, 87 tests), et l'absence de bloc __main__, qui compte zéro (huit fichiers, 174 tests) — et une garde refuse désormais les deux, ainsi que tout retour à une liste de préfixes
- Le vérificateur du transfert mobile n'acceptait que la disposition en packs, quand une compilation réelle livre un tar.gz par dépôt. Il échouait sur `<slug> : index.json absent` et arrêtait `compile_and_run.sh` avant l'APK — depuis le 2026-08-20, pour quiconque est sur le main mobile actuel. Il accepte désormais les deux dispositions, et prouve la présence de CHAQUE fichier promis plutôt qu'un échantillon de vingt : traverser les 139 archives coûte 6 s, et 124 350 fichiers sont comptés
- Le test du bundle gardait la limite d'entrées du ZIP en exigeant un champ `chunk` sur chaque fichier, c'est-à-dire la disposition en packs plutôt que la limite elle-même. Il compte maintenant les entrées que portera l'APK — 278 pour un plafond de 65 535 — si bien que les deux dispositions passent et qu'un retour au fichier-par-source échoue toujours
@ -121,6 +137,7 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- Le pont NAT s'écrivait avant de savoir si le NAT existe. Six lignes d'iptables et « code de retour 1 » arrivaient après que la strophe soit déjà posée dans /etc/network/interfaces, et rien dans ce bruit ne disait qu'il fallait redémarrer : l'hôte tournait le noyau cloud de Debian, dépouillé de netfilter. C'est notre propre install_proxmox.sh qui produit cet état, donc une Proxmox imbriquée fraîchement installée y est TOUJOURS — le garde va désormais là où la conséquence est, et non à la confirmation de l'hôte
## Retiré
- Le contrôle de résidus qui jugeait cassée une langue dont la ligne res_lang porte active à NULL — listée nulle part, plus réactivable. Faux dans la source 18 : un domaine ('active','=',False) compile en (IS NULL OR = FALSE), l'action du menu Langues porte active_test: False, le tri passe par COALESCE(active, FALSE), et la lecture rend bool(value). C'est Odoo lui-même qui écrit ce NULL, active étant un booléen sans défaut et res.lang.csv n'ayant pas cette colonne, soit un NULL par langue ajoutée au catalogue — « zéro avant, non nul après » ne suffit donc pas à déclarer un résidu. Un test refuse désormais une clé de verdict que plus rien ne définit
- Le support d'Ubuntu 20.04 et 22.04, sur toutes les architectures : pikepdf réclame qpdf 12.2, dont la compilation exige C++20, quand focal livre GCC 9 et ne publie pas de `g++-10` pour s390x
## Modifié

View file

@ -15,6 +15,14 @@ Recreating the virtual environment, use installation guide from tool `make`.
## Added
- A `pre-commit` hook lists the comments worth re-reading in the files being staged, and never blocks: over the repository's own sources, 373 files yield 463 signals, and a blocking check at that scale gets uninstalled the following week. The tool behind it, `script/analyse/check_comment_hygiene.py`, reports two families of unequal certainty — identifying data, an address, an e-mail or an account path, which is a finding; and narrative, a witness marker, an absolute date or the first person, which is a signal to RE-READ, since it cannot know whether the sentence states a durable fact. It reads comments and docstrings, `#` lines and shell trailing comments alike, skips vendored code, and falls back on a line scan when a source will not parse, an empty report otherwise declaring clean a file it never read. Exit codes follow the repository convention: 0 nothing to report, 1 findings, 2 the tool failed
- The TODO menu shows the context an assistant is given, scattered as it is over six sources: instructions, rules, skills, deployed commands, git hooks and memory. Each deployed command is compared against its repository template, ignoring the git identity lines the deployment substitutes, so a stale copy shows up where a strict equality would declare them all stale. Nothing from `private/` is reported, not even a count: naming a file whose purpose is to hold what must not go out amounts to pointing at it
- Duplicating a database and neutralising it for good: duplication goes through Odoo's `exp_duplicate_database` rather than `CREATE DATABASE … TEMPLATE`, which copies the tables and nothing else. Only Odoo drops the source's open connections — one Odoo shell left open is enough for PostgreSQL to refuse — regenerates `database.uuid`, copies the filestore, and runs the `neutralize.sql` files of the installed modules. The repository's in-house modules obtained none of the four, and one of them opened a door: deleting every `ir.mail_server` makes Odoo fall back on the configuration file's `smtp_server`, where Odoo's own placeholder server exists precisely to plug that hole. Odoo's neutralisation begins at 16; from 12 to 15 the copy falls back on the repository's long-standing `update_prod_to_dev.sh`, which sets no `is_neutralized`, disables no cron and leaves the payment keys, but does remove the mail servers and lay down a development account. The route taken is ANNOUNCED at run time, a copy whose route is unknown being unjudgeable, and a missing script fails the copy rather than letting it come out raw while announcing itself neutralised
- The migration quality screen says whether a run succeeded, where it only compared the tiers: every verdict shows, 12 through 18 — the only way to see that a failure at one tier was recovered higher up — read from the progression file and tied to the ODOO tier, not to the driver's counter, which is off by one. It names where the traces live, config.conf leaving logfile= empty and Odoo's output dying with the terminal; it shows the step-log passage around each command, keeps by tee what it launches itself and re-reads that without rerunning; and it runs six review steps from « r », asking before it switches the checkout, since replaying a test from another tier opened the database with the wrong version, which writes before it fails. A status is read the way the tools write it — 0 nothing to report, 1 findings, 2 the tool failed — so nothing is painted red where nothing failed. Output is captured through a pseudo-terminal, never a pipe: smoke_public_url requires stdin AND stdout to be terminals and, behind a pipe, silently stops offering the COW view repair; nine runs go through it, two stay out, pty.spawn being born 0×0 where a full-screen tool would lay out on nothing
- Three checks join the migration review, each for what a tier destroys without reporting a single failure. The manifest holes: an addons repository absent from one tier's manifest does not exist on disk during that step, so Odoo declares its modules missing, the driver offers to delete them, and the feature leaves with them — only the hole counts, present before and after, absent in between, which turns 35 candidates into 19 real omissions instead of 46% noise. The tree view type, removed in Odoo 18 with no shim, is read in the SOURCES, where every other review tool reads the database and a module that never installed leaves nothing; the word is still a valid identifier, so of 465 occurrences only 80 break, and lxml and ast decide by the literal's position, never a regex. The chart drift: a bump reloads the localisation template in silence — three of the core l10n scripts call try_loading() without force_create=False, so every account the template cannot match by code is CREATED, and the groups thus added reclassify the existing chart. An absolute count says nothing there, only the GAP between two tiers of one migration can be judged, and the tool prescribes replaying the tier rather than repairing: its natural deletion key also catches accounts re-matched by code
- run.sh warms the registry over HTTP while the server starts. Odoo loads a database's registry only on the FIRST request that concerns it, and on a migrated database whoever opens the page waits tens of seconds; the probe takes that time instead and stops at the first answer, a 303, a 404 or a 500 all proving the registry is loaded. It cannot get in the way: it ALWAYS returns 0, dies with run.sh through a trap, gives up after two minutes, and takes the port from the command line, then config.conf, then the log, exact even when the requested port was taken — an empty or wildcard listen interface is probed on the loopback. `--erplibre-disable-warmup-http` turns it off and is the only flag STRIPPED before odoo_bin.sh, which Odoo rejects; `--no-http` and `--stop-after-init` turn it off too and go through, there being nothing to wake when no one listens
- A writing convention for what stays in git. A commit subject read alone, with no diff and no body, must say which part of the system changes and what is now different there — the symptom, the quoted error and the metaphor are EVIDENCE, and evidence belongs in the body. A comment has the CODE for subject, in the present of what it does, so a sentence whose subject is an incident, a machine, a date or a person goes to the unversioned `tasks/`, while the failure mode the code prevents stays. Nothing identifying outside `private/` — no customer, real database, host, address, e-mail or account path — and an example illustrating that ban is invented rather than borrowed, a test freezing forever what it holds. A measurement that establishes a durable fact stays, stripped of its date and its operator; the reading of what answered that day goes
- A `commit-msg` hook refuses the mechanical part of that writing convention and nothing more: no tag, a subject over 72 characters, a subject opening on a quotation, a body over 10 lines for one language, an IP address, an e-mail, an account path, or a term from a forbidden-names list that lives outside git since the list is what it protects — absent, that last check stays silent. It counts characters, not bytes, or a 72-character French subject would fall on its accents; it excludes the trailers and the `--cleanup=scissors` diff; and its refusal names `git commit --no-verify`, a guard rail that refuses too much getting uninstalled. Whether the subject says what the code is about stays a judgement no hook makes. The predicate telling a fleet address from an Odoo manifest version, a loopback, a mask, a network address or an RFC 5737 documentation block is shared with the comment check, two predicates for one question drifting apart. Its 40 tests weigh the acceptances — a « Merge branch », a rebase fixup — as much as the refusals
- `long_test/` — tests that create real machines and take hours, kept out of `test/` so the unit runner stays runnable in seconds. `deep_proxmox.py` stacks Proxmox in Proxmox, `deep_qemu.py` stacks QEMU in QEMU, and they share one engine. Measured on 28 cores: three levels cost 34 minutes, the fourth 4 h 20 of boot plus 7 h 18 of install — everything there is 15 to 30 times slower, and that is where the vendors stop documenting nesting. The depth is a parameter and defaults to three, because three works
- deep_qemu proves KVM at every level instead of assuming it: `deploy_qemu.py` never passes `--cpu host-passthrough` and, when /dev/kvm is missing, it does not fail — it sets `--virt-type qemu` and creates a fully EMULATED VM, seven and a half minutes to boot, with no exit code to say so. Unguarded, the descent would measure stacked TCG while believing it measured nesting. Each level must show `/dev/kvm`, `nested=Y` and a child domain in `type='kvm'`; what was not read counts as NO
- Both long tests take `--hote` to start from a machine you already own, rather than creating a head VM to host a hypervisor you have on hand — that costs five minutes AND one level of nesting. The plan is then sized on the ROOT, read over ssh; the delays count ABSOLUTE depth; and the root is never a level reached, never destroyed, and its ~/.ssh/config entry is never removed
@ -99,12 +107,20 @@ Recreating the virtual environment, use installation guide from tool `make`.
## Changed
- Staging names the files, never `git add -A`. The sweep stages everything untracked, and this repository keeps two directories untracked ON PURPOSE: `private/`, the only place allowed to hold customer data, and `tasks/`, where the convention sends the investigation precisely because it is not versioned. It also swallows whatever else is in flight in the checkout, under a subject that does not cover it; `git add -p` stages the hunks when one file carries two subjects
- A countdown prompt gives 15 seconds to decide, where five were not enough to READ the question: the countdown exists so a run can be left unattended, not to go fast, and too short it does the opposite — the answer comes by reflex, or a default no one read is taken. A restored database is named after the backup file, which already carries a telling name, rather than « test », under which successive migrations all landed on one name. The name is sanitised, since it ends up in a createdb, and capped at 41 characters: the driver appends « _neutralize_upgrade_18 » and PostgreSQL truncates at 63, which would put two tiers on the same name. A remote download keeps the name the server gave
- todo.py split into nine files, one per subject, with a shared base per form. It carried 9 500 lines more than a file should and every subject went through it; the deployment forms repeated the same field-and-validation machinery, so a fix in one never reached the others. No behaviour changes
- Branch, profile and type are chosen per VM. They were global, which meant switching everything to deploy a single machine differently
- One shared base describes the guest system, where each form used to describe it again
## Fixed
- `odoo_bin.sh db --drop` failed with AccessDenied at every migration tier, and the clone then hit « database already exists »: db_restore.py reads the repository's config.conf, sees admin_passwd = admin and therefore sends no master password, while odoo_bin.sh passed no « -c », so Odoo read ~/.odoorc and its hashed one. ODOO_RC closes that seam in one place instead of twenty call sites, versions 12 to 18 reading it after « -c » and before ~/.odoorc, so an explicit choice still wins
- The account.root SQL view Odoo 17 creates is dropped before the load into 18, where the model carries _auto = False and _table_query = '0', so its name enters no query and the missing-table check skips it. The view is not merely dead but WRONG, built on the code column the 18 ORM no longer writes, and it is the sole pin holding the two legacy columns database_cleanup fails on — no DROP COLUMN, OpenUpgrade still reading them afterwards
- The migration repair created pricelists that should not exist, in two ways. It asked `env.user.has_group()`, which says yes as soon as the caller belongs to the group — and the migration adds it along the way — where the settings checkbox reads something else entirely: what `base.group_user` IMPLIES. Deciding on the caller built a pricelist in a database whose feature is off, and Odoo then warned on every opening of the settings that it would archive it. And a pricelist shared across companies, with an empty `company_id`, did not count as belonging to the company: the repair saw a company without a list and made an empty duplicate beside the existing one, on a database that had lost nothing. Both now read the group implication and the tool's own `pricelist_missing` detector, which already tells the two cases apart; the migration-residue check asked the same wrong question and was corrected with them
- The anonymisation blacklist handed its SQL to psql as a single argument, and Linux caps one argument at 131 072 bytes: the mode that covers the most tables was exactly the one that broke, on « OSError: [Errno 7] Argument list too long ». The SQL now travels through a file with `-f`, which keeps the `--single-transaction` that standard input would have silently dropped. Text columns with a declared length are truncated by `left(…, n)`, the id FIRST so a unique column stays unique, and every identifier is quoted — Odoo allows a field named user or order. Skipping every column under a CHECK constraint had left the partner name untouched: on text, only FORM constraints are out of reach, and an UPDATE that fails writes nothing
- Anonymisation respects what a value MEANS, where the SQL type says only what it IS. Odoo declares `parent_path` as `char` but stores an id path in it — 1/7/12/ — parsed straight back with `int()`, so a name written there breaks the first page load; every `_parent_store` model carries one, and `account_payment_term.days_next_month` goes to `int()` the same way. The two known names are skipped, then the content itself is probed: a column whose every value is a path is left alone, even in an in-house module, and the slashes are required, so a value of pure digits does not escape anonymisation by passing for an id. Numbers were drawn uniformly over 0 to 1000, which is wrong for every number bounded by its usage — hours, percentages, rates: a float holding an hour of day, drawn outside 0..23, makes displaying the record raise « ValueError: hour must be in 0..23 », a bound that lives in Odoo's Python where no PostgreSQL constraint declares it. The draw now respects the only bound the DATA declares, its own range, min and max measured per table; 0 to 1000 remains solely for a column that is empty or cannot be probed
- The unit runner took seven filename prefixes, « the rest needing a database », and ran 1131 tests where all 3703 in the directory pass with PostgreSQL unreachable; it globs test/test_*.py. Two shapes make a file silent without an error — unittest.main() placed mid-file, which exits before the second half is even defined (four files, 87 tests), and no __main__ block at all, which counts zero (eight files, 174 tests) — and a guard now refuses both, along with any return to a list of prefixes
- The mobile bundle check accepted only the pack layout, when a real build ships one tar.gz per repository. It failed on `<slug> : index.json absent` and stopped `compile_and_run.sh` before the APK — since 2026-08-20, for anyone on the current mobile main. It now accepts both layouts, and proves the presence of EVERY promised file rather than a sample of twenty: streaming all 139 archives costs 6 s, and 124 350 files are accounted for
- The bundle test guarded the ZIP entry limit by demanding a `chunk` field on every file, which is the pack layout rather than the limit itself. It now counts the entries the APK will carry — 278 against a ceiling of 65 535 — so either layout passes and a return to file-per-source still fails
@ -119,6 +135,7 @@ Recreating the virtual environment, use installation guide from tool `make`.
- The NAT bridge was written before knowing whether NAT exists. Six lines of iptables and "return code 1" came after the stanza had already gone into /etc/network/interfaces, and nothing in that noise said a reboot was needed: the host was running Debian's cloud kernel, stripped of netfilter. Our own install_proxmox.sh produces that state, so a freshly installed nested Proxmox is ALWAYS in it — the guard now sits where the consequence is, not at host confirmation
## Removed
- The residue check that called a language broken when its res_lang row has active at NULL — listed nowhere, no longer re-enablable. False in the 18 source: an ('active','=',False) domain compiles to (IS NULL OR = FALSE), the Languages menu action carries active_test: False, sorting goes through COALESCE(active, FALSE), and reading returns bool(value). Odoo writes that NULL itself, active being a Boolean with no default and res.lang.csv having no such column, which makes one NULL per language added to the catalogue — so « zero before, nonzero after » is not enough to declare a residue. A test now refuses a verdict key that nothing defines any more
- Ubuntu 20.04 and 22.04 support, on every architecture: pikepdf needs qpdf 12.2, whose build requires C++20, while focal ships GCC 9 and publishes no `g++-10` for s390x
## Changed

View file

@ -19,7 +19,14 @@ Version Odoo par défaut : **18.0** (support officiel ERPLibre 1.6.0)
`ls -d .venv.odoo*` plutôt que de le composer de tête
- Les scripts ERPLibre utilisent `.venv.erplibre/bin/python`
- Le Makefile principal inclut des fragments depuis `conf/make.*.Makefile`
- Les fichiers privés vont dans `private/` (non versionné)
- Les fichiers privés vont dans `private/`. C'est le SEUL endroit qui a le
droit de porter une donnée de client — nom, base, machine, adresse,
chiffres. Il peut être commité, mais seulement sur un dépôt privé : sur
un fork public, ce qui s'y trouve devient public comme le reste
- Partout ailleurs — code, commentaires, messages de commit, documentation
— aucune donnée identifiante, jamais. Ce sont les fichiers qui suivent le
dépôt en amont. La règle complète, avec l'épreuve qui tranche, est dans
`.claude/rules/04-code-conventions.md`
- La DB PostgreSQL par défaut est sur le port 5432, mot de passe admin : `admin`
- Port Odoo par défaut : 8069, longpolling : 8072
- Pour les commits : suivre le format `[TYPE] description` (ex: `[FIX]`, `[UPD]`, `[ADD]`, `[REM]`)

View file

@ -80,7 +80,9 @@ The first five cover every one of the last 400 commits. Reach for `[REM]`,
[TAG] scope: short description in imperative mood
Explain WHY the change was made — the diff already shows what. Name the
symptom that led to it, and what was measured rather than assumed.
failure mode, and what was measured rather than assumed, in words a
stranger to this site can read: the software, the version, the class of
situation — never a customer, a real database, a machine or an address.
Wrap at 80 characters.
--- FR ---
@ -135,21 +137,24 @@ that matter says more in the space than half a sentence does:
That form is a fallback, not a default. Prefer the sentence when it fits.
**The guard rail.** `script/git/hooks/commit-msg` refuses a subject with no
tag, one over 72 characters, and one opening on a quotation. Install it with
`git config core.hooksPath script/git/hooks`; `git commit --no-verify` passes
a legitimate exception. It checks only what is mechanical — whether the
subject says what the code is about stays a judgement, and the test above is
how you make it.
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, and a
`/home/<account>/` path. Install the hook with `git config core.hooksPath script/git/hooks`; `git commit
--no-verify` passes a legitimate exception. It checks only what is mechanical
— whether the subject says what the code is about, and whether the body tells
the story instead of the mechanism, stay judgements, and the tests above are
how you make them.
### Keep it short
The body answers one question: why was this necessary. Stop once it is
answered — the reader owes you nothing beyond that.
**Ten lines per language. Fifteen is already long.** Past that, the reasoning
belongs in a document or a code comment, and the commit points at it. The
budget is per language: bilingual doubles everything, so it buys terseness,
it does not excuse length.
**Eight lines per language. Ten is the ceiling.** Past that, the reasoning
belongs in `tasks/`, which is not versioned, and the commit points at it —
never in a code comment: a comment says how the code WORKS, not what happened
the week it was written. The budget is per language: bilingual doubles
everything, so it buys terseness, it does not excuse length.
Cut, in this order:
@ -160,10 +165,25 @@ Cut, in this order:
- Every clause that would not change what a reader does: no `this commit`,
no `I decided to`, no summary of the summary, no restating the subject.
Keep, always: the symptom that led to the change, the figure you measured
rather than assumed, and one line naming what you verified and how. A single
`Checked: 4 jobs, 1.63 s at parallelism 1 vs 0.58 s at 4` is worth three
paragraphs of prose.
Keep, always: the failure mode the change removes, the figure that bounds it,
and one line naming what you verified and how. A single `Checked: 4 jobs,
1.63 s at parallelism 1 vs 0.58 s at 4` is worth three paragraphs of prose.
Two tests decide what survives, and they apply to every sentence of the body.
**Tense and subject.** Each sentence says what the code now does or refuses,
in the present. A sentence whose subject is an incident, a session, a machine,
a date or a person is cut — including your own reasoning: no `my conclusion
was wrong`, no `three faults found by running it`. That belongs in `tasks/`.
**Nothing identifying.** No customer or third-party organisation, no real
database name, no VM or host name, no IP address, no e-mail, no path carrying
a user name, no label or figure taken from a customer's data. Generalise to
the CLASS of situation instead of censoring: `on a production database`, `on a
development VM`, `on a host that demands interactive sudo` — the class is what
serves the reader; the name never was. A figure that is a durable limit or
threshold stays (65 536 inotify watches); a reading taken during one incident
goes.
### Bilingual body
@ -197,7 +217,9 @@ the work.
- `scope` is the Odoo technical module (`sale_order`, `account`, `stock`) or
the area of the repository (`script todo`, `qemu ssh`, `migration`).
- The commit stands on its own: state what was verified, and how. If a claim
was not checked, say so rather than implying it was.
was not checked, say so rather than implying it was. Standing on its own
means it needs no OTHER COMMIT to be understood — not that it carries the
whole investigation. The line budget above still binds.
- If you cannot explain and defend every line, do not commit it.
### Size and pace
@ -222,8 +244,20 @@ configured `user.email` then fails on the committer.
Use a heredoc rather than `-m`: a body with quotes, backticks or accented
characters survives it unharmed.
**Name the files. Never `git add -A`.** It stages everything untracked, and
this repository keeps two directories untracked ON PURPOSE: `private/`, the
only place allowed to hold customer data, and `tasks/`, where the convention
sends the investigation precisely because it is not versioned. A sweep commits
both. It also swallows whatever else is in flight in the checkout — another
tool's output, a half-finished edit — under a subject that does not cover it.
`git status --porcelain` lists what changed; stage the paths that belong to
the subject you just wrote, and no others. When one file carries two subjects,
`git add -p` stages the hunks that belong to this commit.
```bash
git add -A
git status --porcelain
git add script/module/thing.py test/test_thing.py
git -c user.name="Your Name" -c user.email="your@email.com" commit -F - <<'MSG'
[TAG] scope: description

View file

@ -1042,3 +1042,70 @@ Tester les générations des templates :
```bash
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
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
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

@ -1,20 +1,50 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le sujet d'un commit tient-il la convention ?
"""Le message d'un commit tient-il la convention ?
La convention est dans `.claude/rules/04-code-conventions.md`, son mode
d'emploi dans `conf/template_claude_commands_commit.md`. Ce module en vérifie
la part MÉCANIQUE : le tag, la longueur, et le sujet qui s'ouvre sur une
citation. Le reste — « ce sujet dit-il sur quoi porte le code » — est un
jugement, et aucun hook ne le rendra.
la part MÉCANIQUE, sur deux plans :
Compté en CARACTÈRES et non en octets : « préchauffer » pèse 12 caractères et
14 octets, et une limite en octets refuserait des sujets français conformes.
- le sujet : le tag, la longueur, l'ouverture sur une citation ;
- le corps : sa longueur par langue, et la donnée identifiante.
Le reste — « ce sujet dit-il sur quoi porte le code », « ce corps raconte-t-il
l'enquête plutôt que le fonctionnement » — est un jugement, et aucun hook ne
le rendra.
Compté en CARACTÈRES et non en octets : « préchauffer » pèse 11 caractères et
12 octets, et une limite en octets refuserait des sujets français conformes.
"""
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from lib_identifiant import ( # noqa: E402
NOMS_INTERDITS,
identifiants,
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
# d'UNE des deux moitiés, pas du message entier.
MAX_BODY = 10
TAGS = ("ADD", "FIX", "UPD", "IMP", "REF", "REM", "MOV", "I18N")
# Ce que git écrit lui-même, ou ce qu'un rebase consomme : hors convention.
@ -24,6 +54,22 @@ GENERATED = ("Merge ", "Revert ", "fixup!", "squash!", "amend!")
# preuve va dans le corps — le sujet doit nommer la cause.
QUOTES = ("«", '"', "'", "`", "“", "‘")
# Le marqueur nomme la langue de ce qui SUIT : il sépare les deux moitiés.
MARKER = re.compile(r"^---\s*(FR|EN)\s*---\s*$", re.MULTILINE)
# `git commit --cleanup=scissors` laisse le diff en clair sous cette ligne :
# tout ce qui suit appartient à git, pas à l'auteur.
CISEAUX = re.compile(r"^#?\s*-{2,}\s*>8\s*-{2,}")
# Un trailer porte légitimement une adresse, et `-x` ajoute sa propre ligne.
# La liste est fermée : « Checked: … » reste du corps et se fait vérifier.
TRAILER = re.compile(
r"^(?:Assisted-by|Co-authored-by|Signed-off-by|Reviewed-by|Acked-by"
r"|Tested-by|Reported-by|Suggested-by|Cc|Fixes|Closes|Refs|Link):\s"
r"|^\(cherry picked from commit [0-9a-f]+\)$",
re.IGNORECASE,
)
def subject_of(message: str) -> str:
"""La première ligne utile : ni commentaire, ni ligne vide."""
@ -35,12 +81,50 @@ def subject_of(message: str) -> str:
return ""
def check(message: str) -> list:
"""Rend la liste des problèmes. Vide si le sujet passe."""
subject = subject_of(message)
if not subject or subject.startswith(GENERATED):
def _apres_le_sujet(message: str) -> list:
"""Les lignes du corps : ni commentaire, ni diff de `--verbose`."""
lines = message.split("\n")
for index, line in enumerate(lines):
stripped = line.strip()
if stripped and not stripped.startswith("#"):
lines = lines[index + 1 :]
break
else:
return []
gardees = []
for line in lines:
stripped = line.strip()
if CISEAUX.match(stripped):
break
if stripped.startswith("#"):
continue
gardees.append(line)
return gardees
def body_of(message: str, trailers: bool = False) -> str:
"""Ce qui suit le sujet, sans les lignes que git ajoute ou qu'il ignore.
Sans `trailers`, les lignes `Assisted-by:` et consorts sont retirées : le
budget de lignes ne les compte pas, et l'adresse d'un `Co-authored-by:` est
légitime. Avec, elles restent — un nom de client logé dans un `Refs:` est
tout aussi publié que dans une phrase.
"""
gardees = _apres_le_sujet(message)
if not trailers:
gardees = [
ligne for ligne in gardees if not TRAILER.match(ligne.strip())
]
return "\n".join(gardees)
def _moities(body: str) -> list:
"""Le corps découpé par le marqueur de langue. Une seule moitié sans lui."""
return [part for part in MARKER.split(body) if part not in ("FR", "EN")]
def _check_subject(subject: str) -> list:
problems = []
tag = None
@ -50,22 +134,111 @@ def check(message: 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
def _check_body(sans_trailers: str, avec_trailers: str) -> list:
"""Longueur par langue et donnée identifiante. Rien sur le style.
Le budget de lignes et le courriel se jugent SANS les trailers, qui sont
de git et portent légitimement une adresse. L'adresse IP, le chemin de
compte et le nom privé se jugent AVEC : un `Refs:` publie autant.
"""
problems = []
for moitie in _moities(sans_trailers):
pleines = [ligne for ligne in moitie.split("\n") if ligne.strip()]
if len(pleines) > MAX_BODY:
problems.append(
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
termes = termes_interdits(NOMS_INTERDITS)
par_motif = {}
for motif, extrait, _ in identifiants(avec_trailers, termes):
if motif != "courriel":
par_motif.setdefault(motif, []).append(extrait)
for motif, extrait, _ in identifiants(sans_trailers, ()):
if motif == "courriel":
par_motif.setdefault(motif, []).append(extrait)
adresses = sorted(set(par_motif.get("adresse", [])))
if adresses:
problems.append(
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(
t("the body carries an e-mail address: %s.") % ", ".join(courriels)
)
comptes = sorted(set(par_motif.get("compte", [])))
if comptes:
problems.append(
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(
t(
"the body carries a refused name: %s.\n"
" Generalise — « on a production database » — or drop the\n"
" sentence."
)
% ", ".join(noms)
)
return problems
def check(message: str) -> list:
"""Rend la liste des problèmes. Vide si le message passe."""
subject = subject_of(message)
if not subject or subject.startswith(GENERATED):
return []
return _check_subject(subject) + _check_body(
body_of(message), body_of(message, trailers=True)
)

View file

@ -78,7 +78,7 @@ def main():
whitelist = []
if config.from_backup_path or config.from_backup_name:
# script/database/get_repo_from_backup.py
# --backup_name bpir_prod_5_dec_2025_2026-02-04_14h27m54s.zip
# --backup_name pir_prod_5_dec_2025_2026-02-04_14h27m54s.zip
if config.from_backup_path:
cmd = f"./script/database/get_repo_from_backup.py --backup_path {config.from_backup_path}"
else:

View file

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

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

109
script/lib_identifiant.py Normal file
View file

@ -0,0 +1,109 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ce texte porte-t-il une donnée qui désigne quelqu'un ou quelque chose ?
La règle est dans `.claude/rules/04-code-conventions.md` : ce qui est versionné
ne nomme ni client, ni base réelle, ni machine, ni adresse. Deux garde-fous s'en
servent — `script/git/commit_msg_lib.py` sur le message, et
`script/analyse/check_comment_hygiene.py` sur les commentaires — et ils
partagent CE module. Deux prédicats séparés pour la même question dérivent.
Chaque trouvaille porte sa position dans le texte : l'appelant en déduit la
ligne exacte, plutôt que de rechercher l'extrait à l'aveugle.
"""
from __future__ import annotations
import io
import os
import re
RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
# La liste des clients et des machines ne peut pas vivre dans le dépôt public :
# c'est ce qu'elle protège. Absente, le contrôle qui s'en sert est muet.
NOMS_INTERDITS = os.path.join(RACINE, "private", "noms_interdits.txt")
IPV4 = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
# Les blocs de documentation de la RFC 5737, qui existent pour l'exemple.
IPV4_DOCUMENTAIRES = ("192.0.2.", "198.51.100.", "203.0.113.")
EMAIL = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]*[\w-]\b")
# L'adresse du propriétaire du dépôt n'est pas une donnée de client.
EMAIL_PERMIS = re.compile(r"@(?:technolibre|erplibre)\.", re.IGNORECASE)
# Un segment entre chevrons ou une variable est un gabarit, pas un compte.
HOME = re.compile(r"/(?:home|Users)/(?![<$\"'{])([\w.-]+)/")
HOME_PERMIS = frozenset({"runner", "user", "utilisateur", "USER"})
def adresse_de_machine(valeur):
"""Cette suite de quatre nombres désigne-t-elle une machine du parc ?
Non pour un octet hors bornes, une version de module Odoo (`18.0.1.3`), la
boucle locale, un masque, un bloc documentaire, et une adresse de RÉSEAU —
un dernier octet nul nomme une plage, pas un hôte.
"""
try:
nombres = [int(o) for o in valeur.split(".")]
except ValueError:
return False
if len(nombres) != 4 or any(n > 255 for n in nombres):
return False
if 12 <= nombres[0] <= 18 and nombres[1] == 0:
return False
if nombres[0] in (0, 127, 255) or nombres[3] == 0:
return False
if valeur.startswith(IPV4_DOCUMENTAIRES):
return False
return True
def termes_interdits(chemin=NOMS_INTERDITS):
"""Les termes du fichier privé, en minuscules. Vide s'il n'existe pas."""
try:
with io.open(chemin, encoding="utf-8") as fh:
contenu = fh.read()
except OSError:
return []
termes = []
for ligne in contenu.split("\n"):
terme = ligne.strip()
if terme and not terme.startswith("#"):
termes.append(terme.lower())
return termes
def identifiants(texte, termes=()):
"""Les données identifiantes d'un texte : (motif, extrait, position).
Chaque OCCURRENCE est rendue, et non chaque valeur : la même adresse citée
à deux endroits est à corriger aux deux.
"""
trouves = []
for trouve in IPV4.finditer(texte):
if adresse_de_machine(trouve.group(0)):
trouves.append(("adresse", trouve.group(0), trouve.start()))
for trouve in EMAIL.finditer(texte):
if not EMAIL_PERMIS.search(trouve.group(0)):
trouves.append(("courriel", trouve.group(0), trouve.start()))
for trouve in HOME.finditer(texte):
if trouve.group(1) not in HOME_PERMIS:
trouves.append(
("compte", f"/home/{trouve.group(1)}/", trouve.start())
)
minuscules = texte.lower()
for terme in termes:
depart = minuscules.find(terme)
while depart != -1:
trouves.append(("nom privé", terme, depart))
depart = minuscules.find(terme, depart + 1)
return sorted(trouves, key=lambda t: t[2])

View file

@ -2686,6 +2686,7 @@ class TODO(
"RTK - CLI proxy to reduce LLM token consumption"
)
},
{"prompt_description": t("Show the context given to Claude")},
]
help_info = self.fill_help_info(choices)
@ -2700,6 +2701,8 @@ class TODO(
self._claude_add_automation()
elif status == "3":
self.prompt_execute_rtk()
elif status == "4":
self._show_claude_context()
else:
print(t("Command not found !"))
@ -2761,6 +2764,152 @@ class TODO(
print("-" * 50)
print(f"{t('Total:')}" f" {len(files)}")
def _claude_context_root(self):
"""La racine du dépôt, deux niveaux au-dessus de ce fichier."""
return os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
def _compte_lignes(self, chemin):
"""Le nombre de lignes d'un fichier ; 0 s'il est illisible."""
try:
with open(chemin, encoding="utf-8", errors="replace") as fh:
return sum(1 for _ in fh)
except OSError:
return 0
def _claude_command_state(self, deployed, template):
"""La copie déployée d'une commande suit-elle encore le gabarit ?
La comparaison ignore les lignes qui portent une identité git : le
déploiement y substitue le nom et le courriel, et une égalité stricte
déclarerait périmée toute commande personnalisée.
"""
if not os.path.isfile(template):
return t("not in the repository")
if not os.path.isfile(deployed):
return t("missing")
def stables(chemin):
with open(chemin, encoding="utf-8", errors="replace") as fh:
return [x for x in fh if "user.name=" not in x]
try:
if stables(deployed) == stables(template):
return t("up to date")
except OSError:
return t("missing")
return t("redeploy needed")
def _claude_memory_dir(self):
"""Le répertoire de mémoire de Claude Code pour CE dépôt.
Le nom du projet est le chemin absolu dont chaque séparateur devient
un tiret : c'est la convention de Claude Code, pas la nôtre.
"""
racine = self._claude_context_root()
projet = racine.replace(os.sep, "-")
return os.path.expanduser(
os.path.join("~/.claude/projects", projet, "memory")
)
def _show_claude_context(self):
"""Ce que Claude reçoit avant la première question : sources et état.
Rend None. Écrit un tableau et ne modifie rien. Ne relève que ce qui
est versionné ou déployé ; ce que `private/` contient n'y figure pas.
"""
racine = self._claude_context_root()
largeur = 62
print(f"🧠 {t('Context given to Claude')}")
print("-" * largeur)
instructions = os.path.join(racine, "CLAUDE.md")
if os.path.isfile(instructions):
n = self._compte_lignes(instructions)
print(f"{t('Instructions'):<22} CLAUDE.md {n} {t('lines')}")
else:
print(f"{t('Instructions'):<22} CLAUDE.md {t('missing')}")
regles = os.path.join(racine, ".claude", "rules")
if os.path.isdir(regles):
noms = sorted(f for f in os.listdir(regles) if f.endswith(".md"))
total = sum(
self._compte_lignes(os.path.join(regles, f)) for f in noms
)
print(
f"{t('Rules'):<22} .claude/rules/ {len(noms)}"
f" {t('files')}, {total} {t('lines')}"
)
for nom in noms:
n = self._compte_lignes(os.path.join(regles, nom))
print(f"{'':<22} {nom:<28} {n} {t('lines')}")
else:
print(f"{t('Rules'):<22} .claude/rules/ {t('missing')}")
skills = os.path.join(racine, ".claude", "skills")
if os.path.isdir(skills):
noms = sorted(
d
for d in os.listdir(skills)
if os.path.isfile(os.path.join(skills, d, "SKILL.md"))
)
print(f"{t('Skills'):<22} .claude/skills/ {len(noms)}")
for nom in noms:
print(f"{'':<22} {nom}")
else:
print(f"{t('Skills'):<22} .claude/skills/ {t('missing')}")
print(f"{t('Deployed commands'):<22} ~/.claude/commands/")
gabarits = {
"commit": "template_claude_commands_commit.md",
"todo_add_command": "template_claude_commands_todo_add_command.md",
}
for nom, gabarit in sorted(gabarits.items()):
etat = self._claude_command_state(
os.path.expanduser(f"~/.claude/commands/{nom}.md"),
os.path.join(racine, "conf", gabarit),
)
print(f"{'':<22} /{nom:<26} {etat}")
chemin_hooks = self._git_hooks_path(racine)
print(
f"{t('Git hooks'):<22}"
f" {chemin_hooks or t('hook not installed')}"
)
if chemin_hooks:
absolu = os.path.join(racine, chemin_hooks)
for hook in ("commit-msg", "pre-commit"):
pose = os.access(os.path.join(absolu, hook), os.X_OK)
marque = (
t("hook installed") if pose else t("hook not installed")
)
print(f"{'':<22} {hook:<26} {marque}")
memoire = self._claude_memory_dir()
if os.path.isdir(memoire):
n = len([f for f in os.listdir(memoire) if f.endswith(".md")])
print(f"{t('Memory'):<22} ~/.claude/projects/…/memory/ {n}")
else:
print(f"{t('Memory'):<22} {t('missing')}")
print("-" * largeur)
def _git_hooks_path(self, racine):
"""La valeur de core.hooksPath, ou None si git n'en déclare aucune."""
try:
sortie = subprocess.run(
["git", "config", "--get", "core.hooksPath"],
capture_output=True,
text=True,
cwd=racine,
timeout=10,
)
except (OSError, subprocess.SubprocessError):
return None
chemin = sortie.stdout.strip()
return chemin or None
def _setup_claude_command(
self, command_name, template_filename, personalize=False
):
@ -3694,7 +3843,7 @@ class TODO(
de modèles et de colonnes, et lesquels sont traduits ou uniques.
La confirmation redemande le NOM de la base. Une frappe sur « o »
se donne par réflexe ; recopier « chezlepro_neutralize_upgrade_18 »
se donne par réflexe ; recopier « sireine_neutralize_upgrade_18 »
oblige à regarder ce qu'on détruit.
"""
from script.analyse import monitoring

View file

@ -6856,6 +6856,166 @@ TRANSLATIONS = {
"fr": "l'outil lui-même a échoué",
"en": "the tool itself failed",
},
# Contexte fourni à Claude
"Show the context given to Claude": {
"fr": "Afficher le contexte fourni à Claude",
"en": "Show the context given to Claude",
},
"Context given to Claude": {
"fr": "Contexte fourni à Claude",
"en": "Context given to Claude",
},
"Instructions": {
"fr": "Instructions",
"en": "Instructions",
},
"Rules": {
"fr": "Règles",
"en": "Rules",
},
"Skills": {
"fr": "Skills",
"en": "Skills",
},
"Deployed commands": {
"fr": "Commandes déployées",
"en": "Deployed commands",
},
"Git hooks": {
"fr": "Hooks git",
"en": "Git hooks",
},
"missing": {
"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",
},
"redeploy needed": {
"fr": "à redéployer",
"en": "redeploy needed",
},
"not in the repository": {
"fr": "absent du dépôt",
"en": "not in the repository",
},
"hook installed": {
"fr": "installé",
"en": "installed",
},
"hook not installed": {
"fr": "non installé",
"en": "not installed",
},
"%s identifying, %s to re-read — see .claude/rules/04-code-conventions.md": {
"fr": "%s identifiant, %s à relire — voir .claude/rules/04-code-conventions.md",
"en": "%s identifying, %s to re-read — see .claude/rules/04-code-conventions.md",
},
"do the comments say how the code works, or where it came from": {
"fr": "les commentaires disent-ils le fonctionnement, ou d'où ils viennent",
"en": "do the comments say how the code works, or where it came from",
},
"only the files added to the git index": {
"fr": "seulement les fichiers ajoutés à l'index git",
"en": "only the files added to the git index",
},
"drop the narrative signals, keep the certain findings": {
"fr": "laisser les signaux de récit, garder les trouvailles sûres",
"en": "drop the narrative signals, keep the certain findings",
},
"give a path, or --staged": {
"fr": "donner un chemin, ou --staged",
"en": "give a path, or --staged",
},
"unknown result": {
"fr": "résultat inconnu",
"en": "unknown result",

View file

@ -1754,7 +1754,7 @@ class TodoUpgrade:
# TODO exécuter next line si status != 0 et log contient
# psycopg2.errors.UndefinedTable: relation "discuss_channel" does not exist
# LIGNE 1 : SELECT "discuss_channel"."id" FROM "discuss_channel" WHERE (...
# source ./.venv.odoo18.0_python3.12.10/bin/activate && cat script/postgresql/migration/fix_migration_postgresql_17_to_postgresql_18_module_mail_nov_2025.py | ./odoo18.0/odoo/odoo-bin shell -d ripbylop_stage_prod_17_nov_2025
# source ./.venv.odoo18.0_python3.12.10/bin/activate && cat script/postgresql/migration/fix_migration_postgresql_17_to_postgresql_18_module_mail_nov_2025.py | ./odoo18.0/odoo/odoo-bin shell -d riplop_stage_prod_17_nov_2025
# psycopg2.errors.ForeignKeyViolation: insert or update on table "discuss_channel_member" violates foreign key constraint "discuss_channel_member_channel_id_fkey"
# DÉTAIL : Key (channel_id)=(20) is not present in table "discuss_channel".
# ./script/database/migrate/process_backup_file.py --path_backup_zip image_db/db.zip --path_output_zip image_db/dbFIX.zip --word_to_delete discuss_channel_channel_type_not_null

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."""
def test_ordinary_names_pass(self):
for nom in ("chezlepro", "el_essai", "a", "base-2024", "_interne"):
for nom in ("sireine", "el_essai", "a", "base-2024", "_interne"):
self.assertTrue(dup.nom_valide(nom), nom)
def test_a_name_that_could_carry_sql_is_refused(self):

View file

@ -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,9 +21,41 @@ import sys
import tempfile
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "script", "git"))
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 commit_msg_lib import MAX, check, subject_of # noqa: E402
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,
MAX_BODY,
body_of,
check,
subject_of,
)
HOOK = os.path.join(
os.path.dirname(__file__), "..", "script", "git", "hooks", "commit-msg"
@ -41,7 +77,9 @@ class TestCeQuiPasse(unittest.TestCase):
def test_les_accents_comptent_pour_un_caractere(self):
"""« é » pèse 2 octets : une limite en octets refuserait ce sujet."""
sujet = "[FIX] déploiement : " + "é" * (MAX - len("[FIX] déploiement : "))
sujet = "[FIX] déploiement : " + "é" * (
MAX - len("[FIX] déploiement : ")
)
self.assertEqual(MAX, len(sujet))
self.assertGreater(len(sujet.encode("utf-8")), MAX)
self.assertEqual([], check(sujet))
@ -50,7 +88,7 @@ class TestCeQuiPasse(unittest.TestCase):
for genere in (
"Merge branch 'develop' into master",
"Merge remote-tracking branch 'origin/develop'",
"Revert \"[FIX] portée : quelque chose\"",
'Revert "[FIX] portée : quelque chose"',
"fixup! [FIX] portée : quelque chose",
"squash! [FIX] portée : quelque chose",
):
@ -92,7 +130,9 @@ class TestCeQuiEstRefuse(unittest.TestCase):
def test_un_sujet_qui_ouvre_sur_une_citation(self):
for ouvrant in ("«", '"', "'", "`", "“"):
problemes = check(f"[FIX] proxmox : {ouvrant}il manque le stockage{ouvrant}")
problemes = check(
f"[FIX] proxmox : {ouvrant}il manque le stockage{ouvrant}"
)
self.assertEqual(1, len(problemes), ouvrant)
self.assertIn("citation", problemes[0])
@ -103,10 +143,203 @@ class TestCeQuiEstRefuse(unittest.TestCase):
)
def test_deux_problemes_sont_rapportes_ensemble(self):
problemes = check("proxmox : « un sujet sans tag et beaucoup trop long » " + "a" * 40)
problemes = check(
"proxmox : « un sujet sans tag et beaucoup trop long » " + "a" * 40
)
self.assertEqual(3, len(problemes))
def _message(corps):
return "[FIX] portée : quelque chose\n\n" + corps + "\n"
class TestLeCorps(unittest.TestCase):
"""Le corps est vérifié sur deux plans : sa longueur, et l'identifiant."""
def test_un_corps_conforme(self):
self.assertEqual([], check(_message("Une raison, en trois mots.")))
def test_un_sujet_seul_na_pas_de_corps(self):
self.assertEqual("", body_of("[FIX] portée : sujet\n"))
self.assertEqual([], check("[FIX] portée : sujet\n"))
def test_dix_lignes_par_langue_passent(self):
moitie = "\n".join(f"ligne {n}" for n in range(MAX_BODY))
corps = f"{moitie}\n\n--- FR ---\n\n{moitie}"
self.assertEqual([], check(_message(corps)))
def test_onze_lignes_pour_une_langue_sont_refusees(self):
moitie = "\n".join(f"ligne {n}" for n in range(MAX_BODY + 1))
problemes = check(_message(moitie))
self.assertEqual(1, len(problemes))
self.assertIn(f"{MAX_BODY + 1} lignes", problemes[0])
def test_la_longueur_est_par_langue_et_non_par_message(self):
"""Le bilinguisme achète la concision : il ne double pas le budget."""
moitie = "\n".join(f"ligne {n}" for n in range(MAX_BODY + 1))
self.assertTrue(check(_message(f"{moitie}\n\n--- FR ---\n\n{moitie}")))
def test_les_lignes_vides_ne_comptent_pas(self):
corps = "\n\n".join(f"ligne {n}" for n in range(MAX_BODY))
self.assertEqual([], check(_message(corps)))
def test_les_trailers_et_le_report_ne_comptent_pas(self):
corps = "\n".join(f"ligne {n}" for n in range(MAX_BODY))
corps += "\n\nAssisted-by: Claude Opus 5"
corps += "\nCo-authored-by: Quelqu'un <personne@exemple.ca>"
corps += "\n(cherry picked from commit 0123456789abcdef)"
self.assertEqual([], check(_message(corps)))
def test_une_adresse_ip(self):
problemes = check(_message("La VM répondait en 172.31.7.42."))
self.assertEqual(1, len(problemes))
self.assertIn("172.31.7.42", problemes[0])
def test_les_adresses_sans_porteur_passent(self):
"""0.0.0.0 et 127.0.0.1 ne désignent aucune machine du parc."""
self.assertEqual(
[], check(_message("Le service écoute sur 127.0.0.1."))
)
self.assertEqual(
[], check(_message("Lié à 0.0.0.0, masque 255.255.255.0."))
)
def test_une_version_nest_pas_une_adresse(self):
self.assertEqual([], check(_message("Passage de 17.0 à 18.0.")))
def test_un_courriel_dans_le_corps(self):
problemes = check(_message("Signalé par personne@exemple.ca."))
self.assertEqual(1, len(problemes))
self.assertIn("courriel", problemes[0])
def test_un_chemin_de_compte(self):
problemes = check(
_message("Le venv vit dans /home/sireine/git/erplibre/.")
)
self.assertEqual(1, len(problemes))
self.assertIn("chemin de compte", problemes[0])
def test_un_chemin_en_gabarit_passe(self):
self.assertEqual(
[], check(_message("Le venv vit dans /home/<utilisateur>/."))
)
def test_une_ligne_checked_reste_du_corps(self):
"""« Checked: » ressemble à un trailer : il ne doit pas s'y soustraire."""
self.assertTrue(check(_message("Checked: 10.10.10.152 répond.")))
def test_la_liste_privee_absente_ne_refuse_rien(self):
origine = commit_msg_lib.NOMS_INTERDITS
commit_msg_lib.NOMS_INTERDITS = os.path.join(
os.path.dirname(origine), "absent_de_ce_depot.txt"
)
try:
self.assertEqual([], check(_message("Migration de acmecorp.")))
finally:
commit_msg_lib.NOMS_INTERDITS = origine
def test_la_liste_privee_refuse_le_nom_quelle_porte(self):
origine = commit_msg_lib.NOMS_INTERDITS
with tempfile.NamedTemporaryFile(
"w", suffix=".txt", delete=False, encoding="utf-8"
) as fh:
fh.write("# un commentaire\n\nacmecorp\n")
commit_msg_lib.NOMS_INTERDITS = fh.name
try:
problemes = check(_message("Migration de AcmeCorp, six paliers."))
self.assertEqual(1, len(problemes))
self.assertIn("nom refusé", problemes[0])
finally:
os.unlink(commit_msg_lib.NOMS_INTERDITS)
commit_msg_lib.NOMS_INTERDITS = origine
def test_un_merge_nest_pas_juge(self):
"""git écrit le corps d'un merge : le refuser refuserait le merge."""
self.assertEqual(
[], check("Merge branch 'develop'\n\n" + "ligne\n" * 40)
)
def test_une_version_de_manifeste_odoo_nest_pas_une_adresse(self):
"""« 18.0.1.0 » a quatre nombres et n'est pas une machine."""
for version in ("18.0.1.0", "17.0.1.3", "12.0.2.1"):
self.assertEqual(
[],
check(_message(f"Le manifeste passe à {version}.")),
version,
)
def test_le_diff_de_cleanup_scissors_nest_pas_le_corps(self):
"""Sous la ligne de ciseaux, tout appartient à git."""
corps = "Une raison.\n\n"
corps += "# ------------------------ >8 ------------------------\n"
corps += "diff --git a/x b/x\n"
corps += "".join("+une ligne avec 10.10.10.5\n" for _ in range(30))
self.assertEqual([], check(_message(corps)))
def test_le_courriel_dun_trailer_est_legitime(self):
corps = "Une raison.\n\nCo-authored-by: Quelquun <personne@exemple.ca>"
self.assertEqual([], check(_message(corps)))
def test_un_nom_prive_dans_un_trailer_est_refuse(self):
"""Un « Refs: » publie autant qu'une phrase du corps."""
origine = commit_msg_lib.NOMS_INTERDITS
with tempfile.NamedTemporaryFile(
"w", suffix=".txt", delete=False, encoding="utf-8"
) as fh:
fh.write("acmecorp\n")
commit_msg_lib.NOMS_INTERDITS = fh.name
try:
problemes = check(_message("Une raison.\n\nRefs: acmecorp-42"))
self.assertEqual(1, len(problemes))
self.assertIn("nom refusé", problemes[0])
finally:
os.unlink(commit_msg_lib.NOMS_INTERDITS)
commit_msg_lib.NOMS_INTERDITS = origine
def test_body_of_rend_les_trailers_sur_demande(self):
message = _message("Une raison.\n\nAssisted-by: Un modèle")
self.assertNotIn("Assisted-by", body_of(message))
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."""
@ -130,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")
@ -145,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)

View file

@ -667,7 +667,7 @@ class TestTheVerdictsSection(unittest.TestCase):
)
def test_a_plain_name_is_its_own_lineage(self):
self.assertEqual("copy_chezlepro3", residue.famille("copy_chezlepro3"))
self.assertEqual("copy_sireine3", residue.famille("copy_sireine3"))
def test_another_migration_verdicts_are_not_shown(self):
# Deux migrations partagent le fichier. Attribuer l'échec de

View file

@ -257,7 +257,7 @@ class TestTheDatabaseNameTheFileSuggests(unittest.TestCase):
return database_name_from_file(chemin, **kw)
def test_the_zip_extension_goes_away(self):
self.assertEqual("chezlepro3", self.nom("image_db/chezlepro3.zip"))
self.assertEqual("sireine3", self.nom("image_db/sireine3.zip"))
def test_an_uppercase_extension_goes_away_too(self):
self.assertEqual("client", self.nom("image_db/CLIENT.ZIP"))

View file

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

View file

@ -28,6 +28,7 @@ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
REQUIREMENTS = os.path.join(REPO, "requirement", "erplibre_require-ments.txt")
PATTERNS = (
"script/*.py",
"script/analyse/*.py",
"script/analyse/*/*.py",
"script/odoo/migration/*.py",