#!/usr/bin/env python3 """Matrice d'acceptation / traçabilité MVP (Sprint 8 · QA · buffer L75). Document de RECETTE de MÉTA-NIVEAU : il mappe chaque PROMESSE de la roadmap (8 livrables de sprint L33..L76 + 7 métriques succès MVP L81..L87) vers sa PREUVE livrée (un module gated du CI) OU un hors-périmètre worker SOURCÉ. Il répond à la seule question de recette : « le MVP promis est-il honoré, et ce qui reste est-il explicitement hors périmètre worker ? » Axe distinct des autres harnais méta (non redondant · #5) : - qa/audit_4big note la QUALITÉ statique de chaque module ; - qa/regression prouve que chaque suite S'EXÉCUTE au vert ; - devops/deploy_runbook ORDONNE le déploiement VPS des modules ; - qa/acceptance (ici) prouve la COUVERTURE des promesses roadmap. Périmètre PROUVÉ, pas déclaré : l'ensemble des modules-preuve est confronté à `.gitea/workflows/ci.yml` (réutilise `q4lib.registry.parse_ci` — zéro duplication · #5) de façon BIJECTIVE, et la fenêtre de sprint de chaque module est LUE dans le registre de l'auditeur 4Big (anti-dérive). Tout module gated non tracé OU tout module tracé non gated → génération refusée. Anti-invention (#6) : les nombres des énoncés (<1h, 95/100, 7 dashboards, 2 stores) sont des CITATIONS verbatim de la roadmap ; la matrice ne PRÉTEND aucune mesure de performance ; tout hors-périmètre porte une source. Le n° de ligne (`roadmap_line`) de chaque promesse est VÉRIFIÉ (INV11) contre la structure réelle de la roadmap — il ne peut pas pointer à côté en silence si la roadmap est éditée. Produit : - acceptance_matrix.json → la matrice (une ligne par promesse : preuves résolues, hors-périmètre sourcé, drapeaux) + verdict. - MANIFEST.json → manifeste agrégé (comptes, preuve de couverture bijective vs CI, partition par sprint). Sous-commandes : build [-o OUT] → écrit acceptance_matrix.json / MANIFEST.json (après validation) validate [-o OUT] → (re)génère en mémoire, valide vs acceptance.schema.json + invariants — sort en erreur si un invariant casse. Sortie déterministe (ordre du spec, listes triées, aucun horodatage). """ from __future__ import annotations import argparse import json import os import sys _HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, _HERE) from acclib import builder, deps # noqa: E402 _SPEC_PATH = os.path.join(_HERE, "acceptance_spec.json") _SCHEMA_PATH = os.path.join(_HERE, "acceptance.schema.json") _DEFAULT_OUT = os.path.join(_HERE, "out") _STATUS = {"in_repo", "out_of_scope"} _KINDS = {"sprint_deliverable", "mvp_metric"} def _eprint(*args) -> None: print(*args, file=sys.stderr) def _load(path: str) -> dict: with open(path, encoding="utf-8") as fh: return json.load(fh) def _write_json(path: str, data) -> None: with open(path, "w", encoding="utf-8") as fh: json.dump(data, fh, ensure_ascii=False, indent=2) fh.write("\n") def _build_bundle() -> dict: """Charge le spec, lit la réalité du CI + le registre 4Big, assemble.""" spec = _load(_SPEC_PATH) ci = deps.parse_ci() audit = deps.audit_sprints() return builder.build_matrix(spec, ci, audit, deps) def validate_bundle(bundle: dict, spec: dict, ci: dict, audit: dict) -> list[str]: """Valide la matrice contre acceptance.schema.json + invariants de recette. Fonction pure sur (bundle, spec, ci, audit) → les tests injectent des dictionnaires synthétiques pour prouver le basculement en erreur. """ schema = _load(_SCHEMA_PATH) errors = list(deps.validate(bundle, schema)) matrix = bundle["matrix"] manifest = bundle["manifest"] cov = manifest["coverage"] self_module = spec["self_module"] deliverables = builder.all_deliverables(spec) # INV1 · ids uniques ; livrables de sprint = S1..S8 ordonnés ; métriques = # M1..M7 ordonnées (aucune promesse roadmap omise ni dupliquée). ids = [r["id"] for r in matrix] if len(set(ids)) != len(ids): errors.append(f"lignes: ids en double {ids}.") sprint_ids = [d["id"] for d in spec.get("sprint_deliverables", [])] metric_ids = [d["id"] for d in spec.get("mvp_metrics", [])] if sprint_ids != sorted(sprint_ids): errors.append(f"livrables de sprint non ordonnés {sprint_ids}.") if metric_ids != sorted(metric_ids): errors.append(f"métriques MVP non ordonnées {metric_ids}.") # INV2 · toute preuve module citée est gated (aucune preuve fantôme). On # RECALCULE l'ensemble gated depuis le CI (on ne fait pas confiance au # manifeste). gated = set(builder.gated_modules(ci, self_module)) for r in matrix: for m in r["modules"]: if m["module"] not in gated: errors.append(f"ligne {r['id']}: preuve non gated {m['module']!r}.") if m["gated"] != (m["module"] in gated): errors.append(f"ligne {r['id']}: drapeau `gated` faux pour {m['module']!r}.") # INV3 · COUVERTURE BIJECTIVE (cœur anti-invention · #6) : les modules cités # (lignes in_repo) == l'ensemble gated (hors self). Aucun oubli, aucune # invention. cited: set[str] = set() for d in deliverables: if d.get("status") == "in_repo": cited.update(d.get("evidence_modules", [])) missing = sorted(gated - cited) phantom = sorted(cited - gated) if missing: errors.append(f"couverture: modules gated non tracés {missing}.") if phantom: errors.append(f"couverture: preuves non gated {phantom}.") if not cov["bijective"] and not (missing or phantom): errors.append("couverture: `bijective=false` mais aucun écart recalculé.") if cov["bijective"] and (missing or phantom): errors.append("couverture: `bijective=true` mais écart recalculé présent.") # INV4 · la fenêtre de sprint de chaque module gated est connue (registre # 4Big + extra du spec) — aucun module gated sans sprint. ms_map = builder.module_sprint_map(spec, audit) unknown = sorted(m for m in gated if m not in ms_map) if unknown: errors.append(f"sprint: modules gated sans fenêtre {unknown}.") for path, meta in spec.get("extra_module_sprint", {}).items(): if path in audit and audit[path] != meta["sprint"]: errors.append( f"sprint: extra {path!r} en conflit avec le registre 4Big " f"({meta['sprint']} vs {audit[path]})." ) # INV5 · PARTITION EXACTE par sprint : chaque livrable de sprint SX cite # EXACTEMENT les modules gated de fenêtre SX (ni trou ni chevauchement). gated_by_sprint: dict[str, set] = {} for m in gated: if m in ms_map: gated_by_sprint.setdefault(ms_map[m], set()).add(m) for d in spec.get("sprint_deliverables", []): sx = d["sprint"] want = gated_by_sprint.get(sx, set()) have = set(d.get("evidence_modules", [])) if want != have: errors.append( f"partition {sx}: attendu {sorted(want)} ≠ cité {sorted(have)}." ) if cov["partition_ok"] != all(p["exact"] for p in cov["sprint_partition"]) or \ (cov["unknown_sprint"] and cov["partition_ok"]): errors.append("partition: drapeau `partition_ok` incohérent.") # INV6 · tout hors-périmètre porte une source non vide (aucune omission # silencieuse d'une promesse roadmap). for d in deliverables: for o in d.get("out_of_scope", []): if not o.get("source", "").strip(): errors.append(f"ligne {d['id']}: hors-périmètre sans source: {o.get('item')!r}.") # INV7 · cohérence statut/preuve : in_repo ⇒ ≥1 module OU ≥1 artefact ; # out_of_scope ⇒ 0 module + ≥1 hors-périmètre sourcé. Tout artefact cité # existe sur disque (fait · #6). for d in deliverables: st = d.get("status") if st not in _STATUS: errors.append(f"ligne {d['id']}: statut invalide {st!r}.") mods = d.get("evidence_modules", []) arts = d.get("evidence_artifacts", []) oos = d.get("out_of_scope", []) if st == "in_repo" and not mods and not arts: errors.append(f"ligne {d['id']}: in_repo sans aucune preuve.") if st == "out_of_scope": if mods: errors.append(f"ligne {d['id']}: out_of_scope avec preuve module {mods}.") if not oos: errors.append(f"ligne {d['id']}: out_of_scope sans hors-périmètre sourcé.") for a in arts: if not deps.artifact_exists(a): errors.append(f"ligne {d['id']}: artefact-preuve inexistant {a!r}.") # INV8 · kind cohérent : sprint_deliverable ⇒ sprint renseigné ; mvp_metric # ⇒ sprint null (les métriques transcendent les sprints). for d in deliverables: if d["kind"] not in _KINDS: errors.append(f"ligne {d['id']}: kind invalide {d['kind']!r}.") if d["kind"] == "sprint_deliverable" and not d.get("sprint"): errors.append(f"ligne {d['id']}: sprint_deliverable sans sprint.") if d["kind"] == "mvp_metric" and d.get("sprint") is not None: errors.append(f"ligne {d['id']}: mvp_metric avec sprint non nul {d.get('sprint')!r}.") # INV9 · le manifeste s'exclut lui-même (SoD · ISA 315) — la matrice ne se # cite jamais comme preuve. if self_module in cited: errors.append("SoD: la matrice se cite elle-même comme preuve.") if cov["self_module_excluded"] != self_module: errors.append("SoD: self_module_excluded divergent.") # INV10 · roadmap_line = entier positif ; pas de doublon de ligne. lines = [d["roadmap_line"] for d in deliverables] if any(not isinstance(x, int) or x < 1 for x in lines): errors.append(f"roadmap_line: valeur non positive {lines}.") # INV11 · ANCRAGE ROADMAP (traçabilité VÉRIFIÉE, pas déclarée · #6). INV10 ne # garantit que « roadmap_line est un entier » : un n° de ligne qui pointe à # côté (roadmap éditée, lignes décalées) reste « vert » alors qu'il cite un # bullet qui n'existe plus. On DÉRIVE la structure réelle de la roadmap et on # exige que chaque roadmap_line pointe RÉELLEMENT le bullet qu'il prétend : # · nombre de bullets DELIVERABLE == nombre de livrables de sprint du spec, # et nombre de bullets métrique == nombre de métriques (le « 8 + 7 » n'est # plus un compte figé mais dérivé du fichier roadmap) ; # · chaque livrable SX pointe le bullet **DELIVERABLE** du sprint SX ; # · chaque métrique (dans l'ordre) pointe le k-ième bullet « - ✅ ». try: anchors = deps.parse_roadmap_anchors(spec["roadmap_ref"]) except OSError as e: errors.append(f"roadmap: {spec.get('roadmap_ref')!r} illisible ({e}).") else: dmap = anchors["deliverables"] specd = spec.get("sprint_deliverables", []) specm = spec.get("mvp_metrics", []) if len(dmap) != len(specd): errors.append( f"roadmap: {len(dmap)} bullet(s) DELIVERABLE dans la roadmap ≠ " f"{len(specd)} livrable(s) de sprint du spec (promesse ajoutée/retirée non tracée).") if len(anchors["metrics"]) != len(specm): errors.append( f"roadmap: {len(anchors['metrics'])} bullet(s) métrique dans la roadmap ≠ " f"{len(specm)} métrique(s) du spec (promesse ajoutée/retirée non tracée).") for d in specd: want = dmap.get(d["sprint"]) if want is None: errors.append( f"roadmap: {d['id']}: aucun bullet DELIVERABLE pour le sprint {d['sprint']}.") elif d["roadmap_line"] != want: errors.append( f"roadmap: {d['id']}: roadmap_line={d['roadmap_line']} ne pointe pas " f"le DELIVERABLE de {d['sprint']} (ligne {want}).") for k, d in enumerate(specm): want = anchors["metrics"][k] if k < len(anchors["metrics"]) else None if want is None: errors.append( f"roadmap: {d['id']}: aucun {k + 1}e bullet métrique dans la roadmap.") elif d["roadmap_line"] != want: errors.append( f"roadmap: {d['id']}: roadmap_line={d['roadmap_line']} ≠ {k + 1}e bullet " f"métrique (ligne {want}).") return errors def _validate_or_die(bundle: dict) -> None: spec = _load(_SPEC_PATH) ci = deps.parse_ci() audit = deps.audit_sprints() errs = validate_bundle(bundle, spec, ci, audit) if errs: _eprint("❌ Validation échouée :") for e in errs: _eprint(f" · {e}") sys.exit(1) def cmd_build(args) -> int: bundle = _build_bundle() _validate_or_die(bundle) out_dir = args.out or _DEFAULT_OUT os.makedirs(out_dir, exist_ok=True) _write_json(os.path.join(out_dir, "acceptance_matrix.json"), {"matrix": bundle["matrix"], "verdict": bundle["verdict"]}) _write_json(os.path.join(out_dir, "MANIFEST.json"), bundle["manifest"]) cov = bundle["manifest"]["coverage"] c = bundle["manifest"]["counts"] verdict = "✅ RECETTE PASS" if bundle["verdict"] else "❌ RECETTE FAIL" print(f"{verdict} — {c['sprint_deliverables']} livrables de sprint + " f"{c['mvp_metrics']} métriques MVP · {c['gated_modules']} modules gated " f"tracés (bijectif={cov['bijective']}) · {c['out_of_scope_items']} " f"hors-périmètre sourcés.") print(f" → {os.path.join(out_dir, 'acceptance_matrix.json')}") print(f" → {os.path.join(out_dir, 'MANIFEST.json')}") return 0 def cmd_validate(args) -> int: bundle = _build_bundle() _validate_or_die(bundle) cov = bundle["manifest"]["coverage"] print(f"✅ Matrice valide — bijective={cov['bijective']} · " f"partition_ok={cov['partition_ok']} · verdict={bundle['verdict']}.") return 0 def main(argv=None) -> int: p = argparse.ArgumentParser( prog="acceptance_gen", description="Matrice d'acceptation / traçabilité MVP (roadmap → preuve).") sub = p.add_subparsers(dest="cmd", required=True) b = sub.add_parser("build", help="écrit acceptance_matrix.json / MANIFEST.json") b.add_argument("-o", "--out", help="répertoire de sortie (défaut: ./out)") b.set_defaults(func=cmd_build) v = sub.add_parser("validate", help="valide sans écrire") v.add_argument("-o", "--out", help="(ignoré · symétrie CLI)") v.set_defaults(func=cmd_validate) args = p.parse_args(argv) return args.func(args) if __name__ == "__main__": raise SystemExit(main())