docs(règles): dix numéros retirés, deux tables supprimées, l'index câblé à Doxygen

É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
This commit is contained in:
Patrick Schurig 2026-08-29 09:36:18 +02:00
parent 3731d7b32f
commit b552a1745b
7 changed files with 161 additions and 60 deletions

View File

@ -437,7 +437,7 @@ Implémentation : `rulebasedscheduler.cpp:186`.
> C'est un **artefact de banc**. Sur une installation réelle l'ECS est derrière le
> compteur réseau : sa consommation fait réellement chuter l'export, et le recrédit
> compense exactement ce que la charge vient de retirer. Le supprimer casserait
> l'anti-oscillation (ECS-404) sans rien régler. Cf. `docs/RELEVE_ECS306.md` §4.2.
> l'anti-oscillation sans rien régler. Cf. `docs/RELEVE_ECS306.md` §4.2.
---

View File

@ -37,9 +37,23 @@ CREATE_SUBDIRS = NO
OUTPUT_LANGUAGE = French
# --- Périmètre -------------------------------------------------------------
INPUT = energyplugin/etm
# doc-generated/RULES.md est GÉNÉRÉ par tools/gen-rules.py, que gen-doc.sh appelle avant
# doxygen. Il porte une ancre {#LM-1105} par règle : c'est ce qui rend \rule{} résoluble.
# Les specs elles-mêmes restent HORS du périmètre — les y ajouter ferait entrer deux fois
# le même énoncé (une fois en prose, une fois dans l'index) et doublerait les ancres.
INPUT = energyplugin/etm doc-generated/RULES.md
RECURSIVE = YES
FILE_PATTERNS = *.h *.cpp
FILE_PATTERNS = *.h *.cpp *.md
# \rule{LM-1105} — un lien du COMMENTAIRE vers la règle. C'est le sens que \ref sert
# utilement : un lecteur dans relayrouter.cpp veut le texte de la règle. Le sens inverse —
# la page d'une règle liste ce qui l'implémente — est produit par gen-rules.py depuis les
# citations déjà présentes, sans qu'aucun commentaire ait à être réécrit.
#
# Les citations EXISTANTES restent en texte libre, délibérément : les convertir serait une
# réécriture de 250 commentaires pour un gain de navigation, et le générateur les voit déjà.
# Les nouvelles utilisent \rule{}.
ALIASES = "rule{1}=\ref \1 \"\1\""
# --- Extraction ------------------------------------------------------------
# EXTRACT_ALL = NO : sans cela doxygen documente tout d'office et n'avertit de

View File

@ -213,7 +213,7 @@ public:
void setMeterThingId(const QString &v) { m_meterThingId = v; }
/*!
* \brief Origine du RANG de cette charge — \c "auto", \c "user", ou vide. §12 / LM-1209-c.
* \brief Origine du RANG de cette charge — \c "auto", \c "user", ou vide. \rule{LM-1209-c}
*
* - \c "auto" : l'entrée a été créée d'office par le moteur et **son rang n'a jamais été
* choisi par personne** — c'est un défaut d'insertion (queue de liste), pas une décision.
@ -234,10 +234,14 @@ public:
* laisse celle-ci en \c "auto" : elle n'a toujours été classée par personne. Voir
* \c LoadConfigStore::reconcileRankOrigin().
*/
//! \return \c "auto", \c "user", ou une chaîne vide — l'absence n'affirme rien.
QString rankOrigin() const { return m_rankOrigin; }
//! \brief Pose l'origine du rang. \param v \c "auto" (moteur seul), \c "user", ou vide.
void setRankOrigin(const QString &v) { m_rankOrigin = v; }
//! \brief Valeurs admises de \c rankOrigin. Énumération FERMÉE, déclarée UNE fois.
//! \return \c {"auto", "user"} — le VIDE n'en fait pas partie : il ne vaut pas « autre
//! origine », il vaut « rien n'est affirmé ».
static QStringList knownRankOrigins();
/*!

View File

@ -125,28 +125,29 @@ externalisée. Le mélange avec nymea est superficiel : trois fonctions pures
(`m_levels`, `stageForPower`, `lockWindow`) noyées dans une classe qui fait
aussi l'I/O — une extraction d'environ 40 lignes, pas une reconstruction.
| Exigence | Objet | Statut |
|---|---|---|
| ECS-001 | Pas d'accès cross-thing | ✅ reconfirmé sur le libnymea du build |
| ECS-100 | Désignation par `ThingId` | ✅ `loadconfig.h:46` |
| ECS-101 | Cas à un seul étage | ✅ `relayrouter.cpp:49-64`, testé |
| ECS-102 | Configuration externalisée | ✅ `LoadConfigStore` + RPC — était marqué ouvert à tort |
| ECS-300 | Palier ≤ budget | ⚠️ vrai hors verrou, faux sous verrou → **ECS-306** |
| ECS-301 | Puissances quelconques, non cascadé | ✅ énumération des 2^N sous-ensembles, testé sur une topologie non cascadée |
| ECS-302 | Encodages équivalents | ❌ le second encodage est jeté (structure) — **non exercé par l'installation de référence**, cf. §4.0 |
| ECS-304 | Testable hors nymea | ⚠️ 3 fonctions pures extractibles, le reste couplé |
| ECS-400 | Temporisations non codées en dur | ✅ `minOnS`/`minOffS` de la config |
| ECS-402 | Coupure avant fermeture | ✅ `relayrouter.cpp:240-244`, testé |
| ECS-403 | Clamp lock-aware côté scheduler | ❌ `minStage`/`maxStage` **retirés** du contexte en rév. 2/3 |
| ECS-404 | Anti-oscillation | ✅ recrédit `currentPowerW`, `rulebasedscheduler.cpp:186` |
| ECS-110 | Validation de configuration | ⚠️ partielle |
| ECS-111 | Suppression d'un Thing référencé | ❌ non défini |
| ECS-305 | Compteur de commutations | ❌ absent |
| ECS-410 | Échec d'écriture | ❌ absent, `available` codé en dur |
| ECS-411 | Reprise du palier | ❌ absent |
| ECS-412 | Survie au `rebuild` | ❌ absent (nouveau) |
| §5 | Mesure par charge | ⚠️ demi-pas |
| §6 | Thermique | ❌ absent, et sans canal d'entrée |
> **La table d'état qui vivait ici a été SUPPRIMÉE le 2026-08-28, pas corrigée.**
> Elle déclarait ECS-410, ECS-411 et ECS-412 « ❌ absents » alors que les trois étaient
> implémentées **et** testées. Une colonne de statut tenue à la main est fausse au premier
> commit qui la contredit, et rien ne le signale — c'est deux sources pour un même fait, ce
> qui a déjà donné `renderFr()`.
>
> **L'état de preuve de chaque règle est désormais DÉRIVÉ** de l'endroit où son identifiant
> apparaît — `energyplugin/` pour le code, `tests/` pour les tests, `docs/RELEVE_*` pour la
> machine — et publié par `tools/gen-rules.py` dans `doc-generated/RULES.md`. Il s'y lit
> « cité par », jamais « ✅ » : cela prouve la citation, pas la couverture.
>
> **Dix numéros y sont morts avec elle** — ECS-100, ECS-101, ECS-102, ECS-300, ECS-301,
> ECS-304, ECS-400, ECS-402, ECS-403, ECS-404. Ils n'existaient **que** comme une étiquette
> de trois mots dans cette table : 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.
>
> Ils sont **retirés, pas effacés** : `tools/rules-retired.txt` les porte, l'index les
> publie, et un identifiant croisé dans un vieux relevé renvoie donc à quelque chose. Un
> numéro retiré n'est **jamais** réattribué — le générateur refuse une définition qui en
> reprendrait un.
**ECS-203 — CLOS.** Aucune ThingClass exposée, donc aucune interface nymea à
implémenter. L'unité interne reste le **watt**.
@ -167,7 +168,8 @@ pas des fonctionnalités manquantes. Ils passent devant tout le reste.
> ECS-411 en place : il ne demande aucun code dédié.
**Étape 2 — extraction du noyau de calcul (type pur, sans `ThingManager`).**
Sortir `m_levels`, `stageForPower` et `lockWindow`. Débloque ECS-304 en vrai
Sortir `m_levels`, `stageForPower` et `lockWindow`. Rend la combinatoire testable hors
nymea, en vrai
unitaire et conditionne l'étape 3.
> Ne pas lire « domaine » ici. `specs/spec_loadmodel.md` LM-100 emploie ce mot
@ -429,7 +431,7 @@ simultanément, le seuil d'hystérésis de cette frontière DOIT être élargi p
rapport aux autres.
Justification : une recombinaison complète coûte le maximum de commutations et,
sous l'ordre coupure-avant-fermeture d'ECS-402, fait traverser un creux de
sous l'ordre coupure-avant-fermeture, fait traverser un creux de
puissance à la charge. Une oscillation du surplus autour d'une telle frontière
est donc bien plus coûteuse qu'ailleurs, en usure comme en confort.
@ -450,7 +452,7 @@ premier contacteur arrivera en fin de vie** — la puissance commutée ne le dit
**ECS-307 — Grain des temporisations.** `minOn`/`minOff` sont aujourd'hui **par
charge** (un unique `m_lastSwitch`, `relayrouter.h:107`), ce qui satisfait
ECS-400. Le grain par relais qu'appelle ECS-305 n'existe pas. Décider en étape 3
la configuration. Le grain par relais qu'appelle ECS-305 n'existe pas. Décider en étape 3
s'il est nécessaire, ou si le compteur de commutations suffit à l'observation.
**ECS-308** — Le plafond `MaxRelays = 16` (`relayrouter.cpp:17`) tronque
@ -801,32 +803,34 @@ mécanique reste **L0**. Perte de sonde → mode sans sonde, pas arrêt d'urgenc
## §9 — Traçabilité (exigences ouvertes)
| Exigence | Type | Test |
> **La table de traçabilité qui vivait ici a été SUPPRIMÉE le 2026-08-28.** Elle nommait
> **huit tests qui n'ont jamais été écrits** — `testEcsSwitchCount`, `testEcsSwitchCost`,
> `testEcsLevelsPure`, `testEcsConfigValidation`, `testEcsStageFault`,
> `testEcsTemperatureTarget`, `testEcsLegionella`, `testEcsNoSensorFallback` — sous une
> colonne intitulée « Test ». Une table de traçabilité qui trace vers le vide.
>
> **Son défaut n'était pas d'être périmée, c'était de mélanger un CONSTAT et une
> INTENTION sous la même colonne** : « ECS-306 → `testEcsBudgetUnderLock` » est un fait
> vérifiable, « ECS-303 → banc » est un projet. Réunis, le lecteur ne peut plus distinguer
> les deux — et le second garantit que l'ensemble devient faux.
>
> Les deux sont désormais séparés : **le constat est GÉNÉRÉ** (`doc-generated/RULES.md`,
> section « cité par » de chaque règle — vrai par construction), **l'intention est la prose
> ci-dessous**, qui est ce qu'elle a toujours été.
### Ce qui reste à éprouver
Aucune de ces exigences n'a de test aujourd'hui. La liste est un **plan**, pas un état :
l'état se lit dans l'index généré.
| Exigence | Comment l'éprouver | Pourquoi ça n'est pas fait |
|---|---|---|
| ECS-306 | simulation | `testEcsBudgetUnderLock` |
| ECS-309 | simulation | `testEcsColdStartLockExpires` — les trois causes distinguées par le TEXTE du motif (minOff / budget / minOn), vérifié échouant sans le correctif |
| ECS-309-b | simulation | `testEcsColdStartLockExpires` §4 — palier 0 + budget négatif SANS verrou actif : le motif nomme le budget, jamais `minOn`. Vérifié échouant sans le correctif, où il reproduit le texte exact du banc |
| ECS-412 | simulation | `testEcsRebuildPreservesLock` + **`testEcsColdStartLockExpires`** (armement à froid transitoire) |
| ECS-411-b | simulation | `testEcsRestartRecovery` cas 1 / 1-bis / 1-ter — la reprise s'annonce au palier 0, nomme l'état lu de chaque relais, passe en avertissement sur relais injoignable, et le cas 1-ter couvre la nominale évincée par déduplication. Vérifiés échouant sans les correctifs |
| ECS-303 | banc | relevé de commutations à la frontière de recombinaison (§4.0) — **mesurer avant de coder** |
| ECS-305 | unitaire | `testEcsSwitchCount` — compteur par relais |
| ECS-302 | unitaire | `testEcsSwitchCost` — non exercé par l'installation de référence ; à tester sur un câblage à encodages multiples |
| ECS-304 | unitaire | `testEcsLevelsPure` |
| ECS-410 | simulation | `testEcsPartialFailure` — cible, retour arrière, arrêt total, défaut collant |
| ECS-411 | simulation | `testEcsRestartRecovery` |
| ECS-413 | simulation | `testEcsDisableLeavesSafeState` — + cas négatif : un rebuild sans désactivation ne coupe pas |
| ECS-411 / -411-b **au SG-Ready** | simulation | `testSgReadyRestartRecovery` — état RELU sur les contacts (4, puis neutre, puis contact injoignable → avertissement), relevé contact par contact avec l'issue, **et** le corollaire LM-104 : la remise au neutre ÉCRIT au lieu de se croire idempotente |
| ECS-412 **au `minStateHold`** | simulation | `testSgReadySurplus` volet 0 (armement à froid : gel puis EXPIRATION) + `testSgReadyPartialFailure` + `testRuntimeViewIsSubstantial` — l'armement est paresseux, jamais permanent |
| Repli L2 d'une PAC **de configuration** | simulation | `testLoadTelemetryRpc` — sous compteur muet, `state == 2` **et contacts K1/K2 ouverts** : le motif seul ne prouvait rien, c'est l'écriture qui manquait (lot B-bis) |
| ECS-414 | simulation | `testSgReadyPartialFailure` — plancher = état 2, atomicité du repli, contact injoignable |
| LM-302 (frontière RPC) | simulation | `testLoadConfigRpc` §3bis — va-et-vient Get → Set réinjecté tel quel (vérifié : le test échoue sans le correctif) |
| ECS-110-b | simulation | `testThingOwnershipIsExclusive` — conflit entre charges (3 mécanismes), formes d'uuid, id dupliqué, doublons intra-charge, tolérance d'une charge désactivée |
| ECS-110 (SG-Ready) / LM-300 | simulation | `testSgReadyFromConfig` — refus sans état 2, refus de charges utiles mélangées, round-trip, pilotage, désactivation → état 2 |
| ECS-110, ECS-111 | unitaire | `testEcsConfigValidation` — DOIT s'exécuter aussi en build release (`QT_NO_DEBUG`), sinon il ne prouve rien du binaire livré |
| ECS-501, ECS-502 | simulation | `testEcsStageFault` |
| ECS-601, ECS-602 | simulation | `testEcsTemperatureTarget` |
| ECS-603 | simulation | `testEcsLegionella` |
| ECS-600, ECS-604 | simulation | `testEcsNoSensorFallback` |
| **ECS-302** | unitaire — coût de commutation entre encodages équivalents | **l'installation de référence ne l'exerce pas** (§4.0) : il y faut un câblage à encodages multiples |
| **ECS-303** | **banc** — relevé de commutations à la frontière de recombinaison | à **mesurer avant de coder** : on ne sait pas encore si le phénomène mord |
| **ECS-305** | unitaire — compteur de commutations par relais | le grain par relais n'existe pas encore (§5) |
| **ECS-110 · ECS-111** | unitaire, et **il doit tourner en build release** (`QT_NO_DEBUG`) : sinon il ne prouve rien du binaire livré | la validation de configuration est partiellement couverte par `testThingOwnershipIsExclusive`, sans le cas du Thing supprimé |
| **ECS-501 · ECS-502** | simulation — défaut de palier | dépend de l'échelle de défaut généralisée (ECS-414) |
| **ECS-600 · ECS-601 · ECS-602 · ECS-603 · ECS-604** | simulation — cible de température, légionelle, repli sans sonde | **§8 n'a pas de canal d'entrée** : la sonde est publiée, rien ne la lit encore (LM-1103) |
Acquis à ne pas refaire : `testEcsRelayTopologies`, `testLoadConfigRelayRouter`,
`testLoadConfigBuildsAdapters`, `testLoadConfigRpc`, `testEcsSurplusPV`,

View File

@ -40,6 +40,14 @@ if [ "$manquant" -ne 0 ]; then
exit 127
fi
# L'INDEX DES RÈGLES d'abord : le Doxyfile le prend en entrée, et ses ancres {#LM-1105}
# sont ce qui rend \rule{} résoluble. Un index absent produirait des \ref cassés en masse,
# donc un compte d'avertissements incomparable — exactement ce que ce script existe pour
# empêcher.
#
# Son code de retour ne STOPPE pas la génération : un index incomplet reste utile à lire,
# et refuser de produire la doc parce qu'une règle manque punirait le mauvais geste. Il est
# répercuté à la fin, pour que l'appelant — un humain ou la CI — le voie.
VERSION_REELLE="$(doxygen --version | cut -d' ' -f1)"
if [ "$VERSION_REELLE" != "$VERSION_ATTENDUE" ]; then
# Avertissement et non erreur : la génération reste utile, mais le compte
@ -50,6 +58,19 @@ if [ "$VERSION_REELLE" != "$VERSION_ATTENDUE" ]; then
fi
rm -rf "$SORTIE"
# L'INDEX DES RÈGLES, et il se produit APRÈS le nettoyage — il vit DANS $SORTIE, donc le
# générer avant reviendrait à l'effacer aussitôt. Doxygen le prend en entrée : ses ancres
# {#LM-1105} sont ce qui rend \rule{} résoluble, et un index absent produirait des \ref
# cassés en masse, donc un compte d'avertissements incomparable — exactement ce que ce
# script existe pour empêcher.
#
# Son code de retour ne STOPPE pas la génération : un index incomplet reste utile à lire, et
# refuser de produire la doc parce qu'une règle manque punirait le mauvais geste. Il est
# répercuté à la fin, pour que l'appelant — humain ou CI — le voie.
echo "→ index des règles"
python3 "$RACINE/tools/gen-rules.py"
RC_REGLES=$?
JOURNAL="$(mktemp)"
trap 'rm -f "$JOURNAL"' EXIT
@ -78,4 +99,11 @@ echo " Rappel : le graphe d'héritage est tronqué par construction — la clas
echo " base amont SmartChargingManager est hors INPUT. Voir l'en-tête du"
echo " Doxyfile ; ne pas « réparer » en élargissant le périmètre."
if [ "$RC_REGLES" -ne 0 ]; then
echo ""
echo " ⚠ L'INDEX DES RÈGLES est incomplet — voir la sortie d'erreur ci-dessus."
echo " La documentation est produite quand même ; le code de retour, lui, le dit."
[ "$code" -eq 0 ] && code="$RC_REGLES"
fi
exit $code

View File

@ -24,6 +24,7 @@ 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 ---------------------------------------------------------------------
#
@ -120,18 +121,29 @@ def etat(c):
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)
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()
connues = set(defs) | externes
# 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)
@ -152,6 +164,20 @@ def main():
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]
@ -179,6 +205,10 @@ def main():
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(

21
tools/rules-retired.txt Normal file
View File

@ -0,0 +1,21 @@
# Numéros de règle RETIRÉS — un numéro retiré se dit retiré, il ne disparaît pas.
#
# Un identifiant croisé dans un vieux relevé doit renvoyer à quelque chose. Sans cette
# liste, il renverrait au néant — et le lecteur ne saurait pas distinguer « règle
# supprimée » de « règle qu'on a oublié d'écrire ».
#
# RÈGLE D'USAGE : un numéro retiré n'est JAMAIS réattribué. Le générateur refuse une
# définition qui en reprendrait un — une exigence différente sous un identifiant déjà
# lu ailleurs serait le pire des faux amis.
#
# format : IDENTIFIANT<TAB>date<TAB>motif
ECS-100 2026-08-28 énoncé introuvable — n'existait que comme étiquette de tableau
ECS-101 2026-08-28 énoncé introuvable — n'existait que comme étiquette de tableau
ECS-102 2026-08-28 énoncé introuvable — n'existait que comme étiquette de tableau
ECS-300 2026-08-28 énoncé introuvable — n'existait que comme étiquette de tableau
ECS-301 2026-08-28 énoncé introuvable — n'existait que comme étiquette de tableau
ECS-304 2026-08-28 énoncé introuvable — n'existait que comme étiquette de tableau
ECS-400 2026-08-28 énoncé introuvable — n'existait que comme étiquette de tableau
ECS-402 2026-08-28 énoncé introuvable — n'existait que comme étiquette de tableau
ECS-403 2026-08-28 énoncé introuvable — n'existait que comme étiquette de tableau
ECS-404 2026-08-28 énoncé introuvable — n'existait que comme étiquette de tableau