"""Garde-fou : tout chemin de module Python mentionné dans ``docs/reference/specification.md`` doit être importable. Le drift le plus dommageable identifié dans l'audit de mai 2026 était l'ensemble des chemins de modules listés dans la section « Architecture » qui n'existaient plus dans le code (paquets ``picarones.engines``, ``picarones.measurements``, ``picarones.core``, etc. supprimés à la release 0.9.0). Ce test extrait tous les jetons qui ressemblent à un chemin de module dans le doc — ``picarones.<...>`` ou ``picarones/<...>`` ou des constructions équivalentes — et vérifie qu'ils s'importent. Les paths qui ne sont pas des modules importables (sous-paquets sans ``__init__.py``, fichiers internes, helpers) sont explicitement whitelistés ci-dessous. Portée ------ Spécifique à ``docs/reference/specification.md``. Les autres docs ont leur propre garde-fou (cf. ``test_doc_paths.py`` pour les liens cassés, ``test_views_md_consistency.py`` pour les vues). """ from __future__ import annotations import importlib import re from pathlib import Path _REPO_ROOT = Path(__file__).resolve().parents[2] _SPEC = _REPO_ROOT / "docs" / "reference" / "specification.md" #: Chemins extraits du doc qui sont mentionnés au passé comme #: historiques (paquets retirés à la release 0.9.0) et qui ne #: doivent **pas** être importables. Cette whitelist trace la #: mémoire institutionnelle ; le test ``test_whitelist_paths_ #: actually_dont_resolve`` la garde épurée. _HISTORICAL_REMOVED: frozenset[str] = frozenset({ "picarones.core", "picarones.measurements", "picarones.engines", "picarones.modules", "picarones.report", "picarones.llm", "picarones.pipelines", "picarones.cli", "picarones.web", "picarones.extras", }) #: Pattern qui capture les jetons ``picarones.(.identifier)*`` #: en évitant les fins de phrase (point suivi d'espace ou fin de ligne). _MODULE_PATTERN = re.compile( r"\bpicarones(?:\.[a-zA-Z_][a-zA-Z0-9_]*)+(?=[`\s,)\]\.]|$)", re.MULTILINE, ) def _extract_module_paths(text: str) -> set[str]: """Extrait les chemins de modules ``picarones.<...>`` du texte.""" # On retire les blocs de code YAML/console qui peuvent contenir # des exemples illustratifs sans contrainte d'importabilité. # Mais on garde les blocs Python qui DOIVENT être valides. paths: set[str] = set() for match in _MODULE_PATTERN.finditer(text): path = match.group(0).rstrip(".") # Retire les trailing class names (CapitalizedIdentifier) — on # ne teste que les modules, pas les attributs. parts = path.split(".") # Si le dernier segment commence par une majuscule, c'est # vraisemblablement une classe ou un constant → strip. while parts and parts[-1][:1].isupper(): parts.pop() if len(parts) >= 2: paths.add(".".join(parts)) return paths def _module_importable(path: str) -> bool: """``True`` si ``path`` est un module Python qui s'importe.""" try: importlib.import_module(path) return True except (ImportError, ModuleNotFoundError): return False def test_specification_module_paths_resolve() -> None: """Tout chemin ``picarones.<...>`` mentionné dans specification.md doit être importable ou whitelisté. Si ce test échoue : soit corriger le chemin dans le doc, soit ajouter le chemin à ``_WHITELIST`` avec une justification dans le commentaire correspondant. """ text = _SPEC.read_text(encoding="utf-8") paths = _extract_module_paths(text) broken: list[str] = [] for path in sorted(paths): if path in _HISTORICAL_REMOVED: continue if not _module_importable(path): broken.append(path) assert not broken, ( f"Chemins de modules cassés dans {_SPEC.relative_to(_REPO_ROOT)} : " + ", ".join(broken) + " — soit corriger le doc, soit ajouter à _HISTORICAL_REMOVED " "avec un commentaire justifiant la mémoire historique." ) def test_historical_paths_actually_dont_resolve() -> None: """Garde-fou méta : un path marqué « historiquement supprimé » qui devient à nouveau importable signale qu'il a été réintroduit (volontairement ou par accident). Si ce test échoue : soit le paquet est légitimement de retour (retirer l'entrée de ``_HISTORICAL_REMOVED``), soit il a été réintroduit par erreur (audit). """ unexpectedly_alive: list[str] = [] for path in _HISTORICAL_REMOVED: if _module_importable(path): unexpectedly_alive.append(path) assert not unexpectedly_alive, ( "Paquets historiquement supprimés mais qui s'importent à nouveau : " + ", ".join(unexpectedly_alive) + " — vérifier que c'est une réintroduction volontaire." )