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>
346 lines
15 KiB
Python
Executable File
346 lines
15 KiB
Python
Executable File
#!/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())
|