Spaces:
Sleeping
test(architecture): 4 invariants structurels contre la dérive silencieuse
Browse filesSnapshot 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.
|
@@ -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 |
+
"""
|
|
@@ -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 |
+
)
|
|
@@ -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 |
+
)
|
|
@@ -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 |
+
)
|
|
@@ -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 |
+
)
|