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:
Patrick Schurig 2026-10-17 21:34:17 +02:00
parent 07cb44d35f
commit ead662e044
3 changed files with 487 additions and 5 deletions

View 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)

View File

@ -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"

View 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())