a517619432
Audit de méta-niveau + gate : note la qualité 4Big de 100% des livrables gated et bloque (FAIL) si un module < 95/100 (CLAUDE.md #5). Couverture PROUVÉE par recoupement bijectif registre ↔ working-directory du CI (moins l'auditeur · SoD ISA 315). 5 critères déterministes (DOC/CONTRAT/TESTS/CLI/HANDOFF) renormalisés par archétype. Anti-invention (#6) : chaque note est recalculée depuis des faits du dépôt, jamais saisie ; un invariant recompute chaque note. Résultat : PASS · 17/17 modules à 100/100. Régression 442 tests verts (+34). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
123 lines
5.0 KiB
Python
123 lines
5.0 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
|
|
|
|
_TEST_DEF_RE = re.compile(r"^\s*def (test_[A-Za-z0-9_]+)\s*\(", re.MULTILINE)
|
|
_ADD_PARSER_RE = re.compile(r"add_parser\(|add_subparsers\(")
|
|
|
|
|
|
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 '__name__ == "__main__"' in 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}")
|