From 76fb0e768da8a5ad3173b9f3853867d81287ba0f Mon Sep 17 00:00:00 2001 From: Claude Code DTP Worker Date: Fri, 31 Jul 2026 00:41:16 +0000 Subject: [PATCH] =?UTF-8?q?[DTP-Worker]=20Sprint=208=20=C2=B7=20buffer=20L?= =?UTF-8?q?75=20=C2=B7=20Int=C3=A9grit=C3=A9=20matrice=20r=C3=A9gression?= =?UTF-8?q?=20:=20fix=20gate=20check=5Fregression=20cass=C3=A9=20en=20CI?= =?UTF-8?q?=20(run=20baseline=20commit=C3=A9)=20+=20invariant=20disque?= =?UTF-8?q?=E2=86=92CI=20(INV4=20orphan=5Ftests=5Fdirs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- 05_activity_log/2026-07-31.md | 108 ++++--- .../qa/audit_4big/out/quality_report.json | 4 +- 05_deliverables_mvp/qa/regression/.gitignore | 5 +- 05_deliverables_mvp/qa/regression/README.md | 12 +- .../qa/regression/out/regression_plan.json | 3 +- .../qa/regression/out/regression_run.json | 270 ++++++++++++++++++ .../qa/regression/reglib/discovery.py | 25 ++ .../qa/regression/regression.schema.json | 5 +- .../qa/regression/regression_gen.py | 1 + .../qa/regression/tests/test_regression.py | 22 ++ 10 files changed, 399 insertions(+), 56 deletions(-) create mode 100644 05_deliverables_mvp/qa/regression/out/regression_run.json diff --git a/05_activity_log/2026-07-31.md b/05_activity_log/2026-07-31.md index ef3d4bd..a9ebe29 100644 --- a/05_activity_log/2026-07-31.md +++ b/05_activity_log/2026-07-31.md @@ -1,60 +1,76 @@ # 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 CI/CD)** — roadmap fonctionnellement close (21/21 modules gated à 100/100, 534 -tests verts, chantier cross-linking clos). Recherche d'une **incohérence de -reproductibilité réelle** plutôt qu'un correctif doc cosmétique. +tests verts, 5 gates statiques). Recherche d'une **incohérence de reproductibilité +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 — -`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`. +### Défaut #1 (PRIORITAIRE · bug CI réel) — le gate `check-regression` était cassé en checkout propre -**Vérif de faisabilité avant d'agir (#1 analyser)** : `regression_run.json` **ne -contient aucun horodatage/hôte/chemin absolu** — deux `run` successifs sont -**byte-identiques** (vérifié) et le résumé **matche le commité**. Le run complet -prend **~5,3 s** (stdlib pur). L'égalité exacte est donc licite et bon marché. +- **Symptôme** : `ci/check_regression.sh` (l.52-53) **exige** que + `qa/regression/out/regression_run.json` **existe dans le dépôt**, puis compare un + `run` frais à ce fichier commité (baseline byte-identique). +- **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é) : -- **`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`. +### Défaut #2 (invariant manquant) — dérive silencieuse disque→CI non gardée -**Anti-invention (#6)** : rien inventé — le gate **régénère** la vérité au lieu de -figer un compteur. Les nombres du log (« 534 / 21 / PASS ») sont **relus** du run -frais faisant autorité. +- **Gap** : `discovery.coverage_report` prouvait le sens **CI→disque** (tout module + gated a un `tests/`) mais **pas l'inverse** : un module gagnant un `tests/` sur + 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 -`working-directory`** → `q4lib.registry.parse_ci` (qui recense les modules depuis -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**. +**Anti-invention (#6)** : rien inventé — les deux gates **régénèrent** la vérité (run +frais / couverture recomputée) au lieu de figer un compteur. -**Vérifs** : preuve de morsure — artefact commité **falsifié** (534→999) ⇒ gate -**exit 1** avec diff des `totals` ; restauré ⇒ **exit 0**. Les **5 gates** locaux -verts (`guard_constraints`/`validate_json`/`check_docs`/`check_artifacts`/ -`check_regression`) ; `ci.yml` **YAML valide** ; matrice **534/534 · 21/21 · PASS** -inchangée. +**Régénération des consommateurs (mémoire projet)** : l'édition des tests + du README +de `qa/regression` a fait dériver `qa/audit_4big/out/quality_report.json` (evidence +« README.md 3710→4236 octets » et « 24→26 méthodes test_* ») — **régénéré**, verdict +**PASS · 21/21 · min 100**. Totaux matrice **534/21** **inchangés** : le harnais +`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 -runner Gitea toujours du ressort DevOps, §4 du `ci/README`). +**Vérifs** : **5 gates** locaux verts (`guard_constraints`/`validate_json`/`check_docs`/ +`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. diff --git a/05_deliverables_mvp/qa/audit_4big/out/quality_report.json b/05_deliverables_mvp/qa/audit_4big/out/quality_report.json index e36f392..81c69c0 100644 --- a/05_deliverables_mvp/qa/audit_4big/out/quality_report.json +++ b/05_deliverables_mvp/qa/audit_4big/out/quality_report.json @@ -858,7 +858,7 @@ "criterion": "DOC", "weight": 20, "passed": true, - "evidence": "README.md (3710 octets)" + "evidence": "README.md (4236 octets)" }, { "criterion": "CONTRAT", @@ -870,7 +870,7 @@ "criterion": "TESTS", "weight": 25, "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", diff --git a/05_deliverables_mvp/qa/regression/.gitignore b/05_deliverables_mvp/qa/regression/.gitignore index e0e3358..ae60c15 100644 --- a/05_deliverables_mvp/qa/regression/.gitignore +++ b/05_deliverables_mvp/qa/regression/.gitignore @@ -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. diff --git a/05_deliverables_mvp/qa/regression/README.md b/05_deliverables_mvp/qa/regression/README.md index 5967699..64fd29b 100644 --- a/05_deliverables_mvp/qa/regression/README.md +++ b/05_deliverables_mvp/qa/regression/README.md @@ -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) diff --git a/05_deliverables_mvp/qa/regression/out/regression_plan.json b/05_deliverables_mvp/qa/regression/out/regression_plan.json index d814952..1e9babb 100644 --- a/05_deliverables_mvp/qa/regression/out/regression_plan.json +++ b/05_deliverables_mvp/qa/regression/out/regression_plan.json @@ -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": [ { diff --git a/05_deliverables_mvp/qa/regression/out/regression_run.json b/05_deliverables_mvp/qa/regression/out/regression_run.json new file mode 100644 index 0000000..1be0bc2 --- /dev/null +++ b/05_deliverables_mvp/qa/regression/out/regression_run.json @@ -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" +} diff --git a/05_deliverables_mvp/qa/regression/reglib/discovery.py b/05_deliverables_mvp/qa/regression/reglib/discovery.py index 5b51fa3..09e517e 100644 --- a/05_deliverables_mvp/qa/regression/reglib/discovery.py +++ b/05_deliverables_mvp/qa/regression/reglib/discovery.py @@ -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, } diff --git a/05_deliverables_mvp/qa/regression/regression.schema.json b/05_deliverables_mvp/qa/regression/regression.schema.json index 78672af..4abffe7 100644 --- a/05_deliverables_mvp/qa/regression/regression.schema.json +++ b/05_deliverables_mvp/qa/regression/regression.schema.json @@ -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": { diff --git a/05_deliverables_mvp/qa/regression/regression_gen.py b/05_deliverables_mvp/qa/regression/regression_gen.py index ae3556f..653a43f 100644 --- a/05_deliverables_mvp/qa/regression/regression_gen.py +++ b/05_deliverables_mvp/qa/regression/regression_gen.py @@ -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") diff --git a/05_deliverables_mvp/qa/regression/tests/test_regression.py b/05_deliverables_mvp/qa/regression/tests/test_regression.py index 22eedc2..d1b55af 100644 --- a/05_deliverables_mvp/qa/regression/tests/test_regression.py +++ b/05_deliverables_mvp/qa/regression/tests/test_regression.py @@ -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) # --------------------------------------------------------------------------