[DTP-Worker] Sprint 7 · Générateur Scénarios démo (run-sheet P07 banquier / P05 client) (CRM+Faisabilité · roadmap L68)
Orchestrateur méta-niveau : compose les hand-offs out/ des 10 modules déjà livrés en un run-sheet de démo jouable, via pointeurs RFC 6901 résolus à la construction (zéro chiffre en dur · #6). Réutilise le validateur Publiciste et la preuve de couverture CI de l'auditeur 4Big (#5) — une démo ne s'appuie que sur des modules gated. Enregistre demo/scenarios dans l'audit 4Big (17→18, verdict PASS 18/18 à 100). 32 tests (9 injections négatives) · régression 474 tests verts · guards CI verts · builds déterministes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
"""Cœur anti-invention (#6) : résolution des preuves depuis les hand-offs amont.
|
||||
|
||||
Un scénario démo ne contient AUCUN chiffre en dur. Chaque « evidence » du spec est
|
||||
un pointeur `(module, file, pointer)` vers un artefact `out/` déjà produit par un
|
||||
module livré. Ce fichier :
|
||||
|
||||
1. charge l'artefact JSON (avec cache) et signale toute absence / JSON invalide ;
|
||||
2. résout un pointeur RFC 6901 (`/0/counts/fields`) vers sa valeur verbatim ;
|
||||
3. quand la valeur n'existe pas, renvoie un PLACEHOLDER `{{module:file#pointer}}`
|
||||
— jamais un chiffre fabriqué, jamais un `0` posé.
|
||||
|
||||
La résolution est PURE et rejouable : l'invariant `INV_ANTI_INVENTION` du CLI
|
||||
re-résout indépendamment chaque pointeur et compare à ce qui figure dans le
|
||||
run-sheet — impossible de « poser » une valeur à la main.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from .deps import DELIVERABLES_ROOT
|
||||
|
||||
# Sentinelle « non résolu » — un placeholder lisible, jamais confondable avec une
|
||||
# vraie valeur (ni un nombre, ni une chaîne métier).
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def placeholder(module: str, file: str, pointer: str) -> str:
|
||||
return f"{{{{{module}:{file}#{pointer}}}}}"
|
||||
|
||||
|
||||
def is_placeholder(value: Any) -> bool:
|
||||
return isinstance(value, str) and value.startswith("{{") and value.endswith("}}")
|
||||
|
||||
|
||||
def artifact_path(module: str, file: str) -> str:
|
||||
"""Chemin absolu de l'artefact `out/` (jamais hors des livrables)."""
|
||||
return os.path.join(DELIVERABLES_ROOT, module, file)
|
||||
|
||||
|
||||
class Loader:
|
||||
"""Charge et met en cache les artefacts amont pour une passe de build."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._cache: dict[str, Any] = {}
|
||||
self._errors: dict[str, str] = {}
|
||||
|
||||
def load(self, module: str, file: str) -> Any:
|
||||
key = f"{module}/{file}"
|
||||
if key in self._cache or key in self._errors:
|
||||
return self._cache.get(key, _MISSING)
|
||||
path = artifact_path(module, file)
|
||||
if not os.path.isfile(path):
|
||||
self._errors[key] = "artefact absent"
|
||||
return _MISSING
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
obj = json.load(fh)
|
||||
except (OSError, ValueError) as exc:
|
||||
self._errors[key] = f"JSON invalide : {exc}"
|
||||
return _MISSING
|
||||
self._cache[key] = obj
|
||||
return obj
|
||||
|
||||
def errors(self) -> dict[str, str]:
|
||||
return dict(self._errors)
|
||||
|
||||
|
||||
def _split_pointer(pointer: str) -> list[str]:
|
||||
"""Découpe un pointeur RFC 6901 (`/a/0/b`) en tokens déséchappés."""
|
||||
if pointer == "":
|
||||
return []
|
||||
if not pointer.startswith("/"):
|
||||
raise ValueError(f"pointeur RFC 6901 invalide (doit commencer par '/') : {pointer!r}")
|
||||
tokens = pointer.split("/")[1:]
|
||||
return [t.replace("~1", "/").replace("~0", "~") for t in tokens]
|
||||
|
||||
|
||||
def _descend(obj: Any, token: str) -> Any:
|
||||
"""Un pas de descente : clé d'objet ou index de liste (jamais d'erreur)."""
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(token, _MISSING)
|
||||
if isinstance(obj, list):
|
||||
if token.lstrip("-").isdigit():
|
||||
idx = int(token)
|
||||
if -len(obj) <= idx < len(obj):
|
||||
return obj[idx]
|
||||
return _MISSING
|
||||
return _MISSING
|
||||
|
||||
|
||||
def resolve(loader: Loader, module: str, file: str, pointer: str) -> tuple[Any, bool]:
|
||||
"""Résout un pointeur en `(valeur, resolu)`.
|
||||
|
||||
`resolu=False` ⇒ `valeur` est un placeholder `{{…}}` (artefact absent / JSON
|
||||
invalide / chemin inexistant). `resolu=True` ⇒ valeur verbatim non `None`.
|
||||
Une valeur littérale `null` dans l'artefact est traitée comme NON résolue :
|
||||
un chiffre « à confirmer » ne doit jamais alimenter un pitch (#6).
|
||||
"""
|
||||
root = loader.load(module, file)
|
||||
if root is _MISSING:
|
||||
return placeholder(module, file, pointer), False
|
||||
node: Any = root
|
||||
for token in _split_pointer(pointer):
|
||||
node = _descend(node, token)
|
||||
if node is _MISSING:
|
||||
return placeholder(module, file, pointer), False
|
||||
if node is None:
|
||||
return placeholder(module, file, pointer), False
|
||||
return node, True
|
||||
Reference in New Issue
Block a user