erplibre/script/analyse/check_comment_hygiene.py
Mathieu Benoit 0e5441ac39 [ADD] commentaires : un relevé non bloquant, sa règle, le code nettoyé
Rien ne relevait les commentaires hors convention. L'outil lit commentaires et
docstrings, jamais le code autour, et rend deux familles inégales :
l'identifiant — adresse, courriel, chemin de compte, nom de la liste privée —
est une trouvaille ; le récit — témoignage, date, personne — un signal à relire.

Le hook pre-commit le lance sur l'index, sort toujours en 0 — un contrôle
bloquant à cette échelle se fait désinstaller — et parle quand l'outil échoue.
La règle du générateur et sa doc portent le nettoyage au fur et à mesure, et
l'exemple d'un interdit s'invente : base, adresse, compte et hôte en prennent un.

L'écran du contexte est posé, sans entrée de menu. Vérifié : 252 tests des six
fichiers d'essai touchés passent.

--- EN ---

Nothing reported the comments that break the convention. The tool reads
comments and docstrings, never the code around them, and returns two unequal
families: identifying data — address, e-mail, account path, private-list name
— is a finding; narrative — witness marker, date, person — a signal to re-read.

The pre-commit hook runs it on the index, always exits 0 — a blocking check at
that scale gets uninstalled — and speaks when the tool fails. The generator
rule and its doc carry the clean-up as you go, and a forbidden thing's example
is invented: a real database, address, account and host each take one.

The context screen is in place, with no menu entry. Checked: 252 tests of the
six touched fixture files pass.

Assisted-by: Claude Opus 5
2026-08-30 06:08:30 -04:00

432 lines
14 KiB
Python

#!/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())