"""Registre des modules audités + PREUVE de couverture 100% vs le CI Gitea. Le périmètre revendiqué par cet audit — « 100% des livrables » — n'est crédible que s'il est PROUVÉ, pas déclaré. On lit donc `.gitea/workflows/ci.yml` (la seule autorité sur ce qui est réellement gated) et on vérifie : 1. l'ensemble des modules du registre == l'ensemble des `working-directory:` sous `05_deliverables_mvp/` du CI, PRIVÉ du module auditeur lui-même (séparation des pouvoirs · l'auditeur ne s'auto-note pas · ISA 315) ; 2. chaque module gated alimente bien le job d'agrégat `gate` (via `needs`). Ainsi, tout futur livrable ajouté au CI sans mise à jour du registre casse la couverture → le gate rougit. Aucune omission silencieuse possible. Parsing YAML volontairement minimal (stdlib pure · aucune dépendance pip · #2). """ from __future__ import annotations import os import re from .deps import DELIVERABLES_ROOT _REPO_ROOT = os.path.normpath(os.path.join(DELIVERABLES_ROOT, "..")) _CI_PATH = os.path.join(_REPO_ROOT, ".gitea", "workflows", "ci.yml") _PREFIX = "05_deliverables_mvp/" _JOB_RE = re.compile(r"^ ([A-Za-z0-9_-]+):\s*$") _WD_RE = re.compile(r"^\s*working-directory:\s*(\S+)\s*$") _NEEDS_RE = re.compile(r"^\s*needs:\s*\[(.*)\]\s*$") def _read_ci() -> str: with open(_CI_PATH, encoding="utf-8") as fh: return fh.read() def parse_ci() -> dict: """Retourne {job_id -> chemin module} pour les jobs sous 05_deliverables_mvp et la liste `needs` du job `gate`.""" lines = _read_ci().splitlines() current_job = None job_to_path: dict[str, str] = {} gate_needs: list[str] = [] in_gate = False for line in lines: m = _JOB_RE.match(line) if m: current_job = m.group(1) in_gate = current_job == "gate" continue wd = _WD_RE.match(line) if wd and current_job: val = wd.group(1) if val.startswith(_PREFIX): job_to_path[current_job] = val[len(_PREFIX):] continue if in_gate: nm = _NEEDS_RE.match(line) if nm: gate_needs = [j.strip() for j in nm.group(1).split(",") if j.strip()] return {"job_to_path": job_to_path, "gate_needs": gate_needs} def ci_module_paths(exclude: str | None = None) -> set[str]: """Chemins de modules gated par le CI (hors `exclude`, ex. l'auditeur).""" paths = set(parse_ci()["job_to_path"].values()) if exclude is not None: paths.discard(exclude) return paths def coverage_report(spec: dict) -> dict: """Compare le registre du spec à la réalité du CI. Retourne un diagnostic sérialisable ; `ok=True` ssi couverture bijective + tous gated.""" self_module = spec["self_module"] registry_paths = {m["path"] for m in spec["modules"]} ci = parse_ci() job_to_path = ci["job_to_path"] ci_paths = {p for p in job_to_path.values() if p != self_module} missing_in_registry = sorted(ci_paths - registry_paths) # gated mais non audité missing_in_ci = sorted(registry_paths - ci_paths) # audité mais non gated # Chaque module audité doit alimenter le gate d'agrégat. path_to_jobs: dict[str, list[str]] = {} for job, path in job_to_path.items(): path_to_jobs.setdefault(path, []).append(job) not_in_gate = sorted( p for p in registry_paths if not any(j in ci["gate_needs"] for j in path_to_jobs.get(p, [])) ) ok = not missing_in_registry and not missing_in_ci and not not_in_gate return { "ok": ok, "ci_modules_count": len(ci_paths), "registry_modules_count": len(registry_paths), "missing_in_registry": missing_in_registry, "missing_in_ci": missing_in_ci, "not_in_gate": not_in_gate, "self_module_excluded": self_module, }