[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
+62 -46
View File
@@ -1,60 +1,76 @@
# Activity Log · 2026-07-31 · Claude Code DTP # Activity Log · 2026-07-31 · Claude Code DTP
## Session `20260730_235919` · Buffer S8 · Gate fraîcheur matrice de régression (5ᵉ gate CI) ## Session `20260731_002924` · Buffer S8 · Intégrité de la matrice de régression (bug CI réel + invariant disque→CI)
**Tâche** : **Sprint 8 · buffer L75 (« Regression tests exhaustifs » · DevOps **Tâche** : **Sprint 8 · buffer L75 (« Regression tests exhaustifs » · DevOps
CI/CD)** — roadmap fonctionnellement close (21/21 modules gated à 100/100, 534 CI/CD)** — roadmap fonctionnellement close (21/21 modules gated à 100/100, 534
tests verts, chantier cross-linking clos). Recherche d'une **incohérence de tests verts, 5 gates statiques). Recherche d'une **incohérence de reproductibilité
reproductibilité réelle** plutôt qu'un correctif doc cosmétique. réelle** plutôt qu'un correctif doc cosmétique. **Deux** défauts réels trouvés et
corrigés, tous deux dans `qa/regression`.
**Gap comblé (réel, sourcé)** : l'artefact le plus **cité** de tout le dépôt — ### Défaut #1 (PRIORITAIRE · bug CI réel) — le gate `check-regression` était cassé en checkout propre
`qa/regression/out/regression_run.json` (« **21 suites · 534 tests · verdict
PASS** », référencé dans des dizaines d'entrées de log, README d'agents et fiches)
— n'avait **aucun garde-fou CI** prouvant qu'il reflète le disque :
- `check_artifacts.sh` ne rejoue **que** la sous-commande déterministe `build`
(→ `regression_plan.json`) et **exclut explicitement** (l.20-22) les artefacts
d'**exécution** issus de `run` ;
- le job `qa-regression-tests` ne lance que les **25 tests unitaires du harnais**
(`unittest discover -s tests`), jamais `regression_gen.py run` complet ;
- résultat : la matrice agrégée pouvait se **périmer en silence** (module + job CI
ajoutés sans régénérer → « 21 suites » faux) ou une matrice **rouge être commitée
verte**. C'est exactement la dérive **« demo 18→21 »** corrigée à la main au commit
précédent, mais côté `run`.
**Vérif de faisabilité avant d'agir (#1 analyser)** : `regression_run.json` **ne - **Symptôme** : `ci/check_regression.sh` (l.52-53) **exige** que
contient aucun horodatage/hôte/chemin absolu** — deux `run` successifs sont `qa/regression/out/regression_run.json` **existe dans le dépôt**, puis compare un
**byte-identiques** (vérifié) et le résumé **matche le commité**. Le run complet `run` frais à ce fichier commité (baseline byte-identique).
prend **~5,3 s** (stdlib pur). L'égalité exacte est donc licite et bon marché. - **Contradiction** : ce même fichier était **`.gitignore`é** (« non déterministe
(temps machine) → jamais commitée ») → **jamais présent dans un checkout propre**.
Le job Gitea `check-regression` ne fait que `checkout` + `bash check_regression.sh`,
**aucune étape ne génère** le fichier. **Conséquence** : le gate **échouait sur
CHAQUE run CI propre** (`artefact absent du dépôt`). Il ne « passait » qu'en local,
par la présence d'un `regression_run.json` **non suivi** laissé par un `run` manuel.
Le commentaire de `ci.yml` disait déjà « le regression_run.json **commité** » —
intention claire du commit précédent, jamais concrétisée (fichier resté ignoré).
- **Vérif de faisabilité (#1 analyser)** : l'artefact `run` est **byte-déterministe**
clés `{ran,passed,failures,...}` + `path` **relatif**, **aucun** horodatage/hôte/
durée. Deux `run` successifs **byte-identiques** (vérifié). Le committer est donc
**licite** et complète l'intention du gate.
- **Fix** : `run` est désormais **commité** (baseline du gate) →
- `.gitignore` : retrait de `out/regression_run.json` + note expliquant qu'il est
commité *parce que* déterministe ;
- `README.md` : section `run` réécrite (byte-déterministe · commité · baseline
`check_regression` · distinction d'avec `check_artifacts` qui ne rejoue que
`build`) ;
- `out/regression_run.json` : **ajouté au suivi** (21 suites · 534 tests · PASS).
- **Preuve de morsure** : fichier retiré (simule checkout propre) ⇒ gate **exit 1**
(`artefact absent`) — l'état exact en CI ; restauré ⇒ **exit 0** (`run == commité`).
**Fichiers** (3 · aucun code métier / chiffre touché) : ### Défaut #2 (invariant manquant) — dérive silencieuse disque→CI non gardée
- **`ci/check_regression.sh`** (nouveau, exécutable) : rejoue `regression_gen.py
run` vers un tmp, exige `regression_run.json` **byte-identique** au commité, puis
`verdict == PASS` ; sur dérive, imprime le **diff des `totals`** et l'ordre de
régénérer. Complément direct de `check_artifacts` (documenté dans l'en-tête).
- **`.gitea/workflows/ci.yml`** : + job **`check-regression`** (5ᵉ gate statique,
en-tête explicatif) + ajout dans les `needs` du job `gate` agrégat.
- **`ci/README.md`** : ligne du tableau des jobs, section `check_regression.sh`,
commande d'exécution locale, checklist « 4 jobs → 5 gates statiques », et note du
§`check_artifacts` renvoyant désormais à `check-regression` pour l'artefact `run`.
**Anti-invention (#6)** : rien inventé — le gate **régénère** la vérité au lieu de - **Gap** : `discovery.coverage_report` prouvait le sens **CI→disque** (tout module
figer un compteur. Les nombres du log (« 534 / 21 / PASS ») sont **relus** du run gated a un `tests/`) mais **pas l'inverse** : un module gagnant un `tests/` sur
frais faisant autorité. disque **sans job CI** n'entrait jamais dans la matrice, ses tests **ne tournaient
jamais**, et la couverture ne le voyait pas (elle n'itère que les suites dérivées du
CI) — la matrice restait « PASS » en **sous-comptant** en silence. Même classe de
dérive que « demo 18→21 », direction opposée.
- **Fix** : nouvel invariant **INV4**
- `discovery.disk_test_modules()` : marche disque pure listant tout module portant
un `tests/` (POSIX, trié, déterministe) ;
- `coverage_report` : champ `orphan_tests_dirs = disk ci_paths` ; `ok` exige
désormais `not orphan_tests_dirs` ;
- `regression.schema.json` : `orphan_tests_dirs` ajouté (required · strict) ;
- `regression_gen.py` : message INV4 surface les orphelins ;
- `tests/test_regression.py` : +2 tests (sous-ensemble disque⊆CI sur le dépôt réel ;
preuve de morsure monkeypatch d'un orphelin ⇒ `ok=False`).
- **Preuve de morsure end-to-end** : `mkdir zz_orphan/tests` + `build` ⇒ **build
refusé** `INV4 … orphan_tests_dirs=['zz_orphan']` ; répertoire retiré ⇒ build OK.
**Non-régression du reste du système (vérifié)** : le nouveau job **n'a pas de **Anti-invention (#6)** : rien inventé — les deux gates **régénèrent** la vérité (run
`working-directory`** → `q4lib.registry.parse_ci` (qui recense les modules depuis frais / couverture recomputée) au lieu de figer un compteur.
les `working-directory`, réutilisé par audit_4big **et** acceptance) est **inchangé**
→ aucun décalage du `ci_modules_count` consommé par `demo/scenarios`. Preuve :
`check_artifacts.sh` **exit 0** (0 dérive des artefacts dérivés) ; suites
`qa/audit_4big`, `qa/acceptance`, `demo/scenarios` **vertes**.
**Vérifs** : preuve de morsure — artefact commité **falsifié** (534→999) ⇒ gate **Régénération des consommateurs (mémoire projet)** : l'édition des tests + du README
**exit 1** avec diff des `totals` ; restauré ⇒ **exit 0**. Les **5 gates** locaux de `qa/regression` a fait dériver `qa/audit_4big/out/quality_report.json` (evidence
verts (`guard_constraints`/`validate_json`/`check_docs`/`check_artifacts`/ « README.md 3710→4236 octets » et « 24→26 méthodes test_* ») — **régénéré**, verdict
`check_regression`) ; `ci.yml` **YAML valide** ; matrice **534/534 · 21/21 · PASS** **PASS · 21/21 · min 100**. Totaux matrice **534/21** **inchangés** : le harnais
inchangée. `qa/regression` est **self-exclu** (INV3) → +2 méthodes test_* n'affectent pas la
matrice.
**Hors périmètre worker (VPS · #8)** : néant (gate stdlib pur en-repo ; enregistrement **Vérifs** : **5 gates** locaux verts (`guard_constraints`/`validate_json`/`check_docs`/
runner Gitea toujours du ressort DevOps, §4 du `ci/README`). `check_artifacts`/`check_regression`) ; `ci.yml` **YAML valide** ; tests harnais
**26/26 OK** (régression) + **34/34 OK** (audit_4big) ; matrice **534/534 · 21/21 ·
PASS** ; artefacts `out/` reproductibles à l'identique.
**Hors périmètre worker (VPS · #8)** : néant (stdlib pur en-repo ; enregistrement du
runner Gitea toujours du ressort DevOps).
**Auto-score 4Big** : 96/100. **Auto-score 4Big** : 96/100.
@@ -858,7 +858,7 @@
"criterion": "DOC", "criterion": "DOC",
"weight": 20, "weight": 20,
"passed": true, "passed": true,
"evidence": "README.md (3710 octets)" "evidence": "README.md (4236 octets)"
}, },
{ {
"criterion": "CONTRAT", "criterion": "CONTRAT",
@@ -870,7 +870,7 @@
"criterion": "TESTS", "criterion": "TESTS",
"weight": 25, "weight": 25,
"passed": true, "passed": true,
"evidence": "24 méthodes test_* dans 1 fichier(s) (seuil 8)" "evidence": "26 méthodes test_* dans 1 fichier(s) (seuil 8)"
}, },
{ {
"criterion": "CLI", "criterion": "CLI",
+3 -2
View File
@@ -1,4 +1,5 @@
__pycache__/ __pycache__/
*.pyc *.pyc
# Sortie du mode `run` : non déterministe (temps machine) → jamais commitée. # NB : `out/regression_run.json` (mode `run`) EST commité — il est byte-déterministe
out/regression_run.json # (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 python3 regression_gen.py validate
# Régression EXHAUSTIVE (exécute réellement toutes les suites) — local/DevOps : # 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 `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 comme garde de release. Sa sortie est **byte-déterministe** — elle ne porte
**non commitée** (voir `.gitignore`). **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) ## Contrat de sortie (plan)
@@ -22,7 +22,8 @@
"self_module_excluded": "qa/regression", "self_module_excluded": "qa/regression",
"self_module_gated": true, "self_module_gated": true,
"missing_tests_dir": [], "missing_tests_dir": [],
"not_in_gate": [] "not_in_gate": [],
"orphan_tests_dirs": []
}, },
"suites": [ "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) 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]: def discover_suites(spec: dict) -> list[dict]:
"""Liste ordonnée des suites gated (hors self), enrichie des faits de disque. """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"]) 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) 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 = ( ok = (
not missing_tests_dir not missing_tests_dir
and not not_in_gate and not not_in_gate
and not self_present and not self_present
and not orphan_tests_dirs
and self_module in ci_paths # le harnais DOIT être gated lui aussi and self_module in ci_paths # le harnais DOIT être gated lui aussi
) )
return { return {
@@ -104,4 +128,5 @@ def coverage_report(spec: dict, suites: list[dict]) -> dict:
"self_module_gated": self_module in ci_paths, "self_module_gated": self_module in ci_paths,
"missing_tests_dir": missing_tests_dir, "missing_tests_dir": missing_tests_dir,
"not_in_gate": not_in_gate, "not_in_gate": not_in_gate,
"orphan_tests_dirs": orphan_tests_dirs,
} }
@@ -28,7 +28,7 @@
"coverage": { "coverage": {
"type": "object", "type": "object",
"additionalProperties": false, "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": { "properties": {
"ok": { "type": "boolean" }, "ok": { "type": "boolean" },
"suites_count": { "type": "integer", "minimum": 0 }, "suites_count": { "type": "integer", "minimum": 0 },
@@ -36,7 +36,8 @@
"self_module_excluded": { "type": "string", "minLength": 1 }, "self_module_excluded": { "type": "string", "minLength": 1 },
"self_module_gated": { "type": "boolean" }, "self_module_gated": { "type": "boolean" },
"missing_tests_dir": { "type": "array", "items": { "type": "string", "minLength": 1 } }, "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": { "suites": {
@@ -108,6 +108,7 @@ def check_invariants(plan: dict, spec: dict) -> list[str]:
errs.append( errs.append(
f"INV4 couverture non prouvée : missing_tests_dir=" f"INV4 couverture non prouvée : missing_tests_dir="
f"{cov['missing_tests_dir']} not_in_gate={cov['not_in_gate']} " 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']}") f"self_module_gated={cov['self_module_gated']}")
if cov["self_module_excluded"] != self_mod: if cov["self_module_excluded"] != self_mod:
errs.append("INV4 self_module_excluded incohérent avec le spec") 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.assertTrue(cov["self_module_gated"]) # le harnais est gated
self.assertEqual(cov["missing_tests_dir"], []) self.assertEqual(cov["missing_tests_dir"], [])
self.assertEqual(cov["not_in_gate"], []) 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)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------