File size: 4,662 Bytes
1d1017e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Phase 5.1 audit code-quality — ``picarones.reports/`` (couche 7)
n'importe que depuis les couches **strictement intérieures** : domain,
formats, evaluation, pipeline.

Avant la Phase 5.1, 4 imports illégaux ``reports/ → app/`` (couche
7 → 6) violaient l'orientation des couches :

- ``reports/csv/render.py:53`` → ``from picarones.app.results import RunResult``
- ``reports/json/render.py:51`` → idem
- ``reports/html/render.py:54`` → ``from picarones.app.results import RunDocumentResult, RunResult``

La résolution a déplacé ``RunResult`` / ``RunDocumentResult`` /
``ReportRenderer`` de ``picarones.app.results`` vers
``picarones.pipeline.run_result`` (couche 4) — accessible depuis
``reports/`` selon l'ordre canonique du manifeste CLAUDE.md.

Ce test verrouille la règle pour empêcher la régression : tout PR
qui réintroduit un ``from picarones.app...`` dans ``reports/``
échoue ici.
"""

from __future__ import annotations

import ast
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]
REPORTS_DIR = REPO_ROOT / "picarones" / "reports"

#: Couches strictement intérieures à ``reports/`` (couche 7).  Les
#: imports cross-package du module ``reports`` ne doivent pointer
#: que vers ces noms.
ALLOWED_INNER_LAYERS: frozenset[str] = frozenset({
    "domain",      # 1
    "formats",     # 2
    "evaluation",  # 3
    "pipeline",    # 4
})

#: Couches **extérieures** à ``reports/`` — interdites en import.
FORBIDDEN_OUTER_LAYERS: frozenset[str] = frozenset({
    "adapters",    # 5
    "app",         # 6
    # 7 = reports lui-même (auto-import OK)
    "interfaces",  # 8
})


def _imported_top_packages(path: Path) -> set[str]:
    """Retourne l'ensemble des sous-paquets de ``picarones`` importés
    par un fichier ``reports/...``.  Exclut les sous-modules de
    ``reports`` lui-même."""
    tree = ast.parse(path.read_text(encoding="utf-8"))
    out: set[str] = set()
    for node in ast.walk(tree):
        if isinstance(node, ast.ImportFrom):
            mod = node.module or ""
            parts = mod.split(".")
            if len(parts) >= 2 and parts[0] == "picarones":
                out.add(parts[1])
        elif isinstance(node, ast.Import):
            for alias in node.names:
                parts = alias.name.split(".")
                if len(parts) >= 2 and parts[0] == "picarones":
                    out.add(parts[1])
    return out


def test_reports_does_not_import_from_outer_layers() -> None:
    """``picarones/reports/**.py`` ne contient aucun import depuis
    une couche plus externe (``adapters``, ``app``, ``interfaces``).
    """
    offenders: list[tuple[str, set[str]]] = []
    for path in sorted(REPORTS_DIR.rglob("*.py")):
        if "__pycache__" in path.parts:
            continue
        imported = _imported_top_packages(path)
        bad = imported & FORBIDDEN_OUTER_LAYERS
        if bad:
            offenders.append((str(path.relative_to(REPO_ROOT)), bad))

    if offenders:
        lines = "\n".join(
            f"  {p} importe depuis : {sorted(b)}"
            for p, b in offenders
        )
        raise AssertionError(
            "Imports illégaux ``reports/ → couches externes`` :\n"
            + lines
            + "\n\nLa règle CLAUDE.md interdit à ``reports/`` (couche 7) "
            "d'importer depuis ``adapters/`` (5), ``app/`` (6) ou "
            "``interfaces/`` (8).  Si le type recherché vit dans ces "
            "couches, soit le déplacer vers ``pipeline/`` (couche 4) "
            "ou plus interne, soit créer un protocole côté ``domain/``."
        )


def test_run_result_moved_to_pipeline_layer() -> None:
    """``RunResult`` / ``RunDocumentResult`` / ``ReportRenderer``
    doivent être importables depuis ``picarones.pipeline.run_result``.

    Régression : si un PR repasse les définitions canoniques dans
    ``app/results.py``, le shim de re-export plante.
    """
    from picarones.pipeline.run_result import (
        ReportRenderer,
        RunDocumentResult,
        RunResult,
    )

    assert RunResult is not None
    assert RunDocumentResult is not None
    assert ReportRenderer is not None


def test_app_results_is_compat_shim() -> None:
    """``picarones.app.results`` reste accessible (compat interne)
    et expose les mêmes noms que ``picarones.pipeline.run_result``."""
    from picarones.app import results as app_results
    from picarones.pipeline import run_result as canonical

    assert app_results.RunResult is canonical.RunResult
    assert app_results.RunDocumentResult is canonical.RunDocumentResult
    assert app_results.ReportRenderer is canonical.ReportRenderer