4b0752906d
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>
240 lines
9.8 KiB
Python
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())
|