Patrick Schurig 2ee6807c27 tools: la classe « cadrage » — et « spécifiée seule » veut enfin dire quelque chose
Le premier usage de l'index avait donné 70 règles « spécifiée seule », un nombre
qui ne voulait rien dire : il mélangeait « pas encore construite » avec « rien ne
pourra jamais la citer ». Une règle NÉGATIVE — « un thermostat n'est PAS une
charge », « aucune ThingClass n'est exposée » — s'applique par ABSENCE de code.
Il n'existe aucune ligne à pointer, et la compter comme un trou de couverture
invite à boucher un trou qui n'existe pas.

tools/rules-noncode.txt déclare 22 règles de cadrage, chacune avec son motif :
constats résolus, interdictions, structure du modèle, hors-périmètre, conventions
de nommage, justifications, et un contrat d'affichage client. §10, §14, ECS-5xx
et ECS-6xx n'y sont PAS : ils attendent d'être construits, et c'est ce que
« spécifiée seule » doit continuer de dire.

Résultat : 41 « spécifiée seule » au lieu de 70, dont 27 en territoire non
construit. Restent ~14 règles en territoire livré que rien ne cite — un nombre
qu'on peut lire une par une.

LE GARDE-FOU, éprouvé et non supposé : le générateur CASSE si une règle de
cadrage acquiert une citation de code ou de test. Vérifié en ajoutant une
citation de LM-202 dans un en-tête — RC=1, la règle nommée, le fichier et la
ligne donnés. Même mécanique que le refus de redéfinir un numéro retiré.

Et la réparation est TOUJOURS la même : retirer la ligne. Deux cas la
déclenchent — la règle a été implémentée depuis, ou elle est citée pour expliquer
une absence — et dans les deux « citée par le code » est plus vrai que
« cadrage ». La liste ne peut donc que RÉTRÉCIR : elle ne sait pas mentir dans le
sens qui compte. C'est ce qui rend une liste tenue à la main acceptable ici.

ci-quality 3/3.

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

263 lines
13 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')
CADRAGE = os.path.join(RACINE, 'tools', 'rules-noncode.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, estCadrage=False):
"""DÉRIVÉ de l'endroit où l'identifiant apparaît — aucune tenue à la main.
Seule exception : « cadrage », déclaré dans tools/rules-noncode.txt. Ce n'est pas un
état de preuve mais une NATURE — une règle négative s'applique par absence de code, et
la compter comme un trou de couverture invite à boucher un trou qui n'existe pas. Le
garde-fou vit dans main() : une règle de cadrage citée par du code fait ÉCHOUER la
génération, parce que la classification est alors périmée ou inutile.
"""
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'
if estCadrage: return 'cadrage (aucun porteur attendu)'
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)
cadrage = lire(CADRAGE)
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], r in cadrage) 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, rid in cadrage)))
if rid in cadrage and cadrage[rid]:
out.append('- *Cadrage* : %s' % cadrage[rid][0])
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)))
# Une règle de CADRAGE citée par du code : la classification ne tient plus. Soit la règle
# a été implémentée depuis, soit elle est citée pour expliquer une absence — dans les deux
# cas elle a désormais un porteur lisible, et « citée par le code » est plus vrai que
# « cadrage ». La réparation est toujours la même : retirer la ligne. La liste ne peut donc
# que rétrécir, elle ne sait pas mentir dans le sens qui compte.
cadrage_porte = sorted(r for r in cadrage
if cites.get(r, {}).get('code') or cites.get(r, {}).get('test'))
if cadrage_porte:
pb.append('%d règle(s) déclarée(s) CADRAGE mais citée(s) par du code ou un test — la '
'classification ne tient plus, RETIRER la ligne de tools/rules-noncode.txt :'
'\n%s' % (len(cadrage_porte), '\n'.join(
' %-14s cité par %s' % (r, ', '.join(
'`%s`' % x for fam in ('code', 'test')
for x in cites[r].get(fam, [])))
for r in cadrage_porte)))
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())