Claude commited on
Commit
1e8b84c
·
unverified ·
1 Parent(s): 6724f94

fix(audit): 5 corrections suite à l'audit complet de mes derniers sprints

Browse files

Trois agents Explore indépendants ont audité les commits ee86836
(invariants), 2d6c41d (helpers consolidés) et d641f6e/6724f94
(découpage generator.py). Cinq problèmes réels identifiés et
corrigés :

1. **CRITIQUE — test_module_coverage.py basé sur regex incomplète**
Le pattern regex ne capturait que `from picarones.measurements.X
import Y` et oubliait `from picarones.measurements import X`,
`from . import X`, `from picarones.measurements import (X, Y)`.
Conséquence : 3 faux positifs dans la baseline (alto_metrics,
builtin_metrics, reading_order — importés en __init__.py mais
classés comme test-only) ET 4 faux négatifs (error_absorption,
longitudinal, module_policy, reliability — détectés à tort comme
ayant un consommateur via des imports DANS DES DOCSTRINGS).

Refactor : remplacement de la regex par un parser AST qui
- reconnaît les 5 syntaxes d'import valides Python ;
- ignore correctement les chaînes / docstrings.
Helpers extraits : `_imports_target_module` (absolu) et
`_imports_target_relative` (depuis le package). Baseline
recalibrée : 12 → 13 modules réellement test-only.

2. **MAJEURE — asymétrie d'API dans build_pareto_section**
La fonction (a) prenait `engines_summary` en premier (les autres
`build_*` prenaient `benchmark`) et (b) mutait `engines_summary`
en place. Mutation cachée + signature inhomogène.

Refactor : split en deux fonctions au nom explicite :
- `attach_engine_costs(engines_summary, benchmark)` mute
(le verbe "attach" annonce la mutation) ;
- `build_pareto_section(engines_summary)` est pure et lit les
coûts déjà attachés.
L'orchestrateur appelle les deux dans l'ordre, documenté en
docstring de `build_report_data`.

3. **MINEURE — except Exception silencieux dans assets.py**
`encode_image_b64` retournait `""` sans logguer, en violation
de la règle CLAUDE.md "remplacer except: pass par
logger.warning". Ajout d'un `logger.warning()` avec contexte
(chemin de l'image + exception) — le rapport reste fonctionnel
mais l'absence d'image n'est plus invisible.

4. **MINEURE — tests NaN/Inf manquants dans render_helpers**
`color_traffic_light` n'avait aucun test pour valeurs spéciales
IEEE 754. Ajout de 3 tests :
- NaN → fonction ne crash pas, retourne hex valide ;
- +inf → clamp à scale_max → vert (high_is_good) ;
- -inf → clamp à 0 → rouge ;
- scale_min > scale_max → ne crash pas.

5. **MINEURE — documentation des 3 conventions de bornes**
render_helpers.py expose 3 fonctions de coloration avec 3
conventions différentes (scale_min/max, max_value, max_abs).
Section "Conventions de bornes" ajoutée dans la docstring de
module pour expliquer le pourquoi de chaque convention (lié à
la sémantique métier des cellules concernées) et le choix des
palettes (rouge/jaune/vert pour traffic, bleu/vert/orange
diverging pour daltonisme).

Suite : 3834 passed, 2 skipped (vs 3830 précédemment, +3 NaN/Inf
+ 1 ajustement). 1 échec pré-existant (test_readme_dual_lang).
ruff : All checks passed!

picarones/report/assets.py CHANGED
@@ -43,13 +43,19 @@ def load_vendor_js(name: str) -> str:
43
 
44
 
45
  def encode_image_b64(image_path: str, max_width: int = 1200) -> str:
46
- """Lit une image, la redimensionne si besoin, et retourne un data-URI base64."""
 
 
 
 
 
 
 
 
 
47
  try:
48
  from PIL import Image
49
 
50
- p = Path(image_path)
51
- if not p.exists():
52
- return ""
53
  with Image.open(p) as img:
54
  if img.width > max_width:
55
  ratio = max_width / img.width
@@ -64,7 +70,13 @@ def encode_image_b64(image_path: str, max_width: int = 1200) -> str:
64
  b64 = base64.b64encode(buf.getvalue()).decode("ascii")
65
  mime = "image/jpeg" if fmt == "JPEG" else "image/png"
66
  return f"data:{mime};base64,{b64}"
67
- except Exception: # noqa: BLE001 — fallback silencieux côté report
 
 
 
 
 
 
68
  return ""
69
 
70
 
 
43
 
44
 
45
  def encode_image_b64(image_path: str, max_width: int = 1200) -> str:
46
+ """Lit une image, la redimensionne si besoin, et retourne un data-URI base64.
47
+
48
+ Retourne ``""`` si l'image est introuvable ou si l'encodage
49
+ échoue (Pillow indisponible, format non géré, fichier corrompu).
50
+ Logue un avertissement dans ce dernier cas — le rapport reste
51
+ fonctionnel mais l'image manquera dans la galerie.
52
+ """
53
+ p = Path(image_path)
54
+ if not p.exists():
55
+ return ""
56
  try:
57
  from PIL import Image
58
 
 
 
 
59
  with Image.open(p) as img:
60
  if img.width > max_width:
61
  ratio = max_width / img.width
 
70
  b64 = base64.b64encode(buf.getvalue()).decode("ascii")
71
  mime = "image/jpeg" if fmt == "JPEG" else "image/png"
72
  return f"data:{mime};base64,{b64}"
73
+ except Exception as exc: # noqa: BLE001 — fallback gracieux + warning
74
+ logger.warning(
75
+ "[report] échec d'encodage base64 de l'image %s : %s — "
76
+ "le rapport ignorera cette image",
77
+ image_path,
78
+ exc,
79
+ )
80
  return ""
81
 
82
 
picarones/report/render_helpers.py CHANGED
@@ -21,6 +21,29 @@ API
21
  - :func:`text_color_for_bg` — noir ou blanc selon la luminosité du fond.
22
  - :func:`build_grid_svg` — builder de heatmap SVG paramétré.
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  Palette
25
  -------
26
  Les bornes RGB des dégradés traffic-light sont la moyenne des palettes
 
21
  - :func:`text_color_for_bg` — noir ou blanc selon la luminosité du fond.
22
  - :func:`build_grid_svg` — builder de heatmap SVG paramétré.
23
 
24
+ Conventions de bornes
25
+ ---------------------
26
+ Trois conventions de paramétrage cohabitent (par dessein, pas par
27
+ maladresse) :
28
+
29
+ - :func:`color_traffic_light` accepte ``scale_min`` + ``scale_max``
30
+ parce que les cellules concernées (CER, ECE, deficit) peuvent
31
+ démarrer à une borne basse non nulle (rang 1 = vert, ou
32
+ ``scale_min=0.30`` pour démarrer le dégradé à partir d'un seuil).
33
+ - :func:`color_single_gradient` accepte ``max_value`` parce que ces
34
+ cellules (Jaccard, densité) sont toujours bornées en bas par 0 —
35
+ pas besoin de ``scale_min``.
36
+ - :func:`color_diverging` accepte ``max_abs`` parce que ces cellules
37
+ (deltas signés) sont symétriques autour de 0 — la borne est la
38
+ même des deux côtés.
39
+
40
+ Le choix des couleurs reflète la sémantique métier : traffic-light
41
+ utilise rouge/jaune/vert (échelle universelle de qualité), diverging
42
+ utilise bleu/vert/orange par défaut (vert au centre = neutre,
43
+ extrémités opposées sémantiquement, et ces 3 teintes restent
44
+ distinguables en daltonisme deutéranope contrairement au
45
+ rouge/vert).
46
+
47
  Palette
48
  -------
49
  Les bornes RGB des dégradés traffic-light sont la moyenne des palettes
picarones/report/report_data/__init__.py CHANGED
@@ -14,10 +14,14 @@ Ce sous-package éclate la construction en modules thématiques :
14
  reliability curves, Venn, error clusters, corrélations.
15
  - :mod:`scatter` — Sprint 10 : Gini vs CER, ratio vs anchor.
16
  - :mod:`pareto` — Sprint 19 : 3 fronts Pareto + métadonnées pricing.
 
 
17
 
18
  L'API publique :func:`build_report_data` orchestre ces modules dans
19
- le bon ordre (les coûts du module Pareto enrichissent en place le
20
- ``engines_summary`` produit par :mod:`engines`).
 
 
21
  """
22
 
23
  from __future__ import annotations
@@ -32,7 +36,10 @@ from picarones.report.report_data.documents import (
32
  build_documents,
33
  )
34
  from picarones.report.report_data.engines import build_engines_summary
35
- from picarones.report.report_data.pareto import build_pareto_section
 
 
 
36
  from picarones.report.report_data.scatter import (
37
  build_gini_vs_cer,
38
  build_ratio_vs_anchor,
@@ -53,14 +60,21 @@ def build_report_data(
53
  ) -> dict:
54
  """Transforme un :class:`BenchmarkResult` en dict pour le rapport HTML.
55
 
56
- L'ordre est important : :mod:`pareto` lit et enrichit en place
57
- le ``engines_summary`` produit par :mod:`engines`.
 
 
 
 
 
 
58
  """
59
  engines_summary = build_engines_summary(benchmark)
60
  documents = build_documents(benchmark, images_b64)
61
  annotate_documents_with_difficulty(benchmark, documents)
62
 
63
- pareto_data = build_pareto_section(engines_summary, benchmark)
 
64
 
65
  return {
66
  "meta": {
 
14
  reliability curves, Venn, error clusters, corrélations.
15
  - :mod:`scatter` — Sprint 10 : Gini vs CER, ratio vs anchor.
16
  - :mod:`pareto` — Sprint 19 : 3 fronts Pareto + métadonnées pricing.
17
+ Expose deux fonctions séparées : :func:`attach_engine_costs`
18
+ (mute) et :func:`build_pareto_section` (pure).
19
 
20
  L'API publique :func:`build_report_data` orchestre ces modules dans
21
+ le bon ordre. La séquence Pareto en deux temps
22
+ (``attach_engine_costs`` ``build_pareto_section``) rend la
23
+ mutation explicite — les fonctions ``build_*`` du sous-package
24
+ sont pures sauf ``attach_engine_costs`` dont le nom le dit.
25
  """
26
 
27
  from __future__ import annotations
 
36
  build_documents,
37
  )
38
  from picarones.report.report_data.engines import build_engines_summary
39
+ from picarones.report.report_data.pareto import (
40
+ attach_engine_costs,
41
+ build_pareto_section,
42
+ )
43
  from picarones.report.report_data.scatter import (
44
  build_gini_vs_cer,
45
  build_ratio_vs_anchor,
 
60
  ) -> dict:
61
  """Transforme un :class:`BenchmarkResult` en dict pour le rapport HTML.
62
 
63
+ Ordre critique :
64
+
65
+ 1. Construire ``engines_summary`` (pur).
66
+ 2. Construire ``documents`` puis annoter avec la difficulté (mute
67
+ ``documents``).
68
+ 3. **Attacher** les coûts à ``engines_summary`` (mute, nom
69
+ explicite).
70
+ 4. **Construire** le bloc Pareto (pure, lit les coûts attachés).
71
  """
72
  engines_summary = build_engines_summary(benchmark)
73
  documents = build_documents(benchmark, images_b64)
74
  annotate_documents_with_difficulty(benchmark, documents)
75
 
76
+ attach_engine_costs(engines_summary, benchmark)
77
+ pareto_data = build_pareto_section(engines_summary)
78
 
79
  return {
80
  "meta": {
picarones/report/report_data/pareto.py CHANGED
@@ -6,11 +6,24 @@ Construit trois fronts Pareto avec des axes alternatifs :
6
  - ``speed`` — CER vs durée moyenne par page.
7
  - ``co2`` — CER vs empreinte carbone (g CO₂ / 1000 pages, expérimental).
8
 
9
- **Effet de bord** : :func:`build_pareto_section` enrichit en place
10
- le ``engines_summary`` reçu en argument avec les champs
11
- ``mean_duration_seconds`` et ``cost`` (coût par 1000 pages + détail
12
- de pricing). Cette responsabilité partagée est documentée dans le
13
- module ``__init__.py`` du sous-package.
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  """
15
 
16
  from __future__ import annotations
@@ -27,13 +40,20 @@ if TYPE_CHECKING:
27
  from picarones.core.results import BenchmarkResult
28
 
29
 
30
- def build_pareto_section(
31
  engines_summary: list[dict], benchmark: "BenchmarkResult",
32
- ) -> dict:
33
- """Construit le bloc ``pareto`` du dict de rapport.
 
 
 
 
 
 
 
34
 
35
- Annote en place chaque entrée de ``engines_summary`` avec
36
- ``mean_duration_seconds`` et ``cost``.
37
  """
38
  durations_by_engine: dict[str, float] = {}
39
  for report in benchmark.engine_reports:
@@ -45,11 +65,9 @@ def build_pareto_section(
45
  if durs:
46
  durations_by_engine[report.engine_name] = sum(durs) / len(durs)
47
 
48
- pricing_defaults, _ = load_pricing_database()
49
  costs_by_engine = build_costs_for_benchmark(
50
  engines_summary, durations_by_engine,
51
  )
52
- # Annoter en place chaque résumé moteur avec son coût et sa durée.
53
  for entry in engines_summary:
54
  name = entry["name"]
55
  entry["mean_duration_seconds"] = (
@@ -58,6 +76,24 @@ def build_pareto_section(
58
  )
59
  entry["cost"] = costs_by_engine.get(name)
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  pareto_points = []
62
  for entry in engines_summary:
63
  cer = entry.get("cer")
@@ -120,4 +156,4 @@ def build_pareto_section(
120
  }
121
 
122
 
123
- __all__ = ["build_pareto_section"]
 
6
  - ``speed`` — CER vs durée moyenne par page.
7
  - ``co2`` — CER vs empreinte carbone (g CO₂ / 1000 pages, expérimental).
8
 
9
+ API
10
+ ---
11
+ Deux fonctions séparées pour rendre le contrat explicite :
12
+
13
+ 1. :func:`attach_engine_costs` **mute en place** ``engines_summary``
14
+ en y ajoutant ``mean_duration_seconds`` et ``cost`` (extraits du
15
+ benchmark et de la table de pricing). Le nom dit clairement qu'il
16
+ y a mutation.
17
+ 2. :func:`build_pareto_section` — **fonction pure**, lit les coûts
18
+ déjà attachés à ``engines_summary``. Retourne le dict ``pareto``
19
+ prêt pour le template.
20
+
21
+ L'orchestrateur (``__init__.py``) appelle les deux dans l'ordre.
22
+ Cette séparation rend possible :
23
+
24
+ - Tester :func:`build_pareto_section` indépendamment avec un
25
+ ``engines_summary`` pré-fabriqué.
26
+ - Réutiliser les coûts attachés sans recalculer Pareto.
27
  """
28
 
29
  from __future__ import annotations
 
40
  from picarones.core.results import BenchmarkResult
41
 
42
 
43
+ def attach_engine_costs(
44
  engines_summary: list[dict], benchmark: "BenchmarkResult",
45
+ ) -> None:
46
+ """Annote chaque entrée de ``engines_summary`` avec son coût.
47
+
48
+ **Mute en place** : ajoute deux champs à chaque dict moteur :
49
+
50
+ - ``mean_duration_seconds`` (float ou ``None`` si pas de durée).
51
+ - ``cost`` : dict de la forme ``{cost_per_1k_pages_eur: ...,
52
+ co2_per_1k_pages_g: ..., ...}`` ou ``None`` si pricing
53
+ indisponible.
54
 
55
+ Doit être appelée AVANT :func:`build_pareto_section`, qui lit
56
+ ces deux champs.
57
  """
58
  durations_by_engine: dict[str, float] = {}
59
  for report in benchmark.engine_reports:
 
65
  if durs:
66
  durations_by_engine[report.engine_name] = sum(durs) / len(durs)
67
 
 
68
  costs_by_engine = build_costs_for_benchmark(
69
  engines_summary, durations_by_engine,
70
  )
 
71
  for entry in engines_summary:
72
  name = entry["name"]
73
  entry["mean_duration_seconds"] = (
 
76
  )
77
  entry["cost"] = costs_by_engine.get(name)
78
 
79
+
80
+ def build_pareto_section(engines_summary: list[dict]) -> dict:
81
+ """Construit le bloc ``pareto`` du dict de rapport.
82
+
83
+ **Fonction pure** : ne mute rien. Lit ``mean_duration_seconds``
84
+ et ``cost`` qui doivent avoir été attachés en amont par
85
+ :func:`attach_engine_costs`. Si ces champs sont absents, le
86
+ moteur est silencieusement omis du front (cohérent avec un
87
+ moteur qui n'a pas de prix connu).
88
+
89
+ Retour
90
+ ------
91
+ dict
92
+ Trois fronts Pareto (``cost``, ``speed``, ``co2``) plus
93
+ ``pricing_meta`` (table de pricing utilisée).
94
+ """
95
+ pricing_defaults, _ = load_pricing_database()
96
+
97
  pareto_points = []
98
  for entry in engines_summary:
99
  cer = entry.get("cer")
 
156
  }
157
 
158
 
159
+ __all__ = ["attach_engine_costs", "build_pareto_section"]
tests/architecture/test_file_budgets.py CHANGED
@@ -68,6 +68,10 @@ FILE_BUDGETS: dict[str, int] = {
68
  "picarones/measurements/numerical_sequences.py": 500, # actuel 422
69
  "picarones/measurements/normalization.py": 500, # actuel 420
70
  "picarones/report/comparison.py": 500, # actuel 409
 
 
 
 
71
  }
72
 
73
 
 
68
  "picarones/measurements/numerical_sequences.py": 500, # actuel 422
69
  "picarones/measurements/normalization.py": 500, # actuel 420
70
  "picarones/report/comparison.py": 500, # actuel 409
71
+ # --- Module mutualisé créé par le sprint des render helpers
72
+ # (Sprint « consolidation des renderers » 2026-05-02). Budget
73
+ # calibré sur la taille post-documentation des conventions.
74
+ "picarones/report/render_helpers.py": 480, # actuel 415
75
  }
76
 
77
 
tests/architecture/test_module_coverage.py CHANGED
@@ -5,14 +5,19 @@ 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
 
@@ -32,26 +37,28 @@ Test ratchet :
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
  })
@@ -65,39 +72,94 @@ def _measurements_modules() -> list[str]:
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
 
 
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, recalibré post-audit du 2026-05-02) :
9
+ **13 modules** dans ``measurements/`` n'ont aucun consommateur
10
+ direct hors tests. La baseline initiale (12 modules) reposait sur
11
+ une regex texte qui (a) ne capturait pas la syntaxe
12
+ ``from picarones.measurements import X`` utilisée dans
13
+ ``__init__.py`` (3 faux positifs : alto_metrics, builtin_metrics,
14
+ reading_order), et (b) capturait à tort les imports DANS DES
15
+ DOCSTRINGS (4 faux négatifs : error_absorption, longitudinal,
16
+ module_policy, reliability).
17
+
18
+ Le check est désormais basé sur le module ``ast`` standard de
19
+ Python qui ignore correctement le contenu des chaînes/docstrings
20
+ et reconnaît toutes les formes d'import valides.
21
 
22
  Trois actions possibles, par module :
23
 
 
37
 
38
  from __future__ import annotations
39
 
40
+ import ast
41
  from pathlib import Path
42
 
43
  REPO_ROOT = Path(__file__).resolve().parents[2]
44
  PICARONES_DIR = REPO_ROOT / "picarones"
45
  MEASUREMENTS_DIR = PICARONES_DIR / "measurements"
46
 
47
+ #: Snapshot v1.0.0 (post-audit AST). Modules de
48
+ #: ``picarones/measurements/`` sans consommateur en production.
49
+ #: À résorber par paliers.
50
  TEST_ONLY_BASELINE: frozenset[str] = frozenset({
 
51
  "baseline_comparison",
 
52
  "cost_projection",
53
  "equivalence_profile",
54
+ "error_absorption",
55
  "layout",
56
+ "longitudinal",
57
  "marginal_cost",
58
+ "module_policy",
59
  "ner_backends",
60
  "rare_tokens",
61
+ "reliability",
62
  "taxonomy_cooccurrence",
63
  "taxonomy_intra_doc",
64
  })
 
72
  )
73
 
74
 
75
+ def _imports_target_module(node: ast.AST, module_name: str) -> bool:
76
+ """True si ce nœud AST importe ``picarones.measurements.<module_name>``.
77
+
78
+ Couvre les 5 syntaxes valides Python :
79
+
80
+ - ``import picarones.measurements.X``
81
+ - ``import picarones.measurements.X.sub`` (sous-module)
82
+ - ``from picarones.measurements.X import Y``
83
+ - ``from picarones.measurements import X``
84
+ - ``from picarones.measurements import (X, Y)`` (forme parenthésée)
85
+ """
86
+ target_dotted = f"picarones.measurements.{module_name}"
87
+ if isinstance(node, ast.Import):
88
+ for alias in node.names:
89
+ if alias.name == target_dotted or alias.name.startswith(
90
+ target_dotted + ".",
91
+ ):
92
+ return True
93
+ return False
94
+ if isinstance(node, ast.ImportFrom):
95
+ # ``from picarones.measurements.X import …``
96
+ if node.module == target_dotted:
97
+ return True
98
+ # ``from picarones.measurements import X``
99
+ if node.module == "picarones.measurements":
100
+ for alias in node.names:
101
+ if alias.name == module_name:
102
+ return True
103
+ return False
104
+
105
+
106
+ def _imports_target_relative(
107
+ node: ast.AST, module_name: str, source_dir: Path,
108
+ ) -> bool:
109
+ """True si ce nœud AST importe ``module_name`` via un import relatif
110
+ valide depuis le package ``measurements``.
111
+
112
+ Couvre :
113
+
114
+ - ``from . import X`` (depuis ``measurements/__init__.py`` ou
115
+ tout autre module dans le package).
116
+ - ``from .X import Y``.
117
+ """
118
+ if not isinstance(node, ast.ImportFrom):
119
+ return False
120
+ if node.level < 1:
121
+ return False
122
+ if source_dir != MEASUREMENTS_DIR:
123
+ return False
124
+ # ``from .X import …``
125
+ if node.module == module_name:
126
+ return True
127
+ # ``from . import X``
128
+ if node.module is None:
129
+ for alias in node.names:
130
+ if alias.name == module_name:
131
+ return True
132
+ return False
133
+
134
+
135
  def _has_production_consumer(module_name: str) -> bool:
136
  """True si ``module_name`` est importé par un fichier de production.
137
 
138
  "Production" = sous ``picarones/``, hors le module lui-même.
139
+
140
+ Le check parse l'AST de chaque fichier (au lieu de grep) pour deux
141
+ raisons :
142
+
143
+ 1. **Toutes les syntaxes d'import sont reconnues** sans bricolage
144
+ de regex (``from picarones.measurements import X`` était la
145
+ grosse cible manquée par la regex initiale).
146
+ 2. **Les chaînes/docstrings ne déclenchent pas de faux positif**
147
+ (un exemple de code dans une docstring ne compte pas comme
148
+ import réel).
149
  """
150
  own_file = MEASUREMENTS_DIR / f"{module_name}.py"
 
 
 
 
 
 
 
 
151
  for path in PICARONES_DIR.rglob("*.py"):
152
  if path == own_file:
153
  continue
154
  try:
155
+ tree = ast.parse(path.read_text(encoding="utf-8"))
156
+ except (OSError, SyntaxError):
157
  continue
158
+ for node in ast.walk(tree):
159
+ if _imports_target_module(node, module_name):
160
+ return True
161
+ if _imports_target_relative(node, module_name, path.parent):
162
+ return True
 
 
 
 
163
  return False
164
 
165
 
tests/report/test_render_helpers.py CHANGED
@@ -75,6 +75,28 @@ class TestColorTrafficLight:
75
  assert len(c) == 7
76
  assert c == c.lower()
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
  # ──────────────────────────────────────────────────────────────────
80
  # color_single_gradient
 
75
  assert len(c) == 7
76
  assert c == c.lower()
77
 
78
+ def test_nan_falls_back_to_red(self) -> None:
79
+ # max(0, min(1, NaN)) = NaN → mais ensuite f <= 0.5 est False, donc
80
+ # branche "yellow → green" avec t = (NaN - 0.5) / 0.5 = NaN.
81
+ # Le résultat est techniquement indéfini ; on vérifie au moins que
82
+ # la fonction ne crash pas et retourne un hex valide.
83
+ c = color_traffic_light(float("nan"))
84
+ assert c.startswith("#")
85
+ assert len(c) == 7
86
+
87
+ def test_inf_clamped_to_max(self) -> None:
88
+ # +inf > scale_max → clamp à scale_max → vert (high_is_good)
89
+ assert _hex_to_rgb(color_traffic_light(float("inf"))) == GRADIENT_GREEN_RGB
90
+ # -inf < 0 → clamp à 0 → rouge
91
+ assert _hex_to_rgb(color_traffic_light(float("-inf"))) == GRADIENT_RED_RGB
92
+
93
+ def test_inverted_scale_returns_yellow(self) -> None:
94
+ # scale_min > scale_max → span négatif → géré comme zero span.
95
+ # La fonction ne doit pas crash et retourne une couleur valide.
96
+ c = color_traffic_light(5.0, scale_min=10, scale_max=5)
97
+ assert c.startswith("#")
98
+ assert len(c) == 7
99
+
100
 
101
  # ──────────────────────────────────────────────────────────────────
102
  # color_single_gradient