37c68aca28
_ADD_PARSER_RE exigeait la parenthèse collée (add_parser\(|add_subparsers\() → `add_subparsers ()` / `add_parser ("build")` (espace avant `(`, Python valide, PEP 8 déconseille sans imposer) silencieusement loupés → sous-scorage −20 pts du critère CLI d'un module sain (symptôme fd99362). Durci en add_parser\s*\(|add_subparsers\s*\(, harmonisé aux 2 frères déjà durcis (_TEST_DEF_RE `def\s+`, _MAIN_GUARD_RE `\s*==\s*`) et à la doctrine écrite du fichier (l.18-30 « détecter la forme, pas une graphie »). 4e fix de cette classe dans ce fichier après ac595d0/fd99362/35de616.
Fix latent (aucun module n'utilise l'espace → quality_report.json byte-identique, check_artifacts vert) mais teeth-prouvé (ancien regex False sur les 2 formes espacées, nouveau True + True sur les formes normales). Cores computationnels commissions/bancable audités CLEAN par ailleurs. run_ci 33 PASS 0 FAIL 0 SKIP · 0 gate (#5) · 0 chiffre inventé (#6) · 0 VPS (#8).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
142 lines
6.3 KiB
Python
142 lines
6.3 KiB
Python
"""Les 5 critères 4Big — purs, déterministes, calculés depuis le dépôt.
|
|
|
|
Chaque critère lit UNIQUEMENT le système de fichiers (jamais une valeur saisie)
|
|
et renvoie {passed: bool, evidence: str}. C'est le cœur anti-invention (#6) de
|
|
l'audit : une note ne peut pas être « écrite pour faire 95 », elle est recomputée
|
|
à partir de faits vérifiables (présence d'un fichier, taille, comptage AST léger,
|
|
intégrité JSON).
|
|
|
|
Aucune dépendance pip ; stdlib pure (le runner Gitea tourne sans réseau · #2).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
|
|
# `\w` (Unicode) pour les identifiants accentués (`test_traçabilite_source`) ET
|
|
# `def\s+` (espacement libre) : `def test_x(` / `def\ttest_x(` sont du Python
|
|
# valide (PEP 8 recommande un espace mais le langage n'en impose pas). Forme
|
|
# STRICTEMENT identique à la source sœur `reglib/discovery.py:_TEST_METHOD_RE`,
|
|
# autorité byte-gatée de la colonne « Tests » des fiches — les deux compteurs du
|
|
# MÊME concept doivent coïncider (sinon dérive d'évidence #6, cf. le fix ASCII).
|
|
_TEST_DEF_RE = re.compile(r"^\s*def\s+(test_\w+)\s*\(", re.MULTILINE)
|
|
# `\s*` avant `(` : `add_subparsers ()` / `add_parser ("build")` sont du Python
|
|
# valide (PEP 8 déconseille l'espace mais le langage l'autorise). Même doctrine
|
|
# que ses détecteurs sœurs `_TEST_DEF_RE` / `_MAIN_GUARD_RE` — détecter la forme,
|
|
# pas une graphie (sinon sous-scorage silencieux d'un CLI sain · #6).
|
|
_ADD_PARSER_RE = re.compile(r"add_parser\s*\(|add_subparsers\s*\(")
|
|
# Garde `if __name__ == "__main__":` — les DEUX styles de guillemets sont du
|
|
# Python valide (PEP 8 n'impose rien), l'ordre des opérandes peut être inversé
|
|
# (« Yoda »), et l'espacement autour de `==` est libre. Un littéral figé
|
|
# `'__name__ == "__main__"'` louperait un entrypoint valide simple-quoté →
|
|
# sous-scorage silencieux du critère CLI. Détecter la forme, pas une graphie.
|
|
_MAIN_GUARD_RE = re.compile(
|
|
r"""__name__\s*==\s*['"]__main__['"]"""
|
|
r"""|['"]__main__['"]\s*==\s*__name__"""
|
|
)
|
|
|
|
|
|
def _direct_files(mod_dir: str, suffix: str) -> list[str]:
|
|
"""Fichiers se terminant par `suffix` directement dans mod_dir (non récursif)."""
|
|
if not os.path.isdir(mod_dir):
|
|
return []
|
|
return sorted(
|
|
os.path.join(mod_dir, n)
|
|
for n in os.listdir(mod_dir)
|
|
if n.endswith(suffix) and os.path.isfile(os.path.join(mod_dir, n))
|
|
)
|
|
|
|
|
|
def crit_doc(mod_dir: str, min_bytes: int) -> dict:
|
|
"""DOC — au moins un Markdown (README/SPEC) d'au moins `min_bytes` octets."""
|
|
for md in _direct_files(mod_dir, ".md"):
|
|
size = os.path.getsize(md)
|
|
if size >= min_bytes:
|
|
return {"passed": True,
|
|
"evidence": f"{os.path.basename(md)} ({size} octets)"}
|
|
return {"passed": False,
|
|
"evidence": f"aucun *.md ≥ {min_bytes} octets à la racine du module"}
|
|
|
|
|
|
def crit_contrat(mod_dir: str) -> dict:
|
|
"""CONTRAT — au moins un contrat de sortie *.schema.json."""
|
|
schemas = _direct_files(mod_dir, ".schema.json")
|
|
if schemas:
|
|
return {"passed": True,
|
|
"evidence": ", ".join(os.path.basename(s) for s in schemas)}
|
|
return {"passed": False, "evidence": "aucun *.schema.json à la racine du module"}
|
|
|
|
|
|
def crit_tests(mod_dir: str, min_methods: int) -> dict:
|
|
"""TESTS — ≥ min_methods méthodes `def test_*` dans tests/."""
|
|
tests_dir = os.path.join(mod_dir, "tests")
|
|
count = 0
|
|
files = 0
|
|
if os.path.isdir(tests_dir):
|
|
for root, _dirs, names in os.walk(tests_dir):
|
|
if "__pycache__" in root:
|
|
continue
|
|
for n in names:
|
|
if n.startswith("test_") and n.endswith(".py"):
|
|
files += 1
|
|
with open(os.path.join(root, n), encoding="utf-8") as fh:
|
|
count += len(_TEST_DEF_RE.findall(fh.read()))
|
|
passed = count >= min_methods
|
|
return {"passed": passed,
|
|
"evidence": f"{count} méthodes test_* dans {files} fichier(s) "
|
|
f"(seuil {min_methods})"}
|
|
|
|
|
|
def crit_cli(mod_dir: str) -> dict:
|
|
"""CLI — un entrypoint __main__ avec sous-commandes argparse à la racine."""
|
|
for py in _direct_files(mod_dir, ".py"):
|
|
with open(py, encoding="utf-8") as fh:
|
|
src = fh.read()
|
|
if _MAIN_GUARD_RE.search(src) and _ADD_PARSER_RE.search(src):
|
|
return {"passed": True,
|
|
"evidence": f"{os.path.basename(py)} (argparse + __main__)"}
|
|
return {"passed": False,
|
|
"evidence": "aucun entrypoint __main__ avec add_parser/add_subparsers"}
|
|
|
|
|
|
def crit_handoff(mod_dir: str) -> dict:
|
|
"""HANDOFF — out/MANIFEST.json + ≥1 autre artefact, tous JSON non vides."""
|
|
out_dir = os.path.join(mod_dir, "out")
|
|
manifest = os.path.join(out_dir, "MANIFEST.json")
|
|
if not os.path.isfile(manifest):
|
|
return {"passed": False, "evidence": "out/MANIFEST.json absent"}
|
|
others = [os.path.join(out_dir, n) for n in sorted(os.listdir(out_dir))
|
|
if n.endswith(".json") and n != "MANIFEST.json"]
|
|
if not others:
|
|
return {"passed": False,
|
|
"evidence": "out/ ne contient que MANIFEST.json (aucun artefact)"}
|
|
for art in [manifest] + others:
|
|
try:
|
|
with open(art, encoding="utf-8") as fh:
|
|
data = json.load(fh)
|
|
except (json.JSONDecodeError, OSError) as exc:
|
|
return {"passed": False,
|
|
"evidence": f"{os.path.basename(art)} illisible : {exc}"}
|
|
if data in (None, {}, [], ""):
|
|
return {"passed": False,
|
|
"evidence": f"{os.path.basename(art)} JSON vide"}
|
|
return {"passed": True,
|
|
"evidence": f"MANIFEST + {len(others)} artefact(s) JSON valides"}
|
|
|
|
|
|
# id critère → (fonction, kwargs supplémentaires tirés des thresholds)
|
|
def evaluate(mod_dir: str, criterion_id: str, thresholds: dict) -> dict:
|
|
if criterion_id == "DOC":
|
|
return crit_doc(mod_dir, thresholds["min_doc_bytes"])
|
|
if criterion_id == "CONTRAT":
|
|
return crit_contrat(mod_dir)
|
|
if criterion_id == "TESTS":
|
|
return crit_tests(mod_dir, thresholds["min_test_methods"])
|
|
if criterion_id == "CLI":
|
|
return crit_cli(mod_dir)
|
|
if criterion_id == "HANDOFF":
|
|
return crit_handoff(mod_dir)
|
|
raise KeyError(f"Critère inconnu : {criterion_id}")
|