"""Garde-fou contre les modules sans consommateur en production. Chaque module dans ``picarones/measurements/`` doit être importé par au moins un fichier de production (hors lui-même, hors ``tests/``). Sinon le module est *test-only* — sa couverture de test est haute mais il n'est branché à rien dans le pipeline réel. Snapshot v1.0.0 (2026-05-02, recalibré post-audit du 2026-05-02) : **0 module test-only** après le sprint « câblage des 13 modules test-only ». L'historique : - 12 modules (initial v1.0.0) : regex texte buggy. - 13 modules (audit AST) : 3 faux positifs sortis (alto_metrics, builtin_metrics, reading_order — déjà importés en ``__init__.py``) + 4 faux négatifs ajoutés (error_absorption, longitudinal, module_policy, reliability — détectés à tort comme consommés via des imports DANS DES DOCSTRINGS). - **0 module** (sprint « câblage des modules test-only », mai 2026) : 4 modules réellement câblés dans le rapport HTML (``rare_tokens``, ``taxonomy_cooccurrence``, ``taxonomy_intra_doc``, ``marginal_cost`` via ``picarones/report/report_data/extra_metrics.py``) + 9 modules ajoutés explicitement aux imports de ``picarones/measurements/__init__.py`` (avec ``# noqa: F401`` et justification individuelle de leur scope hors-runner). Le check est basé sur le module ``ast`` standard de Python qui ignore correctement le contenu des chaînes/docstrings et reconnaît toutes les formes d'import valides. Trois actions possibles, par module : 1. **Câbler** dans le runner ou un renderer (le module devient un produit, pas une expérience). 2. **Déplacer** vers ``picarones/extras/`` si c'est expérimental et non livré dans le pipeline standard. 3. **Retirer** si c'est mort (le travail reste dans l'historique git). Test ratchet : - Tout module ``measurements/X.py`` qui devient test-only sans entrer dans la baseline → échec (régression). - Tout module de la baseline qui gagne un consommateur → échec jusqu'à ce que la baseline soit mise à jour pour verrouiller le gain. """ from __future__ import annotations import ast import functools from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] PICARONES_DIR = REPO_ROOT / "picarones" MEASUREMENTS_DIR = PICARONES_DIR / "measurements" #: Snapshot post-sprint « câblage des 13 modules test-only ». #: **Zéro module** test-only : tous sont consommés en production, #: soit via un appel automatique dans le rapport HTML #: (``picarones/report/report_data/extra_metrics.py``), soit via #: l'API publique du package (imports explicites avec directive #: de fin de ligne ``noqa F401`` dans #: ``picarones/measurements/__init__.py``). TEST_ONLY_BASELINE: frozenset[str] = frozenset() def _measurements_modules() -> list[str]: """Modules et sous-packages exposés par ``picarones/measurements/``. Inclut : - Les fichiers ``*.py`` au top-level (hors ``__init__.py``). - Les sous-packages, c'est-à-dire les sous-dossiers contenant un ``__init__.py`` (ex: ``narrative/``, ``statistics/`` après le sprint de découpage de ``statistics.py``). L'ancienne version ne listait que les ``*.py`` ; les sous-packages devenaient invisibles au test → couverture perdue dès qu'un module était éclaté en sous-package. """ modules: set[str] = { p.stem for p in MEASUREMENTS_DIR.glob("*.py") if p.stem != "__init__" } for sub in MEASUREMENTS_DIR.iterdir(): if sub.is_dir() and (sub / "__init__.py").exists(): modules.add(sub.name) return sorted(modules) def _imports_target_module(node: ast.AST, module_name: str) -> bool: """True si ce nœud AST importe ``picarones.measurements.``. Couvre les 6 syntaxes valides Python (essentielles quand X peut être un module ``X.py`` OU un sous-package ``X/``) : - ``import picarones.measurements.X`` - ``import picarones.measurements.X.sub`` (sous-module) - ``from picarones.measurements.X import Y`` - ``from picarones.measurements.X.sub import Y`` (sous-sous-module d'un sous-package — ex: ``from .statistics.wilcoxon import …``) - ``from picarones.measurements import X`` - ``from picarones.measurements import (X, Y)`` (forme parenthésée) """ target_dotted = f"picarones.measurements.{module_name}" if isinstance(node, ast.Import): for alias in node.names: if alias.name == target_dotted or alias.name.startswith( target_dotted + ".", ): return True return False if isinstance(node, ast.ImportFrom): # ``from picarones.measurements.X import …`` ou # ``from picarones.measurements.X.sub import …`` (sous-package). if node.module == target_dotted or ( node.module is not None and node.module.startswith(target_dotted + ".") ): return True # ``from picarones.measurements import X`` if node.module == "picarones.measurements": for alias in node.names: if alias.name == module_name: return True return False def _imports_target_relative( node: ast.AST, module_name: str, source_dir: Path, ) -> bool: """True si ce nœud AST importe ``module_name`` via un import relatif qui pointe vers ``picarones/measurements/``. Couvre les imports relatifs depuis n'importe quel sous-dossier du package ``measurements`` (y compris ``measurements/narrative/`` et ``measurements/narrative/detectors/``) : - ``from . import X`` (level=1) depuis ``measurements/foo.py``. - ``from .X import Y`` (level=1, module=X) depuis le même. - ``from .. import X`` (level=2) depuis ``measurements/sub/foo.py``. - ``from ..X import Y`` (level=2, module=X) depuis le même. - Idem pour level=3 et au-delà depuis sous-sous-packages. L'ancien check ``source_dir == MEASUREMENTS_DIR`` ratait tous les imports relatifs depuis les sous-packages — bombe à retardement qui devient critique dès qu'un sous-package importe un voisin. """ if not isinstance(node, ast.ImportFrom): return False if node.level < 1: return False # Remonter ``node.level - 1`` niveaux pour résoudre le package cible. # Pour ``from . import X`` (level=1) on reste dans ``source_dir`` ; # pour ``from ..X import Y`` (level=2) on remonte d'un niveau ; etc. target_dir = source_dir for _ in range(node.level - 1): target_dir = target_dir.parent if target_dir != MEASUREMENTS_DIR: return False # ``from .X import …`` ou ``from ..X import …`` if node.module == module_name: return True # ``from . import X`` ou ``from .. import X`` if node.module is None: for alias in node.names: if alias.name == module_name: return True return False def _has_production_consumer(module_name: str) -> bool: """True si ``module_name`` est importé par un fichier de production. "Production" = sous ``picarones/``, hors le module lui-même. Le check parse l'AST de chaque fichier (au lieu de grep) pour deux raisons : 1. **Toutes les syntaxes d'import sont reconnues** sans bricolage de regex (``from picarones.measurements import X`` était la grosse cible manquée par la regex initiale). 2. **Les chaînes/docstrings ne déclenchent pas de faux positif** (un exemple de code dans une docstring ne compte pas comme import réel). """ own_file = MEASUREMENTS_DIR / f"{module_name}.py" for path in PICARONES_DIR.rglob("*.py"): if path == own_file: continue try: tree = ast.parse(path.read_text(encoding="utf-8")) except (OSError, SyntaxError): continue for node in ast.walk(tree): if _imports_target_module(node, module_name): return True if _imports_target_relative(node, module_name, path.parent): return True return False @functools.cache def _test_only_modules() -> frozenset[str]: """Retourne les modules de ``measurements/`` sans consommateur prod. Mémoïsée par ``functools.cache`` : les deux tests de ce fichier appellent cette fonction (≈ 12 s par appel sur ~200 fichiers Python), donc sans cache on parsait l'AST de tout le projet deux fois pour rien. """ return frozenset( m for m in _measurements_modules() if not _has_production_consumer(m) ) def test_no_new_test_only_modules() -> None: """Aucun module ne doit devenir test-only sans entrer dans la baseline.""" current = _test_only_modules() new = current - TEST_ONLY_BASELINE assert not new, ( f"\n{len(new)} module(s) de measurements/ sans consommateur en " f"production : {sorted(new)}.\n\n" "Choisis l'une des trois options :\n" " 1. Câble le module dans le runner ou un renderer.\n" " 2. Déplace-le sous picarones/extras/ s'il est expérimental.\n" " 3. Retire-le si c'est mort.\n\n" "En dernier recours, ajoute son nom à TEST_ONLY_BASELINE dans " "tests/architecture/test_module_coverage.py — c'est admettre " "consciemment qu'il vit hors du pipeline standard." ) def test_baseline_modules_still_orphaned() -> None: """Si un module de la baseline a gagné un consommateur, lock le gain. Force à mettre à jour la baseline pour verrouiller chaque câblage, sinon une régression future re-deviendrait test-only sans alerte. """ current = _test_only_modules() fixed = TEST_ONLY_BASELINE - current assert not fixed, ( f"\nExcellent : {len(fixed)} module(s) ont gagné un consommateur en " f"production : {sorted(fixed)}.\n\n" "Retire ces noms de TEST_ONLY_BASELINE dans " "tests/architecture/test_module_coverage.py pour verrouiller le gain." )