Étapes 1 et 3 du chantier de documentation.
DIX NUMÉROS RETIRÉS — ECS-100/101/102/300/301/304/400/402/403/404. Ils
n'existaient QUE comme une étiquette de trois mots dans une table de statut :
aucun énoncé, nulle part. Leur reconstruire un texte depuis l'étiquette et le
code aurait été inventer une exigence en croyant la restituer, et une règle
inventée qui porte un numéro fait autorité pour toujours. Si le texte d'origine
n'existe nulle part, la règle n'existe pas ; ce que le code fait reste vrai, il
n'a simplement plus de règle à citer.
Retirés, pas effacés : tools/rules-retired.txt les porte, l'index les publie avec
leur date, leur motif et les endroits qui les citent encore. Un identifiant
croisé dans un vieux relevé renvoie donc à quelque chose — RELEVE_ECS306.md cite
toujours ECS-404, et c'est bien. Un numéro retiré n'est JAMAIS réattribué : le
générateur refuse une définition qui en reprendrait un, une même référence ne
doit pas désigner deux exigences selon la date de lecture.
Les citations VIVANTES de ces numéros sont retirées (spec_ecs §2/§4/§5,
AGENTS.md) — les phrases tiennent sans elles. Les citations HISTORIQUES restent.
DEUX TABLES SUPPRIMÉES, pas corrigées. §1 déclarait ECS-410/411/412 « absents »
alors que les trois étaient implémentées et testées. §9 « Traçabilité » nommait
HUIT tests qui n'ont jamais été écrits. Son défaut n'était pas d'être périmée
mais de mélanger un CONSTAT et une INTENTION sous la même colonne — « ECS-306 →
testEcsBudgetUnderLock » est un fait, « ECS-303 → banc » est un projet ; réunis,
le second garantit que l'ensemble devient faux. Les deux sont séparés : le
constat est généré, l'intention devient « Ce qui reste à éprouver », avec pour
chaque exigence POURQUOI elle n'est pas éprouvée.
DOXYGEN (étape 3). doc-generated/RULES.md entre en INPUT — ses ancres {#LM-1105}
rendent \rule{} résoluble — et l'alias \rule{1} est défini. Les specs restent
HORS périmètre : les y ajouter ferait entrer deux fois le même énoncé et
doublerait les ancres. Les 250 citations existantes ne sont pas converties : le
générateur les voit déjà, et \ref sert l'autre sens — du commentaire vers la
règle. Éprouvé sur un cas réel, \rule{LM-1209-c} résout sans avertissement.
Piège d'ordonnancement corrigé au passage : gen-rules.py écrit DANS $SORTIE, donc
l'appeler avant `rm -rf "$SORTIE"` l'effaçait aussitôt — doxygen signalait
« RULES.md is not a readable file ». L'index se produit après le nettoyage. Son
code de retour ne stoppe pas la génération (un index incomplet reste utile à
lire) mais est répercuté à la fin.
Avertissements doxygen : 30 → 26, dont 3 étaient les miens — rankOrigin(),
setRankOrigin() et knownRankOrigins() n'étaient pas documentés (DoD 5).
L'index sort désormais RC=0 : 111 règles, aucun orphelin, aucun domicile ambigu.
69 spécifiées seules, 16 citées par un test, 14 par le code, 12 vérifiées sur
machine. Build 0/0, loadmodel 20/20.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015F7G5VeaPVSMeVNjiGj36p
236 lines
11 KiB
Python
Executable File
236 lines
11 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')
|
|
RETIRES = os.path.join(RACINE, 'tools', 'rules-retired.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():
|
|
def lire(chemin):
|
|
tbl = {}
|
|
if os.path.exists(chemin):
|
|
for l in open(chemin, encoding='utf-8'):
|
|
l = l.split('#')[0].rstrip()
|
|
if not l.strip(): continue
|
|
ch = l.split('\t')
|
|
tbl[ch[0].strip()] = [c.strip() for c in ch[1:]]
|
|
return tbl
|
|
|
|
externes = lire(EXTERNES)
|
|
retires = lire(RETIRES)
|
|
|
|
defs, rows, cites = scanner()
|
|
# Un numéro RETIRÉ est connu : il n'est ni orphelin ni à redéfinir.
|
|
connues = set(defs) | set(externes) | set(retires)
|
|
|
|
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}
|
|
# Un numéro retiré n'est JAMAIS réattribué : une exigence différente sous un
|
|
# identifiant déjà lu ailleurs serait le pire des faux amis.
|
|
ressuscites = sorted(set(defs) & set(retires))
|
|
|
|
# --- 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('')
|
|
|
|
if retires:
|
|
out.append('\n## Numéros retirés\n')
|
|
out.append('Un numéro retiré **se dit retiré**, il ne disparaît pas : un identifiant croisé\n'
|
|
'dans un vieux relevé doit renvoyer à quelque chose. Il n\'est jamais réattribué.\n')
|
|
out.append('| Identifiant | Retiré le | Motif | Encore cité |\n|---|---|---|---|')
|
|
for rid in sorted(retires):
|
|
champs = retires[rid]
|
|
lieux = sorted({x for fam in cites.get(rid, {}) for x in cites[rid][fam]})
|
|
out.append('| %s | %s | %s | %s |' % (
|
|
rid, champs[0] if champs else '',
|
|
champs[1] if len(champs) > 1 else '',
|
|
', '.join('`%s`' % x for x in lieux) if lieux else '—'))
|
|
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 ressuscites:
|
|
pb.append('%d numéro(s) RETIRÉ(S) redéfini(s) — un identifiant retiré ne se réattribue '
|
|
'pas : la même référence désignerait deux exigences selon la date de lecture :'
|
|
'\n %s' % (len(ressuscites), ' '.join(ressuscites)))
|
|
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())
|