powersync-docs/scripts/gen_optimizer_reference.py
Patrick Schurig 8e37de4d24 feat(optimiseur): générateur de la référence du protocole depuis le contrat OpenRPC
scripts/gen_optimizer_reference.py lit le contrat machine du moteur
(etm-powersync-optimizer-protocol.openrpc.json) et produit les blocs
__optimizer_<clé>__ : transport, Handshake, contexte GetPlan, plan,
codes de motif, UpdateLearned, machine de repli, règles sémantiques,
tolérances, listes fermées, fiche du contrat. Cadre en anglais, une
seule fois, descriptions reprises telles quelles du contrat.

Copie octet pour octet du contrat servie par le site, pages créées si
absentes et jamais réécrites hors marqueurs, mode --check.

Garde-fou : échec si le publié contient une IPv4 privée hors exemple
nommé du contrat (pointeur + valeur), « cloud » ou « Héos ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 13:02:47 +02:00

710 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
gen_optimizer_reference.py — génère la référence du protocole optimiseur ↔ moteur
depuis le contrat machine du moteur (OpenRPC 1.3).
Source de vérité : `docs/etm-powersync-optimizer-protocol.openrpc.json` du dépôt du
moteur `etm-powersync-energy-plugin-etm` (la CI en clone la branche par défaut ;
en local, `--src` pointe sur une copie). La prose du protocole
(`docs/OPTIMIZER_PROTOCOL.md` du moteur) n'est PAS reprise : la section Optimiseur
du site a sa propre prose courte, traduite, et renvoie au document du moteur.
Produit :
- docs/optimiseur/etm-powersync-optimizer-protocol.openrpc.json — copie octet
pour octet du contrat, servie telle quelle par le site ;
- le contenu des marqueurs `<!-- BEGIN GENERATED: __optimizer_<clé>__ -->`
de toutes les pages de docs/ (tableaux de messages, listes fermées, règles
nommées, machine de repli, transport, fiche du contrat) ;
- docs/optimiseur/protocole*.md et contrat*.md, s'ils sont absents (échafaudage
minimal par langue — une page existante n'est JAMAIS réécrite hors marqueurs).
Garde-fou : échoue si ce qu'il publie (blocs générés, copie du contrat, pages de
docs/optimiseur/) contient une adresse IPv4 privée (10/8, 172.16/12, 192.168/16)
hors des exemples nommés dans CONTRACT_IP_EXAMPLES, ou les mots « cloud » / « Héos ».
Bibliothèque standard seule. Aucun accès réseau.
Usage :
python3 scripts/gen_optimizer_reference.py --src .engine-src/etm-powersync-energy-plugin-etm --docs docs
python3 scripts/gen_optimizer_reference.py --src <dossier ou fichier> --docs docs --check
"""
from __future__ import annotations
import argparse
import hashlib
import ipaddress
import json
import re
import sys
from pathlib import Path
CONTRACT_NAME = "etm-powersync-optimizer-protocol.openrpc.json"
OUT_DIR = "optimiseur"
MARKER_RE = re.compile(
r"(<!-- BEGIN GENERATED: (?P<key>[^\s]+) -->)(?P<body>.*?)(<!-- END GENERATED -->)",
re.DOTALL,
)
# Motif « instant ISO 8601 avec décalage » du contrat : affiché en clair, pas en regex.
INSTANT_PATTERN = (
r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\.[0-9]+)?)?"
r"(Z|[+-][0-9]{2}:[0-9]{2})$"
)
# ═════════════════════════════════════════════════════════════════════════════
# VOCABULAIRE DU BLOC DE RÉFÉRENCE — EN ANGLAIS, UNE SEULE FOIS
#
# Même règle que gen_api_reference.py (README, décision du 2026-10-12) : le
# contenu entre marqueurs est servi IDENTIQUE dans les trois langues. Le cadre
# (titres, colonnes, oui/non) est en anglais ; les `description` viennent du
# contrat TELLES QUELLES — il les écrit en français, et les traduire ici ferait
# une seconde vérité. La colonne le dit (« from the contract, French »).
# ═════════════════════════════════════════════════════════════════════════════
REF = {
"field_cols": ["Field", "Type", "Required", "Values", "Description (contract, French)"],
"yes": "yes",
"no": "no",
"any": "any",
"instant": "instant with offset",
"closed": "Undeclared fields: **rejected** (engine-emitted form, closed).",
"open": "Undeclared fields: **ignored** (optimizer-emitted form, open).",
"at_least_one": "At least one of:",
"one_of": "One of:",
"variant": "variant",
"params": "params",
"result": "result",
"errors": "Errors",
"no_params": "_No parameters._",
"keys": "keys",
"map_of": "map of",
}
# Exemples d'usage du contrat qui portent une adresse privée — admis UNIQUEMENT à
# ce pointeur JSON et avec cette valeur. Si le contrat change la valeur, l'exemption
# tombe d'elle-même et le garde-fou échoue.
CONTRACT_IP_EXAMPLES = {
("/servers/1/variables/host/default", "192.168.1.10"),
}
PRIVATE_NETS = [ipaddress.ip_network(n) for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")]
DOC_NET = ipaddress.ip_network("192.0.2.0/24") # RFC 5737, exemples documentaires
IPV4_RE = re.compile(r"(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])")
WORD_RES = [re.compile(r"cloud", re.IGNORECASE), re.compile(r"h[ée]os", re.IGNORECASE)]
# ── Lecture du contrat ────────────────────────────────────────────────────────
def find_contract(src: Path) -> Path:
if src.is_file():
return src
for cand in (src / "docs" / CONTRACT_NAME, src / CONTRACT_NAME):
if cand.is_file():
return cand
sys.exit(f"ERREUR : {CONTRACT_NAME} introuvable sous {src}")
def load_contract(path: Path) -> tuple[bytes, dict]:
raw = path.read_bytes()
try:
d = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
sys.exit(f"ERREUR : {path} non parsable : {exc}")
for key in ("openrpc", "info", "methods", "components"):
if key not in d:
sys.exit(f"ERREUR : clé « {key} » absente de {path}")
return raw, d
# ── Rendu des schémas ─────────────────────────────────────────────────────────
def cell(text: object) -> str:
s = " ".join(str(text).split())
return s.replace("|", "\\|")
def ref_name(ref: str) -> str:
return ref.rsplit("/", 1)[-1]
def anchor(name: str) -> str:
return "schema-" + name.lower()
def link(name: str) -> str:
return f"[`{name}`](#{anchor(name)})"
def json_lit(v: object) -> str:
return "`" + json.dumps(v, ensure_ascii=False) + "`"
def type_str(s: dict) -> str:
if "$ref" in s:
return link(ref_name(s["$ref"]))
for comb in ("oneOf", "anyOf"):
if comb in s and "type" not in s:
return " \\| ".join(type_str(x) for x in s[comb])
t = s.get("type")
if t is None:
if "const" in s:
t = {bool: "boolean", str: "string", int: "integer", float: "number"}.get(
type(s["const"]), REF["any"])
elif "enum" in s:
return "enum"
else:
return REF["any"]
types = t if isinstance(t, list) else [t]
out = []
for one in types:
if one == "array":
items = s.get("items", {})
out.append("[" + type_str(items) + "]")
elif one == "object" and isinstance(s.get("additionalProperties"), dict) \
and "properties" not in s:
out.append(f"{REF['map_of']} " + type_str(s["additionalProperties"]))
else:
out.append(one)
return " \\| ".join(out)
def values_str(s: dict) -> str:
v = []
if "const" in s:
v.append(json_lit(s["const"]))
if "enum" in s:
v.append(" · ".join(f"`{x}`" for x in s["enum"]))
if s.get("type") == "array" or (isinstance(s.get("type"), list) and "array" in s["type"]):
items = s.get("items", {})
if "enum" in items:
v.append(" · ".join(f"`{x}`" for x in items["enum"]))
if "minimum" in items:
v.append(f"≥ {items['minimum']}")
if "maximum" in items:
v.append(f"≤ {items['maximum']}")
if "minimum" in s:
v.append(f"≥ {s['minimum']}")
if "exclusiveMinimum" in s:
v.append(f"> {s['exclusiveMinimum']}")
if "maximum" in s:
v.append(f"≤ {s['maximum']}")
if "minLength" in s:
v.append(f"≥ {s['minLength']} char.")
if "minItems" in s:
v.append(f"≥ {s['minItems']} items")
if "pattern" in s:
v.append(REF["instant"] if s["pattern"] == INSTANT_PATTERN else f"pattern `{s['pattern']}`")
if isinstance(s.get("propertyNames"), dict) and "enum" in s["propertyNames"]:
v.append(REF["keys"] + " " + " · ".join(f"`{x}`" for x in s["propertyNames"]["enum"]))
return " ; ".join(v)
def property_rows(props: dict, required: list, prefix: str = "") -> list[list[str]]:
rows = []
for name, ps in props.items():
full = prefix + name
rows.append([
f"`{full}`",
type_str(ps),
REF["yes"] if name in required else REF["no"],
cell(values_str(ps)),
cell(ps.get("description", "")),
])
if ps.get("type") == "object" and "properties" in ps:
rows.extend(property_rows(ps["properties"], ps.get("required", []), full + "."))
return rows
def table(rows: list[list[str]], cols: list[str]) -> str:
lines = ["| " + " | ".join(cols) + " |", "|" + "---|" * len(cols)]
lines += ["| " + " | ".join(r) + " |" for r in rows]
return "\n".join(lines)
def object_body(s: dict) -> list[str]:
"""Tableau d'un objet JSON Schema, suivi de ses règles d'ouverture."""
parts = [table(property_rows(s.get("properties", {}), s.get("required", [])),
REF["field_cols"]), ""]
ap = s.get("additionalProperties")
if ap is False:
parts += [REF["closed"], ""]
elif ap is True:
parts += [REF["open"], ""]
if "anyOf" in s and all(set(x) == {"required"} for x in s["anyOf"]):
names = ", ".join(f"`{n}`" for x in s["anyOf"] for n in x["required"])
parts += [f"{REF['at_least_one']} {names}.", ""]
return parts
def render_schema(name: str, s: dict) -> str:
parts = [f"#### `{name}` {{#{anchor(name)}}}", ""]
if s.get("description"):
parts += [cell(s["description"]), ""]
if "properties" in s:
parts += object_body(s)
elif "oneOf" in s or "anyOf" in s:
variants = s.get("oneOf") or s.get("anyOf")
if all("$ref" in x for x in variants):
parts += [f"{REF['one_of']} " + " · ".join(type_str(x) for x in variants) + ".", ""]
else:
for i, x in enumerate(variants, 1):
head = f"**{REF['variant']} {i}**"
if x.get("description"):
head += " — " + cell(x["description"])
parts += [head, ""]
parts += object_body(x) if "properties" in x else [f"`{type_str(x)}`", ""]
else:
extra = values_str(s)
parts += [f"Type: {type_str(s)}" + (f" — {extra}" if extra else ""), ""]
return "\n".join(parts)
def refs_in(o: object) -> list[str]:
"""Noms de schémas référencés, dans l'ordre de lecture, sans doublon."""
seen: list[str] = []
def walk(x: object) -> None:
if isinstance(x, dict):
if "$ref" in x:
n = ref_name(x["$ref"])
if n not in seen:
seen.append(n)
for v in x.values():
walk(v)
elif isinstance(x, list):
for v in x:
walk(v)
walk(o)
return seen
def reach(roots: list[str], schemas: dict) -> list[str]:
out: list[str] = []
todo = list(roots)
while todo:
n = todo.pop(0)
if n in out or n not in schemas:
continue
out.append(n)
todo += [r for r in refs_in(schemas[n]) if r not in out]
return out
def is_closed_list(s: dict) -> bool:
return ("enum" in s or "const" in s) and "properties" not in s
# ── Le modèle : quels schémas vont dans quel bloc ─────────────────────────────
class Model:
def __init__(self, raw: bytes, d: dict):
self.raw = raw
self.d = d
self.schemas: dict = d["components"].get("schemas", {})
self.methods = {m["name"]: m for m in d["methods"]}
for needed in ("Handshake", "GetPlan", "UpdateLearned"):
if needed not in self.methods:
sys.exit(f"ERREUR : méthode {needed} absente du contrat")
closed = [n for n, s in self.schemas.items() if is_closed_list(s)]
placed = set(closed)
self.blocks: dict[str, list[str]] = {"closed_lists": closed}
def take(key: str, roots: list[str]) -> None:
names = [n for n in reach(roots, self.schemas) if n not in placed]
placed.update(names)
self.blocks[key] = names
hs, gp, ul = (self.methods[k] for k in ("Handshake", "GetPlan", "UpdateLearned"))
take("handshake", refs_in(hs.get("params", [])) + refs_in(hs.get("result", {})))
take("context", refs_in(gp.get("params", [])))
take("plan", refs_in(gp.get("result", {})))
take("update_learned", refs_in(ul.get("params", [])))
orphans = [n for n in self.schemas if n not in placed]
if orphans:
sys.exit("ERREUR : schémas du contrat rattachés à aucun bloc : " + ", ".join(orphans)
+ " — étendre Model dans gen_optimizer_reference.py")
# ── blocs ──
def method_block(self, name: str, with_result: bool = True) -> str:
m = self.methods[name]
parts = [f"#### `{name}` — {REF['params']}", ""]
if m.get("summary"):
parts += [cell(m["summary"]), ""]
if m.get("description"):
parts += [cell(m["description"]), ""]
params = m.get("params", [])
if params:
rows = []
for p in params:
ps = p.get("schema", {})
rows.append([f"`{p['name']}`", type_str(ps),
REF["yes"] if p.get("required") else REF["no"],
cell(values_str(ps)),
cell(p.get("description", ps.get("description", "")))])
parts += [table(rows, REF["field_cols"]), ""]
else:
parts += [REF["no_params"], ""]
if with_result and "result" in m:
parts += [f"**{REF['result']}** (`{m['result']['name']}`): "
+ type_str(m["result"].get("schema", {})), ""]
if m.get("errors"):
rows = [[f"`{e['code']}`", cell(e.get("message", "")), cell(e.get("data", ""))]
for e in m["errors"]]
parts += [f"**{REF['errors']}**", "", table(rows, ["Code", "Message", "Note (contract)"]), ""]
return "\n".join(parts)
def schemas_block(self, key: str) -> str:
return "\n".join(render_schema(n, self.schemas[n]) for n in self.blocks[key])
def render(self, key: str) -> str | None:
k = key.removeprefix("__optimizer_").removesuffix("__")
fn = getattr(self, "block_" + k, None)
return fn().rstrip() + "\n" if fn else None
def block_transport(self) -> str:
t = self.d.get("x-etm-transport", {})
bo = t.get("backoffS", {})
roles = t.get("roles", {})
rows = [
["Protocol version", f"`{self.d['info'].get('version', '')}`"],
["Application protocol", f"JSON-RPC `{t.get('jsonrpc', '')}`"],
["Framing", f"`{t.get('framing', '')}` (one message per line)"],
["Encoding", f"`{t.get('encoding', '')}`"],
["Endpoint schemes", " · ".join(f"`{s}://`" for s in t.get("schemes", []))],
["Default endpoint", f"`{t.get('defaultEndpoint', '')}`"],
["Client", f"`{roles.get('client', '')}`"],
["Server", f"`{roles.get('server', '')}`"],
["Max line size", f"{t.get('maxLineBytes', '')} bytes"],
["Handshake timeout", f"{t.get('handshakeTimeoutS', '')} s"],
["Request timeout", f"{t.get('requestTimeoutS', '')} s"],
["Reconnect backoff", f"{bo.get('initial', '')} s → {bo.get('max', '')} s, ×{bo.get('factor', '')}"],
["Requests in flight", f"{t.get('requestsInFlight', '')}"],
]
for k, label in (("ids", "Request ids"), ("lineErrors", "Line errors"),
("tokenMasking", "Token masking")):
if k in t:
rows.append([label, cell(t[k])])
parts = [table(rows, ["Property", "Value (contract)"]), ""]
srv = self.d.get("servers", [])
if srv:
rows = []
for s in srv:
var = s.get("variables", {})
defaults = ", ".join(f"`{k}` = `{v.get('default', '')}`" for k, v in var.items())
rows.append([f"`{s.get('name', '')}`", f"`{s.get('url', '')}`",
cell(s.get("summary", "")), defaults])
parts += ["**Servers**", "", table(rows, ["Name", "URL", "Summary (contract)", "Example values"]), ""]
rows = []
for m in self.d["methods"]:
kind = "notification (no id)" if m.get("x-etm-notification") else "request"
to = f"{m['x-etm-timeout-s']} s" if "x-etm-timeout-s" in m else "—"
rows.append([f"`{m['name']}`", f"`{m.get('x-etm-direction', '')}`", kind, to,
cell(m.get("summary", ""))])
parts += ["**Methods**", "", table(rows, ["Method", "Direction", "Kind", "Timeout", "Summary (contract)"]), ""]
return "\n".join(parts)
def block_handshake(self) -> str:
return self.method_block("Handshake") + "\n" + self.schemas_block("handshake")
def block_context(self) -> str:
return self.method_block("GetPlan", with_result=False) + "\n" + self.schemas_block("context")
def block_plan(self) -> str:
m = self.methods["GetPlan"]
parts = [f"#### `GetPlan` — {REF['result']}", "",
f"`{m['result']['name']}`: " + type_str(m["result"].get("schema", {})), ""]
if m.get("errors"):
rows = [[f"`{e['code']}`", cell(e.get("message", "")), cell(e.get("data", ""))]
for e in m["errors"]]
parts += [f"**{REF['errors']}**", "", table(rows, ["Code", "Message", "Note (contract)"]), ""]
return "\n".join(parts) + "\n" + self.schemas_block("plan")
def block_update_learned(self) -> str:
return self.method_block("UpdateLearned") + "\n" + self.schemas_block("update_learned")
def block_reason_codes(self) -> str:
rp = self.d.get("x-etm-reason-params", {})
codes = self.schemas.get("ReasonCode", {}).get("enum", sorted(rp))
rows = [[f"`{c}`", ", ".join(f"`{k}`" for k in rp.get(c, [])) or "—"] for c in codes]
return table(rows, ["`reasonCode`", "Accepted `reasonParams` keys"]) + "\n"
def block_fallback(self) -> str:
fm = self.d.get("x-etm-fallback-machine", {})
parts = []
if fm.get("description"):
parts += [cell(fm["description"]), ""]
if "failuresBeforeDead" in fm:
parts += [f"Consecutive failed requests before `optimizerAlive = false`: "
f"**{fm['failuresBeforeDead']}**.", ""]
rows = [[cell(r.get("situation", "")), f"`{r.get('optimizerAlive', '')}`",
f"`{r.get('activeStrategy', '')}`"] for r in fm.get("rows", [])]
parts += [table(rows, ["Situation (contract)", "`optimizerAlive`", "`activeStrategy`"]), ""]
return "\n".join(parts)
def block_rules(self) -> str:
sr = self.d.get("x-etm-semantic-rules", {})
parts = []
if sr.get("description"):
parts += [cell(sr["description"]), ""]
rows = []
for i, r in enumerate(sr.get("rules", []), 1):
rows.append([
str(i), f"`{r['name']}`", f"`{r.get('method', '')}`", f"`{r.get('issue', '')}`",
f"`{r.get('fallbackCode', '')}`",
f"`{r['lastErrorCode']}`" if r.get("lastErrorCode") else "—",
REF["yes"] if r.get("countsAsFailure") else REF["no"],
", ".join(f"`{n}`" for n in r.get("needs", [])) or "—",
REF["yes"] if r.get("schemaAlsoCatches") else REF["no"],
cell(r.get("description", "")),
])
parts += [table(rows, ["#", "Rule", "Method", "Outcome", "`fallbackCode`", "`lastErrorCode`",
"Counts as failure", "Also needs", "Schema catches it too",
"What is rejected (contract)"]), ""]
return "\n".join(parts)
def block_tolerances(self) -> str:
tol = self.d.get("x-etm-tolerances", {})
parts = []
if tol.get("description"):
parts += [cell(tol["description"]), ""]
parts += [f"- {cell(x)}" for x in tol.get("items", [])]
return "\n".join(parts) + "\n"
def block_closed_lists(self) -> str:
origin = {}
for it in self.d.get("x-etm-generated", {}).get("items", []):
m = re.fullmatch(r"/components/schemas/([^/]+)/(?:enum|const)", it.get("pointer", ""))
if m:
origin[m.group(1)] = it.get("from", "")
parts = []
for n in self.blocks["closed_lists"]:
s = self.schemas[n]
parts += [f"#### `{n}` {{#{anchor(n)}}}", ""]
if s.get("description"):
parts += [cell(s["description"]), ""]
vals = s["enum"] if "enum" in s else [s["const"]]
parts += [" · ".join(f"`{v}`" for v in vals), ""]
if n in origin:
parts += [f"Regenerated from the engine code: `{origin[n]}`.", ""]
return "\n".join(parts)
def block_contract(self) -> str:
info = self.d["info"]
lic = info.get("license", {})
ext = sorted(k for k in self.d if k.startswith("x-etm-"))
ext_m = sorted({k for m in self.d["methods"] for k in m if k.startswith("x-etm-")})
rows = [
["Title", cell(info.get("title", ""))],
["Protocol version", f"`{info.get('version', '')}`"],
["Format", f"OpenRPC `{self.d.get('openrpc', '')}`, JSON Schema in `components.schemas`"],
["License", cell(lic.get("name", ""))],
["File", f"[`{CONTRACT_NAME}`]({CONTRACT_NAME})"],
["Size", f"{len(self.raw)} bytes"],
["SHA-256", f"`{hashlib.sha256(self.raw).hexdigest()}`"],
["Methods", " · ".join(f"`{m['name']}`" for m in self.d["methods"])],
["Schemas", str(len(self.schemas))],
["Semantic rules", str(len(self.d.get("x-etm-semantic-rules", {}).get("rules", [])))],
["Extensions (document)", " · ".join(f"`{e}`" for e in ext)],
["Extensions (methods)", " · ".join(f"`{e}`" for e in ext_m)],
]
return table(rows, ["", "Contract"]) + "\n"
# ── Échafaudage des pages créées si absentes ──────────────────────────────────
SCAFFOLD = {
"protocole": {
"fr": "Protocole", "de": "Protokoll", "en": "Protocol",
"keys": ["transport", "handshake", "context", "plan", "reason_codes",
"update_learned", "fallback", "rules", "tolerances", "closed_lists"],
},
"contrat": {
"fr": "Contrat", "de": "Vertrag", "en": "Contract",
"keys": ["contract"],
},
}
def page_path(docs: Path, stem: str, loc: str, default: str) -> Path:
suffix = "" if loc == default else f".{loc}"
return docs / OUT_DIR / f"{stem}{suffix}.md"
def ensure_pages(docs: Path, locales: list[str], default: str, check: bool) -> int:
rc = 0
for stem, sc in SCAFFOLD.items():
for loc in locales:
path = page_path(docs, stem, loc, default)
if path.exists():
continue
if check:
print(f" MANQUANT : {path}")
rc = 1
continue
blocks = "\n\n".join(f"<!-- BEGIN GENERATED: __optimizer_{k}__ -->\n<!-- END GENERATED -->"
for k in sc["keys"])
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"# {sc.get(loc, sc['en'])}\n\n{blocks}\n", encoding="utf-8")
print(f" CRÉÉ : {path}")
return rc
# ── Copie servie du contrat ───────────────────────────────────────────────────
def copy_contract(model: Model, docs: Path, check: bool) -> int:
dest = docs / OUT_DIR / CONTRACT_NAME
if dest.exists() and dest.read_bytes() == model.raw:
return 0
if check:
print(f" PÉRIMÉ : {dest}")
return 1
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(model.raw)
print(f" COPIÉ : {dest}")
return 0
# ── Injection dans les marqueurs ──────────────────────────────────────────────
def process(docs: Path, model: Model, check: bool) -> tuple[int, dict[Path, str]]:
"""Retourne (rc, {page: texte publié}) pour le garde-fou."""
changed: list[Path] = []
published: dict[Path, str] = {}
for md in sorted(docs.rglob("*.md")):
text = md.read_text(encoding="utf-8")
if "__optimizer_" not in text:
continue
gen_parts: list[str] = []
def repl(m: re.Match, _md: Path = md) -> str:
key = m.group("key")
if not key.startswith("__optimizer_"):
return m.group(0)
gen = model.render(key)
if gen is None:
sys.exit(f"ERREUR : {_md}: clé inconnue « {key} »")
gen_parts.append(gen)
return f"{m.group(1)}\n\n{gen}\n{m.group(4)}"
new = MARKER_RE.sub(repl, text)
published[md] = new if md.parent.name == OUT_DIR else "\n".join(gen_parts)
if new != text:
changed.append(md)
if not check:
md.write_text(new, encoding="utf-8")
if check:
if changed:
print("Doc optimiseur PAS à jour :", ", ".join(str(c) for c in changed))
return 1, published
print("Doc optimiseur à jour.")
return 0, published
print(f"Régénéré : {len(changed)} page(s).")
for c in changed:
print(f" - {c.relative_to(docs)}")
return 0, published
# ── Garde-fou de contenu ──────────────────────────────────────────────────────
def json_strings(o: object, ptr: str = ""):
if isinstance(o, dict):
for k, v in o.items():
yield from json_strings(v, f"{ptr}/{k}")
elif isinstance(o, list):
for i, v in enumerate(o):
yield from json_strings(v, f"{ptr}/{i}")
elif isinstance(o, str):
yield ptr, o
def private_ips(text: str) -> list[str]:
out = []
for m in IPV4_RE.finditer(text):
try:
ip = ipaddress.ip_address(m.group(0))
except ValueError:
continue
if ip in DOC_NET:
continue
if any(ip in n for n in PRIVATE_NETS):
out.append(m.group(0))
return out
def guard(model: Model, published: dict[Path, str]) -> int:
allowed = set()
for ptr, val in CONTRACT_IP_EXAMPLES:
if dict(json_strings(model.d)).get(ptr) == val:
allowed.add(val)
problems: list[str] = []
notes: list[str] = []
for ptr, s in json_strings(model.d):
for ip in private_ips(s):
if (ptr, ip) in CONTRACT_IP_EXAMPLES:
notes.append(f"{CONTRACT_NAME}{ptr} : {ip} (exemple d'usage du contrat, admis)")
else:
problems.append(f"{CONTRACT_NAME}{ptr} : adresse privée {ip}")
for w in WORD_RES:
if w.search(s):
problems.append(f"{CONTRACT_NAME}{ptr} : mot interdit « {w.search(s).group(0)} »")
for path, text in published.items():
for ip in private_ips(text):
if ip in allowed:
notes.append(f"{path} : {ip} (rendu de l'exemple du contrat, admis)")
else:
problems.append(f"{path} : adresse privée {ip}")
for w in WORD_RES:
for m in w.finditer(text):
problems.append(f"{path} : mot interdit « {m.group(0)} »")
for n in sorted(set(notes)):
print(f" garde-fou : {n}")
if problems:
print("ERREUR garde-fou — contenu à ne pas publier :", file=sys.stderr)
for p in problems:
print(f" - {p}", file=sys.stderr)
return 1
print("Garde-fou : rien à signaler.")
return 0
# ── Point d'entrée ────────────────────────────────────────────────────────────
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--src", required=True, type=Path,
help=f"Clone du moteur, dossier contenant {CONTRACT_NAME}, ou le fichier lui-même")
ap.add_argument("--docs", default="docs", type=Path, help="Dossier docs/ (défaut : docs)")
ap.add_argument("--locales", default="fr,de,en",
help="Langues des pages échafaudées ; la première est la langue par défaut")
ap.add_argument("--check", action="store_true", help="CI : exit 1 si la doc n'est pas à jour")
a = ap.parse_args()
locales = [x.strip() for x in a.locales.split(",") if x.strip()]
path = find_contract(a.src)
raw, d = load_contract(path)
model = Model(raw, d)
print(f"Contrat {d['info'].get('version', '?')} : {len(model.methods)} méthodes · "
f"{len(model.schemas)} schémas · "
f"{len(d.get('x-etm-semantic-rules', {}).get('rules', []))} règles sémantiques")
rc_pages = ensure_pages(a.docs, locales, locales[0], a.check)
rc_copy = copy_contract(model, a.docs, a.check)
rc_proc, published = process(a.docs, model, a.check)
for md in sorted((a.docs / OUT_DIR).glob("*.md")):
published.setdefault(md, md.read_text(encoding="utf-8"))
rc_guard = guard(model, published)
return max(rc_pages, rc_copy, rc_proc, rc_guard)
if __name__ == "__main__":
raise SystemExit(main())