"""Garde-fou informatif : pour chaque couche listée dans ``specification.md § 2``, vérifier que les modules réellement présents dans le code correspondent (à ±N près) à ceux annoncés dans le tableau. Stratégie --------- Ce test est volontairement *informatif* (utilise ``pytest.warns`` plutôt que ``assert``) parce que : - On ne veut pas qu'un PR mineur (ajout d'un helper, renommage) fasse échouer le test doc. - Le doc utilise des grappes (« registry/, evaluation_engine.py, projection_engine.py… ») et énumérer chaque fichier serait contre-productif éditorialement. - Le vrai garde-fou contre les modules fantômes est ``test_specification_module_paths.py`` (qui vérifie que tout chemin mentionné existe). Ce test détecte le drift inverse : un module *présent dans le code* mais *absent du tableau de la spec*. Quand l'écart dépasse une tolérance, le test émet un ``UserWarning`` listant les modules orphelins — au reviewer de décider si c'est intentionnel (helper interne) ou si la spec doit être mise à jour. Pour bloquer ponctuellement : ``pytest -W error::UserWarning tests/docs/test_specification_layer_inventory.py``. """ from __future__ import annotations import warnings from pathlib import Path import pytest _REPO_ROOT = Path(__file__).resolve().parents[2] _SPEC = _REPO_ROOT / "docs" / "reference" / "specification.md" #: Modules réels du code qu'on accepte de ne pas mentionner dans #: la spec (helpers internes, fichiers privés). _OK_TO_OMIT_PATTERNS: tuple[str, ...] = ( "__init__.py", "__pycache__", "_version_fallback.py", ) def _real_modules(layer_dir: Path) -> set[str]: """Liste les modules ``.py`` réels dans une couche (non récursif). Sous-paquets (dossiers) inclus, par leur nom de répertoire. """ if not layer_dir.is_dir(): return set() modules: set[str] = set() for entry in layer_dir.iterdir(): if entry.name.startswith(".") or entry.name in {"__pycache__"}: continue if entry.is_file() and entry.suffix == ".py": if any(entry.name == p for p in _OK_TO_OMIT_PATTERNS): continue modules.add(entry.stem) elif entry.is_dir() and (entry / "__init__.py").exists(): modules.add(entry.name) return modules def _spec_modules_for_layer(layer_section_heading: str) -> set[str]: """Extrait les noms de modules backquotés ``…`` du tableau qui suit la section ``layer_section_heading`` dans la spec. """ text = _SPEC.read_text(encoding="utf-8") idx = text.find(layer_section_heading) if idx < 0: return set() # Section finit à la prochaine section ``###`` de même niveau. end = text.find("\n### ", idx + len(layer_section_heading)) section = text[idx:end] if end > 0 else text[idx:] # Tokens backquotés terminant par .py ou nom de dossier suivi de / import re found: set[str] = set() for m in re.finditer(r"`([a-zA-Z_][a-zA-Z0-9_]*)\.py`", section): found.add(m.group(1)) for m in re.finditer(r"`([a-zA-Z_][a-zA-Z0-9_]*)/`", section): found.add(m.group(1)) return found #: Couches à comparer : (section heading dans la spec, chemin filesystem, #: tolérance d'écart pour ne pas warner). _LAYERS_TO_CHECK: tuple[tuple[str, str, int], ...] = ( ("### 2.1 `picarones/domain/`", "picarones/domain", 2), ("### 2.4 `picarones/pipeline/`", "picarones/pipeline", 3), ) @pytest.mark.parametrize( "heading,layer_path,tolerance", _LAYERS_TO_CHECK, ) def test_layer_inventory_drift_warns( heading: str, layer_path: str, tolerance: int, ) -> None: """Informatif : signale les modules présents dans le code mais pas dans la spec, au-delà d'une tolérance. """ real = _real_modules(_REPO_ROOT / layer_path) spec = _spec_modules_for_layer(heading) missing_from_spec = real - spec if len(missing_from_spec) > tolerance: warnings.warn( ( f"\n Couche {layer_path!r} :\n" f" - réels (code) : {sorted(real)}\n" f" - listés (spec): {sorted(spec)}\n" f" - non listés (>{tolerance} de tolérance) : " f"{sorted(missing_from_spec)}\n" f" Mettre à jour spec.md § {heading.split('`')[0].strip()} " f"si ces modules sont publiquement intéressants." ), UserWarning, stacklevel=2, )