From d961b73ed7ac1cfb7ec00035682060c18a03563a Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 23 Oct 2026 11:13:57 +0200 Subject: [PATCH] Validateur du contrat : juge error.data selon la version de l'optimiseur (protocole 1.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tools/etm-powersync-schema-check-test.py | 169 +++++++++++++++++- tools/README.md | 7 + tools/etm-powersync-schema-check | 168 +++++++++++++++-- 3 files changed, 332 insertions(+), 12 deletions(-) diff --git a/tests/tools/etm-powersync-schema-check-test.py b/tests/tools/etm-powersync-schema-check-test.py index 616cd06..2a42582 100644 --- a/tests/tools/etm-powersync-schema-check-test.py +++ b/tests/tools/etm-powersync-schema-check-test.py @@ -13,7 +13,9 @@ Trois choses : « bons » sont valides, chaque mode « mauvais » est rejeté par le schéma OU par une règle sémantique nommée — et laquelle (`TABLE_MODES`, recopiée au rapport) ; 3. le validateur lui-même ne peut pas se taire : un mot-clé non géré, une règle orpheline, un - champ ajouté, un mauvais type le font échouer. + champ ajouté, un mauvais type le font échouer ; +4. `error.data` du -32602 de GetPlan (protocole 1.3) : forme exigée d'un optimiseur 1.3, avertie + d'un optimiseur 1.2, recopie d'une valeur reçue refusée des deux. """ import copy @@ -59,8 +61,16 @@ TABLE_MODES = { 'unknown-load': ('règle', 'unknown-load'), 'forbidden-kind': ('règle', 'kind-mismatch'), 'missing-load': ('règle', 'incomplete-slot'), + # Modes 1.3 : sur un contexte VALIDE et le bon token, ils se comportent comme `normal`. + 'old-data': ('—', 'valide'), + 'echo-value': ('—', 'valide'), + 'major-first': ('—', 'valide'), } +# Une valeur que nul chemin conforme ne peut contenir (tiret, deux-points) : si elle apparaît dans +# error.data, c'est une recopie. +MARQUEUR = 'valeur-sonde:7Qz9' + def verifier(*args): p = subprocess.run([sys.executable, '-I', OUTIL, *args], capture_output=True, text=True, timeout=60) @@ -117,6 +127,33 @@ def session(mode, dossier): return capture +def session_contexte_invalide(dossier, mode, annonce, params): + """Handshake accepté puis UN GetPlan de contexte invalide ; rend le fichier capturé.""" + sock_path = os.path.join(dossier, 'opt.sock') + capture = os.path.join(dossier, f'invalide-{mode}-{annonce}.ndjson') + proc = subprocess.Popen([sys.executable, '-I', '-B', REF, '--listen', f'unix://{sock_path}', '--mode', mode, + '--announce', annonce, '--capture', capture], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True) + try: + assert proc.stdout.readline().startswith('ÉCOUTE'), 'optimiseur de référence muet' + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.connect(sock_path) + tampon = [b''] + s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 1, 'method': 'Handshake', + 'params': {'protocolVersion': VERSION_MOTEUR, + 'engine': 'etm-powersync-energy-plugin-etm/test'}}).encode() + b'\n') + assert lire_ligne(s, tampon)['result']['protocolVersion'] == annonce + s.sendall(json.dumps({'jsonrpc': '2.0', 'id': 2, 'method': 'GetPlan', 'params': params}).encode() + b'\n') + reponse = lire_ligne(s, tampon) + assert reponse['error']['code'] == -32602, reponse + s.close() + finally: + proc.terminate() + proc.wait(5) + proc.stdout.close() + return capture + + class TestCeQueLeMoteurEmet(unittest.TestCase): def test_les_references_du_serialiseur_sont_conformes(self): self.assertGreaterEqual(len(GOLDEN), 4, 'fichiers de référence du sérialiseur introuvables') @@ -239,5 +276,135 @@ class TestLeValidateurNeSeTaitPas(unittest.TestCase): self.assertNotIn('uniqueItems', sortie) +class TestDonneesDErreur(unittest.TestCase): + """error.data du -32602 de GetPlan (protocole 1.3), sur de vraies sessions de l'optimiseur de + référence. La session porte un GetPlan de contexte invalide : le validateur la juge SANS + --semantic (sinon la réponse error est un refus, quel que soit son data).""" + + # (mode, version annoncée) → (code de sortie, écarts > 0, avertissements > 0) + ATTENDU = { + ('normal', '1.3'): (0, False, False), + ('normal', '1.2'): (0, False, False), + ('old-data', '1.3'): (1, True, False), # forme d'avant 1.3, annoncée 1.3 : refusée + ('old-data', '1.2'): (0, False, True), # la même, annoncée 1.2 : avertie + ('echo-value', '1.3'): (1, True, False), + ('echo-value', '1.2'): (1, True, True), # forme avertie, RECOPIE refusée (règle de 1.2) + } + + def test_forme_exigee_en_1_3_avertie_en_1_2_recopie_refusee(self): + params = copy.deepcopy(json.load(open(REQUETE, encoding='utf-8'))) + params['timestamp'] = MARQUEUR # sans décalage, illisible : `format` + obtenu = {} + with tempfile.TemporaryDirectory() as d: + for (mode, annonce) in self.ATTENDU: + capture = session_contexte_invalide(d, mode, annonce, params) + code, r, sortie = verifier('--ndjson', capture, '--responses-only') + self.assertEqual(r['comptes'].get('GetPlan.error'), 1, sortie) + self.assertNotIn('GetPlan.params', r['comptes']) + self.assertEqual(r['comptes'].get('GetPlan.error.data'), 1, sortie) + obtenu[(mode, annonce)] = (code, r['ecarts'] > 0, r['avertissements'] > 0) + print(f' {mode:11s} {annonce} → code {code}, écarts {r["ecarts"]}, avertissements ' + f'{r["avertissements"]}', file=sys.stderr) + if mode == 'echo-value': + self.assertIn('recopie la valeur reçue en params.timestamp', sortie) + self.assertNotIn(MARQUEUR, sortie, 'le validateur recopie lui-même la valeur') + if (mode, annonce) == ('old-data', '1.2'): + self.assertIn('AVERTISSEMENT', sortie) + self.assertIn('à aligner avant de s\'annoncer 1.3', sortie) + self.assertEqual(obtenu, self.ATTENDU) + + def test_la_forme_vient_du_contrat(self): + # Contre-épreuve : sans x-etm-data-schema, la forme d'avant 1.3 annoncée 1.3 passe — c'est + # bien le contrat qui juge, pas une règle cachée dans l'outil. + with open(SCHEMA, encoding='utf-8') as f: + s = json.load(f) + for e in next(m for m in s['methods'] if m['name'] == 'GetPlan')['errors']: + e.pop('x-etm-data-schema', None) + e.pop('x-etm-data-required-from', None) + with tempfile.TemporaryDirectory() as d: + schema = os.path.join(d, 's.json') + with open(schema, 'w', encoding='utf-8') as f: + json.dump(s, f, ensure_ascii=False) + capture = session_contexte_invalide(d, 'old-data', '1.3', {'timestamp': MARQUEUR}) + self.assertEqual(verifier('--ndjson', capture, '--responses-only')[0], 1) + code, r, sortie = verifier('--schema', schema, '--ndjson', capture, '--responses-only') + self.assertEqual(code, 0, sortie) + self.assertNotIn('GetPlan.error.data', r['comptes']) + + def ecrire(self, d, nom, contenu): + chemin = os.path.join(d, nom) + with open(chemin, 'w', encoding='utf-8') as f: + json.dump(contenu, f, ensure_ascii=False) + return chemin + + def juger(self, d, data, requete=None, version='1.3'): + args = ['--method', 'GetPlan', '--error', + self.ecrire(d, 'e.json', {'code': -32602, 'message': 'contexte invalide', 'data': data}), + '--handshake', self.ecrire(d, 'h.json', {'protocolVersion': version})] + if requete is not None: + args += ['--request', self.ecrire(d, 'q.json', requete)] + return verifier(*args) + + def test_la_forme_ligne_a_ligne(self): + p = {'path': 'params.loads[0].id', 'problem': 'missing'} + cas = [ + ({'problems': [p]}, 0, None), + ({'problems': [{'path': 'params', 'problem': 'type'}]}, 0, None), + ({'problems': [{'path': 'params.loads[0].declared.estimatedPowerW.3', 'problem': 'range'}]}, 0, None), + ({'problems': [p] * 50, 'truncated': True}, 0, None), + ({'problems': []}, 1, '[minItems]'), + ({'problems': [p] * 51}, 1, '[maxItems]'), + ({'problems': [p] * 50, 'truncated': False}, 1, '[const]'), + ({'problems': [p] * 3, 'truncated': True}, 1, '[x-etm-truncated]'), + ({'problems': [dict(p, problem='absent')]}, 1, '[enum]'), + ({'problems': [dict(p, path='/loads/0/id')]}, 1, '[pattern]'), + ({'problems': [dict(p, path='loads[0].id')]}, 1, '[pattern]'), + ({'problems': [dict(p, path='params.loads.0.id ')]}, 1, '[pattern]'), + ({'problems': [dict(p, value=3)]}, 1, '[additionalProperties]'), + ({'problems': [p], 'detail': 'x'}, 1, '[additionalProperties]'), + ({'problems': [{'path': 'params.timestamp'}]}, 1, '[required]'), + ({'problemes': ['params.timestamp : format']}, 1, '[required]'), + ('contexte invalide', 1, '[type]'), + ] + with tempfile.TemporaryDirectory() as d: + for data, code_attendu, mot in cas: + code, r, sortie = self.juger(d, data) + self.assertEqual(code, code_attendu, f'{data!r}\n{sortie}') + if mot: + self.assertIn(mot, sortie, data) + # data absent : écart en 1.3 + chemin = self.ecrire(d, 'e.json', {'code': -32602, 'message': 'contexte invalide'}) + code, _, sortie = verifier('--method', 'GetPlan', '--error', chemin) + self.assertEqual(code, 1, sortie) + self.assertIn('« data » manquant', sortie) + # Un autre code d'erreur n'a pas de forme normée : rien à juger. + chemin = self.ecrire(d, 'e.json', {'code': -32603, 'message': 'erreur interne', 'data': [1, 'x']}) + code, r, sortie = verifier('--method', 'GetPlan', '--error', chemin) + self.assertEqual((code, r['ecarts']), (0, 0), sortie) + + def test_la_recopie_ne_peut_pas_accuser_un_chemin_conforme(self): + # Valeurs de la requête qui sont AUSSI des noms de champ (domain « battery », trigger + # « forecast », meter.source « meter ») : un chemin conforme les contient, ce n'est pas une + # recopie. Une valeur à tiret ou à deux-points recopiée, elle, l'est. + requete = copy.deepcopy(json.load(open(REQUETE, encoding='utf-8'))) + requete['trigger'] = 'forecast' + requete['loads'][0]['domain'] = 'battery' + data = {'problems': [{'path': 'params.battery.socPercent', 'problem': 'range'}, + {'path': 'params.forecast.pv[3].from', 'problem': 'format'}, + {'path': 'params.meter.source', 'problem': 'enum'}]} + with tempfile.TemporaryDirectory() as d: + code, r, sortie = self.juger(d, data, requete) + self.assertEqual(code, 0, sortie) + ident = requete['loads'][0]['id'] + self.assertRegex(ident, '[^A-Za-z0-9_]') + code, r, sortie = self.juger(d, {'problemes': [f'charge {ident} : priorité hors bornes']}, requete, '1.2') + self.assertEqual(code, 1, sortie) + self.assertIn('recopie la valeur reçue en params.loads[0].id', sortie) + self.assertNotIn(ident, sortie.split('RÉSUMÉ')[0]) + # Avant 1.2, la règle n'existait pas : avertie seulement. + code, r, sortie = self.juger(d, {'problemes': [f'charge {ident}']}, requete, '1.1') + self.assertEqual((code, r['ecarts'], r['avertissements'] > 0), (0, 0, True), sortie) + + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/tools/README.md b/tools/README.md index a4ca463..291886f 100644 --- a/tools/README.md +++ b/tools/README.md @@ -60,6 +60,9 @@ Python 3, bibliothèque standard seule, aucun accès réseau. Le contrat : ./tools/etm-powersync-schema-check --method GetPlan --params requete.json ./tools/etm-powersync-schema-check --method GetPlan --result plan.json --request requete.json --semantic ./tools/etm-powersync-schema-check --ndjson session.ndjson --semantic +./tools/etm-powersync-schema-check --ndjson sonde.ndjson --responses-only # requêtes fautives à dessein +./tools/etm-powersync-schema-check --method GetPlan --error erreur.json --request requete.json \ + --handshake handshake.json # error.data d'un -32602 (protocole 1.3) ./tools/etm-powersync-schema-check --trace etm-powersync-optimizer-trace-2026-10-17.ndjson --semantic ./tools/etm-powersync-schema-check --subset # le sous-ensemble de JSON Schema géré @@ -77,6 +80,10 @@ ETM_POWERSYNC_CODE_LISTS=/tmp/listes.json /tests/auto/protocolschema/etm- mot-clé est une erreur de schéma (code 2), jamais ignoré. - **`--semantic`** applique `x-etm-semantic-rules` : ce que le validateur du moteur refuse en plus de la forme, dans son ordre, sous le code qu'il publie (`fallbackCode`). +- **`error.data`** (protocole 1.3) : la forme d'`error.data` d'une erreur, quand le contrat la norme + (`x-etm-data-schema`, `-32602` de `GetPlan`), est exigée d'un optimiseur qui s'annonce + `x-etm-data-required-from` ou plus, et seulement AVERTIE en deçà (ligne `AVERTISSEMENT`, sans effet + sur le code de sortie) ; une valeur reçue recopiée dans `error.data` est un écart dès 1.2. - **Code de sortie** : 0 conforme ; 1 écart de forme ou refus sémantique ; 2 erreur d'usage ou de schéma. - Conformité d'un optimiseur de bout en bout, avec le vrai moteur : suite diff --git a/tools/etm-powersync-schema-check b/tools/etm-powersync-schema-check index 4869bad..f6a904d 100755 --- a/tools/etm-powersync-schema-check +++ b/tools/etm-powersync-schema-check @@ -6,7 +6,10 @@ 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] --ndjson SESSION.ndjson [--semantic] [--requests-only] + 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 @@ -28,18 +31,35 @@ Ce que l'outil lit en plus du JSON Schema : - `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. + 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). + `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. + (`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, puis `RÉSUMÉ {json}` (comptes par méthode et partie, écarts, -verdicts sémantiques). Code : 0 conforme ; 1 au moins un écart de forme, ou (avec `--semantic`) un +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. """ @@ -125,6 +145,10 @@ class Validateur: 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) @@ -284,6 +308,16 @@ class Contrat: 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: @@ -330,6 +364,85 @@ class Contrat: 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', @@ -500,6 +613,7 @@ class Bilan: self.semantique = semantique self.comptes = {} self.ecarts = [] + self.avertissements = [] self.verdicts = {} self.refus = 0 @@ -512,6 +626,22 @@ class Bilan: 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' @@ -529,6 +659,7 @@ class Bilan: 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)) @@ -553,10 +684,11 @@ class ErreurLigne: self.msg = msg -def session(b, chemin, requetes_seules): +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): @@ -569,7 +701,8 @@ def session(b, chemin, requetes_seules): if nom not in b.c.methodes: b.noter(ou, 'enveloppe', [('/method', 'enum', f'méthode « {nom} » absente du contrat')]) continue - b.noter(ou, f'{nom}.params', b.c.valider_params(nom, m.get('params', {}))) + 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 @@ -587,9 +720,11 @@ def session(b, chemin, requetes_seules): 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: @@ -623,12 +758,15 @@ def main(argv=None): 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: @@ -639,10 +777,12 @@ def main(argv=None): contrat = Contrat(lire_json(a.schema)) b = Bilan(contrat, a.semantic) if a.ndjson: - session(b, a.ndjson, a.requests_only) + 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): + 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: @@ -656,8 +796,14 @@ def main(argv=None): 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') + 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