feat(outils): etm-powersync-gen-protocol-doc, prose générée depuis le contrat
Tableaux de messages (champs, types, obligatoire, valeurs, sens), règles sémantiques et codes de motif, entre balises BEGIN/END GENERATED de OPTIMIZER_PROTOCOL.md. --check (code 1 si la prose ou la forme canonique du contrat n'est pas à jour) branché en 5/5 de tools/ci-quality.sh. --enums-from pose les listes fermées du contrat depuis le dump du code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
07cb44d35f
commit
ead662e044
122
tests/tools/etm-powersync-gen-protocol-doc-test.py
Normal file
122
tests/tools/etm-powersync-gen-protocol-doc-test.py
Normal file
@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
"""Test de tools/etm-powersync-gen-protocol-doc (lot « contrat OpenRPC »).
|
||||
|
||||
python3 -I tests/tools/etm-powersync-gen-protocol-doc-test.py
|
||||
|
||||
Le dépôt est à jour ; une prose non régénérée, un bloc édité à la main, une liste du code changée
|
||||
font échouer `--check` ; les balises cassées et un dump du code incomplet sont des erreurs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ICI = os.path.dirname(os.path.abspath(__file__))
|
||||
RACINE = os.path.join(ICI, '..', '..')
|
||||
OUTIL = os.path.join(RACINE, 'tools', 'etm-powersync-gen-protocol-doc')
|
||||
SCHEMA = os.path.join(RACINE, 'docs', 'etm-powersync-optimizer-protocol.openrpc.json')
|
||||
PROSE = os.path.join(RACINE, 'docs', 'OPTIMIZER_PROTOCOL.md')
|
||||
|
||||
|
||||
def lancer(*args):
|
||||
p = subprocess.run([sys.executable, '-I', OUTIL, *args], capture_output=True, text=True, timeout=60)
|
||||
return p.returncode, p.stdout + p.stderr
|
||||
|
||||
|
||||
class TestGenProtocolDoc(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.d = tempfile.mkdtemp()
|
||||
self.schema = os.path.join(self.d, 's.json')
|
||||
self.prose = os.path.join(self.d, 'p.md')
|
||||
shutil.copy(SCHEMA, self.schema)
|
||||
shutil.copy(PROSE, self.prose)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.d)
|
||||
|
||||
def args(self, *x):
|
||||
return ('--schema', self.schema, '--prose', self.prose) + x
|
||||
|
||||
def lire(self, f):
|
||||
with open(f, encoding='utf-8') as h:
|
||||
return h.read()
|
||||
|
||||
def ecrire(self, f, texte):
|
||||
with open(f, 'w', encoding='utf-8') as h:
|
||||
h.write(texte)
|
||||
|
||||
def test_le_depot_est_a_jour(self):
|
||||
code, sortie = lancer('--check')
|
||||
self.assertEqual(code, 0, sortie)
|
||||
self.assertRegex(sortie, r'✓ contrat canonique, [0-9]+ bloc')
|
||||
|
||||
def test_un_contrat_change_sans_regenerer_la_prose_echoue(self):
|
||||
doc = json.loads(self.lire(self.schema))
|
||||
doc['components']['schemas']['Meter']['properties']['exportW']['description'] = 'changée'
|
||||
self.ecrire(self.schema, json.dumps(doc, ensure_ascii=False, indent=2) + '\n')
|
||||
code, sortie = lancer(*self.args('--check'))
|
||||
self.assertEqual(code, 1, sortie)
|
||||
self.assertIn('blocs générés pas à jour', sortie)
|
||||
code, _ = lancer(*self.args())
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn('| changée |', self.lire(self.prose))
|
||||
self.assertEqual(lancer(*self.args('--check'))[0], 0)
|
||||
|
||||
def test_un_bloc_edite_a_la_main_echoue(self):
|
||||
p = self.lire(self.prose).replace('| `timestamp` | string | oui |', '| `timestamp` | string | non |', 1)
|
||||
self.ecrire(self.prose, p)
|
||||
self.assertEqual(lancer(*self.args('--check'))[0], 1)
|
||||
|
||||
def test_un_contrat_non_canonique_echoue(self):
|
||||
self.ecrire(self.schema, json.dumps(json.loads(self.lire(self.schema)), ensure_ascii=False) + '\n')
|
||||
code, sortie = lancer(*self.args('--check'))
|
||||
self.assertEqual(code, 1, sortie)
|
||||
self.assertIn('pas à jour (forme canonique', sortie)
|
||||
|
||||
def test_une_liste_du_code_changee_se_regenere(self):
|
||||
doc = json.loads(self.lire(self.schema))
|
||||
listes = {}
|
||||
for it in doc['x-etm-generated']['items']:
|
||||
noeud = doc
|
||||
for m in it['pointer'][1:].split('/'):
|
||||
noeud = noeud[m]
|
||||
listes[it['from']] = noeud
|
||||
listes['DecisionCode::optimizerEmittableCodes()'] = listes['DecisionCode::optimizerEmittableCodes()'] + ['NOUVEAU_CODE']
|
||||
dump = os.path.join(self.d, 'listes.json')
|
||||
self.ecrire(dump, json.dumps(listes))
|
||||
code, sortie = lancer(*self.args('--enums-from', dump, '--check'))
|
||||
self.assertEqual(code, 1, sortie) # le contrat commité n'a pas la liste du code
|
||||
self.assertEqual(lancer(*self.args('--enums-from', dump))[0], 0)
|
||||
self.assertIn('NOUVEAU_CODE', self.lire(self.schema))
|
||||
self.assertIn('`NOUVEAU_CODE`', self.lire(self.prose)) # la table des codes suit
|
||||
del listes['TariffConfig::includes()']
|
||||
self.ecrire(dump, json.dumps(listes))
|
||||
code, sortie = lancer(*self.args('--enums-from', dump))
|
||||
self.assertEqual(code, 2, sortie)
|
||||
listes['TariffConfig::includes()'] = ['energy']
|
||||
listes['Liste::orpheline()'] = []
|
||||
self.ecrire(dump, json.dumps(listes))
|
||||
code, sortie = lancer(*self.args('--enums-from', dump))
|
||||
self.assertEqual(code, 2, sortie)
|
||||
self.assertIn('orpheline', sortie)
|
||||
|
||||
def test_des_balises_cassees_sont_une_erreur(self):
|
||||
p = self.lire(self.prose)
|
||||
i = p.index('<!-- END GENERATED -->')
|
||||
self.ecrire(self.prose, p[:i] + p[i + len('<!-- END GENERATED -->'):])
|
||||
self.assertEqual(lancer(*self.args('--check'))[0], 2)
|
||||
self.ecrire(self.prose, p.replace('BEGIN GENERATED rules', 'BEGIN GENERATED inconnu', 1))
|
||||
code, sortie = lancer(*self.args('--check'))
|
||||
self.assertEqual(code, 2, sortie)
|
||||
self.ecrire(self.prose, 'aucune balise\n')
|
||||
self.assertEqual(lancer(*self.args('--check'))[0], 2)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@ -2,7 +2,7 @@
|
||||
# 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.
|
||||
# Contrôle de qualité documentaire — CINQ vérifications, un seul endroit.
|
||||
#
|
||||
# ./tools/ci-quality.sh
|
||||
#
|
||||
@ -15,6 +15,11 @@
|
||||
# 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)
|
||||
# 4. Un verdict de test peut-il dépendre de l'HEURE ? (tools/check-simulation-clock.py)
|
||||
# 5. La prose du protocole est-elle GÉNÉRÉE depuis son contrat ?
|
||||
# (tools/etm-powersync-gen-protocol-doc --check : tableaux de messages à jour, contrat
|
||||
# OpenRPC sous sa forme canonique). Ici, l'artefact généré EST commité — la prose est
|
||||
# lue par des humains et des optimiseurs tiers — donc on régénère et on refuse un écart.
|
||||
#
|
||||
# ── CE QU'ON NE VÉRIFIE PAS, ET POURQUOI ────────────────────────────────────
|
||||
#
|
||||
@ -58,7 +63,7 @@ ok() { printf '%s✓ %s%s\n' "$VERT" "$1" "$FIN"; }
|
||||
reparer() { printf ' → réparer : %s\n' "$1"; }
|
||||
|
||||
# --- 1. L'index des règles est-il vrai ? ------------------------------------
|
||||
titre "1/4 · index des règles"
|
||||
titre "1/5 · index des règles"
|
||||
if python3 tools/gen-rules.py; then
|
||||
ok "index complet — aucun orphelin, aucun domicile ambigu"
|
||||
else
|
||||
@ -68,7 +73,7 @@ else
|
||||
fi
|
||||
|
||||
# --- 2. La doc est-elle complète ? ------------------------------------------
|
||||
titre "2/4 · documentation Doxygen"
|
||||
titre "2/5 · 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"
|
||||
@ -91,7 +96,7 @@ else
|
||||
fi
|
||||
|
||||
# --- 3. Le générateur est-il déterministe ? ---------------------------------
|
||||
titre "3/4 · reproductibilité de l'index"
|
||||
titre "3/5 · 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"
|
||||
@ -105,7 +110,7 @@ fi
|
||||
rm -f "$A" "$B"
|
||||
|
||||
# --- 4. Un verdict de test peut-il dépendre de l'HEURE ? ---------------------
|
||||
titre "4/4 · horloge des déclencheurs"
|
||||
titre "4/5 · horloge des déclencheurs"
|
||||
if sortie="$(python3 tools/check-simulation-clock.py 2>&1)"; then
|
||||
ok "${sortie#✓ }"
|
||||
else
|
||||
@ -114,6 +119,16 @@ else
|
||||
reparer "entourer l'appel de #ifndef ENERGY_SIMULATION, comme ses voisins"
|
||||
fi
|
||||
|
||||
# --- 5. La prose du protocole est-elle générée depuis le contrat ? -----------
|
||||
titre "5/5 · protocole optimiseur : prose générée depuis le contrat OpenRPC"
|
||||
if sortie="$(python3 -I tools/etm-powersync-gen-protocol-doc --check 2>&1)"; then
|
||||
ok "${sortie#✓ }"
|
||||
else
|
||||
ko "docs/OPTIMIZER_PROTOCOL.md ou le contrat OpenRPC n'est pas à jour"
|
||||
printf '\n'; printf '%s\n' "$sortie" | head -20 | sed 's/^/ /'
|
||||
reparer "python3 tools/etm-powersync-gen-protocol-doc (puis relire le diff et commiter)"
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
if [ "$echec" -eq 0 ]; then
|
||||
printf '%s✓ qualité documentaire : tout passe.%s\n' "$VERT" "$FIN"
|
||||
|
||||
345
tools/etm-powersync-gen-protocol-doc
Executable file
345
tools/etm-powersync-gen-protocol-doc
Executable file
@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
|
||||
"""Génère les parties « messages » de docs/OPTIMIZER_PROTOCOL.md depuis le contrat OpenRPC.
|
||||
|
||||
etm-powersync-gen-protocol-doc réécrit les blocs générés de la prose
|
||||
etm-powersync-gen-protocol-doc --check code 1 si la prose ou le contrat n'est pas à jour
|
||||
etm-powersync-gen-protocol-doc --enums-from LISTES.json
|
||||
réécrit les listes fermées du contrat depuis le
|
||||
CODE (dump de la suite etm-powersync-protocolschema)
|
||||
|
||||
Bibliothèque standard seule, aucun accès réseau.
|
||||
|
||||
BLOCS GÉNÉRÉS. Entre `<!-- BEGIN GENERATED <quoi> -->` et `<!-- END GENERATED -->`, le texte est
|
||||
celui que produit cet outil, et rien d'autre ; tout le reste (philosophie, transport, §7, annexes)
|
||||
est écrit à la main. `<quoi>` :
|
||||
- `table:<Méthode>.params` — les params d'une méthode ;
|
||||
- `table:<Méthode>.result` — son résultat ;
|
||||
- `table:<Composant>` — un schéma de `components.schemas` (un `oneOf` donne une table par branche) ;
|
||||
- `rules` — `x-etm-semantic-rules` ; `reason-codes` — codes émissibles et leurs clés de paramètres.
|
||||
Une table déroule les objets EN LIGNE (`declared.minPowerW`) ; un `$ref` renvoie à la table de son
|
||||
composant. Un `<quoi>` inconnu, une balise sans fin, un bloc imbriqué : erreur (code 2).
|
||||
|
||||
`--check` vérifie aussi que le contrat est sous sa forme CANONIQUE (`json.dumps`, indentation 2,
|
||||
UTF-8, clés dans leur ordre) : c'est la forme qu'écrit `--enums-from`, et une régénération ne
|
||||
doit produire que le diff des listes.
|
||||
|
||||
LISTES FERMÉES. `x-etm-generated.items[]` donne, pour chaque liste, son pointeur JSON dans le
|
||||
contrat et sa source dans le code (`from`). `--enums-from` lit le dictionnaire `from → valeur`
|
||||
écrit par `ETM_POWERSYNC_CODE_LISTS=<fichier> etm-powersync-protocolschema
|
||||
testClosedListsAreGeneratedFromTheCode`, et pose chaque valeur à son pointeur. Une source absente
|
||||
du dictionnaire, ou une entrée du dictionnaire qu'aucun pointeur ne lit : erreur (code 2).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
ICI = os.path.dirname(os.path.abspath(__file__))
|
||||
SCHEMA = os.path.join(ICI, '..', 'docs', 'etm-powersync-optimizer-protocol.openrpc.json')
|
||||
PROSE = os.path.join(ICI, '..', 'docs', 'OPTIMIZER_PROTOCOL.md')
|
||||
DEBUT = re.compile(r'^<!-- BEGIN GENERATED (\S+) -->$')
|
||||
FIN = '<!-- END GENERATED -->'
|
||||
|
||||
|
||||
class Erreur(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def canonique(doc):
|
||||
return json.dumps(doc, ensure_ascii=False, indent=2) + '\n'
|
||||
|
||||
|
||||
# --- Rendu d'un schéma ------------------------------------------------------------------------------
|
||||
|
||||
class Rendu:
|
||||
def __init__(self, doc):
|
||||
self.doc = doc
|
||||
self.schemas = doc.get('components', {}).get('schemas', {})
|
||||
|
||||
def resoudre(self, s):
|
||||
while isinstance(s, dict) and '$ref' in s:
|
||||
nom = s['$ref'].rsplit('/', 1)[-1]
|
||||
if nom not in self.schemas:
|
||||
raise Erreur(f'$ref {s["$ref"]} introuvable')
|
||||
s = self.schemas[nom]
|
||||
return s
|
||||
|
||||
@staticmethod
|
||||
def nom_ref(s):
|
||||
return s['$ref'].rsplit('/', 1)[-1] if isinstance(s, dict) and '$ref' in s else None
|
||||
|
||||
def type_de(self, s):
|
||||
"""Le type, en une cellule : `string`, `number \\| null`, `[Load]`, `Site`…"""
|
||||
if s is True or s == {}:
|
||||
return 'quelconque'
|
||||
ref = self.nom_ref(s)
|
||||
if ref:
|
||||
cible = self.schemas[ref]
|
||||
if 'type' in cible and cible.get('type') not in ('object', ['object']) and 'properties' not in cible:
|
||||
return self.type_de(cible) # liste fermée : son type, ses valeurs dans « Valeurs »
|
||||
return f'`{ref}`'
|
||||
for cle in ('oneOf', 'anyOf'):
|
||||
if cle in s:
|
||||
return ' \\| '.join(self.type_de(b) for b in s[cle])
|
||||
if 'const' in s:
|
||||
return 'null' if s['const'] is None else type(s['const']).__name__.replace('str', 'string').replace('bool', 'boolean').replace('int', 'integer').replace('float', 'number')
|
||||
t = s.get('type')
|
||||
types = [t] if isinstance(t, str) else list(t or [])
|
||||
out = []
|
||||
for x in types:
|
||||
if x == 'array':
|
||||
out.append('[' + self.type_de(s.get('items', {})) + ']')
|
||||
else:
|
||||
out.append(x)
|
||||
if not out and 'enum' in s:
|
||||
out.append('enum')
|
||||
return ' \\| '.join(out) if out else 'quelconque'
|
||||
|
||||
def valeurs(self, s):
|
||||
"""Valeurs permises, en une cellule."""
|
||||
s0 = s
|
||||
ref = self.nom_ref(s)
|
||||
if ref:
|
||||
s = self.schemas[ref]
|
||||
if s.get('type') == 'array' and isinstance(s.get('items'), dict):
|
||||
return self.valeurs(s['items'])
|
||||
morceaux = []
|
||||
if 'const' in s:
|
||||
morceaux.append(f'`{json.dumps(s["const"], ensure_ascii=False)}`')
|
||||
if 'enum' in s:
|
||||
morceaux.append(' · '.join(f'`{v}`' if isinstance(v, str) else f'`{json.dumps(v)}`' for v in s['enum']))
|
||||
for cle, signe in (('minimum', '≥'), ('exclusiveMinimum', '>'), ('maximum', '≤')):
|
||||
if cle in s:
|
||||
morceaux.append(f'{signe} {s[cle]}')
|
||||
if 'minLength' in s:
|
||||
morceaux.append(f'≥ {s["minLength"]} car.')
|
||||
if 'minItems' in s:
|
||||
morceaux.append(f'≥ {s["minItems"]} élément(s)')
|
||||
if 'pattern' in s and ref is None and 'Instant' not in str(s.get('description', '')):
|
||||
morceaux.append(f'motif `{s["pattern"]}`')
|
||||
if 'pattern' in s and 'Instant' in str(s.get('description', '')):
|
||||
morceaux.append('instant avec décalage')
|
||||
for cle in ('oneOf', 'anyOf'):
|
||||
for b in s.get(cle, []):
|
||||
if self.nom_ref(b) is None and b.get('type') != 'null' and b is not s0:
|
||||
v = self.valeurs(b)
|
||||
if v:
|
||||
morceaux.append(v)
|
||||
return ' ; '.join(m for m in morceaux if m)
|
||||
|
||||
@staticmethod
|
||||
def sens(s):
|
||||
d = s.get('description', '') if isinstance(s, dict) else ''
|
||||
return d.replace('|', '\\|').replace('\n', ' ')
|
||||
|
||||
def lignes(self, props, requis, prefixe=''):
|
||||
out = []
|
||||
for nom, s in props.items():
|
||||
chemin = f'{prefixe}{nom}'
|
||||
r = self.resoudre(s)
|
||||
sens = self.sens(s) or (self.sens(r) if self.nom_ref(s) and r.get('type') not in ('object',) else '')
|
||||
out.append(f'| `{chemin}` | {self.type_de(s)} | {"oui" if nom in requis else "non"} | '
|
||||
f'{self.valeurs(s)} | {sens} |')
|
||||
# Un objet EN LIGNE se déroule ; un composant nommé a sa propre table.
|
||||
if self.nom_ref(s) is None and isinstance(s, dict) and isinstance(s.get('properties'), dict):
|
||||
out += self.lignes(s['properties'], s.get('required', []), chemin + '.')
|
||||
return out
|
||||
|
||||
def table(self, props, requis, titre=None, description=None, ouvert=None):
|
||||
out = []
|
||||
if titre:
|
||||
out.append(f'**{titre}**' + (f' — {description}' if description else ''))
|
||||
out.append('')
|
||||
out.append('| Champ | Type | Obligatoire | Valeurs | Sens |')
|
||||
out.append('|---|---|---|---|---|')
|
||||
out += self.lignes(props, requis)
|
||||
if ouvert is not None:
|
||||
out.append('')
|
||||
out.append('Champs non déclarés : ' + ('**refusés** (forme émise par le moteur, fermée).' if ouvert is False
|
||||
else 'ignorés (§9).'))
|
||||
return out
|
||||
|
||||
def composant(self, nom):
|
||||
if nom not in self.schemas:
|
||||
raise Erreur(f'composant « {nom} » absent du contrat')
|
||||
s = self.schemas[nom]
|
||||
if 'oneOf' in s:
|
||||
out = []
|
||||
if s.get('description'):
|
||||
out += [self.sens(s), '']
|
||||
for i, b in enumerate(s['oneOf'], 1):
|
||||
r = self.nom_ref(b)
|
||||
out += (self.objet(r, self.schemas[r]) if r else self.objet(f'{nom}, forme {i}', b)) + ['']
|
||||
return out[:-1]
|
||||
return self.objet(nom, s)
|
||||
|
||||
def objet(self, nom, s):
|
||||
if 'properties' not in s:
|
||||
return [f'`{nom}` : {self.type_de(s)} — {self.valeurs(s)}. {self.sens(s)}'.rstrip()]
|
||||
out = self.table(s['properties'], s.get('required', []), titre=f'`{nom}`',
|
||||
description=self.sens(s) or None, ouvert=s.get('additionalProperties', True))
|
||||
if 'anyOf' in s and all(set(b) == {'required'} for b in s['anyOf']):
|
||||
out.append('Au moins un de : ' + ', '.join('`' + '`+`'.join(b['required']) + '`' for b in s['anyOf']) + '.')
|
||||
return out
|
||||
|
||||
def methode(self, nom, partie):
|
||||
ms = {m['name']: m for m in self.doc.get('methods', [])}
|
||||
if nom not in ms:
|
||||
raise Erreur(f'méthode « {nom} » absente du contrat')
|
||||
m = ms[nom]
|
||||
if partie == 'params':
|
||||
props = {p['name']: dict(p['schema'], description=p.get('description', p['schema'].get('description', '')))
|
||||
if isinstance(p['schema'], dict) and 'description' in p else p['schema'] for p in m['params']}
|
||||
requis = [p['name'] for p in m['params'] if p.get('required')]
|
||||
return self.table(props, requis, titre=f'`{nom}` — params', ouvert=m.get('x-etm-params-open', False))
|
||||
if 'result' not in m:
|
||||
return [f'`{nom}` est une notification : pas de résultat.']
|
||||
s = m['result']['schema']
|
||||
ref = self.nom_ref(s)
|
||||
if ref:
|
||||
return self.composant(ref)
|
||||
return self.objet(f'{nom}.result', s)
|
||||
|
||||
def regles(self):
|
||||
r = self.doc.get('x-etm-semantic-rules', {})
|
||||
out = [self.sens(r), '', '| Règle | Méthode | Issue | `fallbackCode` | Échec compté | Il faut aussi | Le schéma la voit aussi | Ce qui est refusé |',
|
||||
'|---|---|---|---|---|---|---|---|']
|
||||
for x in r.get('rules', []):
|
||||
out.append(f'| `{x["name"]}` | {x["method"]} | {x["issue"]} | `{x["fallbackCode"]}` | '
|
||||
f'{"oui" if x.get("countsAsFailure") else "non"} | {", ".join(x.get("needs", [])) or "—"} | '
|
||||
f'{"oui" if x.get("schemaAlsoCatches") else "non"} | {self.sens(x)} |')
|
||||
return out
|
||||
|
||||
def codes(self):
|
||||
cles = self.doc.get('x-etm-reason-params', {})
|
||||
out = ['| `reasonCode` | Clés de `reasonParams` admises |', '|---|---|']
|
||||
for code in self.schemas['ReasonCode']['enum']:
|
||||
out.append(f'| `{code}` | {", ".join(f"`{k}`" for k in cles.get(code, [])) or "—"} |')
|
||||
return out
|
||||
|
||||
def bloc(self, quoi):
|
||||
if quoi == 'rules':
|
||||
return self.regles()
|
||||
if quoi == 'reason-codes':
|
||||
return self.codes()
|
||||
if quoi.startswith('table:'):
|
||||
cible = quoi[len('table:'):]
|
||||
if '.' in cible:
|
||||
nom, partie = cible.split('.', 1)
|
||||
if partie not in ('params', 'result'):
|
||||
raise Erreur(f'partie « {partie} » inconnue (params | result)')
|
||||
return self.methode(nom, partie)
|
||||
return self.composant(cible)
|
||||
raise Erreur(f'bloc « {quoi} » inconnu')
|
||||
|
||||
|
||||
def regenerer(prose, rendu):
|
||||
out, i, lignes, vus = [], 0, prose.split('\n'), 0
|
||||
while i < len(lignes):
|
||||
m = DEBUT.match(lignes[i])
|
||||
if lignes[i] == FIN:
|
||||
raise Erreur(f'ligne {i + 1} : {FIN} sans début')
|
||||
if not m:
|
||||
out.append(lignes[i])
|
||||
i += 1
|
||||
continue
|
||||
j = i + 1
|
||||
while j < len(lignes) and lignes[j] != FIN:
|
||||
if DEBUT.match(lignes[j]):
|
||||
raise Erreur(f'ligne {j + 1} : bloc imbriqué')
|
||||
j += 1
|
||||
if j == len(lignes):
|
||||
raise Erreur(f'ligne {i + 1} : bloc « {m.group(1)} » sans {FIN}')
|
||||
out.append(lignes[i])
|
||||
out.append(f'<!-- Généré depuis docs/etm-powersync-optimizer-protocol.openrpc.json par '
|
||||
f'tools/etm-powersync-gen-protocol-doc — ne pas éditer à la main. -->')
|
||||
out.append('')
|
||||
out += rendu.bloc(m.group(1))
|
||||
out.append('')
|
||||
out.append(FIN)
|
||||
vus += 1
|
||||
i = j + 1
|
||||
if vus == 0:
|
||||
raise Erreur('aucun bloc généré dans la prose — balises perdues ?')
|
||||
return '\n'.join(out), vus
|
||||
|
||||
|
||||
def poser(doc, pointeur, valeur):
|
||||
morceaux = pointeur[1:].split('/')
|
||||
noeud = doc
|
||||
for m in morceaux[:-1]:
|
||||
if not isinstance(noeud, dict) or m not in noeud:
|
||||
raise Erreur(f'pointeur {pointeur} : « {m} » absent')
|
||||
noeud = noeud[m]
|
||||
if not isinstance(noeud, dict) or morceaux[-1] not in noeud:
|
||||
raise Erreur(f'pointeur {pointeur} : « {morceaux[-1]} » absent')
|
||||
noeud[morceaux[-1]] = valeur
|
||||
|
||||
|
||||
def depuis_le_code(doc, listes):
|
||||
items = (doc.get('x-etm-generated') or {}).get('items', [])
|
||||
lues = set()
|
||||
for it in items:
|
||||
if it['from'] not in listes:
|
||||
raise Erreur(f'{it["from"]} absent du dump du code')
|
||||
poser(doc, it['pointer'], listes[it['from']])
|
||||
lues.add(it['from'])
|
||||
orphelines = set(listes) - lues
|
||||
if orphelines:
|
||||
raise Erreur(f'listes du code qu\'aucun pointeur ne lit : {sorted(orphelines)}')
|
||||
return len(items)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
p = argparse.ArgumentParser(prog='etm-powersync-gen-protocol-doc',
|
||||
description='Génère les tableaux de messages de OPTIMIZER_PROTOCOL.md depuis le contrat OpenRPC.')
|
||||
p.add_argument('--schema', default=SCHEMA)
|
||||
p.add_argument('--prose', default=PROSE)
|
||||
p.add_argument('--check', action='store_true', help='ne réécrit rien ; code 1 si quelque chose n\'est pas à jour')
|
||||
p.add_argument('--enums-from', help='dump des listes du code (JSON) : réécrit les listes fermées du contrat')
|
||||
a = p.parse_args(argv)
|
||||
try:
|
||||
with open(a.schema, encoding='utf-8') as f:
|
||||
brut = f.read()
|
||||
doc = json.loads(brut)
|
||||
if a.enums_from:
|
||||
with open(a.enums_from, encoding='utf-8') as f:
|
||||
n = depuis_le_code(doc, json.load(f))
|
||||
if not a.check:
|
||||
with open(a.schema, 'w', encoding='utf-8') as f:
|
||||
f.write(canonique(doc))
|
||||
print(f'contrat : {n} liste(s) posée(s) depuis le code')
|
||||
with open(a.prose, encoding='utf-8') as f:
|
||||
prose = f.read()
|
||||
nouvelle, blocs = regenerer(prose, Rendu(doc))
|
||||
except (Erreur, OSError, ValueError, KeyError) as e:
|
||||
print(f'ERREUR : {e}', file=sys.stderr)
|
||||
return 2
|
||||
if a.check:
|
||||
a_jour = True
|
||||
if canonique(doc) != brut:
|
||||
a_jour = False
|
||||
print(f'✗ {a.schema} : pas à jour (forme canonique ou listes du code) — '
|
||||
f'lancer tools/etm-powersync-gen-protocol-doc' + (' --enums-from …' if a.enums_from else ''))
|
||||
if nouvelle != prose:
|
||||
a_jour = False
|
||||
print(f'✗ {a.prose} : blocs générés pas à jour — lancer tools/etm-powersync-gen-protocol-doc')
|
||||
sys.stdout.writelines(list(difflib.unified_diff(prose.splitlines(True), nouvelle.splitlines(True),
|
||||
'commité', 'régénéré', n=1))[:60])
|
||||
if a_jour:
|
||||
print(f'✓ contrat canonique, {blocs} bloc(s) générés à jour')
|
||||
return 0 if a_jour else 1
|
||||
if nouvelle != prose:
|
||||
with open(a.prose, 'w', encoding='utf-8') as f:
|
||||
f.write(nouvelle)
|
||||
print(f'{blocs} bloc(s) générés écrits dans {a.prose}')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Loading…
x
Reference in New Issue
Block a user