[DTP-Worker] Sprint 8 · Générateur Matrice d'acceptation / traçabilité MVP (15 promesses roadmap · 21 modules gated tracés bijectif · partition sprint exacte · 12 hors-périmètre sourcés) (QA · roadmap L75/L80-87)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code DTP Worker
2026-07-30 12:12:22 +00:00
parent 58ed555db0
commit 5d9ea5687d
22 changed files with 2281 additions and 19 deletions
@@ -0,0 +1,282 @@
#!/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.
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}.")
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) -> None:
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')}")
def cmd_validate(args) -> None:
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']}.")
def main(argv=None) -> None:
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)
args.func(args)
if __name__ == "__main__":
main()