É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
206 lines
9.4 KiB
Python
Executable File
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())
|