File size: 5,298 Bytes
ed02e58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40e60ce
 
 
 
 
 
 
 
 
 
 
 
 
ed02e58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40e60ce
ed02e58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
"""Phase 10 audit code-quality (2026-05) — chaque appel
``logger.{warning,info,error,debug,critical,exception}(...)`` dans
le code source doit commencer par un préfixe ``[module]`` qui
identifie la source du log.

Convention CLAUDE.md :

.. code-block:: python

    logger.warning("[ner.attach] %s/%s : extraction NER dégradée : %s", ...)
    logger.info("[job_store] WAL non supporté, fallback rollback")
    logger.debug("[robustness] cleanup tmp file échoué : %s", exc)

Bénéfice : un opérateur qui voit un warning ``"backup failed"`` dans
les logs sans préfixe ne sait pas si ça vient de l'OCR, du job store
ou d'un détecteur narratif.  Avec ``[job_store] backup failed`` la
source est immédiate.

Stratégie : test **ratchet** — accepter le baseline actuel, refuser
toute nouvelle régression.  Le nettoyage complet (~30 sites résiduels)
peut se faire progressivement.
"""

from __future__ import annotations

import ast
import re
from pathlib import Path

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

_LOG_METHODS = frozenset({
    "debug", "info", "warning", "error", "critical", "exception",
})

#: Pattern attendu : le 1er argument commence par ``[<identifier>]``
#: où ``<identifier>`` est un identifiant de module/sous-module.
#: Tolère :
#:
#: - statique : ``[ner.attach]``, ``[job_store]``, ``[friedman_nemenyi]``
#: - placeholder simple : ``[%s]`` (cas des adapters paramétrés type
#:   ``adapters.llm.base`` qui logguent ``[%s] ...`` où ``%s`` =
#:   ``self.name``).
#: - composé : ``[importers/%s]``, ``[narrative.detector.%s]``,
#:   ``[pipeline:%s]``, ``[aggregate_%s]``.
#:
#: Refuse l'absence totale de préfixe (``logger.warning("Erreur ...")``).
_PREFIX_RE = re.compile(r"^\[[\w./\-:%()_]+\]")


def _scan_unprefixed_logs() -> list[tuple[Path, int, str]]:
    """``(path, lineno, snippet)`` pour chaque appel ``logger.<method>``
    dont le premier argument littéral ne commence pas par ``[<module>]``.
    """
    findings: list[tuple[Path, int, str]] = []
    for path in sorted(PRODUCTION.rglob("*.py")):
        if "__pycache__" in path.parts:
            continue
        try:
            tree = ast.parse(path.read_text(encoding="utf-8"))
        except SyntaxError:
            continue
        for node in ast.walk(tree):
            if not isinstance(node, ast.Call):
                continue
            func = node.func
            if not isinstance(func, ast.Attribute):
                continue
            if func.attr not in _LOG_METHODS:
                continue
            # Vérifier que c'est bien ``logger.<method>``.  On accepte
            # aussi ``logging.warning(...)`` (root) et ``self.logger.warning(...)``.
            if not node.args:
                continue
            first = node.args[0]

            # Extraire la string littérale.
            msg: str | None = None
            if isinstance(first, ast.Constant) and isinstance(first.value, str):
                msg = first.value
            elif isinstance(first, ast.JoinedStr):
                # f-string : on prend les morceaux constants au début.
                parts = []
                for v in first.values:
                    if isinstance(v, ast.Constant) and isinstance(v.value, str):
                        parts.append(v.value)
                    else:
                        break
                if parts:
                    msg = "".join(parts)

            if msg is None:
                # Premier argument dynamique (variable, fonction…) — on
                # ne peut pas vérifier statiquement, skip.
                continue

            if not _PREFIX_RE.match(msg):
                findings.append((path, node.lineno, msg[:60]))

    return findings


#: Baseline du nombre de logs sans préfixe.  Phase 10 audit
#: code-quality (2026-05) : ~30 sites résiduels acceptés.  Test
#: ratchet — ne peut que baisser.
UNPREFIXED_LOGS_BASELINE = 0


def test_unprefixed_logs_below_baseline() -> None:
    """Le compteur de logs sans préfixe ``[module]`` ne peut que baisser."""
    findings = _scan_unprefixed_logs()
    count = len(findings)
    if count > UNPREFIXED_LOGS_BASELINE:
        sample = "\n".join(
            f"  {p.relative_to(REPO_ROOT)}:{ln}{msg!r}"
            for p, ln, msg in findings[:30]
        )
        more = (
            f"\n  ... ({count - 30} de plus)"
            if count > 30
            else ""
        )
        raise AssertionError(
            f"Logs sans préfixe ``[module]`` : {count} > baseline "
            f"{UNPREFIXED_LOGS_BASELINE}.\n\n"
            f"{sample}{more}\n\n"
            "Convention CLAUDE.md : chaque log doit commencer par "
            "``[<module>]`` pour identifier sa source.  Exemples : "
            "``logger.warning(\"[ner.attach] extraction NER dégradée\")``"
        )


def test_baseline_must_be_tightened_when_progress_made() -> None:
    """Symétrique : oblige à abaisser ``UNPREFIXED_LOGS_BASELINE``
    quand des sites sont corrigés."""
    count = len(_scan_unprefixed_logs())
    assert count >= UNPREFIXED_LOGS_BASELINE - 5, (
        f"Logs sans préfixe : {count} < baseline {UNPREFIXED_LOGS_BASELINE}.\n"
        f"Abaisser UNPREFIXED_LOGS_BASELINE = {count} pour verrouiller le gain."
    )