ci: le contrôle de qualité documentaire — écrit, éprouvé, PAS en service

Étape 4 du chantier de documentation. tools/ci-quality.sh porte les trois
vérifications qui se posent au même endroit — elles partagent la même sortie
(doc-generated/) et la même commande de réparation, les séparer ferait trois jobs
qui régénèrent trois fois la même chose :

  1. l'index des règles est VRAI (code de retour de gen-rules.py) ;
  2. la doc Doxygen est complète — 0 avertissement, DoD §5 ;
  3. l'index est REPRODUCTIBLE : deux passes, un seul résultat.

PAS EN SERVICE, et l'extension le garantit : ci/gitea-workflow.yml.disabled ne
déclenche rien tant qu'il n'est pas déplacé dans .gitea/workflows/. La raison est
datée dans le fichier — forge muette, plus de trente commits en attente ici et
plus de soixante côté app ; un job activé à l'aveugle échouerait au moment précis
où tout le monde pousse, et le premier réflexe serait de le désactiver.

CHAQUE ÉCHEC DONNE LA COMMANDE À LANCER. Un job qui annonce « l'index est
périmé » sans dire quoi faire se contourne en le désactivant : c'est le chemin de
moindre effort, et il gagne toujours.

VÉRIFIÉ AVANT D'ÊTRE DÉCLARÉ PRÊT, et ça ne passait pas du premier coup.

— Les 26 avertissements Doxygen de ce matin sont CORRIGÉS, pas contournés par un
  cliquet sur un compte de référence : c'était de la dette DoD-5 (retours et
  paramètres non documentés dans relayrouter.h, energyarbitrator.h, loadconfig.h,
  les trois clearFault(), ILoadAdapter::updateSoftConfig), plus deux que j'avais
  introduits — un \rule{} dans un titre \par, et un \param energyLogs qui ne
  correspond à aucun argument. Le seuil est donc ZÉRO, le seul qui ne rote pas.

— Et le script lui-même échouait sur un dépôt SAIN : `grep -c ... || echo 0`
  affiche 0 PUIS sort en 1 quand il ne trouve rien, si bien que le « || » empile
  un second zéro et que le test entier casse. Corrigé ici et dans gen-doc.sh, qui
  portait le même piège. Une commande de mesure dont l'échec produit une lecture
  fausse — le motif de la semaine, appliqué à l'outil.

CE QUI N'EST PAS VÉRIFIÉ, et pourquoi : « régénérer et refuser si ça diffère de
ce qui est commité » n'a rien à comparer, doc-generated/ étant dans .gitignore.
Le risque qu'un tel diff attraperait — un artefact commité qui dérive de sa
source — a été SUPPRIMÉ en ne le commitant pas, pas déplacé. Ce qui reste à
garantir est que l'index soit vrai (contrôle 1) et déterministe (contrôle 3).

État au 2026-08-29 : ./tools/ci-quality.sh → RC=0, 113 règles, 0 avertissement.

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 11:33:46 +02:00
parent a2ff20889c
commit 010a7da0f4
10 changed files with 227 additions and 4 deletions

View File

@ -0,0 +1,63 @@
# ─────────────────────────────────────────────────────────────────────────────
# ÉCRIT, PAS EN SERVICE — et l'extension `.disabled` est ce qui le garantit.
#
# POUR L'ACTIVER, quand la forge répondra et que le retard de commits sera résorbé :
#
# mkdir -p .gitea/workflows
# git mv ci/gitea-workflow.yml.disabled .gitea/workflows/qualite-doc.yml
#
# Le déposer ailleurs que dans `.gitea/workflows/` ne déclenche rien : c'est
# volontaire. Un fichier renommé est un geste ; un fichier au bon endroit est un
# service.
#
# POURQUOI PAS MAINTENANT (2026-08-29). La forge ne répond pas et plus de trente
# commits attendent en local dans ce dépôt, plus de soixante côté app. Un job
# activé à l'aveugle échouerait à son premier passage — au moment précis où tout
# le monde pousse — et le premier réflexe serait de le désactiver. Un garde-fou
# désactivé une fois ne se réactive jamais.
#
# AVANT D'ACTIVER, lancer le contrôle À LA MAIN sur la branche visée :
#
# ./tools/ci-quality.sh
#
# Il passait sur l'état du dépôt au 2026-08-29 — index RC=0 (113 règles), doxygen
# 0 avertissement. S'il échoue, la cause est dans le dépôt, pas dans le job : le
# corriger AVANT l'activation, jamais après. Un job qu'on n'a jamais vu passer
# n'est pas un job, c'est une intention.
#
# CE QU'IL VÉRIFIE — trois choses, un seul endroit, parce qu'elles partagent la
# même sortie et la même commande de réparation :
# 1. l'index des règles est VRAI (orphelins, domiciles ambigus, numéros retirés
# réattribués) ;
# 2. la documentation Doxygen est complète (0 avertissement, DoD §5) ;
# 3. l'index est REPRODUCTIBLE (deux passes, un seul résultat).
#
# Ce qu'il ne vérifie PAS : « régénérer et refuser si ça diffère de ce qui est
# commité ». `doc-generated/` est dans .gitignore ; il n'y a rien à comparer, et
# le risque qu'un tel diff attraperait — un artefact commité qui dérive de sa
# source — a été SUPPRIMÉ en ne le commitant pas, pas déplacé.
# ─────────────────────────────────────────────────────────────────────────────
name: qualité documentaire
on:
push:
branches: [ feature/beta-rulebased, main ]
pull_request:
jobs:
qualite-doc:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# doxygen ET graphviz : dépendances de la CIBLE DOC uniquement, jamais du
# build du paquet. Sans elles le contrôle 2 ne s'exécute pas — et il le DIT
# au lieu de passer en silence, ce qui serait un faux vert.
- name: dépendances de la cible doc
run: sudo apt-get update && sudo apt-get install -y doxygen graphviz python3
# Une commande unique, celle qu'un humain lance. Si le job et la machine de
# développement divergeaient, le job cesserait d'être reproductible chez soi
# — et un contrôle qu'on ne peut pas rejouer localement se contourne.
- name: contrôle de qualité documentaire
run: ./tools/ci-quality.sh

View File

@ -123,6 +123,7 @@ public:
//! La généralisation est portée par **ECS-414** (lot de mise en configuration
//! du SgReadyAdapter). Déclaré explicitement plutôt qu'hérité d'un défaut vide.
//! \brief Lève le verrou de défaut (ECS-414). La consigne réelle est RELUE, pas supposée.
//! \return Vrai si un défaut a réellement été levé ; faux s'il n'y en avait pas.
bool clearFault() override;
//! \return Vrai si la charge est en défaut (plus aucune consigne écrite).

View File

@ -82,6 +82,7 @@ public:
//! du SgReadyAdapter). Déclaré explicitement plutôt qu'hérité d'un défaut vide.
//! Aucun mécanisme de défaut côté EV : il n'y a jamais rien à lever. Le dire
//! explicitement plutôt que de laisser un corps vide (règle 7-c).
//! \return Vrai si un défaut a réellement été levé ; faux s'il n'y en avait pas.
bool clearFault() override { return false; }
/*!

View File

@ -141,6 +141,7 @@ public:
* client qui réordonne ses priorités depuis l'app pourrait faire court-cycler son
* compresseur — et provoquerait des réécritures de relais inutiles.
*
* \param label Nouveau libellé d'affichage.
* \param priority Nouveau rang de service (ASC, 1 = premier servi).
* \param needs Nouveaux besoins déclarés.
* \warning NE DOIT jamais toucher au câblage, aux paliers ni aux verrous : ces

View File

@ -72,6 +72,7 @@ struct RelayStageTable
* combinaison retenue à somme égale, que \c levels et l'appariement position par
* position capturent.
*/
//! \param o Table comparée. \return Vrai si les deux tables décrivent le MÊME câblage.
bool operator==(const RelayStageTable &o) const
{
if (levels != o.levels || mapping.size() != o.mapping.size())
@ -84,9 +85,24 @@ struct RelayStageTable
}
return true;
}
//! \brief Négation de \c operator==. \param o Table comparée. \return Vrai si elles diffèrent.
bool operator!=(const RelayStageTable &o) const { return !(*this == o); }
};
/*!
* \brief Adaptateur d'une charge commandée par une COMBINAISON de relais (rév. 3).
*
* Traduit une enveloppe en watts en un palier, et le palier en contacts à fermer. Toute la
* combinatoire vit ici : l'optimiseur ne voit que des watts, jamais un relais ni un index de
* palier (ECS-306). Les paliers sont DÉRIVÉS des sous-ensembles de relais (\c deriveStages),
* ce qui couvre les topologies non cascadées et les puissances quelconques.
*
* \invariant Coupure avant fermeture à chaque transition — jamais deux contacts en conflit.
* \invariant Les verrous \c minOn / \c minOff sont INTERNES à l'adaptateur, évalués sur le
* \c now reçu en paramètre, jamais sur l'horloge.
* \invariant Un échec d'écriture pose un défaut COLLANT (ECS-410) : plus aucune commande
* n'est émise jusqu'à \c clearFault().
*/
class RelayRouter : public QObject, public ILoadAdapter
{
Q_OBJECT
@ -119,6 +135,7 @@ public:
//! \return currentPowerW = somme des \c currentPower des relais ON (mesuré), sinon nominal commandé.
LoadTelemetry telemetry() const override;
//! \param now Temps de cycle — source unique des verrous (contrat \c ILoadAdapter).
//! \return LoadContext §5 (watts uniquement : powerLevels, currentPowerW — aucun relais).
LoadContext toLoadContext(const QDateTime &now) const override;
@ -131,12 +148,13 @@ public:
*/
LoadAction applyAction(const LoadAction &action, const QDateTime &now) override;
//! \brief Palier courant (0 = tout coupé).
//! \brief Palier courant. \return Index du palier, 0 = tout coupé.
int currentStage() const { return m_currentStage; }
//! \brief Puissance (W) du palier courant.
//! \brief Puissance du palier courant. \return Watts du palier appliqué ; 0 si hors table.
double currentSetpointW() const { return m_currentStage < m_levels.size() ? m_levels.at(m_currentStage) : 0.0; }
//! \brief Met à jour les champs qui ne touchent PAS le matériel (ECS-412).
//! \param label Nouveau libellé d'affichage.
//! \param priority Nouveau rang de service.
//! \param needs Nouveaux besoins déclarés.
//! \note Permet à l'arbitre de refléter un changement de priorité SANS détruire
@ -159,6 +177,7 @@ public:
/*!
* \brief Lève le verrou de défaut (ECS-410, RPC \c NymeaEnergy.ClearLoadFault).
* \return Vrai si un défaut a RÉELLEMENT été levé, faux s'il n'y en avait pas.
* \note Le défaut est COLLANT par conception — « cesser toute commande jusqu'à
* intervention ». Sa levée est donc un acte délibéré et tracé de l'opérateur, jamais
* une expiration. L'état matériel réel étant inconnu après un défaut, il est RELU

View File

@ -107,6 +107,7 @@ public:
* \note L'état réel est RELU depuis les contacts, jamais supposé : après un défaut, le
* motif 2 bits peut être valide mais non voulu.
*/
//! \return Vrai si un défaut a réellement été levé ; faux s'il n'y en avait pas.
bool clearFault() override;
//! \return Vrai si la PAC est en défaut (plus aucune commande émise).

View File

@ -39,6 +39,15 @@ class EnergyArbitrator : public SmartChargingManager
{
Q_OBJECT
public:
/*!
* \brief Construit l'arbitre central. Mêmes dépendances que \c SmartChargingManager amont,
* dont il hérite (justification : AGENTS.md, « 3b-iii »).
* \param energyManager Gestionnaire d'énergie nymea (compteur racine, notifications).
* \param thingManager Registre des Things — résolution des appareils pilotés/mesurés.
* \param spotMarketManager Fournisseur de tarifs dynamiques (proxy EV).
* \param configuration Réglages persistés (limites de phase, tolérance, réserve).
* \param parent Parent QObject.
*/
explicit EnergyArbitrator(EnergyManager *energyManager, ThingManager *thingManager,
SpotMarketManager *spotMarketManager,
EnergyManagerConfiguration *configuration,
@ -137,6 +146,7 @@ public:
* \note Logique injectable (temps en paramètre) — en production appelée par le
* handler \c powerBalanceChanged ; en simulation/test appelée directement. Le
* déclencheur réel (signal) est câblé sous \c \#ifndef ENERGY_SIMULATION.
* \param now Temps de cycle — jamais l'horloge, pour que la logique reste injectable.
*/
void recordMeterUpdate(const QDateTime &now);
@ -145,6 +155,7 @@ public:
*
* Si \c now − \c m_lastMeterUpdate > 90 s et pas déjà dégradé → \c applyDegradedMode().
* Appliqué à la TRANSITION uniquement (idempotent ensuite). \p now = temps de cycle.
* \param now Temps de cycle — jamais l'horloge, pour que la logique reste injectable.
* \note Logique injectable — en production appelée par \c onMeterWatchdogTick() (QTimer
* horloge murale, indépendant car le compteur muet fige aussi \c update()) ; en
* simulation/test appelée directement avec le temps simulé. Symétrique de
@ -205,6 +216,9 @@ public:
/*!
* \brief [3g-1] Cette borne participe-t-elle au waterfall de surplus ?
*
* \param ev Borne interrogée ; \c nullptr rend faux.
* \return Vrai si la borne entre dans le waterfall de surplus ce cycle.
*
* **Deux** exclusions, et elles ne disent pas la même chose : le **mode manuel**
* (\c ChargingModeNormal) est une INTENTION — l'utilisateur pilote, l'arbitre s'abstient ;
* **aucun véhicule branché** (\c pluggedIn) est un CONSTAT — la borne ne peut rien tirer
@ -434,7 +448,7 @@ private:
* Le rang, le libellé, le domaine \c ev, \c enabled. Aucune charge utile : les limites de
* la borne viennent du Thing (voir \c LoadConfig::isEvCharger()).
*
* \par Rang par défaut — EN QUEUE (\rule{LM-1209})
* \par Rang par defaut — EN QUEUE (LM-1209)
* `1 + max(rangs existants)`, donc libre par construction et sans bousculer personne. Ce
* n'est pas une politique du plugin : le rang est un choix de l'installateur, et une
* détection ne réordonne pas ce qu'un humain a décidé. Une borne détectée un mois après la

View File

@ -206,10 +206,15 @@ public:
* non nul : il se **dérive**, et le déclarer en double ouvrirait deux sources d'une même
* vérité. `isValid()` refuse donc la combinaison.
*/
//! \return Plancher de modulation en watts ; 0 = aucun plancher déclaré.
uint minPowerW() const { return m_minPowerW; }
//! \brief Pose le plancher de modulation. \param v Watts, ou 0 pour aucun plancher.
void setMinPowerW(uint v) { m_minPowerW = v; }
//! \brief Compteur dédié à CETTE charge (§11 / LM-1100). \return ThingId, vide si aucun.
//! \note Prioritaire sur la mesure de l'appareil piloté quand il résout (LM-1105).
QString meterThingId() const { return m_meterThingId; }
//! \brief Rattache un compteur dédié. \param v ThingId, ou vide pour n'en rattacher aucun.
void setMeterThingId(const QString &v) { m_meterThingId = v; }
/*!
@ -252,7 +257,9 @@ public:
* (température, puissance, heure) sans lequel les seuils éco/confort du §10 ne pourront
* jamais être réglés autrement qu'au doigt mouillé.
*/
//! \return ThingId de la sonde ; vide si aucune n'est rattachée.
QString sensorThingId() const { return m_sensorThingId; }
//! \brief Rattache une sonde. \param v ThingId, ou vide pour n'en rattacher aucune.
void setSensorThingId(const QString &v) { m_sensorThingId = v; }
//! \return Codes de domaine acceptés, ordre stable. Source unique de l'énumération :

114
tools/ci-quality.sh Executable file
View File

@ -0,0 +1,114 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
#
# Contrôle de qualité documentaire — TROIS vérifications, un seul endroit.
#
# ./tools/ci-quality.sh
#
# Elles se posent au même endroit parce qu'elles partagent la même sortie
# (`doc-generated/`) et la même commande de réparation. Les séparer ferait trois
# jobs qui regénèrent trois fois la même chose.
#
# 1. L'INDEX DES RÈGLES est-il VRAI ? (tools/gen-rules.py, code de retour)
# Casse sur : un identifiant cité et jamais défini ; un identifiant qui n'existe
# qu'en ligne de tableau ; un domicile ambigu ; un numéro retiré réattribué.
# 2. La DOC DOXYGEN est-elle complète ? (0 avertissement — DoD §5 d'AGENTS.md)
# 3. Le tout est-il REPRODUCTIBLE ? (deux passes donnent le même index)
#
# ── CE QU'ON NE VÉRIFIE PAS, ET POURQUOI ────────────────────────────────────
#
# « Régénérer et refuser si ça diffère de ce qui est commité » n'a rien à comparer :
# `doc-generated/` est dans .gitignore, décision du 2026-08-29 — versionner une page
# générée produirait un diff à chaque édition de spec et des conflits sur un fichier
# que personne ne relit. Le risque qu'un tel diff attrape — un artefact commité qui
# dérive de sa source — n'existe pas quand l'artefact n'est pas commité : il a été
# SUPPRIMÉ, pas déplacé. Ce qui reste à garantir est que l'index soit VRAI, et c'est
# la vérification 1. La 3 couvre le reste : un générateur non déterministe.
#
# ── ÉTAT : ÉCRIT, PAS EN SERVICE ────────────────────────────────────────────
#
# Aucun déclencheur automatique n'est posé. `ci/gitea-workflow.yml.disabled` porte le
# workflow prêt à l'emploi et la marche à suivre pour l'activer. La raison est datée :
# au 2026-08-29 la forge ne répond pas et plus de trente commits attendent en local.
# Un job activé à l'aveugle échouerait à son premier passage — au moment précis où
# tout le monde pousse — et le premier réflexe serait de le désactiver. Un garde-fou
# désactivé une fois ne se réactive jamais.
#
# Il PASSE sur l'état du dépôt au 2026-08-29 : index RC=0 (113 règles), doxygen 0
# avertissement. Vérifié avant d'être déclaré prêt, parce qu'un job qu'on n'a jamais
# vu passer n'est pas un job, c'est une intention.
set -u
RACINE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$RACINE" || exit 1
ROUGE=""; VERT=""; GRAS=""; FIN=""
if [ -t 1 ]; then ROUGE=$'\e[31m'; VERT=$'\e[32m'; GRAS=$'\e[1m'; FIN=$'\e[0m'; fi
echec=0
titre() { printf '\n%s── %s %s\n' "$GRAS" "$1" "$FIN"; }
ko() { printf '%s✗ %s%s\n' "$ROUGE" "$1" "$FIN"; echec=1; }
ok() { printf '%s✓ %s%s\n' "$VERT" "$1" "$FIN"; }
# Toute panne doit dire QUOI LANCER. Un job qui annonce « l'index est périmé » sans
# donner la commande se contourne en le désactivant — c'est le chemin de moindre
# effort, et il gagne toujours.
reparer() { printf ' → réparer : %s\n' "$1"; }
# --- 1. L'index des règles est-il vrai ? ------------------------------------
titre "1/3 · index des règles"
if python3 tools/gen-rules.py; then
ok "index complet — aucun orphelin, aucun domicile ambigu"
else
ko "l'index des règles est INCOMPLET (détail ci-dessus)"
reparer "définir la règle manquante dans specs/, ou la déclarer dans tools/rules-retired.txt"
reparer "puis : python3 tools/gen-rules.py"
fi
# --- 2. La doc est-elle complète ? ------------------------------------------
titre "2/3 · documentation Doxygen"
if ! command -v doxygen >/dev/null 2>&1 || ! command -v dot >/dev/null 2>&1; then
ko "doxygen ou graphviz absent — la vérification n'a PAS eu lieu"
reparer "apt install doxygen graphviz"
else
./tools/gen-doc.sh >/dev/null 2>&1
JOURNAL="$RACINE/doc-generated/warnings.txt"
# `wc -l` et non `grep -c` : grep -c AFFICHE 0 puis SORT EN 1 quand il ne trouve rien,
# si bien qu'un « || echo 0 » empile un second zéro et que le test entier échoue sur un
# dépôt SAIN. Une commande de mesure dont l'échec produit une lecture fausse — le motif
# de la semaine, appliqué à l'outil (AGENTS.md, « Lire le journal de la box »).
N=0
[ -f "$JOURNAL" ] && N="$(grep -i "warning" "$JOURNAL" 2>/dev/null | wc -l)"
if [ "$N" -eq 0 ]; then
ok "0 avertissement"
else
ko "$N avertissement(s) Doxygen — DoD §5 : toute méthode publique d'etm/ se documente"
printf '\n'; sed 's|^.*/energyplugin/| |' "$JOURNAL" | head -20
reparer "./tools/gen-doc.sh puis lire doc-generated/warnings.txt"
fi
fi
# --- 3. Le générateur est-il déterministe ? ---------------------------------
titre "3/3 · reproductibilité de l'index"
A="$(mktemp)"; B="$(mktemp)"
python3 tools/gen-rules.py >/dev/null 2>&1; cp doc-generated/RULES.md "$A"
python3 tools/gen-rules.py >/dev/null 2>&1; cp doc-generated/RULES.md "$B"
if diff -q "$A" "$B" >/dev/null; then
ok "deux passes, un seul résultat"
else
ko "l'index n'est PAS reproductible — deux passes ont divergé"
printf '\n'; diff "$A" "$B" | head -20
reparer "un ordre d'itération dépend d'un QHash ou du système de fichiers : trier"
fi
rm -f "$A" "$B"
printf '\n'
if [ "$echec" -eq 0 ]; then
printf '%s✓ qualité documentaire : tout passe.%s\n' "$VERT" "$FIN"
else
printf '%s✗ qualité documentaire : au moins une vérification a échoué.%s\n' "$ROUGE" "$FIN"
printf ' Tout relancer d'"'"'un coup : ./tools/ci-quality.sh\n'
fi
exit "$echec"

View File

@ -85,7 +85,9 @@ if [ -n "$LOGFILE" ] && [ -f "$RACINE/$LOGFILE" ]; then
else
JOURNAL_EFFECTIF="$JOURNAL"
fi
AVERTISSEMENTS="$(grep -c -i "warning" "$JOURNAL_EFFECTIF" 2>/dev/null || echo 0)"
# `wc -l`, jamais `grep -c ... || echo 0` : grep -c affiche 0 PUIS sort en 1 quand il ne
# trouve rien, et le « || » empile alors un second zéro dans la variable.
AVERTISSEMENTS="$(grep -i "warning" "$JOURNAL_EFFECTIF" 2>/dev/null | wc -l)"
echo ""
echo " doxygen $VERSION_REELLE (référence $VERSION_ATTENDUE)"