diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 94a5498..7c3403c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -143,10 +143,28 @@ jobs: - name: Tests générateur de Role Profile run: python3 -m unittest discover -s tests -v + # -------------------------------------------------------------------------- + # Agrégateur RBAC (Sprint 2 · ERPNext Backend) : recoud les 3 volets RBAC + # (fixtures Role+DocPerm, plan User Permission, Role Profile) en un run-book + # d'application VPS ordonné (SPEC §7) + un manifeste agrégé. Valide contre + # apply_plan.schema.json + invariants (ordre + graphe de dépendances SPEC §7, + # cohérence inter-volets, couverture bijective des 50 rôles). stdlib pur. + # -------------------------------------------------------------------------- + rbac-applyplan-tests: + name: RBAC · run-book d'application unifié (agrégat 3 volets) + runs-on: ubuntu-latest + defaults: + run: + working-directory: 05_deliverables_mvp/rbac/apply_plan + steps: + - uses: actions/checkout@v4 + - name: Tests agrégateur RBAC + 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] + 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] steps: - name: Résultat run: echo "✅ Gate qualité 4Big franchi — tous les checks verts." diff --git a/05_deliverables_mvp/daily_reports/2026-07-30-session9.md b/05_deliverables_mvp/daily_reports/2026-07-30-session9.md new file mode 100644 index 0000000..a375f85 --- /dev/null +++ b/05_deliverables_mvp/daily_reports/2026-07-30-session9.md @@ -0,0 +1,93 @@ +# Daily Report · 2026-07-30 · Claude Code DTP Worker (session 9) + +**Session** : `20260730_042654` + +## Tâche exécutée +**Sprint 2 · Livrable ERPNext Backend « Agrégateur RBAC : run-book d'application +VPS unifié (3 volets → 1 plan ordonné) »** +(roadmap `04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md` §Sprint 2 « RBAC 50 rôles » · +`RBAC_50_ROLES_SPEC.md` §7 · **prochaine tâche suggérée** par le daily report +session 8 : « agrégateur MANIFEST unifié Role + DocPerm + User Permission + +Role Profile pour clore le volet RBAC »). + +## Contexte / analyse +- Relu `CLAUDE.md`, `ROADMAP_8_WEEKS_OR_LESS.md`, daily reports sessions 7 et 8, + `rbac_50_roles.json`, les **trois générateurs RBAC** livrés (`fixtures_gen`, + `userperm_gen`, `roleprofile_gen`) + leurs manifestes, le validateur maison + Publiciste, le patron CI, la SPEC §7. +- État Sprint 2 : contrat RBAC (s4), Publiciste (s3), Faisabilité (s5), fixtures + `Role`+`Custom DocPerm` (s6), plan `User Permission` (s7), `Role Profile` (s8) + livrés. Frontend/CRM dépendent de layouts LIVE (VPS) → hors périmètre worker. +- **Priorité évidente** (report s8 + SPEC §7) : les 3 volets existent mais l'ordre + d'application restait **implicite**. Or il porte des contraintes ERPNext dures + (Role importé AVANT Role Profile) et des confirmations préalables dispersées + dans 3 manifestes. Le maillon manquant = la **vue d'ensemble** : un run-book + ordonné + un manifeste agrégé. 100 % autorable sans VPS/pip/API, même patron + que les 3 générateurs, clôt le volet RBAC en-repo. + +## Réalisé — module `05_deliverables_mvp/rbac/apply_plan/` +- `applylib/aggregator.py` — **agrège sans rien recalculer** : appelle les 3 + builders (`fixtures_builder.build_bundle`, `userperm_builder.build_plan`, + `roleprofile_builder.build_bundle`), recoupe la cohérence (même contrat / même + version / même cible 50 → `_require_same` lève sur mélange), fusionne les + confirmations VPS, et produit le **run-book des 6 étapes SPEC §7** (responsable + worker/vps, statut, commande worker, artefacts, `depends_on`, confirmations, + lien doc). Ordre + dépendances = savoir procédural documenté, pas des chiffres. +- `rbac_apply_plan.py` — CLI `build` / `validate`. **Refuse d'écrire** si un + invariant casse : couverture SPEC §7 exacte + `order` séquentiel, graphe de + dépendances (aucune arête en avant, aucun renvoi fantôme), Role Profile **après** + fixtures Role, confirmations ni orphelines ni fantômes, cohérence inter-volets, + **couverture bijective** (Role == 50 == entrées UP == rôles couverts profils), + listes triées. +- `apply_plan.schema.json` — contrat de sortie draft-07 (sous-ensemble supporté + par le **validateur maison Publiciste**, zéro pip · `commande` nullable via + type-array `["string","null"]`). +- `tests/test_apply_plan.py` — **16 tests `unittest` (stdlib pur)** : schéma + (maison + oracle `jsonschema`), fidélité des comptes aux manifestes source, + fusion des confirmations, ordre + graphe SPEC §7, Role avant Role Profile, + garde-fous de cohérence (rejet version/cible discordantes), déterminisme, + détection d'invariants cassés. +- **CI** : job `rbac-applyplan-tests` ajouté au **gate** de + `.gitea/workflows/ci.yml` (Gitea Actions uniquement · #2). +- `README.md` + `.gitignore` (output `out/` non commité, re-généré à la demande). +- **Docs cousues** : `RBAC_50_ROLES_SPEC.md §7` (préambule pointe le run-book + consolidé + nouveau ✅ encart livré). + +## Anti-invention (#6) appliqué +- **Zéro chiffre recalculé** : chaque compte du manifeste agrégé provient du + manifeste d'un builder source (vérifié par `test_counts_come_from_source_manifests`). +- Chaque item « à confirmer VPS » (DocType custom / Company / rôles `equipe`) est + **repris tel quel** des manifestes d'origine, juste trié et fusionné. +- L'ordre des étapes et le graphe de dépendances encodent des **contraintes + ERPNext v15 documentées** (SPEC §7), pas des décisions fabriquées. +- Un mélange de volets (versions de contrat différentes) est **refusé**, jamais + silencieusement concilié. + +## Vérifications effectuées (en-repo, sans toucher au VPS) +- **16/16 tests verts** (schéma maison + oracle `jsonschema`). Génération réelle : + **6 étapes**, comptes agrégés 50 rôles / 116 DocPerm / 45 DocTypes / 28 UP + templates / 6 Role Profile ; confirmations VPS = 4 DocType custom + 5 Company + + 4 rôles `equipe` ; **couverture bijective 50/50 recoupée sur les 3 volets**. +- **Gate CI local vert (exit 0)** : `guard_constraints.sh`, `validate_json.sh`, + `check_docs.sh` (0 lien cassé, mention score présente), YAML `ci.yml` valide. +- **Régression** : 23 Publiciste + 10 RBAC + 11 fixtures + 12 userperm + 11 + roleprofile + 16 apply_plan + 16 Faisabilité = **99 tests verts**. + +## Non fait (hors périmètre worker · VPS) +- Exécution réelle du run-book (`bench migrate`, création DocTypes custom, + affectation `role_profile_name`, matérialisation `User Permission` par + utilisateur) → agent ERPNext Backend (SPEC §7 ét. 2/3/4/5/6). + +## Prochaine tâche suggérée +- **Volet RBAC clos en-repo** (4 générateurs + run-book unifié). Suite logique : + Faisabilité S3 (`40_llm_outputs/` + rapports bancables FR/EN/ES) ; ou préparer + le hand-off ERPNext (packaging `fixtures/` de l'app OTO à partir des 4 sorties). +- Frontend/CRM S2 : dépend des layouts LIVE (VPS) → hors périmètre worker. + +--- + +**Auto-score 4Big du livrable Agrégateur RBAC (run-book) : 96/100.** +Réserve −4 : l'exécution réelle du run-book reste côté VPS (agent ERPNext, hors +périmètre worker, #8). Validé statiquement en-repo (16 tests verts + schéma +conforme + recoupement bijectif des 3 volets + gate CI vert · 99 tests de +régression au total). diff --git a/05_deliverables_mvp/rbac/RBAC_50_ROLES_SPEC.md b/05_deliverables_mvp/rbac/RBAC_50_ROLES_SPEC.md index 9b9165a..5fcc142 100644 --- a/05_deliverables_mvp/rbac/RBAC_50_ROLES_SPEC.md +++ b/05_deliverables_mvp/rbac/RBAC_50_ROLES_SPEC.md @@ -122,7 +122,11 @@ du code sans vérifier l'existence courante »). ## 7. Application sur VPS (hors périmètre worker) Ce worker n'écrit **jamais** sur le VPS. L'agent ERPNext Backend appliquera la -cible côté serveur (`erpnext-backend-1`) : +cible côté serveur (`erpnext-backend-1`) dans l'ordre ci-dessous. Ce run-book +est **consolidé et vérifié** en-repo par l'agrégateur +[`apply_plan/`](apply_plan/README.md) (`python3 rbac_apply_plan.py build` → +`apply_plan.json` + `MANIFEST.json` agrégé : comptes des 3 volets, confirmations +VPS fusionnées, graphe de dépendances, recoupement bijectif des 50 rôles) : 1. Générer les fixtures `Role` + `Custom DocPerm` depuis `rbac_50_roles.json` — **livré** : [`fixtures_gen/`](fixtures_gen/README.md) (`python3 rbac_fixtures_gen.py build`). @@ -159,6 +163,13 @@ cible côté serveur (`erpnext-backend-1`) : > métier + console technique `plateforme`) couvrant les **50 rôles de façon > bijective**, 100 % DocTypes natifs v15 (`Role Profile` + `Has Role`), aucun > DocType custom à confirmer. 11 tests + job CI `rbac-roleprofile-tests`. +> +> ✅ Le **run-book d'application unifié** (agrégat des 3 volets ci-dessus) est +> **livré** en-repo ([`apply_plan/`](apply_plan/README.md)) : les 6 étapes de ce +> §7 sous forme d'un plan ordonné + un manifeste agrégé (comptes consolidés, +> confirmations VPS fusionnées, cohérence inter-volets). Zéro chiffre recalculé +> (tout vient d'un manifeste source, #6), graphe de dépendances validé (Role +> avant Role Profile). 16 tests + job CI `rbac-applyplan-tests`. ## 8. Vérification (en-repo, sans toucher au VPS) diff --git a/05_deliverables_mvp/rbac/apply_plan/.gitignore b/05_deliverables_mvp/rbac/apply_plan/.gitignore new file mode 100644 index 0000000..3b6ddac --- /dev/null +++ b/05_deliverables_mvp/rbac/apply_plan/.gitignore @@ -0,0 +1,4 @@ +# Sortie re-générable à la demande (déterministe) — non commitée. +out/ +__pycache__/ +*.pyc diff --git a/05_deliverables_mvp/rbac/apply_plan/README.md b/05_deliverables_mvp/rbac/apply_plan/README.md new file mode 100644 index 0000000..f8bc88b --- /dev/null +++ b/05_deliverables_mvp/rbac/apply_plan/README.md @@ -0,0 +1,95 @@ +# Agrégateur RBAC · run-book d'application VPS unifié + +**Sprint 2 · agent ERPNext Backend.** Quatrième et dernier maillon RBAC en-repo : +la **vue d'ensemble**. Les trois générateurs livrés produisent chacun une pièce +isolée du puzzle ; cet agrégateur les recoud en **un seul plan d'application +ordonné** (SPEC §7) que l'agent ERPNext suit pas à pas sur le VPS. + +| Générateur | Répond à | Produit | +|---|---|---| +| [`fixtures_gen`](../fixtures_gen/README.md) | quels verbes / quel DocType | `Role` + `Custom DocPerm` | +| [`userperm_gen`](../userperm_gen/README.md) | sur quelles lignes | plan `User Permission` row-level | +| [`roleprofile_gen`](../roleprofile_gen/README.md) | quel bundle assignable | `Role Profile` par portail | +| **`apply_plan`** (ce module) | **dans quel ordre appliquer** | **run-book + manifeste agrégé** | + +> Ce worker **n'écrit jamais sur le VPS** (contrainte #8). Il produit un plan +> ordonné en-repo ; l'application réelle (`bench migrate`, affectations) reste +> côté serveur. + +## Pourquoi un agrégateur (et pas juste trois modules) + +Trois sorties séparées laissent l'ordre d'application implicite. Or cet ordre +porte des **contraintes ERPNext dures** : par exemple, les lignes `Has Role` d'un +`Role Profile` référencent des `Role` qui doivent **déjà exister** → il faut +importer les fixtures `Role` **avant** les `Role Profile`. Certaines étapes +exigent aussi des **confirmations préalables** (DocTypes custom, Companies, rôles +de portée `equipe`). L'agrégateur matérialise ce **graphe de dépendances** et +**recoupe la cohérence** des trois volets (même contrat, même cible 50 rôles, +couverture bijective) — un seul artefact à lire pour l'agent ERPNext. + +## Ce qui est généré (`out/`, non commité) + +| Fichier | Rôle | +|---|---| +| `apply_plan.json` | **Run-book ordonné** : 6 étapes (SPEC §7) — responsable (worker/vps), statut, commande worker, artefacts produits, dépendances, confirmations préalables, lien doc. | +| `MANIFEST.json` | **Manifeste agrégé** : comptes consolidés des 3 volets, items « à confirmer VPS » fusionnés (DocType custom / Company / rôles `equipe`), recoupement de cohérence (bijectivité 50 rôles). | + +## Run-book généré (SPEC §7 · ordre d'application VPS) + +| # | Responsable | Étape | Dépend de | +|---|---|---|---| +| 1 | worker | Générer fixtures `Role` + `Custom DocPerm` | — | +| 2 | vps | Créer les DocTypes DTP custom manquants (après confirmation) | 1 | +| 3 | vps | Déposer `role.json` + `custom_docperm.json` dans `fixtures/` + `bench migrate` | 1, 2 | +| 4 | worker+vps | Matérialiser les `User Permission` row-level par utilisateur | 3 | +| 5 | worker+vps | Importer les `Role Profile` (**après** les `Role`) + affecter `User.role_profile_name` | 3 | +| 6 | vps | Vérification HTTP post-déploiement + audit QA 4Big | 4, 5 | + +## Utilisation + +```bash +# Génère out/apply_plan.json + out/MANIFEST.json +python3 rbac_apply_plan.py build # [-o DOSSIER] + +# Valide le plan (schéma + invariants) sans rien écrire +python3 rbac_apply_plan.py validate + +# Tests (stdlib pur, zéro pip) +python3 -m unittest discover -s tests -v +``` + +## Garde-fous (anti-invention · #6 · déterminisme) + +- **Zéro chiffre recalculé** : chaque compte provient du manifeste du builder + source (fixtures / userperm / roleprofile). Chaque item « à confirmer VPS » est + repris tel quel. Le CLI **refuse d'écrire** si un invariant casse. +- **Cohérence inter-volets** : les trois builders doivent dériver du **même + contrat** (même `source_version`, même cible 50) — un mélange lève `ValueError`. +- **Couverture bijective** re-vérifiée à travers les 3 volets : `Role` fixtures == + cible == entrées du plan `User Permission` == rôles couverts par `Role Profile`. +- **Graphe de dépendances** validé : aucune dépendance en avant, aucun renvoi + fantôme ; `roleprofile-apply` dépend bien de `fixtures-migrate` (Role avant + Role Profile). Aucune confirmation orpheline (item non vide sans étape qui la + cite) ni fantôme (étape citant une clé inexistante). +- Sortie **déterministe** (ordre d'étapes fixe, listes triées, aucun horodatage) + → diffable, re-générable bit-à-bit en CI. + +## Vérification en-repo + +- `python3 -m unittest discover -s tests -v` → **16/16 verts** (schéma maison + + oracle `jsonschema` si présent ; fidélité des comptes aux manifestes source, + fusion des confirmations, ordre + graphe SPEC §7, garde-fous de cohérence, + déterminisme). +- Génération réelle : **6 étapes** · 50 rôles / 116 DocPerm / 28 UP templates / + 6 Role Profile · confirmations VPS 4 DocType custom + 5 Company + 4 rôles + `equipe`. +- Job CI dédié `rbac-applyplan-tests` ajouté au **gate** (`.gitea/workflows/ci.yml`, + Gitea Actions uniquement · #2). + +## Auto-score 4Big du livrable : **96/100** + +_Réserve −4_ : l'exécution réelle du run-book (`bench migrate`, création des +DocTypes custom, affectation `role_profile_name` / matérialisation des +`User Permission` par utilisateur) reste côté VPS (agent ERPNext, hors périmètre +worker, contrainte #8). Validé statiquement en-repo (16 tests verts + schéma +conforme + recoupement bijectif des 3 volets + gate CI). diff --git a/05_deliverables_mvp/rbac/apply_plan/apply_plan.schema.json b/05_deliverables_mvp/rbac/apply_plan/apply_plan.schema.json new file mode 100644 index 0000000..a2adb25 --- /dev/null +++ b/05_deliverables_mvp/rbac/apply_plan/apply_plan.schema.json @@ -0,0 +1,137 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://oto-enterprise-os-dtp/rbac/apply_plan.schema.json", + "title": "Run-book d'application RBAC unifié (agrégat des 3 volets)", + "description": "Contrat de sortie de rbac_apply_plan.py. Validé par le validateur maison Publiciste (zéro pip) dans le job CI rbac-applyplan-tests. Consolide les manifestes des 3 générateurs RBAC (fixtures Role+DocPerm, plan User Permission, Role Profile) en un plan d'application VPS ordonné (SPEC §7) + un manifeste agrégé recoupant leur cohérence. Aucun chiffre recalculé : tout compte provient d'un builder source.", + "type": "object", + "additionalProperties": false, + "required": ["manifest", "apply_plan"], + "properties": { + "manifest": { + "type": "object", + "additionalProperties": false, + "required": [ + "generated_from", + "source_version", + "cible_rbac_roles", + "counts", + "confirmations_vps", + "consistency" + ], + "properties": { + "generated_from": { "type": "string", "const": "rbac_50_roles.json" }, + "source_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "cible_rbac_roles": { "type": "integer", "const": 50 }, + "counts": { + "type": "object", + "additionalProperties": false, + "required": [ + "roles", + "custom_docperm", + "doctypes_uniques", + "user_permission_templates", + "role_profiles" + ], + "properties": { + "roles": { "type": "integer", "minimum": 50, "maximum": 50 }, + "custom_docperm": { "type": "integer", "minimum": 1 }, + "doctypes_uniques": { "type": "integer", "minimum": 1 }, + "user_permission_templates": { "type": "integer", "minimum": 0 }, + "role_profiles": { "type": "integer", "minimum": 1 } + } + }, + "confirmations_vps": { + "type": "object", + "additionalProperties": false, + "required": ["custom_doctypes", "companies", "roles_scope_equipe"], + "properties": { + "custom_doctypes": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "companies": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "roles_scope_equipe": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + } + } + }, + "consistency": { + "type": "object", + "additionalProperties": false, + "required": [ + "sources_coherentes", + "source_version", + "cible_rbac_roles", + "roles_fixtures", + "userperm_plan_entries", + "roleprofile_roles_couverts", + "couverture_bijective" + ], + "properties": { + "sources_coherentes": { "type": "boolean", "const": true }, + "source_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "cible_rbac_roles": { "type": "integer", "const": 50 }, + "roles_fixtures": { "type": "integer", "minimum": 0 }, + "userperm_plan_entries": { "type": "integer", "minimum": 0 }, + "roleprofile_roles_couverts": { "type": "integer", "minimum": 0 }, + "couverture_bijective": { "type": "boolean", "const": true } + } + } + } + }, + "apply_plan": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/definitions/step" } + } + }, + "definitions": { + "step": { + "type": "object", + "additionalProperties": false, + "required": [ + "order", + "id", + "titre", + "responsable", + "statut", + "commande", + "produces", + "depends_on", + "confirmations", + "doc" + ], + "properties": { + "order": { "type": "integer", "minimum": 1 }, + "id": { "type": "string", "pattern": "^[a-z0-9-]+$" }, + "titre": { "type": "string", "minLength": 1 }, + "responsable": { "type": "string", "enum": ["worker", "vps", "worker+vps"] }, + "statut": { "type": "string", "minLength": 1 }, + "commande": { "type": ["string", "null"] }, + "produces": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, + "depends_on": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z0-9-]+$" } + }, + "confirmations": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "enum": ["custom_doctypes", "companies", "roles_scope_equipe"] } + }, + "doc": { "type": "string", "minLength": 1 } + } + } + } +} diff --git a/05_deliverables_mvp/rbac/apply_plan/applylib/__init__.py b/05_deliverables_mvp/rbac/apply_plan/applylib/__init__.py new file mode 100644 index 0000000..cf5f660 --- /dev/null +++ b/05_deliverables_mvp/rbac/apply_plan/applylib/__init__.py @@ -0,0 +1,7 @@ +"""Agrégateur RBAC : run-book d'application VPS unifié (Sprint 2). + +Ce paquet ne dépend que des trois builders RBAC déjà livrés +(`fixturelib`, `permlib`, `profilelib`) — il ne ré-implémente aucune logique +métier (workflow #5 : zéro duplication). Il consolide leurs sorties en un +**plan d'application ordonné** unique (SPEC §7) + un manifeste agrégé. +""" diff --git a/05_deliverables_mvp/rbac/apply_plan/applylib/aggregator.py b/05_deliverables_mvp/rbac/apply_plan/applylib/aggregator.py new file mode 100644 index 0000000..2c02768 --- /dev/null +++ b/05_deliverables_mvp/rbac/apply_plan/applylib/aggregator.py @@ -0,0 +1,194 @@ +"""Agrégation des trois volets RBAC en un plan d'application VPS unique. + +Pourquoi cet agrégateur +----------------------- +Trois générateurs RBAC sont livrés en-repo, chacun répondant à UNE question : + - `fixtures_gen` → « QUELS verbes sur QUEL DocType » (Role + Custom DocPerm). + - `userperm_gen` → « SUR QUELLES LIGNES » (scope_donnees → User Permission). + - `roleprofile_gen`→ « QUEL bundle assignable » (Role Profile par portail). +Il manquait la **vue d'ensemble** : dans quel ORDRE l'agent ERPNext applique-t-il +ces trois sorties sur le VPS, avec quelles dépendances et quelles confirmations +préalables ? C'est le rôle de ce module : produire le **run-book** (SPEC §7) +consolidé, plus un manifeste agrégé qui recoupe la cohérence des trois volets. + +Ce module NE ré-implémente RIEN : il APPELLE les trois builders et lit leurs +manifestes. Il n'invente aucun chiffre (#6) — tout compte vient d'un builder ; +tout item « à confirmer VPS » est repris tel quel du manifeste d'origine. + +Déterminisme : ordre des étapes fixe (SPEC §7), listes de confirmations triées, +aucun horodatage → sortie diffable et re-générable bit-à-bit à contrat constant. +""" + +from __future__ import annotations + +from typing import Any + +# Étapes du run-book (SPEC §7). L'ordre et les dépendances sont du SAVOIR +# procédural documenté (pas des chiffres inventés) : ils encodent les contraintes +# d'application ERPNext v15 (ex. les `Has Role` d'un Role Profile référencent des +# `Role` qui doivent exister → import Role AVANT Role Profile). +# `responsable` : worker (livré en-repo) | vps (agent ERPNext) | worker+vps. +_STEPS: list[dict[str, Any]] = [ + { + "order": 1, + "id": "fixtures-generate", + "titre": "Générer les fixtures Role + Custom DocPerm", + "responsable": "worker", + "statut": "livré", + "commande": "python3 fixtures_gen/rbac_fixtures_gen.py build", + "produces": ["role.json", "custom_docperm.json"], + "depends_on": [], + "confirmations": [], + "doc": "../fixtures_gen/README.md", + }, + { + "order": 2, + "id": "custom-doctypes-create", + "titre": "Créer les DocTypes DTP custom manquants (après confirmation)", + "responsable": "vps", + "statut": "à faire VPS", + "commande": None, + "produces": [], + "depends_on": ["fixtures-generate"], + "confirmations": ["custom_doctypes"], + "doc": "../RBAC_50_ROLES_SPEC.md", + }, + { + "order": 3, + "id": "fixtures-migrate", + "titre": "Déposer role.json + custom_docperm.json dans fixtures/ puis bench migrate", + "responsable": "vps", + "statut": "à faire VPS", + "commande": None, + "produces": [], + "depends_on": ["fixtures-generate", "custom-doctypes-create"], + "confirmations": [], + "doc": "../fixtures_gen/README.md", + }, + { + "order": 4, + "id": "userperm-apply", + "titre": "Matérialiser les User Permission row-level par utilisateur", + "responsable": "worker+vps", + "statut": "plan livré · matérialisation VPS", + "commande": "python3 userperm_gen/userperm_gen.py build", + "produces": ["user_permission_plan.json"], + "depends_on": ["fixtures-migrate"], + "confirmations": ["companies", "roles_scope_equipe"], + "doc": "../userperm_gen/README.md", + }, + { + "order": 5, + "id": "roleprofile-apply", + "titre": "Importer les Role Profile (après les Role) puis affecter User.role_profile_name", + "responsable": "worker+vps", + "statut": "bundle livré · affectation VPS", + "commande": "python3 roleprofile_gen/roleprofile_gen.py build", + "produces": ["role_profile.json"], + # Dépend de fixtures-migrate : les Has Role référencent des Role existants. + "depends_on": ["fixtures-migrate"], + "confirmations": [], + "doc": "../roleprofile_gen/README.md", + }, + { + "order": 6, + "id": "verify-http-qa", + "titre": "Vérification HTTP post-déploiement + audit QA 4Big", + "responsable": "vps", + "statut": "à faire VPS", + "commande": None, + "produces": [], + "depends_on": ["userperm-apply", "roleprofile-apply"], + "confirmations": [], + "doc": "../RBAC_50_ROLES_SPEC.md", + }, +] + + +def _require_same(label: str, values: list[Any]) -> Any: + """Renvoie l'unique valeur partagée par les trois volets, ou lève.""" + uniques = sorted({repr(v) for v in values}) + if len(uniques) != 1: + raise ValueError( + f"Volets RBAC incohérents sur {label} : {uniques} — " + "les trois générateurs doivent dériver du MÊME contrat." + ) + return values[0] + + +def build_apply_plan( + contract: dict, + fixtures_bundle: dict, + userperm_plan: dict, + roleprofile_bundle: dict, +) -> dict[str, Any]: + """Consolide les trois volets RBAC en run-book + manifeste agrégé. + + Aucun chiffre n'est recalculé : chaque compte provient du manifeste du + builder correspondant (source unique). La cohérence inter-volets (même + version de contrat, même cible 50 rôles) est vérifiée et refuse un mélange. + """ + fm = fixtures_bundle["manifest"] + um = userperm_plan["manifest"] + pm = roleprofile_bundle["manifest"] + + # Cohérence : les trois volets doivent dériver du même contrat (version + cible). + source_version = _require_same( + "source_version", [fm["source_version"], um["source_version"], pm["source_version"]] + ) + cible = _require_same( + "cible_rbac_roles", + [fm["cible_rbac_roles"], um["cible_rbac_roles"], pm["cible_rbac_roles"]], + ) + generated_from = _require_same( + "generated_from", [fm["generated_from"], um["generated_from"], pm["generated_from"]] + ) + + # Confirmations VPS consolidées (reprises telles quelles des manifestes source). + confirmations_vps = { + "custom_doctypes": sorted(fm.get("custom_doctypes_a_confirmer", [])), + "companies": sorted(um.get("companies_a_confirmer", [])), + "roles_scope_equipe": sorted(um.get("roles_scope_equipe_a_confirmer", [])), + } + + # Comptes agrégés : chaque valeur vient d'un manifeste de builder (pas d'invention). + counts = { + "roles": fm["counts"]["role"], + "custom_docperm": fm["counts"]["custom_docperm"], + "doctypes_uniques": fm["counts"]["doctypes_uniques"], + "user_permission_templates": um["counts"]["user_permission_templates"], + "role_profiles": pm["counts"]["role_profiles"], + } + + # Cohérence bijective : le nombre de rôles Role == cible == rôles couverts par + # les Role Profile == entrées du plan User Permission (un plan par rôle). + up_entries = um["counts"]["plan_entries"] + rp_covered = pm["counts"]["roles_couverts"] + consistency = { + "sources_coherentes": True, # garanti par _require_same ci-dessus + "source_version": source_version, + "cible_rbac_roles": cible, + "roles_fixtures": counts["roles"], + "userperm_plan_entries": up_entries, + "roleprofile_roles_couverts": rp_covered, + "couverture_bijective": counts["roles"] == cible == up_entries == rp_covered, + } + + manifest = { + "generated_from": generated_from, + "source_version": source_version, + "cible_rbac_roles": cible, + "counts": counts, + "confirmations_vps": confirmations_vps, + "consistency": consistency, + } + + return { + "manifest": manifest, + "apply_plan": [dict(step) for step in _STEPS], + } + + +def step_ids() -> list[str]: + """IDs des étapes du run-book (pour les invariants du CLI/tests).""" + return [s["id"] for s in _STEPS] diff --git a/05_deliverables_mvp/rbac/apply_plan/rbac_apply_plan.py b/05_deliverables_mvp/rbac/apply_plan/rbac_apply_plan.py new file mode 100644 index 0000000..49c84f1 --- /dev/null +++ b/05_deliverables_mvp/rbac/apply_plan/rbac_apply_plan.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Agrégateur RBAC · run-book d'application VPS unifié (Sprint 2). + +Quatrième et dernier maillon RBAC en-repo — la **vue d'ensemble**. Les trois +générateurs livrés répondent chacun à une question isolée : + - `../fixtures_gen` → Role + Custom DocPerm (quels verbes / quel DocType). + - `../userperm_gen` → plan User Permission row-level (sur quelles lignes). + - `../roleprofile_gen`→ Role Profile par portail (quel bundle assignable). +Il manquait l'**ordre d'application** consolidé : ce module produit le run-book +(SPEC §7) + un manifeste agrégé qui recoupe la cohérence des trois volets (même +contrat, même cible 50 rôles, couverture bijective). + +Cible de portage VPS : agent ERPNext Backend. Ce worker n'écrit JAMAIS sur le +VPS — il produit en-repo un plan ordonné que l'agent suit pas à pas. + +Produit : + - apply_plan.json → run-book ordonné (6 étapes SPEC §7 : responsable, statut, + commande worker, artefacts produits, dépendances, + confirmations préalables VPS, lien doc). + - MANIFEST.json → manifeste agrégé (comptes consolidés des 3 volets, items + « à confirmer VPS » fusionnés, recoupement de cohérence). + +Sous-commandes : + build [-o OUT] → écrit apply_plan.json / MANIFEST.json + validate [-o OUT] → (re)génère en mémoire, valide vs apply_plan.schema.json + + invariants — sort en erreur si un invariant casse. + +Sortie déterministe (ordre d'étapes fixe, listes triées, aucun horodatage). +Anti-invention (#6) : aucun chiffre recalculé — tout compte provient d'un +manifeste de builder ; chaque item « à confirmer » est repris tel quel. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_RBAC_DIR = os.path.normpath(os.path.join(_HERE, "..")) # rbac/ +_DELIVERABLES = os.path.normpath(os.path.join(_RBAC_DIR, "..")) # 05_deliverables_mvp/ + +# Réutilisation (workflow #5 : zéro duplication) des trois builders RBAC + du +# validateur maison Publiciste. Paquets aux noms distincts (fixturelib / permlib +# / profilelib / applylib / lib) → aucune collision de module. +sys.path.insert(0, _HERE) +sys.path.insert(0, os.path.join(_RBAC_DIR, "fixtures_gen")) +sys.path.insert(0, os.path.join(_RBAC_DIR, "userperm_gen")) +sys.path.insert(0, os.path.join(_RBAC_DIR, "roleprofile_gen")) +sys.path.insert(0, os.path.join(_DELIVERABLES, "publiciste")) + +from applylib import aggregator # noqa: E402 +from fixturelib import builder as fixtures_builder # type: ignore # noqa: E402 +from permlib import builder as userperm_builder # type: ignore # noqa: E402 +from profilelib import builder as roleprofile_builder # type: ignore # noqa: E402 +from lib import validator as maison # type: ignore # noqa: E402 + +_CONTRACT_PATH = os.path.join(_RBAC_DIR, "rbac_50_roles.json") +_SCHEMA_PATH = os.path.join(_HERE, "apply_plan.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 _build_plan() -> dict: + """Appelle les 3 builders sur le contrat, puis agrège en run-book.""" + contract = _load(_CONTRACT_PATH) + return aggregator.build_apply_plan( + contract, + fixtures_builder.build_bundle(contract), + userperm_builder.build_plan(contract), + roleprofile_builder.build_bundle(contract), + ) + + +def _validate_plan(plan: dict) -> list[str]: + """Valide le plan contre apply_plan.schema.json + invariants RBAC agrégés.""" + schema = _load(_SCHEMA_PATH) + errors = list(maison.validate(plan, schema)) + + manifest = plan["manifest"] + steps = plan["apply_plan"] + + # Invariant 1 · le run-book couvre EXACTEMENT les étapes SPEC §7, dans l'ordre. + expected_ids = aggregator.step_ids() + got_ids = [s["id"] for s in steps] + if got_ids != expected_ids: + errors.append(f"apply_plan: étapes {got_ids} ≠ SPEC §7 {expected_ids}.") + orders = [s["order"] for s in steps] + if orders != sorted(orders) or orders != list(range(1, len(steps) + 1)): + errors.append(f"apply_plan: champ `order` non séquentiel {orders}.") + + # Invariant 2 · intégrité du graphe de dépendances (pas de renvoi fantôme, + # pas de dépendance en avant → l'agent applique dans l'ordre sans blocage). + id_to_order = {s["id"]: s["order"] for s in steps} + for s in steps: + for dep in s["depends_on"]: + if dep not in id_to_order: + errors.append(f"{s['id']}: dépendance inconnue {dep!r}.") + elif id_to_order[dep] >= s["order"]: + errors.append(f"{s['id']}: dépend d'une étape postérieure {dep!r}.") + + # Invariant 3 · dépendance métier ERPNext critique : Role Profile importé + # APRÈS la migration des fixtures Role (Has Role référence des Role existants). + rp = next((s for s in steps if s["id"] == "roleprofile-apply"), None) + if rp is not None and "fixtures-migrate" not in rp["depends_on"]: + errors.append("roleprofile-apply doit dépendre de fixtures-migrate (Role avant Role Profile).") + + # Invariant 4 · toute confirmation citée par une étape existe dans le manifeste + # ET toute confirmation non vide du manifeste est référencée par au moins une + # étape (aucun item « à confirmer » orphelin, aucune référence fantôme). + conf = manifest["confirmations_vps"] + referenced: set[str] = set() + for s in steps: + for key in s["confirmations"]: + if key not in conf: + errors.append(f"{s['id']}: confirmation inconnue {key!r}.") + referenced.add(key) + for key, items in conf.items(): + if items and key not in referenced: + errors.append(f"confirmations_vps.{key} non vide mais référencée par aucune étape.") + + # Invariant 5 · cohérence inter-volets : les 3 volets dérivent du même contrat. + cons = manifest["consistency"] + if not cons["sources_coherentes"]: + errors.append("consistency.sources_coherentes=false (volets RBAC incohérents).") + if cons["source_version"] != manifest["source_version"]: + errors.append("consistency.source_version ≠ manifest.source_version.") + if cons["cible_rbac_roles"] != manifest["cible_rbac_roles"]: + errors.append("consistency.cible_rbac_roles ≠ manifest.cible_rbac_roles.") + + # Invariant 6 · couverture bijective des 50 rôles à travers les 3 volets : + # fixtures Role == cible == entrées plan UP == rôles couverts par Role Profile. + cible = manifest["cible_rbac_roles"] + if not (cons["roles_fixtures"] == cible == cons["userperm_plan_entries"] == cons["roleprofile_roles_couverts"]): + errors.append( + "Couverture non bijective : " + f"Role={cons['roles_fixtures']}, cible={cible}, " + f"UP={cons['userperm_plan_entries']}, RoleProfile={cons['roleprofile_roles_couverts']}." + ) + if not cons["couverture_bijective"]: + errors.append("consistency.couverture_bijective=false alors que les comptes concordent (ou l'inverse).") + if manifest["counts"]["roles"] != cible: + errors.append("counts.roles ≠ cible_rbac_roles (les fixtures ne couvrent pas les 50 rôles).") + + # Invariant 7 · déterminisme : listes de confirmations triées. + for key, items in conf.items(): + if items != sorted(items): + errors.append(f"confirmations_vps.{key} non trié (déterminisme).") + + return errors + + +def cmd_build(args: argparse.Namespace) -> int: + plan = _build_plan() + errors = _validate_plan(plan) + if errors: + _eprint("❌ Run-book invalide — génération refusée (anti-régression) :") + for e in errors: + _eprint(f" - {e}") + return 1 + + out = os.path.abspath(args.out) + os.makedirs(out, exist_ok=True) + _write_json(os.path.join(out, "apply_plan.json"), plan["apply_plan"]) + _write_json(os.path.join(out, "MANIFEST.json"), plan["manifest"]) + + m = plan["manifest"] + c = m["counts"] + print(f"✅ Run-book RBAC généré dans {out}") + print( + f" apply_plan.json : {len(plan['apply_plan'])} étapes (SPEC §7) · " + f"MANIFEST.json : {c['roles']} rôles, {c['custom_docperm']} DocPerm, " + f"{c['user_permission_templates']} UP templates, {c['role_profiles']} Role Profile" + ) + for s in plan["apply_plan"]: + cmd = f" → {s['commande']}" if s["commande"] else "" + print(f" {s['order']}. [{s['responsable']}] {s['titre']} ({s['statut']}){cmd}") + conf = m["confirmations_vps"] + print( + f" Confirmations VPS : {len(conf['custom_doctypes'])} DocType custom, " + f"{len(conf['companies'])} Company, {len(conf['roles_scope_equipe'])} rôles `equipe`." + ) + return 0 + + +def cmd_validate(args: argparse.Namespace) -> int: + plan = _build_plan() + errors = _validate_plan(plan) + if errors: + _eprint("❌ Validation KO :") + for e in errors: + _eprint(f" - {e}") + return 1 + cons = plan["manifest"]["consistency"] + print( + f"✅ Validation OK — {len(plan['apply_plan'])} étapes, " + f"couverture bijective {cons['roles_fixtures']}/50 sur les 3 volets, " + f"schéma + invariants verts." + ) + return 0 + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser( + description="Agrégateur RBAC : run-book d'application VPS unifié (3 volets → 1 plan ordonné)." + ) + sub = p.add_subparsers(dest="cmd", required=True) + + pb = sub.add_parser("build", help="génère apply_plan.json / MANIFEST.json") + pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (défaut: ./out)") + pb.set_defaults(func=cmd_build) + + pv = sub.add_parser("validate", help="valide le plan (schéma + invariants) sans écrire") + pv.add_argument("-o", "--out", default=_DEFAULT_OUT, help="ignoré (compat)") + pv.set_defaults(func=cmd_validate) + + args = p.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/05_deliverables_mvp/rbac/apply_plan/tests/test_apply_plan.py b/05_deliverables_mvp/rbac/apply_plan/tests/test_apply_plan.py new file mode 100644 index 0000000..eca80ca --- /dev/null +++ b/05_deliverables_mvp/rbac/apply_plan/tests/test_apply_plan.py @@ -0,0 +1,182 @@ +"""Tests de l'agrégateur RBAC (run-book d'application VPS unifié). + +Stdlib pur (`unittest`) → aucune install pip requise sur le runner Gitea. La +bibliothèque `jsonschema`, si présente, sert d'oracle draft-07 en complément du +validateur maison Publiciste (parité). Sinon elle est ignorée. + +Couverture : schéma (maison + oracle), fidélité des comptes au manifeste de +chaque builder source (anti-invention #6), fusion des confirmations VPS, ordre +et graphe de dépendances du run-book (SPEC §7), garde-fou de cohérence +inter-volets, déterminisme. +""" + +from __future__ import annotations + +import json +import os +import sys +import unittest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_MODULE_DIR = os.path.normpath(os.path.join(_HERE, "..")) # apply_plan/ +_RBAC_DIR = os.path.normpath(os.path.join(_MODULE_DIR, "..")) # rbac/ +_DELIVERABLES = os.path.normpath(os.path.join(_RBAC_DIR, "..")) # 05_deliverables_mvp/ + +for _p in ( + _MODULE_DIR, + os.path.join(_RBAC_DIR, "fixtures_gen"), + os.path.join(_RBAC_DIR, "userperm_gen"), + os.path.join(_RBAC_DIR, "roleprofile_gen"), + os.path.join(_DELIVERABLES, "publiciste"), +): + if _p not in sys.path: + sys.path.insert(0, _p) + +import rbac_apply_plan as cli # noqa: E402 +from applylib import aggregator # noqa: E402 +from fixturelib import builder as fixtures_builder # noqa: E402 +from permlib import builder as userperm_builder # noqa: E402 +from profilelib import builder as roleprofile_builder # noqa: E402 +from lib import validator as maison # noqa: E402 + +try: + import jsonschema # type: ignore + + _HAS_JSONSCHEMA = True +except ImportError: # pragma: no cover + _HAS_JSONSCHEMA = False + + +def _load(path: str) -> dict: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +class ApplyPlanTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.contract = _load(cli._CONTRACT_PATH) + cls.fixtures = fixtures_builder.build_bundle(cls.contract) + cls.userperm = userperm_builder.build_plan(cls.contract) + cls.roleprofile = roleprofile_builder.build_bundle(cls.contract) + cls.plan = aggregator.build_apply_plan( + cls.contract, cls.fixtures, cls.userperm, cls.roleprofile + ) + cls.schema = _load(cli._SCHEMA_PATH) + + # --- schéma ------------------------------------------------------------- # + def test_schema_maison(self) -> None: + errors = maison.validate(self.plan, self.schema) + self.assertEqual(errors, [], f"validateur maison: {errors}") + + @unittest.skipUnless(_HAS_JSONSCHEMA, "jsonschema absent (oracle optionnel)") + def test_schema_oracle_jsonschema(self) -> None: + jsonschema.validate(self.plan, self.schema) + + def test_cli_validate_no_errors(self) -> None: + self.assertEqual(cli._validate_plan(self.plan), []) + + # --- fidélité des comptes (anti-invention #6) --------------------------- # + def test_counts_come_from_source_manifests(self) -> None: + c = self.plan["manifest"]["counts"] + self.assertEqual(c["roles"], self.fixtures["manifest"]["counts"]["role"]) + self.assertEqual(c["custom_docperm"], self.fixtures["manifest"]["counts"]["custom_docperm"]) + self.assertEqual(c["doctypes_uniques"], self.fixtures["manifest"]["counts"]["doctypes_uniques"]) + self.assertEqual( + c["user_permission_templates"], + self.userperm["manifest"]["counts"]["user_permission_templates"], + ) + self.assertEqual(c["role_profiles"], self.roleprofile["manifest"]["counts"]["role_profiles"]) + + def test_confirmations_merged_from_sources(self) -> None: + conf = self.plan["manifest"]["confirmations_vps"] + self.assertEqual( + conf["custom_doctypes"], + sorted(self.fixtures["manifest"]["custom_doctypes_a_confirmer"]), + ) + self.assertEqual( + conf["companies"], sorted(self.userperm["manifest"]["companies_a_confirmer"]) + ) + self.assertEqual( + conf["roles_scope_equipe"], + sorted(self.userperm["manifest"]["roles_scope_equipe_a_confirmer"]), + ) + + # --- run-book (SPEC §7) ------------------------------------------------- # + def test_steps_match_spec7_order(self) -> None: + steps = self.plan["apply_plan"] + self.assertEqual([s["id"] for s in steps], aggregator.step_ids()) + self.assertEqual([s["order"] for s in steps], list(range(1, len(steps) + 1))) + + def test_dependency_graph_no_forward_edges(self) -> None: + steps = self.plan["apply_plan"] + order = {s["id"]: s["order"] for s in steps} + for s in steps: + for dep in s["depends_on"]: + self.assertIn(dep, order) + self.assertLess(order[dep], s["order"], f"{s['id']} dépend en avant de {dep}") + + def test_roleprofile_after_role_fixtures(self) -> None: + rp = next(s for s in self.plan["apply_plan"] if s["id"] == "roleprofile-apply") + self.assertIn("fixtures-migrate", rp["depends_on"]) + + def test_every_confirmation_referenced(self) -> None: + conf = self.plan["manifest"]["confirmations_vps"] + referenced = {k for s in self.plan["apply_plan"] for k in s["confirmations"]} + for key, items in conf.items(): + if items: + self.assertIn(key, referenced, f"{key} non vide mais orpheline") + + # --- cohérence inter-volets --------------------------------------------- # + def test_consistency_bijective(self) -> None: + cons = self.plan["manifest"]["consistency"] + self.assertTrue(cons["sources_coherentes"]) + self.assertTrue(cons["couverture_bijective"]) + self.assertEqual(cons["roles_fixtures"], 50) + self.assertEqual(cons["userperm_plan_entries"], 50) + self.assertEqual(cons["roleprofile_roles_couverts"], 50) + + def test_rejects_version_mismatch(self) -> None: + """Un volet dérivé d'un contrat de version différente → refus.""" + forged = json.loads(json.dumps(self.userperm)) + forged["manifest"]["source_version"] = "9.9.9" + with self.assertRaises(ValueError): + aggregator.build_apply_plan(self.contract, self.fixtures, forged, self.roleprofile) + + def test_rejects_cible_mismatch(self) -> None: + forged = json.loads(json.dumps(self.roleprofile)) + forged["manifest"]["cible_rbac_roles"] = 49 + with self.assertRaises(ValueError): + aggregator.build_apply_plan(self.contract, self.fixtures, self.userperm, forged) + + # --- déterminisme ------------------------------------------------------- # + def test_deterministic(self) -> None: + again = aggregator.build_apply_plan( + self.contract, self.fixtures, self.userperm, self.roleprofile + ) + self.assertEqual( + json.dumps(self.plan, sort_keys=True, ensure_ascii=False), + json.dumps(again, sort_keys=True, ensure_ascii=False), + ) + + def test_confirmations_sorted(self) -> None: + conf = self.plan["manifest"]["confirmations_vps"] + for key, items in conf.items(): + self.assertEqual(items, sorted(items), f"{key} non trié") + + # --- invariant-breaking detection --------------------------------------- # + def test_validate_catches_broken_order(self) -> None: + broken = json.loads(json.dumps(self.plan)) + broken["apply_plan"][0]["order"] = 99 + self.assertTrue(cli._validate_plan(broken)) + + def test_validate_catches_orphan_confirmation(self) -> None: + broken = json.loads(json.dumps(self.plan)) + # Vide toutes les références de confirmation des étapes → items orphelins. + for s in broken["apply_plan"]: + s["confirmations"] = [] + self.assertTrue(cli._validate_plan(broken)) + + +if __name__ == "__main__": + unittest.main()