6b61ff8b68
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
332 lines
15 KiB
Python
332 lines
15 KiB
Python
#!/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())
|