[DTP-Worker] Sprint 7 · Générateur Audit 4Big qualité (95+/100 sur 100% deliverables) (QA · roadmap L69)

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>
This commit is contained in:
Claude Code DTP Worker
2026-07-30 10:11:02 +00:00
parent 17ad5c0a01
commit a517619432
17 changed files with 2157 additions and 1 deletions
@@ -0,0 +1,122 @@
"""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}")