[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:
Claude Code DTP Worker
2026-07-30 11:09:08 +00:00
parent cac65386ab
commit 4b0752906d
20 changed files with 1503 additions and 8 deletions
@@ -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)