"""Assemblage de la matrice d'acceptation à partir du spec + de la réalité CI. La matrice mappe chaque promesse roadmap → preuve gated OU hors-périmètre sourcé. La liste des modules-preuve n'est PAS crue sur parole : elle est confrontée à `.gitea/workflows/ci.yml` (via q4lib.registry.parse_ci, réutilisé) de façon BIJECTIVE, et la fenêtre de sprint de chaque module est LUE dans le registre de l'auditeur 4Big (zéro re-déclaration · anti-dérive). Fonctions PURES (aucune I/O, aucun horodatage) → sortie déterministe, diffable, re-générable bit-à-bit. Le CLI injecte le spec chargé, `parse_ci()` et `audit_sprints()` ; les tests injectent des dictionnaires synthétiques (facile à mettre en défaut → tests négatifs). """ from __future__ import annotations from typing import Any def gated_modules(ci: dict, self_module: str) -> list[str]: """Chemins de modules gated par le CI, hors la matrice elle-même (SoD).""" paths = set(ci.get("job_to_path", {}).values()) paths.discard(self_module) return sorted(paths) def _path_to_job(ci: dict) -> dict[str, str]: """Inverse job→path en path→job (job minimal en cas de collision).""" out: dict[str, str] = {} for job, path in ci.get("job_to_path", {}).items(): if path not in out or job < out[path]: out[path] = job return out def module_sprint_map(spec: dict, audit_sprints: dict) -> dict[str, str]: """{module -> sprint}, dérivé du registre 4Big + complété par le spec. Le registre de l'auditeur est la source ; l'auditeur lui-même n'y figure pas (SoD) → son sprint vient de `extra_module_sprint` (avec source). Une entrée `extra` qui écraserait un module déjà daté par le registre est un conflit : on la garde ici mais l'incohérence est signalée par l'invariant du CLI.""" out = dict(audit_sprints) for path, meta in spec.get("extra_module_sprint", {}).items(): out.setdefault(path, meta["sprint"]) return out def all_deliverables(spec: dict) -> list[dict]: """Livrables de sprint + métriques MVP, dans l'ordre du spec.""" return list(spec.get("sprint_deliverables", [])) + list(spec.get("mvp_metrics", [])) def _resolve_row(d: dict, gated_set: set, p2j: dict, deps_mod) -> dict[str, Any]: """Une ligne de matrice : preuves résolues + drapeaux de cohérence.""" ev_modules = list(d.get("evidence_modules", [])) modules = [ {"module": m, "ci_job": p2j.get(m), "gated": m in gated_set} for m in ev_modules ] artifacts = [ {"path": a, "exists": deps_mod.artifact_exists(a)} for a in d.get("evidence_artifacts", []) ] oos = [ {"item": o["item"], "source": o.get("source")} for o in d.get("out_of_scope", []) ] has_evidence = bool(ev_modules) or bool(d.get("evidence_artifacts")) return { "id": d["id"], "kind": d["kind"], "sprint": d.get("sprint"), "roadmap_line": d["roadmap_line"], "statement": d["statement"], "status": d["status"], "modules": modules, "artifacts": artifacts, "out_of_scope": oos, "n_modules": len(ev_modules), "all_modules_gated": all(m in gated_set for m in ev_modules), "all_artifacts_exist": all(a["exists"] for a in artifacts), "has_evidence": has_evidence, } def build_matrix(spec: dict, ci: dict, audit_sprints: dict, deps_mod) -> dict[str, Any]: """Consolide spec + CI + registre 4Big en matrice + manifeste agrégé. Ne lève JAMAIS : les incohérences (module manquant / en trop / partition de sprint fausse / artefact absent) sont REMONTÉES dans `manifest.coverage` pour que le CLI/les tests les signalent explicitement. Une recette ne doit pas oublier silencieusement une promesse.""" self_module = spec["self_module"] gated = gated_modules(ci, self_module) gated_set = set(gated) p2j = _path_to_job(ci) ms_map = module_sprint_map(spec, audit_sprints) deliverables = all_deliverables(spec) rows = [_resolve_row(d, gated_set, p2j, deps_mod) for d in deliverables] # --- Couverture BIJECTIVE (cœur anti-invention · #6) ------------------ # cited = tout module-preuve des lignes IN_REPO ; doit égaler l'ensemble # gated (hors self). Un module gated non cité = promesse orpheline ; un # module cité non gated = preuve fantôme. cited: set[str] = set() for d in deliverables: if d.get("status") == "in_repo": cited.update(d.get("evidence_modules", [])) missing = sorted(gated_set - cited) # gated mais non tracé phantom = sorted(cited - gated_set) # tracé mais non gated bijective = not missing and not phantom # --- Partition par sprint -------------------------------------------- # Chaque livrable de sprint SX doit citer EXACTEMENT les modules gated dont # la fenêtre est SX (ni plus, ni moins). Prouve que la matrice répartit les # livraisons par sprint sans trou ni chevauchement. gated_by_sprint: dict[str, set] = {} unknown_sprint = sorted(m for m in gated if m not in ms_map) for m in gated: if m in ms_map: gated_by_sprint.setdefault(ms_map[m], set()).add(m) sprint_partition: list[dict] = [] for d in spec.get("sprint_deliverables", []): sx = d["sprint"] want = gated_by_sprint.get(sx, set()) have = set(d.get("evidence_modules", [])) sprint_partition.append({ "sprint": sx, "expected": sorted(want), "cited": sorted(have), "missing": sorted(want - have), "extra": sorted(have - want), "exact": want == have, }) partition_ok = all(p["exact"] for p in sprint_partition) and not unknown_sprint # --- Artefacts hors-CI (docs) ---------------------------------------- missing_artifacts = sorted( a["path"] for r in rows for a in r["artifacts"] if not a["exists"] ) # --- Complétude roadmap ---------------------------------------------- sprint_ids = [d["id"] for d in spec.get("sprint_deliverables", [])] metric_ids = [d["id"] for d in spec.get("mvp_metrics", [])] oos_count = sum(len(r["out_of_scope"]) for r in rows) in_repo = sum(1 for r in rows if r["status"] == "in_repo") out_of_scope_full = sum(1 for r in rows if r["status"] == "out_of_scope") manifest = { "generated_from": f"acceptance_spec.json v{spec.get('version')}", "reference_cadre": spec.get("reference_cadre"), "roadmap_ref": spec.get("roadmap_ref"), "cible_portage": spec.get("cible_portage"), "self_module": self_module, "counts": { "sprint_deliverables": len(sprint_ids), "mvp_metrics": len(metric_ids), "deliverables_total": len(deliverables), "in_repo": in_repo, "out_of_scope_full": out_of_scope_full, "gated_modules": len(gated), "cited_modules": len(cited), "out_of_scope_items": oos_count, }, "coverage": { "ci_modules_count": len(gated), "cited_modules_count": len(cited), "bijective": bijective, "missing_in_matrix": missing, "phantom_evidence": phantom, "partition_ok": partition_ok, "sprint_partition": sprint_partition, "unknown_sprint": unknown_sprint, "missing_artifacts": missing_artifacts, "self_module_excluded": self_module, }, "roadmap": { "sprint_ids": sprint_ids, "metric_ids": metric_ids, }, } verdict = bijective and partition_ok and not missing_artifacts return {"matrix": rows, "manifest": manifest, "verdict": verdict}