diff --git a/AGENTS.md b/AGENTS.md index 5ad9f2c..daf44f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. --- diff --git a/Doxyfile b/Doxyfile index fa0830c..1556237 100644 --- a/Doxyfile +++ b/Doxyfile @@ -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 diff --git a/energyplugin/etm/types/loadconfig.h b/energyplugin/etm/types/loadconfig.h index 797c6ee..bd3cea8 100644 --- a/energyplugin/etm/types/loadconfig.h +++ b/energyplugin/etm/types/loadconfig.h @@ -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(); /*! diff --git a/specs/spec_ecs.md b/specs/spec_ecs.md index 3c23094..7399477 100644 --- a/specs/spec_ecs.md +++ b/specs/spec_ecs.md @@ -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`, diff --git a/tools/gen-doc.sh b/tools/gen-doc.sh index 1342e61..794f170 100755 --- a/tools/gen-doc.sh +++ b/tools/gen-doc.sh @@ -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 diff --git a/tools/gen-rules.py b/tools/gen-rules.py index b05e1b8..06076dc 100755 --- a/tools/gen-rules.py +++ b/tools/gen-rules.py @@ -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( diff --git a/tools/rules-retired.txt b/tools/rules-retired.txt new file mode 100644 index 0000000..4715d02 --- /dev/null +++ b/tools/rules-retired.txt @@ -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 : IDENTIFIANTdatemotif +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