[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>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
"""reglib · briques de la matrice de régression exhaustive (Sprint 8 · QA).
|
||||
|
||||
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.
|
||||
|
||||
deps → réutilise q4lib/registry.parse_ci + le validateur maison
|
||||
Publiciste (zéro pip · zéro duplication · #5)
|
||||
discovery → dérive les suites depuis le CI + faits de disque (fichiers /
|
||||
méthodes test_*) + preuve de couverture (disque + gate)
|
||||
runner → exécute une suite (subprocess unittest) + parseur PUR de la
|
||||
sortie unittest (Ran/OK/FAILED) → compteurs réels (#6)
|
||||
builder → assemble le plan déterministe (build) et la matrice live (run)
|
||||
"""
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Assemblage : plan déterministe (`build`) et matrice live (`run`).
|
||||
|
||||
Deux artefacts, deux natures :
|
||||
|
||||
build → PLAN (out/regression_plan.json) : recensement EXHAUSTIF et
|
||||
DÉTERMINISTE des suites gated (chemin, jobs, gate, fichiers/méthodes
|
||||
test_*). Aucun compteur de résultat → diffable, re-générable, commité.
|
||||
Verdict PASS ssi couverture prouvée + chaque suite ≥ planchers.
|
||||
|
||||
run → MATRICE live : exécute réellement chaque suite et agrège les compteurs
|
||||
(ran/passed/failures/errors/skipped) + verdict global. Non déterministe
|
||||
(dépend de la machine) → non commité (voir .gitignore), c'est la sortie
|
||||
qui fait autorité sur « N tests verts ».
|
||||
|
||||
Aucune date/horodatage → build reproductible (gate CI stable)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from . import discovery, runner
|
||||
from .deps import DELIVERABLES_ROOT
|
||||
|
||||
MATRIX_NAME = "OTO QA · Matrice de régression exhaustive"
|
||||
|
||||
|
||||
def build_plan(spec: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Plan déterministe des suites gated (sans exécution)."""
|
||||
suites = discovery.discover_suites(spec)
|
||||
coverage = discovery.coverage_report(spec, suites)
|
||||
th = spec["thresholds"]
|
||||
min_files = th["min_test_files"]
|
||||
min_methods = th["min_methods_per_suite"]
|
||||
|
||||
under = [
|
||||
s["path"] for s in suites
|
||||
if s["test_files"] < min_files or s["test_methods"] < min_methods
|
||||
]
|
||||
totals = {
|
||||
"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),
|
||||
}
|
||||
verdict = "PASS" if (coverage["ok"] and not under) else "FAIL"
|
||||
|
||||
return {
|
||||
"matrix": MATRIX_NAME,
|
||||
"version": spec["version"],
|
||||
"reference_cadre": spec["reference_cadre"],
|
||||
"self_module": spec["self_module"],
|
||||
"thresholds": {
|
||||
"min_test_files": min_files,
|
||||
"min_methods_per_suite": min_methods,
|
||||
},
|
||||
"discover_cmd": list(spec["discover_cmd"]),
|
||||
"coverage": coverage,
|
||||
"suites": suites,
|
||||
"under_threshold": under,
|
||||
"totals": totals,
|
||||
"verdict": verdict,
|
||||
"notes": spec.get("notes", []),
|
||||
}
|
||||
|
||||
|
||||
def run_matrix(spec: dict[str, Any], *, python: str | None = None) -> dict[str, Any]:
|
||||
"""Exécute réellement toutes les suites du plan et agrège les compteurs.
|
||||
|
||||
Non déterministe (temps, machine) → destiné à un artefact non commité."""
|
||||
suites = discovery.discover_suites(spec)
|
||||
discover_cmd = list(spec["discover_cmd"])
|
||||
# `python3 -m unittest ...` → on retire le binaire, on garde les args.
|
||||
cmd_args = discover_cmd[1:] if discover_cmd and discover_cmd[0].startswith("python") \
|
||||
else discover_cmd
|
||||
|
||||
results: list[dict] = []
|
||||
for s in suites:
|
||||
abs_path = os.path.join(DELIVERABLES_ROOT, s["path"])
|
||||
res = runner.run_suite(abs_path, python=python, discover_cmd=cmd_args)
|
||||
results.append({
|
||||
"id": s["id"],
|
||||
"path": s["path"],
|
||||
"expected_methods": s["test_methods"],
|
||||
**res,
|
||||
})
|
||||
|
||||
totals = {
|
||||
"suites": len(results),
|
||||
"green": sum(1 for r in results if r["ok"]),
|
||||
"red": sum(1 for r in results if not r["ok"]),
|
||||
"ran": sum(r["ran"] for r in results),
|
||||
"passed": sum(r["passed"] for r in results),
|
||||
"failures": sum(r["failures"] for r in results),
|
||||
"errors": sum(r["errors"] for r in results),
|
||||
"skipped": sum(r["skipped"] for r in results),
|
||||
}
|
||||
verdict = "PASS" if totals["red"] == 0 and totals["suites"] > 0 else "FAIL"
|
||||
return {
|
||||
"matrix": MATRIX_NAME,
|
||||
"version": spec["version"],
|
||||
"self_module": spec["self_module"],
|
||||
"suites": results,
|
||||
"totals": totals,
|
||||
"verdict": verdict,
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Réutilisation des briques déjà livrées (workflow #5 · zéro duplication).
|
||||
|
||||
Le harnais de régression ne redéfinit RIEN qui existe ailleurs :
|
||||
|
||||
- `parse_ci` : le parseur de `.gitea/workflows/ci.yml` de l'auditeur 4Big
|
||||
(`qa/audit_4big/q4lib/registry.py`) — la SEULE autorité sur les
|
||||
jobs de test réellement gated. On le réutilise pour DÉRIVER
|
||||
l'ensemble des suites de la matrice (jamais une liste à la main)
|
||||
et pour prouver que chaque suite alimente bien le job `gate`.
|
||||
- `validate` : le validateur JSON-Schema maison du Publiciste (draft-07,
|
||||
sous-ensemble) — le plan est validé SANS pip, car le gate CI
|
||||
Gitea Actions tourne sans réseau (CLAUDE.md #2).
|
||||
|
||||
Import par manipulation de `sys.path` (idiome des modules voisins :
|
||||
`q4lib/deps.py`, `scenlib/deps.py`) plutôt que duplication — une seule source de
|
||||
vérité pour tout le mandat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
# qa/regression/reglib → 05_deliverables_mvp
|
||||
_DELIVERABLES = os.path.normpath(os.path.join(_HERE, "..", "..", ".."))
|
||||
_PUB = os.path.join(_DELIVERABLES, "publiciste")
|
||||
_AUDIT = os.path.join(_DELIVERABLES, "qa", "audit_4big")
|
||||
|
||||
for _p in (_PUB, _AUDIT):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
from lib import validator # type: ignore # noqa: E402
|
||||
from q4lib import registry # type: ignore # noqa: E402
|
||||
|
||||
validate = validator.validate
|
||||
parse_ci = registry.parse_ci
|
||||
|
||||
# Racine des livrables, exposée aux autres modules reglib.
|
||||
DELIVERABLES_ROOT = _DELIVERABLES
|
||||
|
||||
__all__ = ["validate", "parse_ci", "DELIVERABLES_ROOT"]
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Découverte des suites de tests + preuve de couverture (faits, pas déclaration).
|
||||
|
||||
Les suites de la matrice sont DÉRIVÉES du CI Gitea (`parse_ci`), jamais listées à
|
||||
la main : le périmètre « exhaustif » est donc auto-prouvé — tout job de test
|
||||
ajouté au CI entre dans la matrice, toute suite retirée en sort. Pour chaque suite
|
||||
on lit ensuite des FAITS de disque (fichiers `test_*.py`, méthodes `def test_`) —
|
||||
aucun compteur de résultat n'est inventé ici (#6).
|
||||
|
||||
La couverture est PROUVÉE : chaque suite gated doit (a) exister sur disque avec un
|
||||
répertoire `tests/`, (b) alimenter le job d'agrégat `gate`. Le harnais lui-même
|
||||
(`self_module`) est exclu — séparation des pouvoirs (ISA 315), et il ne
|
||||
s'auto-exécute pas (évite la récursion).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from .deps import DELIVERABLES_ROOT, parse_ci
|
||||
|
||||
_TEST_FILE_RE = re.compile(r"^test_.*\.py$")
|
||||
_TEST_METHOD_RE = re.compile(r"^\s*def\s+(test_\w+)\s*\(")
|
||||
|
||||
|
||||
def _slug(path: str) -> str:
|
||||
"""Identifiant stable d'une suite depuis son chemin (déterministe)."""
|
||||
return path.replace("/", ".")
|
||||
|
||||
|
||||
def count_tests(tests_dir: str) -> tuple[int, int]:
|
||||
"""(nb fichiers test_*.py, nb méthodes def test_*) dans `tests_dir`.
|
||||
|
||||
Faits de disque purs — base du recensement exhaustif. Zéro exécution."""
|
||||
if not os.path.isdir(tests_dir):
|
||||
return (0, 0)
|
||||
files = 0
|
||||
methods = 0
|
||||
for name in os.listdir(tests_dir):
|
||||
if not _TEST_FILE_RE.match(name):
|
||||
continue
|
||||
files += 1
|
||||
with open(os.path.join(tests_dir, name), encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
if _TEST_METHOD_RE.match(line):
|
||||
methods += 1
|
||||
return (files, methods)
|
||||
|
||||
|
||||
def discover_suites(spec: dict) -> list[dict]:
|
||||
"""Liste ordonnée des suites gated (hors self), enrichie des faits de disque.
|
||||
|
||||
Ordonné par `path` → sortie déterministe et diffable."""
|
||||
self_module = spec["self_module"]
|
||||
ci = parse_ci()
|
||||
job_to_path = ci["job_to_path"]
|
||||
gate_needs = set(ci["gate_needs"])
|
||||
|
||||
path_to_jobs: dict[str, list[str]] = {}
|
||||
for job, path in job_to_path.items():
|
||||
path_to_jobs.setdefault(path, []).append(job)
|
||||
|
||||
suites: list[dict] = []
|
||||
for path in sorted(p for p in job_to_path.values() if p != self_module):
|
||||
jobs = sorted(path_to_jobs[path])
|
||||
abs_path = os.path.join(DELIVERABLES_ROOT, path)
|
||||
tests_dir = os.path.join(abs_path, "tests")
|
||||
files, methods = count_tests(tests_dir)
|
||||
suites.append({
|
||||
"id": _slug(path),
|
||||
"path": path,
|
||||
"jobs": jobs,
|
||||
"in_gate": all(j in gate_needs for j in jobs),
|
||||
"has_tests_dir": os.path.isdir(tests_dir),
|
||||
"test_files": files,
|
||||
"test_methods": methods,
|
||||
})
|
||||
return suites
|
||||
|
||||
|
||||
def coverage_report(spec: dict, suites: list[dict]) -> dict:
|
||||
"""Diagnostic de couverture sérialisable. `ok=True` ssi chaque suite gated a
|
||||
un répertoire `tests/` sur disque ET alimente le gate, et que le harnais
|
||||
lui-même est bien exclu du périmètre."""
|
||||
self_module = spec["self_module"]
|
||||
ci = parse_ci()
|
||||
ci_paths = set(ci["job_to_path"].values())
|
||||
|
||||
missing_tests_dir = sorted(s["path"] for s in suites if not s["has_tests_dir"])
|
||||
not_in_gate = sorted(s["path"] for s in suites if not s["in_gate"])
|
||||
self_present = any(s["path"] == self_module for s in suites)
|
||||
|
||||
ok = (
|
||||
not missing_tests_dir
|
||||
and not not_in_gate
|
||||
and not self_present
|
||||
and self_module in ci_paths # le harnais DOIT être gated lui aussi
|
||||
)
|
||||
return {
|
||||
"ok": ok,
|
||||
"suites_count": len(suites),
|
||||
"gated_in_ci": len(ci_paths),
|
||||
"self_module_excluded": self_module,
|
||||
"self_module_gated": self_module in ci_paths,
|
||||
"missing_tests_dir": missing_tests_dir,
|
||||
"not_in_gate": not_in_gate,
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Exécution réelle des suites + parseur PUR de la sortie unittest.
|
||||
|
||||
C'est le cœur de la régression EXÉCUTÉE (mode `run`) : on lance chaque suite dans
|
||||
un sous-processus isolé (`python -m unittest discover -s tests`, exactement comme
|
||||
le CI Gitea), on parse sa sortie réelle, et on agrège les compteurs. Aucun chiffre
|
||||
n'est fabriqué — tout provient de la sortie de unittest (#6 anti-invention).
|
||||
|
||||
Isolation par subprocess (et non import in-process) : les modules du mandat ont
|
||||
des fichiers `tests/test_*.py` aux noms qui se recouvrent → un discover global
|
||||
in-process provoquerait des collisions de modules. Le subprocess reproduit
|
||||
fidèlement l'environnement du CI.
|
||||
|
||||
`parse_unittest_output` est PUR (str → dict) donc testable sans exécuter quoi que
|
||||
ce soit — on lui injecte des sorties unittest synthétiques (OK / FAILED / skipped).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
_RAN_RE = None # compilé paresseusement pour rester importable sans `re` en tête
|
||||
|
||||
|
||||
def _res():
|
||||
import re
|
||||
return (
|
||||
re.compile(r"Ran (\d+) tests? in"),
|
||||
re.compile(r"failures=(\d+)"),
|
||||
re.compile(r"errors=(\d+)"),
|
||||
re.compile(r"skipped=(\d+)"),
|
||||
)
|
||||
|
||||
|
||||
def parse_unittest_output(text: str, returncode: int) -> dict:
|
||||
"""Extrait les compteurs de la sortie texte de unittest.
|
||||
|
||||
unittest écrit son résumé sur stderr :
|
||||
« Ran 12 tests in 0.03s » puis « OK » | « OK (skipped=2) »
|
||||
| « FAILED (failures=1, errors=2, skipped=3) ».
|
||||
`ok` reflète le code de retour du processus (source de vérité), pas un parse
|
||||
fragile du mot « OK »."""
|
||||
ran_re, fail_re, err_re, skip_re = _res()
|
||||
ran_m = ran_re.search(text)
|
||||
fail_m = fail_re.search(text)
|
||||
err_m = err_re.search(text)
|
||||
skip_m = skip_re.search(text)
|
||||
ran = int(ran_m.group(1)) if ran_m else 0
|
||||
failures = int(fail_m.group(1)) if fail_m else 0
|
||||
errors = int(err_m.group(1)) if err_m else 0
|
||||
skipped = int(skip_m.group(1)) if skip_m else 0
|
||||
passed = max(ran - failures - errors - skipped, 0)
|
||||
return {
|
||||
"ran": ran,
|
||||
"passed": passed,
|
||||
"failures": failures,
|
||||
"errors": errors,
|
||||
"skipped": skipped,
|
||||
"ok": returncode == 0 and ran > 0,
|
||||
"returncode": returncode,
|
||||
}
|
||||
|
||||
|
||||
def run_suite(abs_path: str, *, python: str | None = None,
|
||||
discover_cmd: list[str] | None = None,
|
||||
timeout: int = 600) -> dict:
|
||||
"""Exécute une suite dans son propre répertoire et renvoie ses compteurs.
|
||||
|
||||
`abs_path` = répertoire du module (contenant `tests/`). Aucune sortie n'est
|
||||
imprimée ici — l'agrégateur décide de l'affichage."""
|
||||
py = python or sys.executable
|
||||
cmd = list(discover_cmd or ["-m", "unittest", "discover", "-s", "tests"])
|
||||
tests_dir = os.path.join(abs_path, "tests")
|
||||
if not os.path.isdir(tests_dir):
|
||||
return {
|
||||
"ran": 0, "passed": 0, "failures": 0, "errors": 0, "skipped": 0,
|
||||
"ok": False, "returncode": -1, "note": "tests/ absent",
|
||||
}
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[py, *cmd], cwd=abs_path, capture_output=True, text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"ran": 0, "passed": 0, "failures": 0, "errors": 0, "skipped": 0,
|
||||
"ok": False, "returncode": -2, "note": f"timeout {timeout}s",
|
||||
}
|
||||
# unittest écrit son résumé sur stderr ; on garde stdout en secours.
|
||||
return parse_unittest_output((proc.stderr or "") + (proc.stdout or ""),
|
||||
proc.returncode)
|
||||
Reference in New Issue
Block a user