diff --git a/energyplugin/etm/adapters/relayrouter.h b/energyplugin/etm/adapters/relayrouter.h index d4d2185..f16c3be 100644 --- a/energyplugin/etm/adapters/relayrouter.h +++ b/energyplugin/etm/adapters/relayrouter.h @@ -185,7 +185,7 @@ public: * FERMÉS.** Juger la capacité sur les relais fermés ferait répondre « non mesurable » au * palier 0, où aucun ne l'est — la source publiée basculerait à chaque extinction, sur * une installation dont rien n'a changé (LM-1106). - * \note **Tous, et pas au moins un.** Une topologie mixte — un relais mesurant, deux nus — + * \note **Tous, et pas au moins un (LM-1105-a).** Une topologie mixte — un relais mesurant, deux nus — * produirait une somme SOUS-ÉVALUÉE présentée comme la mesure de la charge, et l'écart * commandé↔mesuré enverrait chercher une panne qui n'existe pas (LM-1102). Une mesure * partielle n'est pas une mesure ; c'est le cas où \c meterThingId garde tout son sens. diff --git a/energyplugin/etm/adapters/sgreadyadapter.h b/energyplugin/etm/adapters/sgreadyadapter.h index a9afc46..d412a54 100644 --- a/energyplugin/etm/adapters/sgreadyadapter.h +++ b/energyplugin/etm/adapters/sgreadyadapter.h @@ -121,7 +121,7 @@ public: LoadRuntimeView runtimeView(const QDateTime &now) const override; /*! - * \brief Toujours « non mesurable » — et c'est une propriété du MÉCANISME (§11 / LM-1105). + * \brief Toujours « non mesurable » — et c'est une propriété du MÉCANISME (LM-1105-b). * * \return \c available toujours faux. * diff --git a/tests/auto/simulation/simulation.cpp b/tests/auto/simulation/simulation.cpp index a44dea8..c696adc 100644 --- a/tests/auto/simulation/simulation.cpp +++ b/tests/auto/simulation/simulation.cpp @@ -5715,14 +5715,14 @@ void Simulation::testMeasurementSourceIsPublished() QVERIFY2(mRouteur.contains("powerW"), "une charge mesurable doit publier sa valeur"); QCOMPARE(qRound(mRouteur.value("powerW").toDouble()), 0); - // Le relay-router est servi : son relais est fermé et le mock rapporte 1 000 W. La mesure + // LM-1105-a — le relay-router est servi : relais fermé, le mock rapporte 1 000 W. La mesure // traverse bien le relais — c'est ce qui distingue un organe de COMMUTATION d'un contact // de signal (voir la PAC juste dessous). const QVariantMap mEcs = tele.value("ecs-mesure").value("measurement").toMap(); QCOMPARE(mEcs.value("source").toString(), QString("device")); QCOMPARE(qRound(mEcs.value("powerW").toDouble()), 1000); - // La PAC : « none », ALORS QUE ses contacts portent currentPower. Un contact SG-Ready est + // LM-1105-b — la PAC : « none », ALORS QUE ses contacts portent currentPower. Un contact est // un contact de signal — mesurer sa bobine ne mesure pas la PAC. C'est un état POSITIF, // et powerW est OMIS : une nominale qui se ferait passer pour une mesure produirait un // écart identiquement nul à tous les cycles. diff --git a/tools/gen-rules.py b/tools/gen-rules.py new file mode 100755 index 0000000..b05e1b8 --- /dev/null +++ b/tools/gen-rules.py @@ -0,0 +1,205 @@ +#!/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())