File size: 4,512 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
"""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,
        )