Files
oto-enterprise-os-dtp/05_deliverables_mvp/qa/regression/regression_gen.py
T
Claude Code DTP Worker 4b0752906d [DTP-Worker] Sprint 8 · Générateur Matrice de régression exhaustive (19 suites · 474 tests · gate méta-niveau) (QA · roadmap L74)
Harnais méta-niveau : agrège l'exécution de toutes les suites gated en une
matrice + verdict PASS/FAIL et fournit le compte agrégé faisant autorité
(N tests verts). Périmètre dérivé du CI (réutilise q4lib/registry.parse_ci ·
zéro duplication) ; anti-invention (#6) : le plan ne contient aucun compteur de
résultat, recomputé à la validation. Enregistré dans l'audit 4Big (18→19
modules · PASS 19/19). run exhaustif : 19/19 suites vertes · 474 tests passés.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 11:09:08 +00:00

240 lines
9.8 KiB
Python

#!/usr/bin/env python3
"""Générateur de la matrice de régression exhaustive · Sprint 8 · QA.
Roadmap Sprint 8 · QA : « Regression tests exhaustifs ». Harnais de MÉTA-NIVEAU :
il agrège l'exécution de TOUTES les suites de tests gated du mandat en une matrice
unique + un verdict PASS/FAIL, et fournit le compte agrégé faisant autorité
(« N tests verts ») — celui que les rapports citaient jusqu'ici à la main.
Le périmètre « exhaustif » est PROUVÉ, pas déclaré : les suites sont DÉRIVÉES de
`.gitea/workflows/ci.yml` (réutilise `q4lib/registry.parse_ci` · zéro duplication
· #5), jamais listées à la main. Le harnais s'exclut lui-même (séparation des
pouvoirs · ISA 315 · évite la récursion).
ANTI-INVENTION (#6) : le `build` (plan) ne contient AUCUN compteur de résultat —
seuls des faits de disque (fichiers/méthodes `test_*`) et de CI (job, gate). Les
compteurs verts/rouges sont produits UNIQUEMENT par `run`, en parsant la sortie
réelle de unittest.
Sous-commandes :
build [-o OUT] → écrit regression_plan.json + MANIFEST.json (déterministe)
validate → (re)génère le plan en mémoire, valide schéma + invariants ;
sort en erreur si la couverture n'est pas prouvée ou si une
suite passe sous les planchers structurels.
run [-o OUT] → exécute réellement toutes les suites, agrège la matrice
live + verdict ; sort en erreur (code ≠ 0) si une suite est
rouge. Sortie NON déterministe → non commitée.
Le `build`/`validate` est déterministe (tri stable, aucun horodatage) → diffable.
"""
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 reglib import builder, discovery # noqa: E402
from reglib.deps import validate as maison_validate # noqa: E402
_SPEC_PATH = os.path.join(_HERE, "regression_spec.json")
_SCHEMA_PATH = os.path.join(_HERE, "regression.schema.json")
_DEFAULT_OUT = os.path.join(_HERE, "out")
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 _manifest(spec: dict, plan: dict) -> dict:
return {
"generated_from": "regression_spec.json",
"generator": "regression_gen.py",
"matrix": plan["matrix"],
"version": plan["version"],
"verdict": plan["verdict"],
"suites": plan["totals"]["suites"],
"test_methods": plan["totals"]["test_methods"],
"coverage_ok": plan["coverage"]["ok"],
"artifacts": ["regression_plan.json"],
"roadmap": "Sprint 8 · QA · Regression tests exhaustifs",
"hors_perimetre_worker": (
"Exécution planifiée de `run` sur le runner CI/VPS + publication du "
"compte agrégé dans le desk ERPNext / le pipeline de release → agent "
"QA / DevOps (#8). Le mode `run` reste manuel (non déterministe)."
),
}
def check_invariants(plan: dict, spec: dict) -> list[str]:
"""Invariants de cohérence de la matrice. Retourne la liste des violations."""
errs: list[str] = []
# 1. Schéma de sortie.
schema = _load(_SCHEMA_PATH)
errs += [f"schema: {e}" for e in maison_validate(plan, schema)]
# 2. Anti-invention : le plan ne DOIT contenir aucun compteur de résultat.
# (verdict vert/rouge, tests passés) — ceux-ci n'existent qu'en mode run.
forbidden = {"green", "red", "passed", "ran", "failures", "errors"}
leaked = forbidden & set(plan["totals"].keys())
if leaked:
errs.append(f"INV2 compteurs de résultat dans le plan (invention ?) : "
f"{sorted(leaked)}")
# 3. Le harnais ne s'auto-recense pas (séparation des pouvoirs · récursion).
self_mod = spec["self_module"]
if any(s["path"] == self_mod for s in plan["suites"]):
errs.append(f"INV3 harnais {self_mod} présent dans les suites exécutées")
# 4. Couverture prouvée (disque + gate + self gated + self exclu).
cov = plan["coverage"]
if not cov["ok"]:
errs.append(
f"INV4 couverture non prouvée : missing_tests_dir="
f"{cov['missing_tests_dir']} not_in_gate={cov['not_in_gate']} "
f"self_module_gated={cov['self_module_gated']}")
if cov["self_module_excluded"] != self_mod:
errs.append("INV4 self_module_excluded incohérent avec le spec")
# 5. Recompute des suites depuis le disque (aucun compte figé/fabriqué).
fresh = discovery.discover_suites(spec)
if [s["id"] for s in fresh] != [s["id"] for s in plan["suites"]]:
errs.append("INV5 ensemble/ordre des suites non reproductible")
fresh_by_id = {s["id"]: s for s in fresh}
for s in plan["suites"]:
f = fresh_by_id.get(s["id"])
if f and (f["test_files"], f["test_methods"]) != (
s["test_files"], s["test_methods"]):
errs.append(f"INV5 {s['id']} comptes disque {s['test_files']}/"
f"{s['test_methods']} ≠ recomputés {f['test_files']}/"
f"{f['test_methods']} (compte figé ?)")
# 6. Planchers structurels : chaque suite ≥ seuils, cohérent avec la liste.
th = spec["thresholds"]
under = [s["path"] for s in plan["suites"]
if s["test_files"] < th["min_test_files"]
or s["test_methods"] < th["min_methods_per_suite"]]
if under != plan["under_threshold"]:
errs.append(f"INV6 under_threshold {plan['under_threshold']}"
f"recomputé {under}")
# 7. Totaux cohérents (recomputés depuis les suites).
t = plan["totals"]
suites = plan["suites"]
exp = {
"suites": len(suites),
"test_files": sum(s["test_files"] for s in suites),
"test_methods": sum(s["test_methods"] for s in suites),
"min_methods": min((s["test_methods"] for s in suites), default=0),
"under_threshold": len(under),
}
for k, v in exp.items():
if t.get(k) != v:
errs.append(f"INV7 totals[{k}]={t.get(k)} ≠ recomputé {v}")
# 8. Verdict global cohérent (GATE : couverture prouvée ET zéro sous-plancher).
expect = "PASS" if (cov["ok"] and not under) else "FAIL"
if plan["verdict"] != expect:
errs.append(f"INV8 verdict {plan['verdict']} ≠ attendu {expect}")
return errs
def cmd_build(args: argparse.Namespace) -> int:
spec = _load(_SPEC_PATH)
plan = builder.build_plan(spec)
errs = check_invariants(plan, spec)
if errs:
_eprint("❌ Invariants violés — build refusé :")
for e in errs:
_eprint(f" · {e}")
return 1
out_dir = args.out or _DEFAULT_OUT
os.makedirs(out_dir, exist_ok=True)
_write_json(os.path.join(out_dir, "regression_plan.json"), plan)
_write_json(os.path.join(out_dir, "MANIFEST.json"), _manifest(spec, plan))
print(f"✅ Plan de régression écrit dans {out_dir}/ — verdict "
f"{plan['verdict']} · {plan['totals']['suites']} suites · "
f"{plan['totals']['test_methods']} méthodes test_* recensées.")
return 0
def cmd_validate(args: argparse.Namespace) -> int:
spec = _load(_SPEC_PATH)
plan = builder.build_plan(spec)
errs = check_invariants(plan, spec)
if errs:
_eprint("❌ Validation échouée :")
for e in errs:
_eprint(f" · {e}")
return 1
if plan["verdict"] != "PASS":
_eprint(f"❌ Verdict régression = {plan['verdict']} "
f"(couverture={plan['coverage']['ok']} · "
f"sous-plancher={plan['under_threshold']})")
return 1
print(f"✅ Matrice de régression valide — {plan['totals']['suites']} suites "
f"gated · {plan['totals']['test_methods']} méthodes recensées · "
f"couverture prouvée.")
return 0
def cmd_run(args: argparse.Namespace) -> int:
spec = _load(_SPEC_PATH)
print("▶ Exécution exhaustive des suites gated (peut prendre un moment)…",
file=sys.stderr)
matrix = builder.run_matrix(spec)
out_dir = args.out or _DEFAULT_OUT
os.makedirs(out_dir, exist_ok=True)
_write_json(os.path.join(out_dir, "regression_run.json"), matrix)
t = matrix["totals"]
for r in matrix["suites"]:
mark = "" if r["ok"] else ""
print(f" {mark} {r['path']:32s} ran={r['ran']:3d} "
f"fail={r['failures']} err={r['errors']} skip={r['skipped']}")
print(f"\n{'' if matrix['verdict'] == 'PASS' else ''} Régression "
f"{matrix['verdict']}{t['green']}/{t['suites']} suites vertes · "
f"{t['passed']} tests passés · {t['failures']} échecs · "
f"{t['errors']} erreurs.")
return 0 if matrix["verdict"] == "PASS" else 1
def build_parser() -> argparse.ArgumentParser:
ap = argparse.ArgumentParser(description="Matrice de régression exhaustive "
"(Sprint 8 · QA).")
sub = ap.add_subparsers(dest="cmd", required=True)
b = sub.add_parser("build", help="écrit le plan déterministe + MANIFEST")
b.add_argument("-o", "--out", help="répertoire de sortie")
b.set_defaults(func=cmd_build)
v = sub.add_parser("validate", help="valide schéma + invariants (gate)")
v.set_defaults(func=cmd_validate)
r = sub.add_parser("run", help="exécute réellement toutes les suites (live)")
r.add_argument("-o", "--out", help="répertoire de sortie")
r.set_defaults(func=cmd_run)
return ap
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
return args.func(args)
if __name__ == "__main__":
raise SystemExit(main())