diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index dcf153e..2a84f71 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -373,10 +373,31 @@ jobs: - name: Tests générateur Scénarios démo run: python3 -m unittest discover -s tests -v + # -------------------------------------------------------------------------- + # Matrice de régression exhaustive (Sprint 8 · QA · roadmap L74 « Regression + # tests exhaustifs »). Harnais de MÉTA-NIVEAU : recense TOUTES les suites de + # tests gated (dérivées du CI — zéro liste à la main · #5), prouve la + # couverture (disque + gate), et fournit — via `run` — le compte agrégé + # faisant autorité (« N tests verts »). Anti-invention (#6) : le plan ne + # contient AUCUN compteur de résultat ; ceux-ci ne naissent qu'à l'exécution. + # Ce job teste le harnais (parseur unittest, découverte, invariants) sans + # ré-exécuter les suites (le gate le fait déjà, job par job). stdlib pur. + # -------------------------------------------------------------------------- + qa-regression-tests: + name: QA · Matrice de régression exhaustive (Sprint 8) + runs-on: ubuntu-latest + defaults: + run: + working-directory: 05_deliverables_mvp/qa/regression + steps: + - uses: actions/checkout@v4 + - name: Tests harnais de régression + run: python3 -m unittest discover -s tests -v + gate: name: Gate qualité (agrégat) runs-on: ubuntu-latest - needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests, fiscal-ecf-tests, frontend-portails-tests, legal-confotur-tests, qa-audit-5d-tests, seo-tests, chat-otoia-tests, qa-audit-4big-tests, demo-scenario-tests] + needs: [constraints-guard, validate-json, check-docs, publiciste-tests, rbac-tests, faisabilite-gen-tests, rbac-fixtures-tests, rbac-userperm-tests, rbac-roleprofile-tests, rbac-applyplan-tests, bancable-tests, crm-workflow-vente-tests, crm-dossier-vente-tests, crm-commissions-tests, fiscal-ecf-tests, frontend-portails-tests, legal-confotur-tests, qa-audit-5d-tests, seo-tests, chat-otoia-tests, qa-audit-4big-tests, demo-scenario-tests, qa-regression-tests] steps: - name: Résultat run: echo "✅ Gate qualité 4Big franchi — tous les checks verts." diff --git a/05_activity_log/2026-07-30.md b/05_activity_log/2026-07-30.md index 466ca5a..258932a 100644 --- a/05_activity_log/2026-07-30.md +++ b/05_activity_log/2026-07-30.md @@ -1,5 +1,56 @@ # Activity Log · 2026-07-30 · Claude Code DTP +## Session `20260730_105732` (session 22) + +**Tâche** : **Sprint 8 · QA** — Générateur de la **Matrice de régression +exhaustive** (roadmap Sprint 8 · QA « **Regression tests exhaustifs** »). Sprint 7 +réalisable en repo étant clos (L68/L69 livrés · L67 polish `otov7.com` dépend du +site live · #8), on enchaîne sur le premier volet Sprint 8 réalisable en repo. + +**Décision d'architecture** : harnais de **MÉTA-NIVEAU** + gate, pas un simple +runner. Il 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 »). **Distinct de l'audit 4Big** (non redondant · #5) : l'audit note la +qualité **statique** par module ; ce harnais prouve que chaque suite **s'exécute +au vert**. Deux artefacts : `build` → plan déterministe commité (**aucun compteur +de résultat**) ; `run` → matrice live non déterministe (non commitée). + +**Zéro invention (#6)** : périmètre **dérivé du CI** via `q4lib/registry.parse_ci` +(réutilisé · zéro duplication) ; les comptes de disque sont **recomputés** à la +validation (INV5) → un compte figé/fabriqué est détecté ; le nombre de tests verts +n'existe **qu'à l'exécution**. **Séparation des pouvoirs (ISA 315)** : le harnais +s'exclut lui-même (pas de récursion, pas d'auto-comptage). + +**Fichiers créés** — `05_deliverables_mvp/qa/regression/` : +- `regression_spec.json` (contrat · planchers structurels · 0 chiffre métier) +- `reglib/{__init__,deps,discovery,runner,builder}.py` (réutilise `parse_ci` + + validateur Publiciste ; parseur PUR de sortie unittest ; plan + matrice) +- `regression_gen.py` (CLI `build`/`validate`/`run` · **8 invariants**) +- `regression.schema.json` (contrat de sortie draft-07) +- `out/{regression_plan,MANIFEST}.json` (hand-off) · `tests/test_regression.py` + (**24 tests** dont 3 exécutions réelles en tmpdir + 6 injections d'invariant) · + `README.md` · `.gitignore` (exclut la sortie non déterministe de `run`) + +**Fichiers modifiés** : +- `.gitea/workflows/ci.yml` : job `qa-regression-tests` + ajout au `gate`. +- `qa/audit_4big/` : enregistrement du module `qa/regression` (couverture + bijective **18 → 19 modules** · PASS 19/19 à 100) ; `out/` régénéré ; test + rendu robuste (`len(spec['modules'])` au lieu du littéral `18`). + +**Résultat `run` exhaustif** : **19/19 suites vertes · 474 tests passés · 0 échec · +0 erreur** — compte agrégé reproductible en une commande. + +**Vérifs** : 24/24 tests module ; 34/34 audit_4big ; gate CI local vert (guard + +JSON + docs + YAML) ; builds déterministes. + +**Hors périmètre worker (VPS · #8)** : planifier `run` sur le runner CI/VPS + +publier le compte agrégé dans le desk ERPNext → agent QA / DevOps. + +**Détail complet** : voir +[`05_deliverables_mvp/daily_reports/2026-07-30-session22.md`](../05_deliverables_mvp/daily_reports/2026-07-30-session22.md). + +**Auto-score 4Big** : 96/100. + ## Session `20260730_102730` (session 21) **Tâche** : **Sprint 7 · CRM + Faisabilité** — Générateur des **Scénarios démo diff --git a/05_deliverables_mvp/daily_reports/2026-07-30-session22.md b/05_deliverables_mvp/daily_reports/2026-07-30-session22.md new file mode 100644 index 0000000..9e77417 --- /dev/null +++ b/05_deliverables_mvp/daily_reports/2026-07-30-session22.md @@ -0,0 +1,83 @@ +# Rapport de session · 2026-07-30 · session 22 + +## Tâche + +**Sprint 8 · QA — Générateur de la Matrice de régression exhaustive** +(roadmap Sprint 8 · QA « **Regression tests exhaustifs** »). + +Sprint 7 réalisable en repo est clos (L69 audit 4Big + L68 scénarios démo livrés +sessions 20–21 ; L67 polish `otov7.com` dépend du site live · hors périmètre +worker · #8). On enchaîne donc sur le premier volet Sprint 8 réalisable en repo. + +## Décision d'architecture + +Harnais de **MÉTA-NIVEAU** + **gate**, pas un simple runner. Il agrège +l'exécution de **toutes** les suites de tests gated du mandat en **une matrice + +un verdict PASS/FAIL** et fournit le **compte agrégé faisant autorité** (« N tests +verts ») — celui que les rapports quotidiens citaient jusqu'ici à la main. + +**Distinct de l'audit 4Big (non redondant · #5)** : l'audit note la **qualité +statique** par module (doc / schéma / CLI / hand-off) ; ce harnais prouve que +chaque suite **s'exécute au vert** et calcule le **total**. Deux axes +complémentaires. + +**Deux artefacts, deux natures :** +- `build` → **plan** déterministe (`out/regression_plan.json`, commité) : + recensement exhaustif des suites (chemin, jobs, gate, fichiers/méthodes + `test_*`). **Aucun compteur de résultat** → diffable, re-générable. +- `run` → **matrice live** : exécute réellement chaque suite (subprocess + `unittest`, comme le CI), agrège les compteurs + verdict. **Non déterministe** + (temps machine) → **non commitée** (`.gitignore`). + +## Zéro invention · zéro duplication (#5 · #6) + +- **Périmètre dérivé du CI** : les suites proviennent de `.gitea/workflows/ci.yml` + via `q4lib/registry.parse_ci` (**réutilisé** de l'audit 4Big — une seule source + de vérité). Tout job de test ajouté au CI entre automatiquement dans la matrice. +- **Anti-invention** : le plan ne contient **aucun** compteur de résultat ; les + comptes de disque sont **recomputés** à la validation (INV5) → un compte figé / + fabriqué est détecté. Le nombre de tests verts n'existe **qu'à l'exécution**. +- **Séparation des pouvoirs (ISA 315)** : le harnais **s'exclut lui-même** — pas + d'auto-exécution (évite la récursion), pas de comptage de soi. + +## Fichiers créés — `05_deliverables_mvp/qa/regression/` + +- `regression_spec.json` (contrat · planchers structurels · 0 chiffre métier) +- `reglib/{__init__,deps,discovery,runner,builder}.py` + - `deps` : réutilise `parse_ci` (audit 4Big) + validateur maison Publiciste + - `discovery` : dérive les suites du CI + faits de disque + preuve de couverture + - `runner` : subprocess unittest + **parseur PUR** de la sortie (OK/FAILED/skip) + - `builder` : plan déterministe (`build`) + matrice live (`run`) +- `regression_gen.py` (CLI `build` / `validate` / `run` · **8 invariants**) +- `regression.schema.json` (contrat de sortie draft-07) +- `out/{regression_plan,MANIFEST}.json` (hand-off) · `tests/test_regression.py` + (**24 tests** dont 3 exécutions réelles en tmpdir + 6 injections d'invariant) +- `README.md` · `.gitignore` (exclut la sortie non déterministe de `run`) + +## Fichiers modifiés + +- `.gitea/workflows/ci.yml` : job `qa-regression-tests` + ajout au `gate`. +- `qa/audit_4big/` : enregistrement du module `qa/regression` dans le registre + (`quality_spec.json`) → couverture bijective **18 → 19 modules** (verdict **PASS + 19/19 à 100**) ; `out/` régénéré ; test `test_real_build_passes_gate` rendu + **robuste** (`len(spec['modules'])` au lieu du littéral `18`) — plus de casse à + chaque nouveau livrable. + +## Vérifications + +- **24/24** tests du module ; **34/34** audit_4big (compte dérivé du spec). +- **`run` exhaustif : 19/19 suites vertes · 474 tests passés · 0 échec · 0 + erreur** — compte agrégé faisant autorité, reproductible en une commande. +- Gate CI local vert : `guard_constraints.sh` · `validate_json.sh` · + `check_docs.sh` · `ci.yml` YAML valide. +- Builds déterministes (plan identique en double build). + +## Hors périmètre worker (VPS · #8) + +Planifier `run` sur le runner CI/VPS + publier le compte agrégé dans le desk +ERPNext / le pipeline de release → agent **QA / DevOps**. Le mode `run` reste +manuel (non déterministe · non gated push/PR), comme la baseline E2E. + +## Auto-score 4Big + +**96/100**. diff --git a/05_deliverables_mvp/qa/audit_4big/out/MANIFEST.json b/05_deliverables_mvp/qa/audit_4big/out/MANIFEST.json index 1009782..5e686fb 100644 --- a/05_deliverables_mvp/qa/audit_4big/out/MANIFEST.json +++ b/05_deliverables_mvp/qa/audit_4big/out/MANIFEST.json @@ -4,7 +4,7 @@ "audit": "OTO QA · Audit 4Big Qualité", "version": "1.0", "verdict": "PASS", - "modules_audited": 18, + "modules_audited": 19, "min_score": 100, "pass_score": 95, "coverage_ok": true, 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 c28ed08..cd584ba 100644 --- a/05_deliverables_mvp/qa/audit_4big/out/quality_report.json +++ b/05_deliverables_mvp/qa/audit_4big/out/quality_report.json @@ -5,8 +5,8 @@ "pass_score": 95, "coverage": { "ok": true, - "ci_modules_count": 18, - "registry_modules_count": 18, + "ci_modules_count": 19, + "registry_modules_count": 19, "missing_in_registry": [], "missing_in_ci": [], "not_in_gate": [], @@ -759,11 +759,53 @@ "evidence": "MANIFEST + 1 artefact(s) JSON valides" } ] + }, + { + "id": "qa-regression", + "path": "qa/regression", + "sprint": "S8", + "archetype": "generator", + "applicable_weight": 100, + "earned_weight": 100, + "score": 100, + "verdict": "PASS", + "checks": [ + { + "criterion": "DOC", + "weight": 20, + "passed": true, + "evidence": "README.md (3710 octets)" + }, + { + "criterion": "CONTRAT", + "weight": 20, + "passed": true, + "evidence": "regression.schema.json" + }, + { + "criterion": "TESTS", + "weight": 25, + "passed": true, + "evidence": "24 méthodes test_* dans 1 fichier(s) (seuil 8)" + }, + { + "criterion": "CLI", + "weight": 15, + "passed": true, + "evidence": "regression_gen.py (argparse + __main__)" + }, + { + "criterion": "HANDOFF", + "weight": 20, + "passed": true, + "evidence": "MANIFEST + 1 artefact(s) JSON valides" + } + ] } ], "totals": { - "modules": 18, - "pass": 18, + "modules": 19, + "pass": 19, "fail": 0, "min_score": 100, "max_score": 100, diff --git a/05_deliverables_mvp/qa/audit_4big/quality_spec.json b/05_deliverables_mvp/qa/audit_4big/quality_spec.json index 51800b3..cdf595a 100644 --- a/05_deliverables_mvp/qa/audit_4big/quality_spec.json +++ b/05_deliverables_mvp/qa/audit_4big/quality_spec.json @@ -78,7 +78,8 @@ { "id": "qa-audit-5d", "path": "qa/audit_5d", "sprint": "S5", "archetype": "generator", "source": "roadmap Sprint 5 · Audit 5D conformité · ci.yml qa-audit-5d-tests" }, { "id": "seo", "path": "seo", "sprint": "S6", "archetype": "generator", "source": "roadmap Sprint 6 · SEO trilingue mots-clés/schema.org/hreflang · ci.yml seo-tests" }, { "id": "frontend-chat-otoia", "path": "frontend/chat_otoia", "sprint": "S6", "archetype": "generator", "source": "roadmap Sprint 6 · Chat OTOIA embarqué (Custom Block) · ci.yml chat-otoia-tests" }, - { "id": "demo-scenarios", "path": "demo/scenarios", "sprint": "S7", "archetype": "generator", "source": "roadmap Sprint 7 · Scénarios démo (P07 banquier / P05 client) · ci.yml demo-scenario-tests" } + { "id": "demo-scenarios", "path": "demo/scenarios", "sprint": "S7", "archetype": "generator", "source": "roadmap Sprint 7 · Scénarios démo (P07 banquier / P05 client) · ci.yml demo-scenario-tests" }, + { "id": "qa-regression", "path": "qa/regression", "sprint": "S8", "archetype": "generator", "source": "roadmap Sprint 8 · Regression tests exhaustifs (matrice méta-niveau) · ci.yml qa-regression-tests" } ], "notes": [ "Audit de MÉTA-NIVEAU : chaque note de module est CALCULÉE depuis des faits du dépôt (présence README/schéma, nombre de méthodes test_*, entrypoint CLI, intégrité out/) — jamais saisie à la main (#6).", diff --git a/05_deliverables_mvp/qa/audit_4big/tests/test_audit_4big.py b/05_deliverables_mvp/qa/audit_4big/tests/test_audit_4big.py index 66b8f57..0cc8c02 100644 --- a/05_deliverables_mvp/qa/audit_4big/tests/test_audit_4big.py +++ b/05_deliverables_mvp/qa/audit_4big/tests/test_audit_4big.py @@ -259,7 +259,7 @@ class BuildTest(unittest.TestCase): def test_real_build_passes_gate(self): self.assertEqual(self.report["verdict"], "PASS") self.assertEqual(self.report["totals"]["fail"], 0) - self.assertEqual(self.report["totals"]["modules"], 18) + self.assertEqual(self.report["totals"]["modules"], len(self.spec["modules"])) self.assertGreaterEqual(self.report["totals"]["min_score"], 95) def test_invariants_clean_on_real_report(self): diff --git a/05_deliverables_mvp/qa/regression/.gitignore b/05_deliverables_mvp/qa/regression/.gitignore new file mode 100644 index 0000000..e0e3358 --- /dev/null +++ b/05_deliverables_mvp/qa/regression/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +# Sortie du mode `run` : non déterministe (temps machine) → jamais commitée. +out/regression_run.json diff --git a/05_deliverables_mvp/qa/regression/README.md b/05_deliverables_mvp/qa/regression/README.md new file mode 100644 index 0000000..5967699 --- /dev/null +++ b/05_deliverables_mvp/qa/regression/README.md @@ -0,0 +1,75 @@ +# QA · Matrice de régression exhaustive (Sprint 8) + +> Roadmap **Sprint 8 · QA** : « **Regression tests exhaustifs** ». +> Harnais de **méta-niveau** : 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 quotidiens citaient jusqu'ici à la main. + +## Pourquoi ce livrable + +Le mandat compte des dizaines de suites `tests/` (une par module). Le gate CI les +exécute **job par job** ; mais aucun artefact ne prouvait, **en une commande**, +que **l'ensemble** tourne au vert, ni ne produisait le **total** de tests verts de +façon reproductible. Ce harnais formalise cette régression exhaustive. + +Il est **complémentaire**, pas redondant, avec l'**audit 4Big** +([`../audit_4big`](../audit_4big/README.md)) : + +| | Audit 4Big | Matrice de régression | +|---|---|---| +| Axe | **Qualité statique** par module (doc, schéma, CLI, hand-off) | **Exécution** : chaque suite tourne-t-elle au **vert** ? | +| Sortie | Note ≥ 95/100 par module | Compte agrégé « N tests verts » + verdict | +| Nature | Lecture du système de fichiers | `build` (statique) + `run` (exécution réelle) | + +## Zéro invention · zéro duplication (CLAUDE.md #5 · #6) + +- **Périmètre dérivé du CI, pas listé à la main** : les suites proviennent de + `.gitea/workflows/ci.yml` via `q4lib/registry.parse_ci` (réutilisé de l'audit + 4Big — une seule source de vérité). Tout job de test ajouté au CI entre + automatiquement dans la matrice ; toute suite retirée en sort. +- **Anti-invention** : le `build` (plan) ne contient **aucun compteur de + résultat** — seulement des **faits de disque** (fichiers / méthodes `test_*`) et + de CI (job, gate). Le nombre de tests verts n'existe **qu'à l'exécution** (`run`), + parsé de la sortie réelle de `unittest`. Un invariant refuse tout compteur figé. +- **Séparation des pouvoirs (ISA 315)** : le harnais **s'exclut lui-même** — il ne + s'auto-exécute pas (évite la récursion) et ne se compte pas dans le total. + +## Utilisation + +```bash +cd 05_deliverables_mvp/qa/regression + +# Recensement déterministe des suites gated (commité) : +python3 regression_gen.py build # → out/regression_plan.json + MANIFEST + +# Gate : schéma + invariants + couverture prouvée (ce que fait le CI) : +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é) +``` + +`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`). + +## Contrat de sortie (plan) + +- `regression.schema.json` — draft-07 (sous-ensemble), validé par le validateur + maison Publiciste (zéro pip). +- Invariants (`regression_gen.py:check_invariants`) : schéma · anti-invention + (INV2) · self exclu (INV3) · couverture prouvée (INV4) · recompute disque + (INV5) · planchers structurels (INV6) · totaux recomputés (INV7) · verdict + gate (INV8). + +## Hand-off VPS (hors périmètre worker · #8) + +Planifier `run` sur le runner CI/VPS et publier le compte agrégé dans le desk +ERPNext / le pipeline de release → agent **QA / DevOps**. Le mode `run` reste +**manuel** (non déterministe · non gated push/PR), comme la baseline E2E. + +## Auto-score 4Big + +**96/100** — recensement exhaustif prouvé (couverture dérivée du CI), anti-invention +strict (aucun compteur figé), déterminisme, réutilisation sans duplication. diff --git a/05_deliverables_mvp/qa/regression/out/MANIFEST.json b/05_deliverables_mvp/qa/regression/out/MANIFEST.json new file mode 100644 index 0000000..0f5bc9d --- /dev/null +++ b/05_deliverables_mvp/qa/regression/out/MANIFEST.json @@ -0,0 +1,15 @@ +{ + "generated_from": "regression_spec.json", + "generator": "regression_gen.py", + "matrix": "OTO QA · Matrice de régression exhaustive", + "version": "1.0", + "verdict": "PASS", + "suites": 19, + "test_methods": 474, + "coverage_ok": true, + "artifacts": [ + "regression_plan.json" + ], + "roadmap": "Sprint 8 · QA · Regression tests exhaustifs", + "hors_perimetre_worker": "Exécution planifiée de `run` sur le runner CI/VPS + publication du compte agrégé dans le desk ERPNext / le pipeline de release → agent QA / DevOps (#8). Le mode `run` reste manuel (non déterministe)." +} diff --git a/05_deliverables_mvp/qa/regression/out/regression_plan.json b/05_deliverables_mvp/qa/regression/out/regression_plan.json new file mode 100644 index 0000000..57b6dd0 --- /dev/null +++ b/05_deliverables_mvp/qa/regression/out/regression_plan.json @@ -0,0 +1,254 @@ +{ + "matrix": "OTO QA · Matrice de régression exhaustive", + "version": "1.0", + "reference_cadre": "Roadmap Sprint 8 · QA « Regression tests exhaustifs » · CLAUDE.md #5 (zéro régression silencieuse · niveau 4Big)", + "self_module": "qa/regression", + "thresholds": { + "min_test_files": 1, + "min_methods_per_suite": 8 + }, + "discover_cmd": [ + "python3", + "-m", + "unittest", + "discover", + "-s", + "tests" + ], + "coverage": { + "ok": true, + "suites_count": 19, + "gated_in_ci": 20, + "self_module_excluded": "qa/regression", + "self_module_gated": true, + "missing_tests_dir": [], + "not_in_gate": [] + }, + "suites": [ + { + "id": "crm.commissions", + "path": "crm/commissions", + "jobs": [ + "crm-commissions-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 25 + }, + { + "id": "crm.dossier_vente", + "path": "crm/dossier_vente", + "jobs": [ + "crm-dossier-vente-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 31 + }, + { + "id": "crm.workflow_vente", + "path": "crm/workflow_vente", + "jobs": [ + "crm-workflow-vente-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 25 + }, + { + "id": "demo.scenarios", + "path": "demo/scenarios", + "jobs": [ + "demo-scenario-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 32 + }, + { + "id": "faisabilite.bancable", + "path": "faisabilite/bancable", + "jobs": [ + "bancable-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 22 + }, + { + "id": "faisabilite.generator", + "path": "faisabilite/generator", + "jobs": [ + "faisabilite-gen-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 16 + }, + { + "id": "fiscal.ecf_dgii", + "path": "fiscal/ecf_dgii", + "jobs": [ + "fiscal-ecf-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 39 + }, + { + "id": "frontend.chat_otoia", + "path": "frontend/chat_otoia", + "jobs": [ + "chat-otoia-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 31 + }, + { + "id": "frontend.portails", + "path": "frontend/portails", + "jobs": [ + "frontend-portails-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 19 + }, + { + "id": "legal.confotur", + "path": "legal/confotur", + "jobs": [ + "legal-confotur-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 44 + }, + { + "id": "publiciste", + "path": "publiciste", + "jobs": [ + "publiciste-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 23 + }, + { + "id": "qa.audit_4big", + "path": "qa/audit_4big", + "jobs": [ + "qa-audit-4big-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 34 + }, + { + "id": "qa.audit_5d", + "path": "qa/audit_5d", + "jobs": [ + "qa-audit-5d-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 37 + }, + { + "id": "rbac", + "path": "rbac", + "jobs": [ + "rbac-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 10 + }, + { + "id": "rbac.apply_plan", + "path": "rbac/apply_plan", + "jobs": [ + "rbac-applyplan-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 16 + }, + { + "id": "rbac.fixtures_gen", + "path": "rbac/fixtures_gen", + "jobs": [ + "rbac-fixtures-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 11 + }, + { + "id": "rbac.roleprofile_gen", + "path": "rbac/roleprofile_gen", + "jobs": [ + "rbac-roleprofile-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 11 + }, + { + "id": "rbac.userperm_gen", + "path": "rbac/userperm_gen", + "jobs": [ + "rbac-userperm-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 12 + }, + { + "id": "seo", + "path": "seo", + "jobs": [ + "seo-tests" + ], + "in_gate": true, + "has_tests_dir": true, + "test_files": 1, + "test_methods": 36 + } + ], + "under_threshold": [], + "totals": { + "suites": 19, + "test_files": 19, + "test_methods": 474, + "min_methods": 10, + "under_threshold": 0 + }, + "verdict": "PASS", + "notes": [ + "Harnais de MÉTA-NIVEAU : agrège l'exécution de TOUTES les suites de tests gated du mandat en une seule matrice + un verdict PASS/FAIL. Sprint 8 · QA « Regression tests exhaustifs ».", + "Périmètre PROUVÉ, pas déclaré : les suites sont DÉRIVÉES de .gitea/workflows/ci.yml (réutilise q4lib/registry.parse_ci — zéro duplication · #5), jamais listées à la main. Tout job de test ajouté au CI entre automatiquement dans la matrice.", + "Séparation des pouvoirs (ISA 315) : le harnais s'exclut lui-même (self_module) — il ne s'auto-exécute pas (évite la récursion) et ne se compte pas dans le total régression.", + "ANTI-INVENTION (#6) : le `build` (plan) ne contient AUCUN compteur de résultats (0 test vert écrit à la main) ; seuls des FAITS de disque (fichiers/méthodes test_*) et de CI (job, gate) y figurent. Les compteurs verts/rouges sont produits UNIQUEMENT par `run`, en parsant la sortie réelle de unittest.", + "Distinct de l'audit 4Big : audit_4big note la QUALITÉ statique par module (doc/schema/CLI/hand-off) ; ce harnais prouve que chaque suite S'EXÉCUTE AU VERT et fournit le compte agrégé faisant autorité (« N tests verts »). Axes complémentaires, non redondants." + ] +} diff --git a/05_deliverables_mvp/qa/regression/reglib/__init__.py b/05_deliverables_mvp/qa/regression/reglib/__init__.py new file mode 100644 index 0000000..b4f8591 --- /dev/null +++ b/05_deliverables_mvp/qa/regression/reglib/__init__.py @@ -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) +""" diff --git a/05_deliverables_mvp/qa/regression/reglib/builder.py b/05_deliverables_mvp/qa/regression/reglib/builder.py new file mode 100644 index 0000000..6c8c2a0 --- /dev/null +++ b/05_deliverables_mvp/qa/regression/reglib/builder.py @@ -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, + } diff --git a/05_deliverables_mvp/qa/regression/reglib/deps.py b/05_deliverables_mvp/qa/regression/reglib/deps.py new file mode 100644 index 0000000..34c9d76 --- /dev/null +++ b/05_deliverables_mvp/qa/regression/reglib/deps.py @@ -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"] diff --git a/05_deliverables_mvp/qa/regression/reglib/discovery.py b/05_deliverables_mvp/qa/regression/reglib/discovery.py new file mode 100644 index 0000000..5b51fa3 --- /dev/null +++ b/05_deliverables_mvp/qa/regression/reglib/discovery.py @@ -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, + } diff --git a/05_deliverables_mvp/qa/regression/reglib/runner.py b/05_deliverables_mvp/qa/regression/reglib/runner.py new file mode 100644 index 0000000..98d70cd --- /dev/null +++ b/05_deliverables_mvp/qa/regression/reglib/runner.py @@ -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) diff --git a/05_deliverables_mvp/qa/regression/regression.schema.json b/05_deliverables_mvp/qa/regression/regression.schema.json new file mode 100644 index 0000000..78672af --- /dev/null +++ b/05_deliverables_mvp/qa/regression/regression.schema.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://oto-enterprise-os-dtp/qa/regression.schema.json", + "title": "Plan de regression exhaustive genere depuis regression_spec.json + les faits du depot", + "description": "Contrat de sortie du generateur regression_gen.py (sous-commande build). Recensement deterministe des suites de tests gated, sans compteur de resultat. Valide par le validateur maison Publiciste (zero pip) dans le job CI qa-regression-tests.", + "type": "object", + "additionalProperties": false, + "required": ["matrix", "version", "reference_cadre", "self_module", "thresholds", "discover_cmd", "coverage", "suites", "under_threshold", "totals", "verdict", "notes"], + "properties": { + "matrix": { "type": "string", "const": "OTO QA · Matrice de régression exhaustive" }, + "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+$" }, + "reference_cadre": { "type": "string", "minLength": 1 }, + "self_module": { "type": "string", "minLength": 1 }, + "thresholds": { + "type": "object", + "additionalProperties": false, + "required": ["min_test_files", "min_methods_per_suite"], + "properties": { + "min_test_files": { "type": "integer", "minimum": 1 }, + "min_methods_per_suite": { "type": "integer", "minimum": 1 } + } + }, + "discover_cmd": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + }, + "coverage": { + "type": "object", + "additionalProperties": false, + "required": ["ok", "suites_count", "gated_in_ci", "self_module_excluded", "self_module_gated", "missing_tests_dir", "not_in_gate"], + "properties": { + "ok": { "type": "boolean" }, + "suites_count": { "type": "integer", "minimum": 0 }, + "gated_in_ci": { "type": "integer", "minimum": 0 }, + "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 } } + } + }, + "suites": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "path", "jobs", "in_gate", "has_tests_dir", "test_files", "test_methods"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "path": { "type": "string", "minLength": 1 }, + "jobs": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } }, + "in_gate": { "type": "boolean" }, + "has_tests_dir": { "type": "boolean" }, + "test_files": { "type": "integer", "minimum": 0 }, + "test_methods": { "type": "integer", "minimum": 0 } + } + } + }, + "under_threshold": { "type": "array", "items": { "type": "string", "minLength": 1 } }, + "totals": { + "type": "object", + "additionalProperties": false, + "required": ["suites", "test_files", "test_methods", "min_methods", "under_threshold"], + "properties": { + "suites": { "type": "integer", "minimum": 1 }, + "test_files": { "type": "integer", "minimum": 0 }, + "test_methods": { "type": "integer", "minimum": 0 }, + "min_methods": { "type": "integer", "minimum": 0 }, + "under_threshold": { "type": "integer", "minimum": 0 } + } + }, + "verdict": { "type": "string", "enum": ["PASS", "FAIL"] }, + "notes": { + "type": "array", + "items": { "type": "string", "minLength": 1 } + } + } +} diff --git a/05_deliverables_mvp/qa/regression/regression_gen.py b/05_deliverables_mvp/qa/regression/regression_gen.py new file mode 100644 index 0000000..ae3556f --- /dev/null +++ b/05_deliverables_mvp/qa/regression/regression_gen.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""Générateur de la matrice de régression exhaustive · Sprint 8 · QA. + +Roadmap Sprint 8 · QA : « Regression tests exhaustifs ». 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. + +Le périmètre « exhaustif » est PROUVÉ, pas déclaré : les suites sont DÉRIVÉES de +`.gitea/workflows/ci.yml` (réutilise `q4lib/registry.parse_ci` · zéro duplication +· #5), jamais listées à la main. Le harnais s'exclut lui-même (séparation des +pouvoirs · ISA 315 · évite la récursion). + +ANTI-INVENTION (#6) : le `build` (plan) ne contient AUCUN compteur de résultat — +seuls des faits de disque (fichiers/méthodes `test_*`) et de CI (job, gate). Les +compteurs verts/rouges sont produits UNIQUEMENT par `run`, en parsant la sortie +réelle de unittest. + +Sous-commandes : + build [-o OUT] → écrit regression_plan.json + MANIFEST.json (déterministe) + validate → (re)génère le plan en mémoire, valide schéma + invariants ; + sort en erreur si la couverture n'est pas prouvée ou si une + suite passe sous les planchers structurels. + run [-o OUT] → exécute réellement toutes les suites, agrège la matrice + live + verdict ; sort en erreur (code ≠ 0) si une suite est + rouge. Sortie NON déterministe → non commitée. + +Le `build`/`validate` est déterministe (tri stable, aucun horodatage) → diffable. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, _HERE) + +from reglib import builder, discovery # noqa: E402 +from reglib.deps import validate as maison_validate # noqa: E402 + +_SPEC_PATH = os.path.join(_HERE, "regression_spec.json") +_SCHEMA_PATH = os.path.join(_HERE, "regression.schema.json") +_DEFAULT_OUT = os.path.join(_HERE, "out") + + +def _eprint(*args) -> None: + print(*args, file=sys.stderr) + + +def _load(path: str) -> dict: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +def _write_json(path: str, data) -> None: + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, indent=2) + fh.write("\n") + + +def _manifest(spec: dict, plan: dict) -> dict: + return { + "generated_from": "regression_spec.json", + "generator": "regression_gen.py", + "matrix": plan["matrix"], + "version": plan["version"], + "verdict": plan["verdict"], + "suites": plan["totals"]["suites"], + "test_methods": plan["totals"]["test_methods"], + "coverage_ok": plan["coverage"]["ok"], + "artifacts": ["regression_plan.json"], + "roadmap": "Sprint 8 · QA · Regression tests exhaustifs", + "hors_perimetre_worker": ( + "Exécution planifiée de `run` sur le runner CI/VPS + publication du " + "compte agrégé dans le desk ERPNext / le pipeline de release → agent " + "QA / DevOps (#8). Le mode `run` reste manuel (non déterministe)." + ), + } + + +def check_invariants(plan: dict, spec: dict) -> list[str]: + """Invariants de cohérence de la matrice. Retourne la liste des violations.""" + errs: list[str] = [] + + # 1. Schéma de sortie. + schema = _load(_SCHEMA_PATH) + errs += [f"schema: {e}" for e in maison_validate(plan, schema)] + + # 2. Anti-invention : le plan ne DOIT contenir aucun compteur de résultat. + # (verdict vert/rouge, tests passés) — ceux-ci n'existent qu'en mode run. + forbidden = {"green", "red", "passed", "ran", "failures", "errors"} + leaked = forbidden & set(plan["totals"].keys()) + if leaked: + errs.append(f"INV2 compteurs de résultat dans le plan (invention ?) : " + f"{sorted(leaked)}") + + # 3. Le harnais ne s'auto-recense pas (séparation des pouvoirs · récursion). + self_mod = spec["self_module"] + if any(s["path"] == self_mod for s in plan["suites"]): + errs.append(f"INV3 harnais {self_mod} présent dans les suites exécutées") + + # 4. Couverture prouvée (disque + gate + self gated + self exclu). + cov = plan["coverage"] + if not cov["ok"]: + errs.append( + f"INV4 couverture non prouvée : missing_tests_dir=" + f"{cov['missing_tests_dir']} not_in_gate={cov['not_in_gate']} " + 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") + + # 5. Recompute des suites depuis le disque (aucun compte figé/fabriqué). + fresh = discovery.discover_suites(spec) + if [s["id"] for s in fresh] != [s["id"] for s in plan["suites"]]: + errs.append("INV5 ensemble/ordre des suites non reproductible") + fresh_by_id = {s["id"]: s for s in fresh} + for s in plan["suites"]: + f = fresh_by_id.get(s["id"]) + if f and (f["test_files"], f["test_methods"]) != ( + s["test_files"], s["test_methods"]): + errs.append(f"INV5 {s['id']} comptes disque {s['test_files']}/" + f"{s['test_methods']} ≠ recomputés {f['test_files']}/" + f"{f['test_methods']} (compte figé ?)") + + # 6. Planchers structurels : chaque suite ≥ seuils, cohérent avec la liste. + th = spec["thresholds"] + under = [s["path"] for s in plan["suites"] + if s["test_files"] < th["min_test_files"] + or s["test_methods"] < th["min_methods_per_suite"]] + if under != plan["under_threshold"]: + errs.append(f"INV6 under_threshold {plan['under_threshold']} ≠ " + f"recomputé {under}") + + # 7. Totaux cohérents (recomputés depuis les suites). + t = plan["totals"] + suites = plan["suites"] + exp = { + "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), + } + for k, v in exp.items(): + if t.get(k) != v: + errs.append(f"INV7 totals[{k}]={t.get(k)} ≠ recomputé {v}") + + # 8. Verdict global cohérent (GATE : couverture prouvée ET zéro sous-plancher). + expect = "PASS" if (cov["ok"] and not under) else "FAIL" + if plan["verdict"] != expect: + errs.append(f"INV8 verdict {plan['verdict']} ≠ attendu {expect}") + + return errs + + +def cmd_build(args: argparse.Namespace) -> int: + spec = _load(_SPEC_PATH) + plan = builder.build_plan(spec) + errs = check_invariants(plan, spec) + if errs: + _eprint("❌ Invariants violés — build refusé :") + for e in errs: + _eprint(f" · {e}") + return 1 + out_dir = args.out or _DEFAULT_OUT + os.makedirs(out_dir, exist_ok=True) + _write_json(os.path.join(out_dir, "regression_plan.json"), plan) + _write_json(os.path.join(out_dir, "MANIFEST.json"), _manifest(spec, plan)) + print(f"✅ Plan de régression écrit dans {out_dir}/ — verdict " + f"{plan['verdict']} · {plan['totals']['suites']} suites · " + f"{plan['totals']['test_methods']} méthodes test_* recensées.") + return 0 + + +def cmd_validate(args: argparse.Namespace) -> int: + spec = _load(_SPEC_PATH) + plan = builder.build_plan(spec) + errs = check_invariants(plan, spec) + if errs: + _eprint("❌ Validation échouée :") + for e in errs: + _eprint(f" · {e}") + return 1 + if plan["verdict"] != "PASS": + _eprint(f"❌ Verdict régression = {plan['verdict']} " + f"(couverture={plan['coverage']['ok']} · " + f"sous-plancher={plan['under_threshold']})") + return 1 + print(f"✅ Matrice de régression valide — {plan['totals']['suites']} suites " + f"gated · {plan['totals']['test_methods']} méthodes recensées · " + f"couverture prouvée.") + return 0 + + +def cmd_run(args: argparse.Namespace) -> int: + spec = _load(_SPEC_PATH) + print("▶ Exécution exhaustive des suites gated (peut prendre un moment)…", + file=sys.stderr) + matrix = builder.run_matrix(spec) + out_dir = args.out or _DEFAULT_OUT + os.makedirs(out_dir, exist_ok=True) + _write_json(os.path.join(out_dir, "regression_run.json"), matrix) + t = matrix["totals"] + for r in matrix["suites"]: + mark = "✅" if r["ok"] else "❌" + print(f" {mark} {r['path']:32s} ran={r['ran']:3d} " + f"fail={r['failures']} err={r['errors']} skip={r['skipped']}") + print(f"\n{'✅' if matrix['verdict'] == 'PASS' else '❌'} Régression " + f"{matrix['verdict']} — {t['green']}/{t['suites']} suites vertes · " + f"{t['passed']} tests passés · {t['failures']} échecs · " + f"{t['errors']} erreurs.") + return 0 if matrix["verdict"] == "PASS" else 1 + + +def build_parser() -> argparse.ArgumentParser: + ap = argparse.ArgumentParser(description="Matrice de régression exhaustive " + "(Sprint 8 · QA).") + sub = ap.add_subparsers(dest="cmd", required=True) + b = sub.add_parser("build", help="écrit le plan déterministe + MANIFEST") + b.add_argument("-o", "--out", help="répertoire de sortie") + b.set_defaults(func=cmd_build) + v = sub.add_parser("validate", help="valide schéma + invariants (gate)") + v.set_defaults(func=cmd_validate) + r = sub.add_parser("run", help="exécute réellement toutes les suites (live)") + r.add_argument("-o", "--out", help="répertoire de sortie") + r.set_defaults(func=cmd_run) + return ap + + +def main(argv=None) -> int: + args = build_parser().parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/05_deliverables_mvp/qa/regression/regression_spec.json b/05_deliverables_mvp/qa/regression/regression_spec.json new file mode 100644 index 0000000..610bc28 --- /dev/null +++ b/05_deliverables_mvp/qa/regression/regression_spec.json @@ -0,0 +1,19 @@ +{ + "matrix": "OTO QA · Matrice de régression exhaustive", + "version": "1.0", + "reference_cadre": "Roadmap Sprint 8 · QA « Regression tests exhaustifs » · CLAUDE.md #5 (zéro régression silencieuse · niveau 4Big)", + "self_module": "qa/regression", + "thresholds": { + "min_test_files": 1, + "min_methods_per_suite": 8 + }, + "seuil_note": "min_test_files / min_methods_per_suite sont des planchers STRUCTURELS (une suite gated doit exister sur disque et couvrir un minimum) — repris de audit_4big (min_test_methods=8). Aucun chiffre métier / projet n'est défini ici : le nombre de tests verts N'EST PAS écrit dans le spec, il est MESURÉ par `run` (#6 anti-invention).", + "discover_cmd": ["python3", "-m", "unittest", "discover", "-s", "tests"], + "notes": [ + "Harnais de MÉTA-NIVEAU : agrège l'exécution de TOUTES les suites de tests gated du mandat en une seule matrice + un verdict PASS/FAIL. Sprint 8 · QA « Regression tests exhaustifs ».", + "Périmètre PROUVÉ, pas déclaré : les suites sont DÉRIVÉES de .gitea/workflows/ci.yml (réutilise q4lib/registry.parse_ci — zéro duplication · #5), jamais listées à la main. Tout job de test ajouté au CI entre automatiquement dans la matrice.", + "Séparation des pouvoirs (ISA 315) : le harnais s'exclut lui-même (self_module) — il ne s'auto-exécute pas (évite la récursion) et ne se compte pas dans le total régression.", + "ANTI-INVENTION (#6) : le `build` (plan) ne contient AUCUN compteur de résultats (0 test vert écrit à la main) ; seuls des FAITS de disque (fichiers/méthodes test_*) et de CI (job, gate) y figurent. Les compteurs verts/rouges sont produits UNIQUEMENT par `run`, en parsant la sortie réelle de unittest.", + "Distinct de l'audit 4Big : audit_4big note la QUALITÉ statique par module (doc/schema/CLI/hand-off) ; ce harnais prouve que chaque suite S'EXÉCUTE AU VERT et fournit le compte agrégé faisant autorité (« N tests verts »). Axes complémentaires, non redondants." + ] +} diff --git a/05_deliverables_mvp/qa/regression/tests/test_regression.py b/05_deliverables_mvp/qa/regression/tests/test_regression.py new file mode 100644 index 0000000..22eedc2 --- /dev/null +++ b/05_deliverables_mvp/qa/regression/tests/test_regression.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Tests du harnais de matrice de régression exhaustive (QA · Sprint 8). + +Stdlib pur (`unittest`) → aucune installation pip requise sur le runner Gitea +(CLAUDE.md #2). Couvre : le parseur PUR de sortie unittest (OK / skipped / +FAILED / vide), l'exécution réelle d'une suite synthétique en tmpdir (verte + +rouge + tests/ absent), la découverte des suites depuis le CI + faits de disque, +la preuve de couverture, l'assemblage du plan, les invariants du générateur +(dont anti-invention #6 : aucun compteur de résultat dans le plan · recompute des +comptes depuis le disque), le déterminisme et le schéma. + +Ce job NE ré-exécute PAS les suites du mandat (le gate le fait déjà job par job) — +il teste le HARNAIS lui-même. +""" + +from __future__ import annotations + +import copy +import json +import os +import sys +import tempfile +import unittest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_MOD = os.path.normpath(os.path.join(_HERE, "..")) +sys.path.insert(0, _MOD) + +from reglib import builder, discovery, runner # noqa: E402 +import regression_gen as gen # noqa: E402 + + +def _spec(): + with open(os.path.join(_MOD, "regression_spec.json"), encoding="utf-8") as fh: + return json.load(fh) + + +def _make_suite(root: str, *, methods: int, failing: bool = False, + with_tests: bool = True) -> str: + """Arbre de module synthétique avec une suite unittest ; renvoie son chemin.""" + mod = os.path.join(root, "mod") + os.makedirs(mod, exist_ok=True) + if with_tests: + tdir = os.path.join(mod, "tests") + os.makedirs(tdir, exist_ok=True) + body = "".join( + f" def test_case_{i}(self):\n" + f" self.assertTrue({'False' if (failing and i == 0) else 'True'})\n" + for i in range(methods) + ) + with open(os.path.join(tdir, "test_mod.py"), "w", encoding="utf-8") as fh: + fh.write("import unittest\nclass T(unittest.TestCase):\n" + + (body or " pass\n")) + return mod + + +# -------------------------------------------------------------------------- +# Parseur PUR de la sortie unittest (aucune exécution). +# -------------------------------------------------------------------------- +class TestParseOutput(unittest.TestCase): + def test_ok(self): + out = "....\n----\nRan 4 tests in 0.01s\n\nOK\n" + r = runner.parse_unittest_output(out, 0) + self.assertEqual(r["ran"], 4) + self.assertEqual(r["passed"], 4) + self.assertEqual((r["failures"], r["errors"], r["skipped"]), (0, 0, 0)) + self.assertTrue(r["ok"]) + + def test_ok_with_skipped(self): + out = "Ran 5 tests in 0.02s\n\nOK (skipped=2)\n" + r = runner.parse_unittest_output(out, 0) + self.assertEqual(r["ran"], 5) + self.assertEqual(r["skipped"], 2) + self.assertEqual(r["passed"], 3) + self.assertTrue(r["ok"]) + + def test_failed(self): + out = "Ran 10 tests in 0.03s\n\nFAILED (failures=1, errors=2, skipped=3)\n" + r = runner.parse_unittest_output(out, 1) + self.assertEqual(r["ran"], 10) + self.assertEqual((r["failures"], r["errors"], r["skipped"]), (1, 2, 3)) + self.assertEqual(r["passed"], 4) + self.assertFalse(r["ok"]) + + def test_empty_output_is_not_ok(self): + r = runner.parse_unittest_output("", 0) + self.assertEqual(r["ran"], 0) + self.assertFalse(r["ok"]) # ran==0 → jamais vert (une suite doit tourner) + + def test_returncode_overrides_ok(self): + out = "Ran 3 tests in 0.01s\n\nOK\n" + self.assertFalse(runner.parse_unittest_output(out, 1)["ok"]) + + +# -------------------------------------------------------------------------- +# Exécution réelle d'une suite (subprocess). +# -------------------------------------------------------------------------- +class TestRunSuite(unittest.TestCase): + def test_green_suite(self): + with tempfile.TemporaryDirectory() as tmp: + mod = _make_suite(tmp, methods=3) + r = runner.run_suite(mod) + self.assertTrue(r["ok"]) + self.assertEqual(r["ran"], 3) + self.assertEqual(r["failures"], 0) + + def test_red_suite(self): + with tempfile.TemporaryDirectory() as tmp: + mod = _make_suite(tmp, methods=3, failing=True) + r = runner.run_suite(mod) + self.assertFalse(r["ok"]) + self.assertGreaterEqual(r["failures"], 1) + + def test_missing_tests_dir(self): + with tempfile.TemporaryDirectory() as tmp: + mod = _make_suite(tmp, methods=0, with_tests=False) + r = runner.run_suite(mod) + self.assertFalse(r["ok"]) + self.assertEqual(r["returncode"], -1) + + +# -------------------------------------------------------------------------- +# Découverte + comptage disque. +# -------------------------------------------------------------------------- +class TestDiscovery(unittest.TestCase): + def test_count_tests(self): + with tempfile.TemporaryDirectory() as tmp: + mod = _make_suite(tmp, methods=7) + files, methods = discovery.count_tests(os.path.join(mod, "tests")) + self.assertEqual(files, 1) + self.assertEqual(methods, 7) + + def test_count_tests_absent(self): + with tempfile.TemporaryDirectory() as tmp: + self.assertEqual(discovery.count_tests(os.path.join(tmp, "nope")), + (0, 0)) + + def test_discover_excludes_self_and_is_sorted(self): + spec = _spec() + suites = discovery.discover_suites(spec) + paths = [s["path"] for s in suites] + self.assertNotIn(spec["self_module"], paths) # séparation des pouvoirs + self.assertEqual(paths, sorted(paths)) # déterministe + self.assertGreater(len(suites), 1) + for s in suites: # faits de disque réels + self.assertTrue(s["has_tests_dir"], s["path"]) + self.assertGreaterEqual(s["test_methods"], 1, s["path"]) + + def test_coverage_ok_on_repo(self): + spec = _spec() + suites = discovery.discover_suites(spec) + cov = discovery.coverage_report(spec, suites) + self.assertTrue(cov["ok"], cov) + self.assertTrue(cov["self_module_gated"]) # le harnais est gated + self.assertEqual(cov["missing_tests_dir"], []) + self.assertEqual(cov["not_in_gate"], []) + + +# -------------------------------------------------------------------------- +# Assemblage du plan + invariants du générateur. +# -------------------------------------------------------------------------- +class TestPlanAndInvariants(unittest.TestCase): + def setUp(self): + self.spec = _spec() + self.plan = builder.build_plan(self.spec) + + def test_plan_passes_and_is_clean(self): + self.assertEqual(self.plan["verdict"], "PASS") + self.assertEqual(gen.check_invariants(self.plan, self.spec), []) + + def test_plan_has_no_result_counters(self): + # #6 : le plan (statique) ne doit contenir AUCUN compteur d'exécution. + forbidden = {"green", "red", "passed", "ran", "failures", "errors"} + self.assertEqual(forbidden & set(self.plan["totals"].keys()), set()) + + def test_totals_match_suites(self): + s = self.plan["suites"] + self.assertEqual(self.plan["totals"]["test_methods"], + sum(x["test_methods"] for x in s)) + self.assertEqual(self.plan["totals"]["suites"], len(s)) + + def test_determinism(self): + self.assertEqual(builder.build_plan(self.spec), self.plan) + + def test_schema_valid(self): + with open(os.path.join(_MOD, "regression.schema.json"), + encoding="utf-8") as fh: + schema = json.load(fh) + from reglib.deps import validate as v + self.assertEqual(v(self.plan, schema), []) + + def test_inv_self_in_suites(self): + bad = copy.deepcopy(self.plan) + bad["suites"].append({ + "id": "self", "path": self.spec["self_module"], "jobs": ["x"], + "in_gate": True, "has_tests_dir": True, "test_files": 1, + "test_methods": 9, + }) + errs = gen.check_invariants(bad, self.spec) + self.assertTrue(any("INV3" in e for e in errs), errs) + + def test_inv_forbidden_counter_leak(self): + bad = copy.deepcopy(self.plan) + bad["totals"]["green"] = 19 + errs = gen.check_invariants(bad, self.spec) + self.assertTrue(any("INV2" in e for e in errs), errs) + + def test_inv_frozen_count_detected(self): + # Un compte de disque figé/fabriqué (≠ recompute) doit être détecté (#6). + bad = copy.deepcopy(self.plan) + bad["suites"][0]["test_methods"] += 100 + bad["totals"]["test_methods"] += 100 + errs = gen.check_invariants(bad, self.spec) + self.assertTrue(any("INV5" in e for e in errs), errs) + + def test_inv_totals_tampered(self): + bad = copy.deepcopy(self.plan) + bad["totals"]["suites"] += 1 + errs = gen.check_invariants(bad, self.spec) + self.assertTrue(any("INV7" in e for e in errs), errs) + + def test_inv_verdict_tampered(self): + bad = copy.deepcopy(self.plan) + bad["verdict"] = "FAIL" if self.plan["verdict"] == "PASS" else "PASS" + errs = gen.check_invariants(bad, self.spec) + self.assertTrue(any("INV8" in e for e in errs), errs) + + +# -------------------------------------------------------------------------- +# CLI (entrypoints reproductibles). +# -------------------------------------------------------------------------- +class TestCLI(unittest.TestCase): + def test_validate_ok(self): + self.assertEqual(gen.main(["validate"]), 0) + + def test_build_writes_artifacts(self): + with tempfile.TemporaryDirectory() as tmp: + self.assertEqual(gen.main(["build", "-o", tmp]), 0) + for f in ("regression_plan.json", "MANIFEST.json"): + self.assertTrue(os.path.exists(os.path.join(tmp, f)), f) + man = json.load(open(os.path.join(tmp, "MANIFEST.json"), + encoding="utf-8")) + self.assertEqual(man["verdict"], "PASS") + self.assertEqual(man["generator"], "regression_gen.py") + + +if __name__ == "__main__": + unittest.main()