etm-powersync-energy-plugin.../tools/etm-powersync-schema-check
Patrick Schurig d961b73ed7 Validateur du contrat : juge error.data selon la version de l'optimiseur (protocole 1.3)
tools/etm-powersync-schema-check :
- lit methods[].errors[].x-etm-data-schema : error.data d'une réponse error de ce code est jugé
  contre ce schéma (audité comme les autres) ; exigé à partir de x-etm-data-required-from, la
  version étant celle que l'optimiseur annonce dans SON Handshake ; en deçà, écart écrit en
  AVERTISSEMENT, sans effet sur le code de sortie ; version inconnue = celle du contrat ;
- truncated n'accompagne que 50 problèmes exactement ;
- aucune valeur reçue recopiée dans error.data : écart dès 1.2 (règle de 1.2). Le contrôle ne
  porte que sur les valeurs qu'un chemin conforme ne peut pas contenir — il ne peut pas accuser
  un chemin conforme (domain « battery » / params.battery) ;
- --error (un objet error, avec --request et --handshake) et --responses-only (session de sonde,
  requêtes fautives à dessein) ; RÉSUMÉ compte les avertissements.

Tests : forme exigée en 1.3 et avertie en 1.2 sur de vraies sessions de l'optimiseur de
référence (normal, old-data, echo-value × 1.3, 1.2), forme ligne à ligne, recopie sans faux
positif, contre-épreuve « sans x-etm-data-schema, rien n'est jugé ». Les cinq nouveaux tests
échouent avec le validateur précédent.

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

819 lines
38 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync
"""Valide des messages du protocole moteur ↔ optimiseur contre son contrat OpenRPC.
etm-powersync-schema-check [--schema FICHIER] --method M --params FICHIER.json
etm-powersync-schema-check [--schema FICHIER] --method M --result FICHIER.json
[--request PARAMS.json] [--handshake RESULT.json] [--semantic]
etm-powersync-schema-check [--schema FICHIER] --method M --error FICHIER.json
[--request PARAMS.json] [--handshake RESULT.json]
etm-powersync-schema-check [--schema FICHIER] --ndjson SESSION.ndjson [--semantic]
[--requests-only | --responses-only]
etm-powersync-schema-check [--schema FICHIER] --trace TRACE.ndjson [--semantic]
etm-powersync-schema-check --subset
Le schéma par défaut est `docs/etm-powersync-optimizer-protocol.openrpc.json` du dépôt. Aucun
accès réseau ; bibliothèque standard seule.
SOUS-ENSEMBLE DE JSON SCHEMA géré (le schéma n'en utilise pas d'autre) : `$ref` (local, `#/…`),
`type` (dont `integer` : 3.0 en est un), `enum`, `const`, `properties`, `required`,
`additionalProperties` (booléen ou schéma), `propertyNames`, `items` (un schéma), `minItems`,
`maxItems`, `minimum`, `maximum`, `exclusiveMinimum`, `minLength`, `maxLength` (en caractères),
`pattern` (recherche, non ancrée), `oneOf`, `anyOf`, `allOf`. Annotations lues sans effet :
`title`, `description`, `examples`, `default`, `deprecated`, `$comment`, et toute clé `x-…`.
**Tout autre mot-clé est une ERREUR de schéma (code 2), jamais ignoré en silence** : un mot-clé
qu'on croirait appliqué et qui ne l'est pas ferait passer pour valide ce qu'il devait refuser.
Ce que l'outil lit en plus du JSON Schema :
- les `params` d'une méthode `by-name` forment un objet FERMÉ aux noms déclarés (le moteur les
émet : un nom de plus est un champ ajouté sans le contrat), sauf `x-etm-params-open: true` ;
- `x-etm-reason-params` : clés de `reasonParams` admises par `reasonCode` (contrôle de forme) ;
- `x-etm-semantic-rules` (avec `--semantic`) : ce que le validateur du moteur refuse EN PLUS de
la forme, dans le même ordre, sous le même nom. Une règle du schéma sans implantation ici, ou
l'inverse, est une erreur de schéma (code 2) : le contrôle ne disparaît pas en silence ;
- `methods[].errors[].x-etm-data-schema` (protocole 1.3) : la forme de `error.data` d'une réponse
error de ce code — `InvalidContextData` pour le `-32602` de `GetPlan`. Exigée d'un optimiseur qui
s'annonce `x-etm-data-required-from` ou plus (version lue dans SON `Handshake`) ; plus ancienne,
un écart n'est qu'un AVERTISSEMENT, qui ne change pas le code de sortie. Une version inconnue (pas
de handshake) est jugée comme celle du contrat. En plus de la forme, deux contrôles :
`truncated` n'accompagne que 50 problèmes exactement ; et AUCUNE VALEUR REÇUE n'est recopiée dans
`error.data` (§7 — règle de 1.2, donc un écart dès 1.2, jamais un avertissement). Ce dernier
contrôle porte sur les valeurs textuelles de la requête qu'un chemin conforme ne peut pas
contenir (4 caractères au moins, dont un hors `[A-Za-z0-9_]` : instants, identifiants à tiret,
libellés) : il ne peut pas accuser un chemin conforme. Une recopie de nombre ou de mot nu lui
échappe ; la forme 1.3, fermée, la rend inexprimable.
Modes :
- `--ndjson` : une session, un message JSON-RPC par ligne, dans les deux sens. Une réponse est
rapprochée de sa requête par `id` ; un `GetPlan` est jugé avec SA requête et le dernier
`Handshake` accepté. `--requests-only` ne juge que ce qu'émet le moteur (requêtes) ;
`--responses-only` que ce qu'émet l'optimiseur (réponses) — pour une session de SONDE, dont les
requêtes sont fautives à dessein (un contexte invalide, pour juger le `-32602` qui lui répond).
- `--trace` : traces de décision du moteur (annexe D) — `request` contre les params de
`GetPlan` ; `response.result` contre son résultat quand la ligne vient d'un optimiseur
(`latencyMs` présent) — le plan du rule-based tracé n'est pas une réponse d'optimiseur. Une
erreur n'y porte que le code d'échec du moteur : rien à juger de son `data`.
- `--error` : l'objet `error` d'une réponse à `--method` ; son `data` est jugé comme en session,
la version tirée de `--handshake` (le `result` du Handshake), la requête de `--request`.
Sortie : une ligne par écart, une ligne `AVERTISSEMENT …` par écart toléré (forme normée après la
version de l'optimiseur), puis `RÉSUMÉ {json}` (comptes par méthode et partie, écarts,
avertissements, verdicts sémantiques). Code : 0 conforme ; 1 au moins un écart de forme, ou (avec `--semantic`) un
verdict qui REFUSE (`invalid`, `connection-refused`) ; 2 erreur d'usage ou de schéma.
Les verdicts `abstention` et `expired` sont dits, sans échec : ce sont des réponses permises.
"""
import argparse
import json
import math
import os
import re
import sys
import unicodedata
from datetime import datetime
ICI = os.path.dirname(os.path.abspath(__file__))
SCHEMA_DEFAUT = os.path.join(ICI, '..', 'docs', 'etm-powersync-optimizer-protocol.openrpc.json')
GERES = {'$ref', 'type', 'enum', 'const', 'properties', 'required', 'additionalProperties',
'propertyNames', 'items', 'minItems', 'maxItems', 'minimum', 'maximum',
'exclusiveMinimum', 'minLength', 'maxLength', 'pattern', 'oneOf', 'anyOf', 'allOf'}
ANNOTATIONS = {'title', 'description', 'examples', 'default', 'deprecated', '$comment'}
TYPES = {'null', 'boolean', 'object', 'array', 'number', 'integer', 'string'}
class ErreurSchema(Exception):
"""Le schéma lui-même est inutilisable (mot-clé non géré, $ref cassé, règle orpheline)."""
# --- Validation JSON Schema (sous-ensemble) -------------------------------------------------------
def est_nombre(v):
return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
def a_le_type(v, t):
if t == 'null':
return v is None
if t == 'boolean':
return isinstance(v, bool)
if t == 'object':
return isinstance(v, dict)
if t == 'array':
return isinstance(v, list)
if t == 'string':
return isinstance(v, str)
if t == 'number':
return est_nombre(v)
if t == 'integer':
return est_nombre(v) and float(v).is_integer()
return False
def egal_json(a, b):
"""Égalité JSON : 1 == 1.0, mais true n'est pas 1."""
if isinstance(a, bool) or isinstance(b, bool):
return isinstance(a, bool) and isinstance(b, bool) and a == b
if est_nombre(a) and est_nombre(b):
return a == b
if isinstance(a, list) and isinstance(b, list):
return len(a) == len(b) and all(egal_json(x, y) for x, y in zip(a, b))
if isinstance(a, dict) and isinstance(b, dict):
return a.keys() == b.keys() and all(egal_json(a[k], b[k]) for k in a)
return type(a) is type(b) and a == b
def court(v):
t = json.dumps(v, ensure_ascii=False)
return t if len(t) <= 60 else t[:57] + '…'
class Validateur:
def __init__(self, doc):
self.doc = doc
self.regex = {}
self.auditer()
# -- audit : chaque mot-clé de chaque sous-schéma est géré, chaque $ref se résout
def auditer(self):
racines = []
for nom, s in (self.doc.get('components', {}).get('schemas', {}) or {}).items():
racines.append((f'#/components/schemas/{nom}', s))
for m in self.doc.get('methods', []):
for p in m.get('params', []):
racines.append((f'méthode {m.get("name")}, param {p.get("name")}', p.get('schema')))
if 'result' in m:
racines.append((f'méthode {m.get("name")}, result', m['result'].get('schema')))
for e in m.get('errors', []):
if 'x-etm-data-schema' in e:
racines.append((f'méthode {m.get("name")}, erreur {e.get("code")}, data',
e['x-etm-data-schema']))
for ou, s in racines:
self.auditer_schema(s, ou)
def auditer_schema(self, s, ou):
if isinstance(s, bool):
return
if not isinstance(s, dict):
raise ErreurSchema(f'{ou} : un schéma doit être un objet ou un booléen')
for cle, val in s.items():
if cle in ANNOTATIONS or cle.startswith('x-'):
continue
if cle not in GERES:
raise ErreurSchema(f'{ou} : mot-clé « {cle} » non géré par cet outil — refusé plutôt '
f'qu\'ignoré (sous-ensemble : --subset)')
if cle == '$ref':
self.resoudre(val, ou)
elif cle == 'type':
for t in [val] if isinstance(val, str) else val:
if t not in TYPES:
raise ErreurSchema(f'{ou} : type « {t} » inconnu')
elif cle == 'properties':
for k, sous in val.items():
self.auditer_schema(sous, f'{ou}/properties/{k}')
elif cle in ('items', 'propertyNames') or (cle == 'additionalProperties' and isinstance(val, dict)):
self.auditer_schema(val, f'{ou}/{cle}')
elif cle in ('oneOf', 'anyOf', 'allOf'):
for i, sous in enumerate(val):
self.auditer_schema(sous, f'{ou}/{cle}/{i}')
elif cle == 'pattern':
self.motif(val)
def resoudre(self, ref, ou='?'):
if not isinstance(ref, str) or not ref.startswith('#/'):
raise ErreurSchema(f'{ou} : $ref « {ref} » non local')
noeud = self.doc
for morceau in ref[2:].split('/'):
morceau = morceau.replace('~1', '/').replace('~0', '~')
if not isinstance(noeud, dict) or morceau not in noeud:
raise ErreurSchema(f'{ou} : $ref « {ref} » ne se résout pas')
noeud = noeud[morceau]
return noeud
def motif(self, p):
if p not in self.regex:
try:
self.regex[p] = re.compile(p)
except re.error as e:
raise ErreurSchema(f'pattern « {p} » illisible : {e}')
return self.regex[p]
def discriminant_rate(self, v, s):
"""Vrai si une propriété à const/enum de la branche contredit l'instance."""
while isinstance(s, dict) and '$ref' in s:
s = self.resoudre(s['$ref'])
if not isinstance(v, dict) or not isinstance(s, dict):
return False
for k, p in (s.get('properties') or {}).items():
if not isinstance(p, dict) or not ('const' in p or 'enum' in p):
continue
if k not in v:
if k in s.get('required', []):
return True
elif self.valider(v[k], p):
return True
return False
# -- validation : liste d'écarts (chemin, mot-clé, message)
def valider(self, v, s, chemin=''):
if s is True or s == {}:
return []
if s is False:
return [(chemin or '/', 'false', 'aucune valeur permise')]
e = []
if '$ref' in s:
e += self.valider(v, self.resoudre(s['$ref']), chemin)
if 'type' in s:
types = [s['type']] if isinstance(s['type'], str) else s['type']
if not any(a_le_type(v, t) for t in types):
return e + [(chemin or '/', 'type', f'{court(v)} n\'est pas {"|".join(types)}')]
if 'const' in s and not egal_json(v, s['const']):
e.append((chemin or '/', 'const', f'{court(v)} ≠ {court(s["const"])}'))
if 'enum' in s and not any(egal_json(v, x) for x in s['enum']):
e.append((chemin or '/', 'enum', f'{court(v)} hors de {court(s["enum"])}'))
if isinstance(v, dict):
props = s.get('properties', {})
for k in s.get('required', []):
if k not in v:
e.append((chemin or '/', 'required', f'« {k} » manquant'))
for k, x in v.items():
if k in props:
e += self.valider(x, props[k], f'{chemin}/{k}')
elif 'additionalProperties' in s:
ap = s['additionalProperties']
if ap is False:
e.append((f'{chemin}/{k}', 'additionalProperties', f'champ « {k} » non déclaré'))
elif isinstance(ap, dict):
e += self.valider(x, ap, f'{chemin}/{k}')
if 'propertyNames' in s:
e += [(f'{chemin}/{k}', 'propertyNames', m) for _, _, m in self.valider(k, s['propertyNames'], '')]
if isinstance(v, list):
if 'minItems' in s and len(v) < s['minItems']:
e.append((chemin or '/', 'minItems', f'{len(v)} élément(s) < {s["minItems"]}'))
if 'maxItems' in s and len(v) > s['maxItems']:
e.append((chemin or '/', 'maxItems', f'{len(v)} élément(s) > {s["maxItems"]}'))
if 'items' in s:
for i, x in enumerate(v):
e += self.valider(x, s['items'], f'{chemin}/{i}')
if est_nombre(v):
if 'minimum' in s and v < s['minimum']:
e.append((chemin or '/', 'minimum', f'{v} < {s["minimum"]}'))
if 'maximum' in s and v > s['maximum']:
e.append((chemin or '/', 'maximum', f'{v} > {s["maximum"]}'))
if 'exclusiveMinimum' in s and v <= s['exclusiveMinimum']:
e.append((chemin or '/', 'exclusiveMinimum', f'{v} ≤ {s["exclusiveMinimum"]}'))
if isinstance(v, str):
if 'minLength' in s and len(v) < s['minLength']:
e.append((chemin or '/', 'minLength', f'{len(v)} caractère(s) < {s["minLength"]}'))
if 'maxLength' in s and len(v) > s['maxLength']:
e.append((chemin or '/', 'maxLength', f'{len(v)} caractère(s) > {s["maxLength"]}'))
if 'pattern' in s and not self.motif(s['pattern']).search(v):
e.append((chemin or '/', 'pattern', f'{court(v)} ne suit pas {s["pattern"]}'))
for sous in s.get('allOf', []):
e += self.valider(v, sous, chemin)
if 'anyOf' in s:
essais = [self.valider(v, sous, chemin) for sous in s['anyOf']]
if all(essais):
e += [(c, k, m + ' (aucune branche de anyOf)') for c, k, m in min(essais, key=len)]
if 'oneOf' in s:
essais = [self.valider(v, sous, chemin) for sous in s['oneOf']]
ok = sum(1 for x in essais if not x)
if ok == 0:
# La branche dont le DISCRIMINANT (propriété à const/enum) correspond : c'est elle
# que l'émetteur visait, ses écarts sont ceux qui l'informent.
rang = [(self.discriminant_rate(v, sous), len(x)) for sous, x in zip(s['oneOf'], essais)]
meilleur = essais[rang.index(min(rang))]
e += [(c, k, m + ' (aucune branche de oneOf)') for c, k, m in meilleur]
elif ok > 1:
e.append((chemin or '/', 'oneOf', f'{ok} branches de oneOf valides, une seule attendue'))
return e
# --- Le contrat : méthodes, contrôles propres au protocole ----------------------------------------
class Contrat:
def __init__(self, doc):
self.doc = doc
self.v = Validateur(doc)
self.methodes = {m['name']: m for m in doc.get('methods', [])}
regles = (doc.get('x-etm-semantic-rules') or {}).get('rules', [])
noms = {r['name'] for r in regles}
manquantes = noms - set(REGLES_IMPLANTEES)
orphelines = set(REGLES_IMPLANTEES) - noms
if manquantes or orphelines:
raise ErreurSchema('x-etm-semantic-rules et cet outil divergent — '
f'sans implantation : {sorted(manquantes)} ; absentes du schéma : {sorted(orphelines)}')
self.regles = {r['name']: r for r in regles}
self.kinds = self.v.resoudre('#/components/schemas/ActionKind').get('enum', [])
self.version = str(self.v.resoudre('#/components/schemas/ProtocolVersion').get('const', ''))
self.cles_motif = doc.get('x-etm-reason-params') or {}
# (méthode, code) → (schéma de error.data, version de l'optimiseur à partir de laquelle il est exigé)
self.donnees_erreur = {}
for m in doc.get('methods', []):
for e in m.get('errors', []):
if 'x-etm-data-schema' in e:
depuis = version_lue(e.get('x-etm-data-required-from'))
if depuis is None:
raise ErreurSchema(f'{m["name"]}, erreur {e.get("code")} : x-etm-data-required-from '
f'absent ou illisible')
self.donnees_erreur[(m['name'], e.get('code'))] = (e['x-etm-data-schema'], depuis)
def methode(self, nom):
if nom not in self.methodes:
raise ErreurSchema(f'méthode « {nom} » absente du schéma')
return self.methodes[nom]
def schema_params(self, nom):
m = self.methode(nom)
if m.get('paramStructure', 'either') not in ('by-name', 'either'):
raise ErreurSchema(f'{nom} : seuls les params par nom sont gérés')
return {'type': 'object',
'properties': {p['name']: p['schema'] for p in m.get('params', [])},
'required': [p['name'] for p in m.get('params', []) if p.get('required')],
'additionalProperties': bool(m.get('x-etm-params-open', False))}
def valider_params(self, nom, params):
return self.v.valider(params, self.schema_params(nom))
def valider_result(self, nom, result):
m = self.methode(nom)
if 'result' not in m:
return [('/', 'result', f'{nom} est une notification : pas de résultat')]
e = self.v.valider(result, m['result']['schema'])
if nom == 'GetPlan' and isinstance(result, dict) and isinstance(result.get('slots'), list):
e += self.controler_motifs(result['slots'])
return e
def controler_motifs(self, slots):
"""x-etm-reason-params : seules les clés documentées du code."""
e = []
for i, s in enumerate(slots):
if not isinstance(s, dict) or not isinstance(s.get('actions'), list):
continue
for j, a in enumerate(s['actions']):
if not isinstance(a, dict) or not isinstance(a.get('reasonParams'), dict):
continue
admises = self.cles_motif.get(a.get('reasonCode'))
if admises is None:
continue # code hors liste : déjà un écart d'enum
for k in a['reasonParams']:
if k not in admises:
e.append((f'/slots/{i}/actions/{j}/reasonParams/{k}', 'x-etm-reason-params',
f'clé non admise pour {a.get("reasonCode")} (admises : {admises})'))
return e
# --- error.data (protocole 1.3) ----------------------------------------------------------------------
def version_lue(v):
"""« 1.3 » → (1, 3) ; « 1 » → (1, 0) ; illisible → None."""
if not isinstance(v, str):
return None
morceaux = v.split('.')
try:
return int(morceaux[0]), int(morceaux[1]) if len(morceaux) > 1 else 0
except ValueError:
return None
TEXTE_HORS_CHEMIN = re.compile(r'[^A-Za-z0-9_]')
RECOPIE_DEPUIS = (1, 2) # « jamais la valeur reçue » : règle de 1.2 (§7)
MAX_PROBLEMES = 50
def valeurs_reconnaissables(params, chemin='params'):
"""Les valeurs textuelles de la requête qu'un chemin conforme ne peut pas contenir : (chemin, valeur)."""
if isinstance(params, dict):
for k, v in params.items():
yield from valeurs_reconnaissables(v, f'{chemin}.{k}')
elif isinstance(params, list):
for i, v in enumerate(params):
yield from valeurs_reconnaissables(v, f'{chemin}[{i}]')
elif isinstance(params, str) and len(params) >= 4 and TEXTE_HORS_CHEMIN.search(params):
yield chemin, params
def textes(v):
"""Toutes les chaînes d'un JSON, clés comprises."""
if isinstance(v, str):
yield v
elif isinstance(v, dict):
for k, x in v.items():
yield k
yield from textes(x)
elif isinstance(v, list):
for x in v:
yield from textes(x)
def juger_donnees_erreur(contrat, methode, erreur, requete, version):
"""error.data d'une réponse error : (écarts, avertissements), chacun (chemin, mot-clé, message).
`version` : protocolVersion annoncée par l'optimiseur au handshake (None : inconnue, jugée comme
celle du contrat). La forme n'est exigée qu'à partir de x-etm-data-required-from ; la recopie
d'une valeur reçue est un écart dès 1.2."""
if not isinstance(erreur, dict):
return [], []
forme = contrat.donnees_erreur.get((methode, erreur.get('code')))
if forme is None:
return [], []
schema, depuis = forme
v = version_lue(version) or version_lue(contrat.version)
if 'data' not in erreur:
ecarts_forme = [('/error', 'required', '« data » manquant')]
else:
data = erreur['data']
ecarts_forme = contrat.v.valider(data, schema, '/error/data')
if isinstance(data, dict) and data.get('truncated') is True and isinstance(data.get('problems'), list) \
and len(data['problems']) != MAX_PROBLEMES:
ecarts_forme.append(('/error/data/truncated', 'x-etm-truncated',
f'truncated avec {len(data["problems"])} problème(s) : seulement quand '
f'{MAX_PROBLEMES} sont donnés et d\'autres omis'))
recopies = []
if 'data' in erreur and isinstance(requete, dict):
vus = list(textes(erreur['data']))
for chemin, valeur in valeurs_reconnaissables(requete):
if any(valeur in t for t in vus):
recopies.append(('/error/data', 'x-etm-no-echo',
f'recopie la valeur reçue en {chemin} — jamais la valeur reçue (§7)'))
ecarts, avertissements = [], []
(ecarts if v >= depuis else avertissements).extend(ecarts_forme)
(ecarts if v >= RECOPIE_DEPUIS else avertissements).extend(recopies)
return ecarts, avertissements
# --- Règles sémantiques : le validateur du moteur, transcrit dans son ordre -----------------------
REGLES_IMPLANTEES = ['abstain', 'malformed-plan', 'empty-plan', 'bad-time', 'slots-unordered',
'malformed-action', 'unknown-load', 'unknown-kind', 'kind-mismatch',
'kind-not-announced', 'missing-reason', 'bad-value', 'duplicate-load', 'expired',
'no-current-slot', 'incomplete-slot',
'handshake-refused', 'invalid-handshake', 'major-mismatch']
INSTANT = re.compile(r'^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2}(\.[0-9]+)?)?(Z|[+-][0-9]{2}:[0-9]{2})$')
def instant(v):
"""lireInstant() du moteur : chaîne ISO 8601 AVEC décalage, sinon None."""
if not isinstance(v, str) or not INSTANT.match(v):
return None
try:
return datetime.fromisoformat(v.replace('Z', '+00:00'))
except ValueError:
return None
def vrai_qvariant(v):
"""QVariant::toBool() — ce que lit le moteur pour `abstain`."""
if isinstance(v, bool):
return v
if est_nombre(v):
return v != 0
if isinstance(v, str):
return v.strip().lower() not in ('', '0', 'false')
return False
def assaini(v):
"""assainirTexte() : contrôles, séparateurs de ligne et formats → blanc, puis resserré."""
if not isinstance(v, str):
return ''
return ''.join(' ' if unicodedata.category(c) in ('Cc', 'Zl', 'Zp', 'Cf') else c for c in v).strip()
def kind_du_mecanisme(adapter):
if adapter in ('evcharger', 'relay-router', 'etmvariableload'):
return 'setpoint'
if adapter == 'sg-ready':
return 'state'
return ''
def nombre_moteur(v):
"""lireNombre() : un nombre JSON fini ; ni booléen, ni chaîne."""
return v if est_nombre(v) else None
def juger_action(a, charges, kinds, annonces):
if not isinstance(a, dict):
return 'malformed-action'
ident = a.get('loadId')
if not isinstance(ident, str) or not ident:
return 'malformed-action'
lc = charges.get(ident)
if lc is None:
return 'unknown-load'
kind = a.get('kind')
if not isinstance(kind, str) or kind not in kinds:
return 'unknown-kind'
attendu = kind_du_mecanisme(lc.get('adapter'))
if kind != attendu:
return 'kind-mismatch'
if annonces is not None and kind not in annonces:
return 'kind-not-announced'
if not assaini(a.get('reason')):
return 'missing-reason'
if attendu == 'state':
s = nombre_moteur(a.get('state'))
if s is None or not float(s).is_integer() or not 1 <= s <= 4:
return 'bad-value'
elif lc.get('adapter') == 'evcharger':
a_courant, a_puissance = 'currentA' in a, 'powerW' in a
c, p = nombre_moteur(a.get('currentA')), nombre_moteur(a.get('powerW'))
ph = nombre_moteur(a.get('phases'))
ok = ((a_courant or a_puissance)
and (not a_courant or (c is not None and c >= 0))
and (not a_puissance or (p is not None and p >= 0))
and ('phases' not in a or ph in (1, 3)))
if not ok:
return 'bad-value'
else:
p = nombre_moteur(a.get('powerW'))
if p is None or p < 0:
return 'bad-value'
return None
def juger_plan(result, request, handshake, kinds):
"""OptimizerPlan::valider() : le premier code qui refuse, ou None si le plan est applicable."""
if vrai_qvariant(result.get('abstain')):
return 'abstain'
slots = result.get('slots')
if not isinstance(slots, list):
return 'malformed-plan'
if not slots:
return 'empty-plan'
charges = {}
for lc in (request or {}).get('loads') or []:
if isinstance(lc, dict) and isinstance(lc.get('id'), str):
charges[lc['id']] = lc
annonces = None
caps = (handshake or {}).get('capabilities')
if isinstance(caps, dict) and 'kinds' in caps:
annonces = [str(k) for k in caps['kinds']] if isinstance(caps['kinds'], list) else []
creneaux = []
for s in slots:
if not isinstance(s, dict):
return 'malformed-plan'
debut, fin = instant(s.get('from')), instant(s.get('to'))
if debut is None or fin is None or debut >= fin:
return 'bad-time'
if creneaux and debut < creneaux[-1][1]:
return 'slots-unordered'
actions = s.get('actions')
if actions is not None and not isinstance(actions, list):
return 'malformed-plan'
vues = set()
for a in actions or []:
code = juger_action(a, charges, kinds, annonces)
if code:
return code
if a['loadId'] in vues:
return 'duplicate-load'
vues.add(a['loadId'])
creneaux.append((debut, fin, vues))
maintenant = instant((request or {}).get('timestamp'))
if 'validUntil' in result and result['validUntil'] is not None:
vu = instant(result['validUntil'])
if vu is None:
return 'bad-time'
if maintenant is not None and vu <= maintenant:
return 'expired'
courant = None
for c in creneaux:
if maintenant is not None and c[0] <= maintenant < c[1]:
courant = c
if courant is None:
return 'no-current-slot'
for ident, lc in charges.items():
if (lc.get('telemetry') or {}).get('available', True) is not False and ident not in courant[2]:
return 'incomplete-slot'
return None
def juger_handshake(message, version_moteur):
if 'error' in message:
return 'handshake-refused'
r = message.get('result')
if not isinstance(r, dict) or not isinstance(r.get('protocolVersion'), str) or not r['protocolVersion']:
return 'invalid-handshake'
if 'capabilities' in r and not isinstance(r['capabilities'], dict):
return 'invalid-handshake'
if r['protocolVersion'].split('.')[0] != version_moteur.split('.')[0]:
return 'major-mismatch'
return None
# --- Lecture, comptes, sortie ---------------------------------------------------------------------
class Bilan:
def __init__(self, contrat, semantique):
self.c = contrat
self.semantique = semantique
self.comptes = {}
self.ecarts = []
self.avertissements = []
self.verdicts = {}
self.refus = 0
def compter(self, cle):
self.comptes[cle] = self.comptes.get(cle, 0) + 1
def noter(self, ou, partie, ecarts):
self.compter(partie)
for chemin, mot, msg in ecarts:
self.ecarts.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg})
print(f'{ou} {partie} {chemin} : {msg} [{mot}]')
def avertir(self, ou, partie, ecarts, pourquoi):
for chemin, mot, msg in ecarts:
self.avertissements.append({'ou': ou, 'partie': partie, 'chemin': chemin, 'mot': mot, 'message': msg})
print(f'AVERTISSEMENT {ou} {partie} {chemin} : {msg} [{mot}] — {pourquoi}')
def juger_erreur(self, ou, methode, erreur, requete, version):
ecarts, avertissements = juger_donnees_erreur(self.c, methode, erreur, requete, version)
if (methode, erreur.get('code') if isinstance(erreur, dict) else None) not in self.c.donnees_erreur:
return
partie = f'{methode}.error.data'
self.noter(ou, partie, ecarts)
forme = self.c.donnees_erreur[(methode, erreur['code'])]
self.avertir(ou, partie, avertissements,
f'optimiseur en {str(version)[:16]}, forme exigée à partir de '
f'{forme[1][0]}.{forme[1][1]} : à aligner avant de s\'annoncer {forme[1][0]}.{forme[1][1]}')
def verdict(self, ou, code):
if code is None:
code = 'valide'
self.verdicts[code] = self.verdicts.get(code, 0) + 1
regle = self.c.regles.get(code)
if regle and regle['issue'] in ('invalid', 'connection-refused'):
self.refus += 1
print(f'{ou} règle sémantique « {code} » ({regle["issue"]}, fallbackCode {regle["fallbackCode"]}) : '
f'{regle["description"]}')
elif regle:
print(f'{ou} verdict « {code} » ({regle["issue"]}) — réponse permise')
def code(self):
return 1 if self.ecarts or (self.semantique and self.refus) else 0
def resume(self):
print('RÉSUMÉ ' + json.dumps({'comptes': self.comptes, 'ecarts': len(self.ecarts),
'avertissements': len(self.avertissements),
'verdicts': self.verdicts, 'refus': self.refus},
ensure_ascii=False, sort_keys=True))
def lire_json(chemin):
with open(chemin, encoding='utf-8') as f:
return json.load(f)
def lignes(chemin):
with open(chemin, encoding='utf-8') as f:
for n, ligne in enumerate(f, 1):
if ligne.strip():
try:
yield n, json.loads(ligne)
except ValueError as e:
yield n, ErreurLigne(str(e))
class ErreurLigne:
def __init__(self, msg):
self.msg = msg
def session(b, chemin, requetes_seules, reponses_seules=False):
"""Un message JSON-RPC par ligne ; réponses rapprochées par id."""
attente = {} # id → (méthode, params)
handshake = None # dernier result de Handshake accepté
version = None # protocolVersion de l'optimiseur, lue dans ce result (error.data, 1.3)
for n, m in lignes(chemin):
ou = f'ligne {n}'
if isinstance(m, ErreurLigne) or not isinstance(m, dict):
b.noter(ou, 'ligne', [('/', 'json', 'ligne illisible ou non objet')])
continue
if m.get('jsonrpc') != '2.0':
b.noter(ou, 'enveloppe', [('/jsonrpc', 'const', 'jsonrpc doit valoir "2.0"')])
if 'method' in m:
nom = m['method']
if nom not in b.c.methodes:
b.noter(ou, 'enveloppe', [('/method', 'enum', f'méthode « {nom} » absente du contrat')])
continue
if not reponses_seules:
b.noter(ou, f'{nom}.params', b.c.valider_params(nom, m.get('params', {})))
if m.get('id') is not None:
attente[m['id']] = (nom, m.get('params'))
continue
if requetes_seules:
continue
if m.get('id') not in attente:
b.noter(ou, 'enveloppe', [('/id', 'id', 'réponse sans requête correspondante')])
continue
nom, params = attente.pop(m['id'])
if nom == 'Handshake':
if 'result' in m:
b.noter(ou, 'Handshake.result', b.c.valider_result('Handshake', m['result']))
code = juger_handshake(m, b.c.version)
if b.semantique:
b.verdict(ou, code)
if code is None:
handshake = m['result']
version = handshake.get('protocolVersion')
elif 'error' in m:
b.compter(f'{nom}.error')
print(f'{ou} {nom} : réponse error (compte comme un échec de la requête, rpc-error)')
b.juger_erreur(ou, nom, m['error'], params, version)
if b.semantique:
b.refus += 1
else:
b.noter(ou, f'{nom}.result', b.c.valider_result(nom, m.get('result')))
if b.semantique and nom == 'GetPlan' and isinstance(m.get('result'), dict):
b.verdict(ou, juger_plan(m['result'], params, handshake, b.c.kinds))
def trace(b, chemin):
"""Traces de décision (annexe D)."""
for n, t in lignes(chemin):
ou = f'ligne {n}'
if isinstance(t, ErreurLigne) or not isinstance(t, dict):
b.noter(ou, 'ligne', [('/', 'json', 'ligne illisible ou non objet')])
continue
if 'request' not in t:
b.compter('sans-requête') # L2 : aucune requête, rien à juger
continue
b.noter(ou, 'GetPlan.params', b.c.valider_params('GetPlan', t['request']))
reponse = t.get('response') or {}
if 'latencyMs' in t and isinstance(reponse, dict) and 'result' in reponse:
b.noter(ou, 'GetPlan.result', b.c.valider_result('GetPlan', reponse['result']))
if b.semantique and isinstance(reponse['result'], dict):
b.verdict(ou, juger_plan(reponse['result'], t['request'], None, b.c.kinds))
def main(argv=None):
p = argparse.ArgumentParser(prog='etm-powersync-schema-check',
description='Valide des messages du protocole optimiseur contre son contrat OpenRPC.')
p.add_argument('--schema', default=SCHEMA_DEFAUT, help='document OpenRPC (défaut : celui du dépôt)')
p.add_argument('--method', help='méthode du message (--params / --result)')
p.add_argument('--params', help='fichier JSON : les params d\'une requête')
p.add_argument('--result', help='fichier JSON : le result d\'une réponse')
p.add_argument('--error', help='fichier JSON : l\'objet error d\'une réponse (forme de son data)')
p.add_argument('--request', help='fichier JSON : les params de la requête (règles sémantiques de GetPlan)')
p.add_argument('--handshake', help='fichier JSON : le result du Handshake (kinds annoncés)')
p.add_argument('--ndjson', help='session JSON-RPC, un message par ligne')
p.add_argument('--trace', help='traces de décision du moteur (annexe D)')
p.add_argument('--semantic', action='store_true', help='applique x-etm-semantic-rules')
p.add_argument('--requests-only', action='store_true', help='--ndjson : ne juge que les requêtes')
p.add_argument('--responses-only', action='store_true',
help='--ndjson : ne juge que les réponses (session de sonde, requêtes fautives à dessein)')
p.add_argument('--subset', action='store_true', help='écrit le sous-ensemble géré et sort')
a = p.parse_args(argv)
if a.subset:
print('mots-clés : ' + ' '.join(sorted(GERES)))
print('annotations : ' + ' '.join(sorted(ANNOTATIONS)) + ' x-…')
return 0
try:
contrat = Contrat(lire_json(a.schema))
b = Bilan(contrat, a.semantic)
if a.ndjson:
if a.requests_only and a.responses_only:
p.error('--requests-only et --responses-only s\'excluent')
session(b, a.ndjson, a.requests_only, a.responses_only)
elif a.trace:
trace(b, a.trace)
elif a.method and (a.params or a.result or a.error):
if a.params:
b.noter(a.params, f'{a.method}.params', contrat.valider_params(a.method, lire_json(a.params)))
if a.result:
r = lire_json(a.result)
b.noter(a.result, f'{a.method}.result', contrat.valider_result(a.method, r))
if a.semantic and a.method == 'GetPlan' and isinstance(r, dict):
req = lire_json(a.request) if a.request else None
hs = lire_json(a.handshake) if a.handshake else None
if req is None:
raise ErreurSchema('--semantic sur GetPlan exige --request (charges, instant)')
b.verdict(a.result, juger_plan(r, req, hs, contrat.kinds))
elif a.semantic and a.method == 'Handshake':
b.verdict(a.result, juger_handshake({'result': r}, contrat.version))
if a.error:
contrat.methode(a.method)
hs = lire_json(a.handshake) if a.handshake else None
b.compter(f'{a.method}.error')
b.juger_erreur(a.error, a.method, lire_json(a.error), lire_json(a.request) if a.request else None,
hs.get('protocolVersion') if isinstance(hs, dict) else None)
else:
p.error('rien à valider : --ndjson, --trace, ou --method avec --params / --result / --error')
except ErreurSchema as e:
print(f'ERREUR DE SCHÉMA : {e}', file=sys.stderr)
return 2
except (OSError, ValueError) as e:
print(f'ERREUR : {e}', file=sys.stderr)
return 2
b.resume()
return b.code()
if __name__ == '__main__':
sys.exit(main())