#!/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())