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