File size: 4,915 Bytes
44c1e95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
"""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>(.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."
    )