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
263 lines
13 KiB
Python
Executable File
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())
|