Claude commited on
Commit
ee86836
·
unverified ·
1 Parent(s): 6221160

test(architecture): 4 invariants structurels contre la dérive silencieuse

Browse files

Snapshot v1.0.0 (2026-05-02) calibré sur l'état actuel du projet.

- test_file_budgets : budget par fichier (≥ 400 lignes), current + ~15 %
de marge. 27 fichiers surveillés. Garde-fou contre la croissance
silencieuse des god-modules. Le rétrécissement (statistics, generator,
runner) reste un travail séparé.
- test_render_helpers : ratchet à 27 helpers locaux dans picarones/report/
(color_for_*, build_heatmap_svg, etc.). Doit baisser via extraction
vers picarones/report/render_helpers.py.
- test_doc_paths : ratchet à 119 chemins picarones/.../X.py cassés dans
CLAUDE.md, CHANGELOG.md, README.md, docs/**/*.md. Dette documentaire
connue (presque tous les modules described as core/ vivent réellement
dans measurements/).
- test_module_coverage : ratchet sur 12 modules de measurements/ sans
consommateur en production (test-only). À résorber par câblage runner,
déplacement vers extras/, ou suppression.

Chaque test a une fonction "must_be_tightened" qui force à abaisser la
baseline quand on consolide, pour verrouiller le gain.

Re-calibrer à chaque release tag.

tests/architecture/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Invariants structurels du projet.
2
+
3
+ Ces tests ne vérifient pas un comportement métier mais la **forme**
4
+ du code lui-même : taille des fichiers, unicité des helpers de rendu,
5
+ cohérence des chemins documentés, couverture des modules par un
6
+ consommateur de production.
7
+
8
+ Ils existent pour casser le cycle « Claude dit que c'est propre ↔
9
+ audit suivant trouve une dérive ». Tant que ces invariants sont verts,
10
+ le projet est *structurellement* sain selon les seuils calibrés au
11
+ dernier release tag. Quand un invariant échoue, c'est un signal de
12
+ réveil : refactor, ou relèvement délibéré du seuil avec
13
+ justification dans le commit.
14
+
15
+ Re-calibrer à chaque release (``git tag vX.Y.Z``).
16
+ """
tests/architecture/test_doc_paths.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Garde-fou contre la dérive doc-vs-code.
2
+
3
+ Scanne ``CLAUDE.md``, ``README.md``, ``docs/**/*.md`` à la recherche de
4
+ chemins de la forme ``picarones/.../X.py`` et vérifie qu'ils existent
5
+ dans le repo.
6
+
7
+ Snapshot v1.0.0 (2026-05-02) : **119 chemins cassés**, presque tous
8
+ dans ``CLAUDE.md`` et ``CHANGELOG.md`` qui décrivent systématiquement
9
+ des modules sous ``picarones/core/...`` alors qu'ils vivent dans
10
+ ``picarones/measurements/...``. C'est une dette documentaire connue
11
+ qu'il faut résorber par paliers.
12
+
13
+ Test ratchet : le nombre de chemins cassés ne peut que diminuer. Pour
14
+ le faire baisser :
15
+
16
+ 1. Soit corriger le chemin dans la doc.
17
+ 2. Soit déplacer le module au chemin documenté (rare — la doc se
18
+ trompe presque toujours).
19
+ 3. Soit retirer la référence devenue obsolète.
20
+
21
+ Puis abaisser :data:`BROKEN_PATHS_BASELINE` du même montant.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from pathlib import Path
28
+
29
+ REPO_ROOT = Path(__file__).resolve().parents[2]
30
+
31
+ #: Snapshot v1.0.0. Doit baisser, jamais monter.
32
+ BROKEN_PATHS_BASELINE = 119
33
+
34
+ #: Patrons de fichiers de documentation à scanner.
35
+ DOC_GLOBS: tuple[str, ...] = (
36
+ "CLAUDE.md",
37
+ "README.md",
38
+ "CHANGELOG.md",
39
+ "SPECS.md",
40
+ "docs/**/*.md",
41
+ )
42
+
43
+ #: Pattern minimal d'un chemin Python dans le repo.
44
+ PATH_PATTERN: re.Pattern[str] = re.compile(
45
+ r"picarones/[a-z_][a-z_0-9]*(?:/[a-z_][a-z_0-9]*)*\.py"
46
+ )
47
+
48
+
49
+ def _doc_files() -> list[Path]:
50
+ files: list[Path] = []
51
+ for glob in DOC_GLOBS:
52
+ files.extend(REPO_ROOT.glob(glob))
53
+ return sorted({f for f in files if f.is_file()})
54
+
55
+
56
+ def _broken_paths() -> list[tuple[str, str]]:
57
+ """Liste des (doc_relatif, chemin_cassé), dédoublonnée et triée."""
58
+ broken: set[tuple[str, str]] = set()
59
+ for doc in _doc_files():
60
+ try:
61
+ text = doc.read_text(encoding="utf-8")
62
+ except OSError:
63
+ continue
64
+ rel_doc = doc.relative_to(REPO_ROOT).as_posix()
65
+ for match in PATH_PATTERN.findall(text):
66
+ if not (REPO_ROOT / match).exists():
67
+ broken.add((rel_doc, match))
68
+ return sorted(broken)
69
+
70
+
71
+ def test_broken_doc_paths_below_baseline() -> None:
72
+ """Le nombre de chemins cassés ne peut que diminuer."""
73
+ broken = _broken_paths()
74
+ if len(broken) > BROKEN_PATHS_BASELINE:
75
+ sample = "\n".join(f" {doc} → {path}" for doc, path in broken[:30])
76
+ more = f"\n ... ({len(broken) - 30} de plus)" if len(broken) > 30 else ""
77
+ raise AssertionError(
78
+ f"\n{len(broken)} chemins de doc cassés (baseline "
79
+ f"{BROKEN_PATHS_BASELINE}).\n"
80
+ f"Régression : la doc référence un fichier qui n'existe pas.\n\n"
81
+ f"Échantillon :\n{sample}{more}\n\n"
82
+ "Soit corrige le chemin, soit le code, soit retire la référence."
83
+ )
84
+
85
+
86
+ def test_baseline_must_be_tightened_when_progress_made() -> None:
87
+ """Si on est sous le baseline, mettre à jour :data:`BROKEN_PATHS_BASELINE`.
88
+
89
+ Verrouille chaque correction de doc pour empêcher une régression
90
+ future de glisser sous le seuil obsolète.
91
+ """
92
+ broken = _broken_paths()
93
+ assert len(broken) >= BROKEN_PATHS_BASELINE, (
94
+ f"\nExcellent : {len(broken)} chemins cassés vs baseline "
95
+ f"{BROKEN_PATHS_BASELINE}.\n\n"
96
+ f"Mets à jour BROKEN_PATHS_BASELINE = {len(broken)} dans "
97
+ "tests/architecture/test_doc_paths.py pour verrouiller le gain."
98
+ )
tests/architecture/test_file_budgets.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Garde-fou contre la croissance silencieuse des fichiers.
2
+
3
+ Chaque fichier listé dans :data:`FILE_BUDGETS` a un budget en lignes.
4
+ Si un fichier dépasse son budget, le test échoue et la PR est forcée
5
+ à choisir entre :
6
+
7
+ 1. **Refactor** pour rentrer dans le budget (extraire un sous-module,
8
+ factoriser, supprimer du code mort).
9
+ 2. **Relever le budget délibérément** : modifier la valeur dans ce
10
+ fichier en l'expliquant dans le message de commit. La hausse devient
11
+ un acte conscient, plus une dérive silencieuse.
12
+
13
+ Calibration : snapshot v1.0.0 (2026-05-02), ``current + ~15 %`` de marge
14
+ pour l'évolution naturelle. Les god-modules historiques (statistics,
15
+ generator, runner) gardent un budget proche de leur taille actuelle ; le
16
+ choix de les dégonfler est une décision dédiée à un sprint de refactor,
17
+ pas un sous-produit de l'invariant.
18
+
19
+ Re-calibrer à chaque release tag.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from pathlib import Path
25
+
26
+ import pytest
27
+
28
+ REPO_ROOT = Path(__file__).resolve().parents[2]
29
+
30
+
31
+ # Format : chemin relatif → max_lines.
32
+ # Seuls les fichiers ≥ 400 lignes sont surveillés (les petits fichiers
33
+ # n'ont pas besoin de budget — leur croissance est gérée par les tests
34
+ # de couverture, pas par un seuil dur).
35
+ FILE_BUDGETS: dict[str, int] = {
36
+ # --- God-modules : budget actuel + 15 % de marge.
37
+ # Le rétrécissement sera l'objet d'un sprint de refactor dédié.
38
+ "picarones/measurements/statistics.py": 1300, # actuel 1128
39
+ "picarones/report/generator.py": 1250, # actuel 1063
40
+ "picarones/measurements/runner.py": 1200, # actuel 1019
41
+ # --- Fichiers métier larges.
42
+ "picarones/measurements/robustness.py": 850, # actuel 731
43
+ "picarones/report/pipeline_render.py": 825, # actuel 717
44
+ "picarones/core/results.py": 750, # actuel 636
45
+ "picarones/report/philological_render.py": 725, # actuel 615
46
+ "picarones/measurements/history.py": 725, # actuel 615
47
+ "picarones/measurements/modern_archives.py": 700, # actuel 599
48
+ "picarones/measurements/builtin_hooks.py": 700, # actuel 590
49
+ "picarones/core/pipeline.py": 675, # actuel 571
50
+ "picarones/extras/importers/iiif.py": 675, # actuel 567
51
+ "picarones/extras/importers/gallica.py": 675, # actuel 563
52
+ "picarones/measurements/levers.py": 675, # actuel 561
53
+ "picarones/extras/importers/escriptorium.py": 650, # actuel 553
54
+ "picarones/web/security.py": 625, # actuel 532
55
+ "picarones/core/corpus.py": 600, # actuel 511
56
+ "picarones/fixtures.py": 600, # actuel 510
57
+ "picarones/measurements/inter_engine.py": 575, # actuel 484
58
+ "picarones/measurements/roman_numerals.py": 575, # actuel 478
59
+ "picarones/extras/importers/htr_united.py": 575, # actuel 473
60
+ "picarones/cli/_workflows.py": 550, # actuel 469
61
+ "picarones/extras/importers/huggingface.py": 550, # actuel 464
62
+ "picarones/core/metric_hooks.py": 500, # actuel 423
63
+ "picarones/measurements/numerical_sequences.py": 500, # actuel 422
64
+ "picarones/measurements/normalization.py": 500, # actuel 420
65
+ "picarones/report/comparison.py": 500, # actuel 409
66
+ }
67
+
68
+
69
+ def _line_count(path: Path) -> int:
70
+ """Compte les lignes physiques (y compris vides)."""
71
+ return len(path.read_text(encoding="utf-8").splitlines())
72
+
73
+
74
+ @pytest.mark.parametrize(
75
+ ("rel_path", "budget"),
76
+ sorted(FILE_BUDGETS.items()),
77
+ )
78
+ def test_file_size_within_budget(rel_path: str, budget: int) -> None:
79
+ """Chaque fichier surveillé doit rester ≤ budget."""
80
+ path = REPO_ROOT / rel_path
81
+ assert path.exists(), (
82
+ f"Fichier disparu : {rel_path}. "
83
+ "Retire l'entrée de FILE_BUDGETS dans "
84
+ "tests/architecture/test_file_budgets.py."
85
+ )
86
+ actual = _line_count(path)
87
+ assert actual <= budget, (
88
+ f"\n{rel_path} a {actual} lignes (budget {budget}).\n\n"
89
+ "Soit refactor pour rentrer dans le budget, soit relève le budget "
90
+ "consciemment dans tests/architecture/test_file_budgets.py "
91
+ "avec une justification dans le message de commit."
92
+ )
93
+
94
+
95
+ def test_no_orphaned_budget_entries() -> None:
96
+ """Toute entrée de FILE_BUDGETS doit pointer vers un fichier existant."""
97
+ missing = [p for p in FILE_BUDGETS if not (REPO_ROOT / p).exists()]
98
+ assert not missing, (
99
+ f"Entrées orphelines dans FILE_BUDGETS : {missing}. "
100
+ "Le fichier a été déplacé/supprimé — retire l'entrée."
101
+ )
102
+
103
+
104
+ def test_budget_table_covers_all_large_files() -> None:
105
+ """Tout fichier ≥ 400 lignes doit avoir une entrée dans FILE_BUDGETS.
106
+
107
+ Empêche un fichier nouveau ou subitement gros d'échapper à la
108
+ surveillance. Si un fichier dépasse 400 lignes, ajoute-le à
109
+ FILE_BUDGETS avec son budget (current + 15 %).
110
+ """
111
+ threshold = 400
112
+ untracked: list[tuple[str, int]] = []
113
+ for path in (REPO_ROOT / "picarones").rglob("*.py"):
114
+ rel = path.relative_to(REPO_ROOT).as_posix()
115
+ if rel in FILE_BUDGETS:
116
+ continue
117
+ count = _line_count(path)
118
+ if count >= threshold:
119
+ untracked.append((rel, count))
120
+ assert not untracked, (
121
+ f"\nFichiers ≥ {threshold} lignes non surveillés :\n"
122
+ + "\n".join(f" {p} ({n} lignes)" for p, n in sorted(untracked))
123
+ + "\n\nAjoute-les à FILE_BUDGETS dans "
124
+ "tests/architecture/test_file_budgets.py avec budget = current + ~15 %."
125
+ )
tests/architecture/test_module_coverage.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Garde-fou contre les modules sans consommateur en production.
2
+
3
+ Chaque module dans ``picarones/measurements/`` doit être importé par
4
+ au moins un fichier de production (hors lui-même, hors ``tests/``).
5
+ Sinon le module est *test-only* — sa couverture de test est haute mais
6
+ il n'est branché à rien dans le pipeline réel.
7
+
8
+ Snapshot v1.0.0 (2026-05-02) : **12 modules** dans ``measurements/``
9
+ n'ont aucun consommateur direct hors tests :
10
+
11
+ - ``alto_metrics``, ``baseline_comparison``, ``builtin_metrics``,
12
+ ``cost_projection``, ``equivalence_profile``, ``layout``,
13
+ ``marginal_cost``, ``ner_backends``, ``rare_tokens``,
14
+ ``reading_order``, ``taxonomy_cooccurrence``,
15
+ ``taxonomy_intra_doc``.
16
+
17
+ Trois actions possibles, par module :
18
+
19
+ 1. **Câbler** dans le runner ou un renderer (le module devient un
20
+ produit, pas une expérience).
21
+ 2. **Déplacer** vers ``picarones/extras/`` si c'est expérimental
22
+ et non livré dans le pipeline standard.
23
+ 3. **Retirer** si c'est mort (le travail reste dans l'historique git).
24
+
25
+ Test ratchet :
26
+
27
+ - Tout module ``measurements/X.py`` qui devient test-only sans entrer
28
+ dans la baseline → échec (régression).
29
+ - Tout module de la baseline qui gagne un consommateur → échec
30
+ jusqu'à ce que la baseline soit mise à jour pour verrouiller le gain.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import re
36
+ from pathlib import Path
37
+
38
+ REPO_ROOT = Path(__file__).resolve().parents[2]
39
+ PICARONES_DIR = REPO_ROOT / "picarones"
40
+ MEASUREMENTS_DIR = PICARONES_DIR / "measurements"
41
+
42
+ #: Snapshot v1.0.0. Modules de ``picarones/measurements/`` sans
43
+ #: consommateur en production. À résorber par paliers.
44
+ TEST_ONLY_BASELINE: frozenset[str] = frozenset({
45
+ "alto_metrics",
46
+ "baseline_comparison",
47
+ "builtin_metrics",
48
+ "cost_projection",
49
+ "equivalence_profile",
50
+ "layout",
51
+ "marginal_cost",
52
+ "ner_backends",
53
+ "rare_tokens",
54
+ "reading_order",
55
+ "taxonomy_cooccurrence",
56
+ "taxonomy_intra_doc",
57
+ })
58
+
59
+
60
+ def _measurements_modules() -> list[str]:
61
+ return sorted(
62
+ p.stem
63
+ for p in MEASUREMENTS_DIR.glob("*.py")
64
+ if p.stem != "__init__"
65
+ )
66
+
67
+
68
+ def _has_production_consumer(module_name: str) -> bool:
69
+ """True si ``module_name`` est importé par un fichier de production.
70
+
71
+ "Production" = sous ``picarones/``, hors le module lui-même.
72
+ On accepte les imports absolus (``from picarones.measurements.X``
73
+ et ``import picarones.measurements.X``) ainsi que les imports
74
+ relatifs depuis le package ``measurements`` (``from .X``).
75
+ """
76
+ own_file = MEASUREMENTS_DIR / f"{module_name}.py"
77
+ absolute_pattern = re.compile(
78
+ rf"\bfrom\s+picarones\.measurements\.{re.escape(module_name)}\b"
79
+ rf"|\bimport\s+picarones\.measurements\.{re.escape(module_name)}\b"
80
+ )
81
+ relative_pattern = re.compile(
82
+ rf"\bfrom\s+\.\s*{re.escape(module_name)}\b"
83
+ rf"|\bfrom\s+\.measurements\.{re.escape(module_name)}\b"
84
+ )
85
+ for path in PICARONES_DIR.rglob("*.py"):
86
+ if path == own_file:
87
+ continue
88
+ try:
89
+ text = path.read_text(encoding="utf-8")
90
+ except OSError:
91
+ continue
92
+ if absolute_pattern.search(text):
93
+ return True
94
+ # Imports relatifs : ne sont valides que depuis l'arbre measurements.
95
+ try:
96
+ path.relative_to(MEASUREMENTS_DIR)
97
+ except ValueError:
98
+ continue
99
+ if relative_pattern.search(text):
100
+ return True
101
+ return False
102
+
103
+
104
+ def _test_only_modules() -> frozenset[str]:
105
+ return frozenset(
106
+ m for m in _measurements_modules()
107
+ if not _has_production_consumer(m)
108
+ )
109
+
110
+
111
+ def test_no_new_test_only_modules() -> None:
112
+ """Aucun module ne doit devenir test-only sans entrer dans la baseline."""
113
+ current = _test_only_modules()
114
+ new = current - TEST_ONLY_BASELINE
115
+ assert not new, (
116
+ f"\n{len(new)} module(s) de measurements/ sans consommateur en "
117
+ f"production : {sorted(new)}.\n\n"
118
+ "Choisis l'une des trois options :\n"
119
+ " 1. Câble le module dans le runner ou un renderer.\n"
120
+ " 2. Déplace-le sous picarones/extras/ s'il est expérimental.\n"
121
+ " 3. Retire-le si c'est mort.\n\n"
122
+ "En dernier recours, ajoute son nom à TEST_ONLY_BASELINE dans "
123
+ "tests/architecture/test_module_coverage.py — c'est admettre "
124
+ "consciemment qu'il vit hors du pipeline standard."
125
+ )
126
+
127
+
128
+ def test_baseline_modules_still_orphaned() -> None:
129
+ """Si un module de la baseline a gagné un consommateur, lock le gain.
130
+
131
+ Force à mettre à jour la baseline pour verrouiller chaque câblage,
132
+ sinon une régression future re-deviendrait test-only sans alerte.
133
+ """
134
+ current = _test_only_modules()
135
+ fixed = TEST_ONLY_BASELINE - current
136
+ assert not fixed, (
137
+ f"\nExcellent : {len(fixed)} module(s) ont gagné un consommateur en "
138
+ f"production : {sorted(fixed)}.\n\n"
139
+ "Retire ces noms de TEST_ONLY_BASELINE dans "
140
+ "tests/architecture/test_module_coverage.py pour verrouiller le gain."
141
+ )
tests/architecture/test_render_helpers.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Garde-fou contre la prolifération des helpers de rendu.
2
+
3
+ Les renderers HTML dans ``picarones/report/`` ont accumulé des helpers
4
+ locaux dupliqués (couleur, heatmap SVG, etc.) qui devraient vivre dans
5
+ un unique ``picarones/report/render_helpers.py``.
6
+
7
+ Snapshot v1.0.0 (2026-05-02) :
8
+
9
+ - 25 fonctions ``_color_for_*`` distinctes (dont plusieurs portent le
10
+ même nom dans des fichiers différents : ``_color_for_score`` ×5,
11
+ ``_color_for_delta`` ×2, ``_color_for_cer`` ×2).
12
+ - 1 fonction ``_color`` simple (``inter_engine_render``).
13
+ - 2 fonctions ``_build_heatmap_svg`` (``taxonomy_cooccurrence``,
14
+ ``taxonomy_intra_doc``).
15
+
16
+ Soit **27 helpers locaux** dupliqués.
17
+
18
+ Test ratchet : ce nombre ne peut que descendre. Pour le faire baisser,
19
+ extraire un helper dans ``picarones/report/render_helpers.py`` et
20
+ l'importer depuis les renderers qui en avaient besoin, puis abaisser
21
+ :data:`HELPER_BASELINE` du même montant.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+ from pathlib import Path
28
+
29
+ REPO_ROOT = Path(__file__).resolve().parents[2]
30
+ REPORT_DIR = REPO_ROOT / "picarones" / "report"
31
+
32
+ #: Snapshot v1.0.0. Doit baisser, jamais monter.
33
+ HELPER_BASELINE = 27
34
+
35
+ #: Le module mutualisé est exempté (c'est *là* qu'on veut les voir).
36
+ HELPERS_MODULE_NAME = "render_helpers.py"
37
+
38
+ #: Fichiers à ignorer (pas des renderers).
39
+ IGNORED_FILES: frozenset[str] = frozenset({"__init__.py", HELPERS_MODULE_NAME})
40
+
41
+ #: Patterns capturant les helpers à mutualiser.
42
+ #:
43
+ #: On vise spécifiquement la duplication observée : coloration et
44
+ #: builders SVG génériques. Les helpers vraiment locaux (extraction
45
+ #: depuis une structure de données spécifique au domaine, formatage
46
+ #: dépendant de la métrique) ne sont *pas* visés.
47
+ HELPER_PATTERNS: tuple[re.Pattern[str], ...] = (
48
+ re.compile(r"^def\s+_color_for\w*\s*\("),
49
+ re.compile(r"^def\s+_color\s*\("),
50
+ re.compile(r"^def\s+_build_heatmap\w*\s*\("),
51
+ )
52
+
53
+
54
+ def _scan_helpers() -> list[tuple[str, int, str]]:
55
+ """Retourne la liste des (chemin_relatif, ligne, signature)."""
56
+ found: list[tuple[str, int, str]] = []
57
+ for path in sorted(REPORT_DIR.rglob("*.py")):
58
+ if path.name in IGNORED_FILES:
59
+ continue
60
+ try:
61
+ text = path.read_text(encoding="utf-8")
62
+ except OSError:
63
+ continue
64
+ for line_num, line in enumerate(text.splitlines(), 1):
65
+ for pattern in HELPER_PATTERNS:
66
+ if pattern.match(line):
67
+ rel = path.relative_to(REPO_ROOT).as_posix()
68
+ found.append((rel, line_num, line.strip()))
69
+ break
70
+ return found
71
+
72
+
73
+ def test_render_helpers_below_baseline() -> None:
74
+ """Le nombre de helpers locaux ne peut que descendre.
75
+
76
+ Quand on consolide un helper vers ``render_helpers.py``, abaisser
77
+ aussi :data:`HELPER_BASELINE` dans le même commit pour verrouiller
78
+ le gain.
79
+ """
80
+ helpers = _scan_helpers()
81
+ count = len(helpers)
82
+ locations = "\n".join(
83
+ f" {rel}:{line} — {sig}" for rel, line, sig in helpers
84
+ )
85
+ assert count <= HELPER_BASELINE, (
86
+ f"\n{count} helpers locaux trouvés (baseline {HELPER_BASELINE}).\n"
87
+ f"Régression : un nouveau helper a été ajouté.\n\n"
88
+ f"Localisations :\n{locations}\n\n"
89
+ "Soit déplace ce helper dans picarones/report/render_helpers.py "
90
+ "et importe-le, soit relève HELPER_BASELINE consciemment dans "
91
+ "tests/architecture/test_render_helpers.py."
92
+ )
93
+
94
+
95
+ def test_baseline_must_be_tightened_when_progress_made() -> None:
96
+ """Si le compte est sous le baseline, abaisse :data:`HELPER_BASELINE`.
97
+
98
+ Force à verrouiller chaque consolidation : sans cette étape, le
99
+ progrès n'est pas figé et une régression future passerait inaperçue
100
+ sous le seuil obsolète.
101
+ """
102
+ count = len(_scan_helpers())
103
+ assert count >= HELPER_BASELINE, (
104
+ f"\nExcellent : {count} helpers vs baseline {HELPER_BASELINE}.\n\n"
105
+ f"Mets à jour HELPER_BASELINE = {count} dans "
106
+ "tests/architecture/test_render_helpers.py pour verrouiller le gain."
107
+ )