"""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}")