Patrick Schurig 3731d7b32f tools: l'index des règles, généré — et son garde-fou
Étape 2 du chantier de documentation. tools/gen-rules.py produit
doc-generated/RULES.md : chaque règle, son énoncé d'une ligne, son domicile, son
état de preuve DÉRIVÉ, et la liste de ce qui la cite. Sort en erreur sur un
identifiant cité et jamais défini, sur un identifiant qui n'existe qu'en ligne de
tableau, et sur un domicile ambigu.

L'état de preuve ne demande AUCUNE convention nouvelle : il se lit dans l'endroit
où l'identifiant apparaît — energyplugin/ pour le code, tests/ pour les tests,
docs/RELEVE_* pour la machine. Citer les règles en commentaire est déjà
l'habitude de la maison, et c'est cette habitude qui devient l'index.

« cité par », jamais « ✅ » : cet index prouve la CITATION, pas la couverture. Un
✅ automatique serait le même mensonge que celui qu'on corrige, en plus rapide.
C'est écrit en tête de la page générée, pas seulement dans un commit.

DEUX MESURES PRÉALABLES ÉTAIENT FAUSSES, et le générateur le montre. Un
identifiant admet des suffixes ALPHABÉTIQUES et NUMÉRIQUES — LM-1006-1,
LM-1105-b, LM-1209-a-bis. Un tiret ordinaire ne peut donc pas séparer
l'identifiant de l'énoncé : « **LM-1006-1** (…) », une citation, se lisait comme
une définition concurrente de LM-1006. D'où le séparateur cadratin, et d'où la
disparition des « 49 orphelins » et des « 20 domiciles ambigus » annoncés :
il en reste DIX, et ce sont les mêmes dans les deux catégories.

Et la première sortie a trouvé une faiblesse dans les citations écrites hier :
LES SOUS-RÈGLES N'HÉRITENT DE RIEN. Citer LM-1105 ne marque pas LM-1105-b, qui
sortait « spécifiée seule » alors qu'elle est implémentée et testée. Corrigé aux
quatre endroits concernés — la règle de convention est désormais : citer
l'identifiant EXACT, jamais le parent.

Aucun changement de comportement : les quatre citations touchées sont des
commentaires. Build 0/0, testMeasurementSourceIsPublished 3/3.

doc-generated/ reste ignoré par git, comme le reste de la sortie de gen-doc.sh :
versionner une page générée ferait un diff à chaque édition de spec. La garantie
qu'elle est à jour appartient au contrôle CI (étape 4).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
2026-08-28 16:08:22 +02:00

206 lines
9.4 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
"""Index des règles métier — GÉNÉRÉ, jamais écrit à la main.
Produit `doc-generated/RULES.md` : chaque règle, son énoncé d'une ligne, son domicile,
son état de preuve DÉRIVÉ, et la liste de ce qui la cite.
Pourquoi généré. Ce dépôt a porté DEUX tables d'état tenues à la main, et les deux
mentaient : `spec_ecs.md` §1 déclarait ECS-410/411/412 « absents » alors que les trois
sont implémentées et testées, et §9 « Traçabilité » nommait huit tests qui n'ont jamais
été écrits. Une table d'état à la main est fausse au premier commit qui la contredit,
et rien ne le signale.
Ce que cet index NE prétend PAS. Il prouve la CITATION, pas la couverture : une règle
nommée dans un commentaire de test n'est pas forcément assertée par ce test. D'où
« cité par », jamais un ✅ — un ✅ automatique serait le même mensonge que celui qu'on
corrige, en plus rapide.
PÉRIMÈTRE : ce dépôt seul. Les règles d'un autre dépôt s'y citent mais ne s'y définissent
pas ; elles sortent en « externes » et non en orphelines (voir tools/rules-external.txt).
"""
import os, re, sys, collections
RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SORTIE = os.path.join(RACINE, 'doc-generated', 'RULES.md')
EXTERNES = os.path.join(RACINE, 'tools', 'rules-external.txt')
# --- Grammaire ---------------------------------------------------------------------
#
# L'identifiant admet des suffixes ALPHABÉTIQUES *et* NUMÉRIQUES : LM-1006-1, LM-1105-b,
# LM-1209-a-bis. C'est ce qui a fait échouer deux mesures préalables — un suffixe « -1 »
# lu comme le tiret d'un énoncé transformait « **LM-1006-1** (…) », une CITATION, en une
# définition concurrente de LM-1006. D'où le séparateur : un TIRET CADRATIN, jamais « - ».
ID = r'(?:LM|ECS)-\d+(?:-[A-Za-z0-9]+)*'
RE_ID = re.compile(r'\b(' + ID + r')\b')
# Trois formes de DÉFINITION coexistent, toutes trois « identifiant, cadratin, énoncé ».
# Elles sont acceptées telles quelles : les unifier coûterait une réécriture de 37 lignes
# pour un gain nul — la donnée extraite est la même.
RE_DEF = [
re.compile(r'^\*\*(' + ID + r')\s*—\s*(.+)$'), # **LM-1105 — énoncé**
re.compile(r'^\*\*(' + ID + r')\*\*\s*—\s*(.+)$'), # **LM-100** — énoncé
re.compile(r'^#{2,4}\s+(' + ID + r')\s*—\s*(.+)$'), # ### LM-1400 — énoncé
]
# Une définition COMPOSÉE : « **ECS-004 / ECS-005 — Frontière tenue.** »
RE_DEF_MULTI = re.compile(r'^\*\*((?:' + ID + r')(?:\s*/\s*(?:' + ID + r'))+)\s*—\s*(.+)$')
# Une LIGNE DE TABLEAU n'est PAS une définition : elle porte une étiquette de trois mots,
# pas un énoncé — et, dans ce dépôt, elle portait aussi un statut faux. Repérée pour
# pouvoir le DIRE, jamais pour valoir domicile.
RE_ROW = re.compile(r'^\|\s*(' + ID + r')\s*\|')
def rel(p):
return os.path.relpath(p, RACINE)
def nettoyer(txt):
"""Un énoncé d'UNE ligne : on coupe à la première phrase, on retire le gras."""
txt = txt.strip().rstrip('*').strip()
txt = re.sub(r'\*\*(.+?)\*\*', r'\1', txt)
txt = re.sub(r'`([^`]+)`', r'\1', txt)
coupe = re.split(r'(?<=[.:])\s', txt, maxsplit=1)[0]
return coupe.strip() if coupe.strip() else txt
def famille(p):
r = rel(p)
if r.startswith('tests/'): return 'test'
if r.startswith('energyplugin/'): return 'code'
if 'RELEVE' in os.path.basename(r): return 'releve'
if r.startswith('specs/'): return 'spec'
return 'doc'
def scanner():
defs = collections.defaultdict(list) # id -> [(fichier, ligne, énoncé)]
rows = collections.defaultdict(list) # id -> [(fichier, ligne)]
cites = collections.defaultdict(lambda: collections.defaultdict(list))
for dp, dn, fn in os.walk(RACINE):
dn[:] = [d for d in dn if d not in ('.git', 'doc-generated', 'build', '.cache')]
for f in sorted(fn):
if not f.endswith(('.md', '.cpp', '.h')):
continue
p = os.path.join(dp, f)
try:
lignes = open(p, encoding='utf-8').read().splitlines()
except (UnicodeDecodeError, OSError):
continue
for i, l in enumerate(lignes, 1):
st = l.strip()
pose = False
m = RE_DEF_MULTI.match(st)
if m:
for rid in RE_ID.findall(m.group(1)):
defs[rid].append((p, i, nettoyer(m.group(2))))
pose = True
if not pose:
for rx in RE_DEF:
m = rx.match(st)
if m:
defs[m.group(1)].append((p, i, nettoyer(m.group(2))))
pose = True
break
if not pose:
m = RE_ROW.match(st)
if m:
rows[m.group(1)].append((p, i))
for rid in set(RE_ID.findall(l)):
cites[rid][famille(p)].append('%s:%d' % (rel(p), i))
return defs, rows, cites
def etat(c):
"""DÉRIVÉ de l'endroit où l'identifiant apparaît — aucune tenue à la main."""
if c.get('releve'): return 'vérifiée sur machine'
if c.get('test'): return 'citée par un test'
if c.get('code'): return 'citée par le code'
return 'spécifiée seule'
def main():
externes = set()
if os.path.exists(EXTERNES):
for l in open(EXTERNES, encoding='utf-8'):
l = l.split('#')[0].strip()
if l: externes.add(l)
defs, rows, cites = scanner()
connues = set(defs) | externes
orphelins = sorted(set(cites) - connues)
sans_enonce = sorted(r for r in rows if r not in connues)
doubles = {k: v for k, v in defs.items() if len(v) > 1}
# --- La page ---------------------------------------------------------------
os.makedirs(os.path.dirname(SORTIE), exist_ok=True)
out = []
out.append('# Index des règles — GÉNÉRÉ\n')
out.append('> **Ne pas éditer.** Produit par `tools/gen-rules.py`. Toute correction se fait\n'
'> dans le document qui DÉFINIT la règle.\n')
out.append('> **Périmètre : ce dépôt seul.** Une règle définie ailleurs se cite ici sans s\'y\n'
'> définir ; elle est déclarée dans `tools/rules-external.txt`.\n')
out.append('> **« cité par » n\'est pas « couvert par ».** Cet index prouve qu\'un identifiant\n'
'> est nommé à un endroit, pas qu\'il y est éprouvé. Le lecteur juge sur pièce —\n'
'> c\'est délibéré : un ✅ automatique mentirait comme les tables qu\'il remplace.\n')
par_etat = collections.Counter(etat(cites[r]) for r in defs)
out.append('\n| | |\n|---|---|')
out.append('| Règles définies | **%d** |' % len(defs))
for e, n in par_etat.most_common():
out.append('| %s | %d |' % (e, n))
out.append('')
for rid in sorted(defs, key=lambda r: (r.split('-')[0], int(r.split('-')[1]), r)):
f, i, enonce = defs[rid][0]
c = cites[rid]
out.append('\n## %s {#%s}\n' % (rid, rid))
out.append('%s\n' % enonce)
out.append('**Domicile** : `%s:%d` · **État** : %s\n' % (rel(f), i, etat(c)))
for fam, titre in (('code', 'Implémentée — cité par'),
('test', 'Éprouvée — cité par'),
('releve', 'Machine — cité par'),
('doc', 'Discutée dans'),
('spec', 'Rappelée dans')):
lieux = [x for x in c.get(fam, []) if not x.startswith(rel(f))] if fam == 'spec' \
else c.get(fam, [])
if lieux:
out.append('- *%s* : %s' % (titre, ', '.join('`%s`' % x for x in sorted(set(lieux)))))
open(SORTIE, 'w', encoding='utf-8').write('\n'.join(out) + '\n')
# --- Le garde-fou ----------------------------------------------------------
pb = []
if orphelins:
pb.append('%d identifiant(s) CITÉ(S) et jamais défini(s) — une règle que plusieurs '
'agents invoquent sans que personne ne puisse la lire :\n %s'
% (len(orphelins), ' '.join(orphelins)))
if sans_enonce:
pb.append('%d identifiant(s) présent(s) UNIQUEMENT en ligne de tableau : une étiquette '
'de trois mots n\'est pas un énoncé, et ne peut pas valoir domicile :\n %s'
% (len(sans_enonce), ' '.join(sans_enonce)))
if doubles:
pb.append('%d règle(s) définie(s) PLUSIEURS fois — le domicile est ambigu :\n%s'
% (len(doubles), '\n'.join(
' %-14s %s' % (k, ', '.join('%s:%d' % (rel(a), b) for a, b, _ in v))
for k, v in sorted(doubles.items()))))
print('%d règles indexées → %s' % (len(defs), rel(SORTIE)))
for e, n in par_etat.most_common():
print(' %-22s %3d' % (e, n))
if pb:
print('\n' + '=' * 78, file=sys.stderr)
print('INDEX INCOMPLET — %d point(s) à trancher.' % len(pb), file=sys.stderr)
print('La page est écrite quand même : elle sert à voir. Le code de retour, lui,',
file=sys.stderr)
print('refuse de laisser passer un index qui se croirait complet.', file=sys.stderr)
print('=' * 78, file=sys.stderr)
for x in pb:
print('\n· ' + x, file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())