[DTP-Worker] Sprint 8 · buffer L75 · Intégrité matrice régression : fix gate check_regression cassé en CI (run baseline commité) + invariant disque→CI (INV4 orphan_tests_dirs)
Défaut #1 (bug CI réel) : ci/check_regression.sh exigeait regression_run.json commité comme baseline, mais le fichier était .gitignore → absent en checkout propre → le job Gitea check-regression échouait sur CHAQUE run CI (ne passait qu'en local via un fichier non suivi). L'artefact run est byte-déterministe (aucun horodatage/hôte/durée · path relatif) → committer est licite. Fix : run désormais commité (baseline), .gitignore + README réécrits. Défaut #2 (invariant manquant) : coverage_report prouvait CI→disque mais pas l'inverse. Nouveau disk_test_modules() + orphan_tests_dirs dans INV4 : un module gagnant un tests/ sans job CI fait chuter la couverture (fini la sous-comptée silencieuse de la matrice). Schéma + 2 tests de morsure. Consommateur régénéré : qa/audit_4big/quality_report.json (README 3710→4236 o, 24→26 méthodes). Matrice 534/21/PASS inchangée (harnais self-exclu, INV3). 5 gates verts · 26+34 tests OK · YAML valide.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
# Sortie du mode `run` : non déterministe (temps machine) → jamais commitée.
|
||||
out/regression_run.json
|
||||
# NB : `out/regression_run.json` (mode `run`) EST commité — il est byte-déterministe
|
||||
# (aucun horodatage/hôte/durée ; `path` relatif) et sert de baseline au gate CI
|
||||
# `ci/check_regression.sh`, qui exige qu'un run frais lui soit byte-identique.
|
||||
|
||||
@@ -47,12 +47,18 @@ python3 regression_gen.py build # → out/regression_plan.json + MANI
|
||||
python3 regression_gen.py validate
|
||||
|
||||
# Régression EXHAUSTIVE (exécute réellement toutes les suites) — local/DevOps :
|
||||
python3 regression_gen.py run # → out/regression_run.json (non commité)
|
||||
python3 regression_gen.py run # → out/regression_run.json (COMMITÉ)
|
||||
```
|
||||
|
||||
`run` renvoie un code de sortie ≠ 0 si **une seule** suite est rouge → utilisable
|
||||
comme garde de release. Sa sortie est **non déterministe** (temps machine) donc
|
||||
**non commitée** (voir `.gitignore`).
|
||||
comme garde de release. Sa sortie est **byte-déterministe** — elle ne porte
|
||||
**aucun** horodatage / hôte / durée / chemin absolu (`path` relatif, compteurs
|
||||
`ran/passed/failures` seuls) : deux `run` successifs sont **byte-identiques**.
|
||||
`out/regression_run.json` est donc **commité** et sert de **baseline** au gate CI
|
||||
`ci/check_regression.sh`, qui régénère un `run` frais et exige l'**identité
|
||||
byte-for-byte** avec le fichier commité (+ verdict `PASS`) — c'est ce qui empêche
|
||||
une matrice périmée ou rouge d'être commitée verte. `check_artifacts.sh`, lui, ne
|
||||
rejoue que `build` (déterministe) et **ignore** cet artefact d'exécution.
|
||||
|
||||
## Contrat de sortie (plan)
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"self_module_excluded": "qa/regression",
|
||||
"self_module_gated": true,
|
||||
"missing_tests_dir": [],
|
||||
"not_in_gate": []
|
||||
"not_in_gate": [],
|
||||
"orphan_tests_dirs": []
|
||||
},
|
||||
"suites": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
{
|
||||
"matrix": "OTO QA · Matrice de régression exhaustive",
|
||||
"version": "1.0",
|
||||
"self_module": "qa/regression",
|
||||
"suites": [
|
||||
{
|
||||
"id": "crm.commissions",
|
||||
"path": "crm/commissions",
|
||||
"expected_methods": 25,
|
||||
"ran": 25,
|
||||
"passed": 25,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "crm.dossier_vente",
|
||||
"path": "crm/dossier_vente",
|
||||
"expected_methods": 31,
|
||||
"ran": 31,
|
||||
"passed": 31,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "crm.workflow_vente",
|
||||
"path": "crm/workflow_vente",
|
||||
"expected_methods": 25,
|
||||
"ran": 25,
|
||||
"passed": 25,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "demo.scenarios",
|
||||
"path": "demo/scenarios",
|
||||
"expected_methods": 32,
|
||||
"ran": 32,
|
||||
"passed": 32,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "devops.deploy_runbook",
|
||||
"path": "devops/deploy_runbook",
|
||||
"expected_methods": 29,
|
||||
"ran": 29,
|
||||
"passed": 29,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "faisabilite.bancable",
|
||||
"path": "faisabilite/bancable",
|
||||
"expected_methods": 22,
|
||||
"ran": 22,
|
||||
"passed": 22,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "faisabilite.generator",
|
||||
"path": "faisabilite/generator",
|
||||
"expected_methods": 16,
|
||||
"ran": 16,
|
||||
"passed": 16,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "fiscal.ecf_dgii",
|
||||
"path": "fiscal/ecf_dgii",
|
||||
"expected_methods": 39,
|
||||
"ran": 39,
|
||||
"passed": 39,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "frontend.chat_otoia",
|
||||
"path": "frontend/chat_otoia",
|
||||
"expected_methods": 31,
|
||||
"ran": 31,
|
||||
"passed": 31,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "frontend.portails",
|
||||
"path": "frontend/portails",
|
||||
"expected_methods": 19,
|
||||
"ran": 19,
|
||||
"passed": 19,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "legal.confotur",
|
||||
"path": "legal/confotur",
|
||||
"expected_methods": 44,
|
||||
"ran": 44,
|
||||
"passed": 44,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "publiciste",
|
||||
"path": "publiciste",
|
||||
"expected_methods": 23,
|
||||
"ran": 23,
|
||||
"passed": 23,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "qa.acceptance",
|
||||
"path": "qa/acceptance",
|
||||
"expected_methods": 31,
|
||||
"ran": 31,
|
||||
"passed": 31,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "qa.audit_4big",
|
||||
"path": "qa/audit_4big",
|
||||
"expected_methods": 34,
|
||||
"ran": 34,
|
||||
"passed": 34,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "qa.audit_5d",
|
||||
"path": "qa/audit_5d",
|
||||
"expected_methods": 37,
|
||||
"ran": 37,
|
||||
"passed": 37,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "rbac",
|
||||
"path": "rbac",
|
||||
"expected_methods": 10,
|
||||
"ran": 10,
|
||||
"passed": 10,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "rbac.apply_plan",
|
||||
"path": "rbac/apply_plan",
|
||||
"expected_methods": 16,
|
||||
"ran": 16,
|
||||
"passed": 16,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "rbac.fixtures_gen",
|
||||
"path": "rbac/fixtures_gen",
|
||||
"expected_methods": 11,
|
||||
"ran": 11,
|
||||
"passed": 11,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "rbac.roleprofile_gen",
|
||||
"path": "rbac/roleprofile_gen",
|
||||
"expected_methods": 11,
|
||||
"ran": 11,
|
||||
"passed": 11,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "rbac.userperm_gen",
|
||||
"path": "rbac/userperm_gen",
|
||||
"expected_methods": 12,
|
||||
"ran": 12,
|
||||
"passed": 12,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
},
|
||||
{
|
||||
"id": "seo",
|
||||
"path": "seo",
|
||||
"expected_methods": 36,
|
||||
"ran": 36,
|
||||
"passed": 36,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0,
|
||||
"ok": true,
|
||||
"returncode": 0
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"suites": 21,
|
||||
"green": 21,
|
||||
"red": 0,
|
||||
"ran": 534,
|
||||
"passed": 534,
|
||||
"failures": 0,
|
||||
"errors": 0,
|
||||
"skipped": 0
|
||||
},
|
||||
"verdict": "PASS"
|
||||
}
|
||||
@@ -47,6 +47,25 @@ def count_tests(tests_dir: str) -> tuple[int, int]:
|
||||
return (files, methods)
|
||||
|
||||
|
||||
def disk_test_modules() -> list[str]:
|
||||
"""Chemins de modules (relatifs à DELIVERABLES_ROOT, POSIX) portant un
|
||||
répertoire `tests/` sur DISQUE — indépendamment du CI.
|
||||
|
||||
Sert à prouver le sens INVERSE de la couverture : tout module qui gagne des
|
||||
tests sur disque DOIT être câblé au CI, sinon ses tests ne tournent jamais et
|
||||
n'entrent jamais dans la matrice (dérive silencieuse disque→CI). Faits de
|
||||
disque purs, ordonnés → déterministe."""
|
||||
mods: set[str] = set()
|
||||
for dirpath, dirnames, _ in os.walk(DELIVERABLES_ROOT):
|
||||
# On n'explore pas l'intérieur des caches/tests eux-mêmes.
|
||||
dirnames[:] = [d for d in dirnames if d != "__pycache__"]
|
||||
if os.path.basename(dirpath) == "tests":
|
||||
module_abs = os.path.dirname(dirpath)
|
||||
rel = os.path.relpath(module_abs, DELIVERABLES_ROOT)
|
||||
mods.add(rel.replace(os.sep, "/"))
|
||||
return sorted(mods)
|
||||
|
||||
|
||||
def discover_suites(spec: dict) -> list[dict]:
|
||||
"""Liste ordonnée des suites gated (hors self), enrichie des faits de disque.
|
||||
|
||||
@@ -90,10 +109,15 @@ def coverage_report(spec: dict, suites: list[dict]) -> dict:
|
||||
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)
|
||||
|
||||
# Sens inverse : tout module portant un `tests/` sur disque DOIT être gated
|
||||
# (sinon ses tests ne tournent jamais → dérive silencieuse disque→CI).
|
||||
orphan_tests_dirs = sorted(set(disk_test_modules()) - ci_paths)
|
||||
|
||||
ok = (
|
||||
not missing_tests_dir
|
||||
and not not_in_gate
|
||||
and not self_present
|
||||
and not orphan_tests_dirs
|
||||
and self_module in ci_paths # le harnais DOIT être gated lui aussi
|
||||
)
|
||||
return {
|
||||
@@ -104,4 +128,5 @@ def coverage_report(spec: dict, suites: list[dict]) -> dict:
|
||||
"self_module_gated": self_module in ci_paths,
|
||||
"missing_tests_dir": missing_tests_dir,
|
||||
"not_in_gate": not_in_gate,
|
||||
"orphan_tests_dirs": orphan_tests_dirs,
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"coverage": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["ok", "suites_count", "gated_in_ci", "self_module_excluded", "self_module_gated", "missing_tests_dir", "not_in_gate"],
|
||||
"required": ["ok", "suites_count", "gated_in_ci", "self_module_excluded", "self_module_gated", "missing_tests_dir", "not_in_gate", "orphan_tests_dirs"],
|
||||
"properties": {
|
||||
"ok": { "type": "boolean" },
|
||||
"suites_count": { "type": "integer", "minimum": 0 },
|
||||
@@ -36,7 +36,8 @@
|
||||
"self_module_excluded": { "type": "string", "minLength": 1 },
|
||||
"self_module_gated": { "type": "boolean" },
|
||||
"missing_tests_dir": { "type": "array", "items": { "type": "string", "minLength": 1 } },
|
||||
"not_in_gate": { "type": "array", "items": { "type": "string", "minLength": 1 } }
|
||||
"not_in_gate": { "type": "array", "items": { "type": "string", "minLength": 1 } },
|
||||
"orphan_tests_dirs": { "type": "array", "items": { "type": "string", "minLength": 1 } }
|
||||
}
|
||||
},
|
||||
"suites": {
|
||||
|
||||
@@ -108,6 +108,7 @@ def check_invariants(plan: dict, spec: dict) -> list[str]:
|
||||
errs.append(
|
||||
f"INV4 couverture non prouvée : missing_tests_dir="
|
||||
f"{cov['missing_tests_dir']} not_in_gate={cov['not_in_gate']} "
|
||||
f"orphan_tests_dirs={cov['orphan_tests_dirs']} "
|
||||
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")
|
||||
|
||||
@@ -154,6 +154,28 @@ class TestDiscovery(unittest.TestCase):
|
||||
self.assertTrue(cov["self_module_gated"]) # le harnais est gated
|
||||
self.assertEqual(cov["missing_tests_dir"], [])
|
||||
self.assertEqual(cov["not_in_gate"], [])
|
||||
self.assertEqual(cov["orphan_tests_dirs"], []) # aucun tests/ hors CI
|
||||
|
||||
def test_disk_test_modules_subset_of_ci(self):
|
||||
# Sens inverse de la couverture : tout `tests/` sur disque est gated.
|
||||
disk = set(discovery.disk_test_modules())
|
||||
ci = set(discovery.parse_ci()["job_to_path"].values())
|
||||
self.assertIn(_spec()["self_module"], disk) # le harnais a des tests
|
||||
self.assertEqual(disk - ci, set(), f"orphelins disque→CI : {disk - ci}")
|
||||
|
||||
def test_orphan_tests_dir_detected(self):
|
||||
# Preuve de morsure : un module portant un `tests/` non câblé au CI doit
|
||||
# faire chuter la couverture (dérive silencieuse disque→CI interdite).
|
||||
spec = _spec()
|
||||
suites = discovery.discover_suites(spec)
|
||||
real = discovery.disk_test_modules
|
||||
try:
|
||||
discovery.disk_test_modules = lambda: real() + ["orphan/module"]
|
||||
cov = discovery.coverage_report(spec, suites)
|
||||
finally:
|
||||
discovery.disk_test_modules = real
|
||||
self.assertIn("orphan/module", cov["orphan_tests_dirs"])
|
||||
self.assertFalse(cov["ok"], cov)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user