#!/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())