[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:
Claude Code DTP Worker
2026-07-31 00:41:16 +00:00
parent 0eaf5bde39
commit 76fb0e768d
10 changed files with 399 additions and 56 deletions
+3 -2
View File
@@ -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.
+9 -3
View File
@@ -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)
# --------------------------------------------------------------------------