Files

190 lines
7.7 KiB
Python

"""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}